├── .envrc ├── .github ├── dependabot.yml ├── stale.yml └── workflows │ ├── build-zip.yml │ ├── e2e.yml │ ├── go.yml │ ├── lint.yml │ └── prettier.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── authz └── roundtripper.go ├── client ├── client.go ├── guest.go └── local │ └── local.go ├── cmd ├── ask.go ├── browser │ └── open.go ├── component │ ├── fetch │ │ └── fetch.go │ ├── generate_runbook.go │ ├── list │ │ ├── ask_delegate.go │ │ └── list.go │ └── viewport │ │ └── viewport.go ├── explain.go ├── file.go ├── history.go ├── init.go ├── internal │ ├── current.go │ ├── internal.go │ ├── next.go │ ├── previous.go │ └── set-param.go ├── logger.go ├── login.go ├── record.go ├── root.go ├── run.go ├── send.go ├── setup │ ├── bash-hooks.sh │ ├── bash-preexec.sh │ ├── bash.go │ ├── fish.go │ ├── savvy.fish │ ├── savvy.zsh │ └── zsh.go ├── sync.go ├── upgrade.go ├── version.go ├── whoami.go └── write.go ├── config ├── api_host.go ├── config.go ├── dashboard_host.go ├── host_dev.go └── version.go ├── demos ├── ask-command.gif ├── ask-runbook.gif ├── savvy-explain-errors.gif ├── savvy-explain-openssl.gif ├── savvy-history.gif ├── savvy-param-dashboard.jpeg ├── savvy-param-run.jpeg ├── savvy-run.gif └── savvy-sync.gif ├── display ├── error.go ├── info.go └── success.go ├── export ├── markdown │ └── markdown.go └── savvy.go ├── extension ├── cmd │ └── main.go └── process.go ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── idgen └── gen.go ├── install └── install.sh ├── llm ├── llm.go ├── service │ ├── custom_llm.go │ └── service.go └── streamer.go ├── login └── login.go ├── main.go ├── model ├── code_info.go ├── command.go └── question_info.go ├── param ├── param.go └── param_test.go ├── redact └── redact.go ├── savvy-extension ├── .envrc ├── .eslintignore ├── .eslintrc ├── .example.env ├── .gitignore ├── .husky │ └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── UPDATE-PACKAGE-VERSIONS.md ├── assets │ └── images │ │ ├── cli-extension.png │ │ ├── export.png │ │ ├── select.png │ │ └── time-range.png ├── chrome-extension │ ├── manifest.js │ ├── package.json │ ├── public │ │ ├── content.css │ │ ├── icon-128.png │ │ ├── icon-34.png │ │ ├── icon-48.png │ │ └── icon-64.png │ ├── src │ │ └── background │ │ │ └── index.ts │ ├── tsconfig.json │ ├── utils │ │ ├── plugins │ │ │ └── make-manifest-plugin.ts │ │ └── refresh.js │ └── vite.config.mts ├── flake.lock ├── flake.nix ├── package.json ├── packages │ ├── dev-utils │ │ ├── .eslintignore │ │ ├── index.ts │ │ ├── lib │ │ │ ├── logger.ts │ │ │ └── manifest-parser │ │ │ │ ├── impl.ts │ │ │ │ ├── index.ts │ │ │ │ └── type.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── hmr │ │ ├── index.ts │ │ ├── lib │ │ │ ├── constant.ts │ │ │ ├── initializers │ │ │ │ ├── initClient.ts │ │ │ │ └── initReloadServer.ts │ │ │ ├── injections │ │ │ │ ├── refresh.ts │ │ │ │ └── reload.ts │ │ │ ├── interpreter │ │ │ │ └── index.ts │ │ │ ├── plugins │ │ │ │ ├── index.ts │ │ │ │ ├── make-entry-point-plugin.ts │ │ │ │ ├── watch-public-plugin.ts │ │ │ │ └── watch-rebuild-plugin.ts │ │ │ └── types.ts │ │ ├── package.json │ │ ├── rollup.config.mjs │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── i18n │ │ ├── .eslintignore │ │ ├── .gitignore │ │ ├── README.md │ │ ├── build.dev.mjs │ │ ├── build.mjs │ │ ├── build.prod.mjs │ │ ├── genenrate-i18n.mjs │ │ ├── index.ts │ │ ├── lib │ │ │ ├── getMessageFromLocale.ts │ │ │ ├── i18n-dev.ts │ │ │ ├── i18n-prod.ts │ │ │ └── type.ts │ │ ├── locales │ │ │ └── en │ │ │ │ └── messages.json │ │ ├── package.json │ │ └── tsconfig.json │ ├── shared │ │ ├── .eslintignore │ │ ├── README.md │ │ ├── build.mjs │ │ ├── index.ts │ │ ├── lib │ │ │ ├── hoc │ │ │ │ ├── index.ts │ │ │ │ ├── withErrorBoundary.tsx │ │ │ │ └── withSuspense.tsx │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ ├── useAPI.tsx │ │ │ │ └── useStorage.tsx │ │ │ └── utils │ │ │ │ ├── api_config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── shared-types.ts │ │ │ │ └── storage.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── storage │ │ ├── .eslintignore │ │ ├── build.mjs │ │ ├── index.ts │ │ ├── lib │ │ │ ├── base │ │ │ │ ├── base.ts │ │ │ │ ├── enums.ts │ │ │ │ └── types.ts │ │ │ ├── impl │ │ │ │ ├── exampleThemeStorage.ts │ │ │ │ ├── index.ts │ │ │ │ └── tokenStorage.ts │ │ │ └── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── tailwind-config │ │ ├── package.json │ │ └── tailwind.config.ts │ ├── tsconfig │ │ ├── app.json │ │ ├── base.json │ │ ├── package.json │ │ └── utils.json │ ├── ui │ │ ├── README.md │ │ ├── build.mjs │ │ ├── index.ts │ │ ├── lib │ │ │ ├── components │ │ │ │ ├── Button.tsx │ │ │ │ └── index.ts │ │ │ ├── global.css │ │ │ ├── utils.ts │ │ │ └── withUI.ts │ │ ├── package.json │ │ └── tsconfig.json │ ├── vite-config │ │ ├── index.mjs │ │ ├── lib │ │ │ ├── env.mjs │ │ │ └── withPageConfig.mjs │ │ └── package.json │ └── zipper │ │ ├── .eslintignore │ │ ├── index.ts │ │ ├── lib │ │ └── zip-bundle │ │ │ └── index.ts │ │ ├── package.json │ │ └── tsconfig.json ├── pages │ ├── content-runtime │ │ ├── package.json │ │ ├── src │ │ │ ├── App.tsx │ │ │ ├── Root.tsx │ │ │ ├── index.css │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── content-ui │ │ ├── package.json │ │ ├── public │ │ │ └── logo.svg │ │ ├── src │ │ │ ├── App.tsx │ │ │ ├── index.tsx │ │ │ └── tailwind-input.css │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── content │ │ ├── package.json │ │ ├── public │ │ │ └── logo.svg │ │ ├── src │ │ │ ├── index.ts │ │ │ └── toggleTheme.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── devtools-panel │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ ├── logo_horizontal.svg │ │ │ └── logo_horizontal_dark.svg │ │ ├── src │ │ │ ├── Panel.css │ │ │ ├── Panel.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── devtools │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ └── logo.svg │ │ ├── src │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── new-tab │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ ├── logo_horizontal.svg │ │ │ └── logo_horizontal_dark.svg │ │ ├── src │ │ │ ├── NewTab.css │ │ │ ├── NewTab.scss │ │ │ ├── NewTab.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── options │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ ├── logo_horizontal.svg │ │ │ └── logo_horizontal_dark.svg │ │ ├── src │ │ │ ├── Options.css │ │ │ ├── Options.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ ├── popup │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ │ ├── logo_vertical.svg │ │ │ └── logo_vertical_dark.svg │ │ ├── src │ │ │ ├── Popup.css │ │ │ ├── Popup.tsx │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ └── side-panel │ │ ├── components.json │ │ ├── index.html │ │ ├── package.json │ │ ├── public │ │ ├── logo_vertical.svg │ │ └── logo_vertical_dark.svg │ │ ├── src │ │ ├── SidePanel.css │ │ ├── SidePanel.tsx │ │ ├── assets │ │ │ └── fonts │ │ │ │ └── Inter-SemiBold.ttf │ │ ├── components │ │ │ ├── Copy.tsx │ │ │ ├── HistoryViewer.tsx │ │ │ └── ui │ │ │ │ ├── badge.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── toast.tsx │ │ │ │ ├── toaster.tsx │ │ │ │ └── tooltip.tsx │ │ ├── hooks │ │ │ └── use-toast.ts │ │ ├── index.css │ │ ├── index.tsx │ │ └── lib │ │ │ └── utils.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tests │ └── e2e │ │ ├── config │ │ ├── wdio.browser.conf.ts │ │ ├── wdio.conf.ts │ │ └── wdio.d.ts │ │ ├── helpers │ │ └── theme.ts │ │ ├── package.json │ │ ├── specs │ │ ├── page-content-runtime.test.ts │ │ ├── page-content-ui.test.ts │ │ ├── page-content.test.ts │ │ ├── page-popup.test.ts │ │ └── page-side-panel.test.ts │ │ ├── tsconfig.json │ │ └── utils │ │ └── extension-path.ts ├── turbo.json ├── update_version.sh └── vite-env.d.ts ├── server ├── cleanup │ └── permission.go ├── client.go ├── mode │ └── mode.go ├── run │ ├── client.go │ ├── run.go │ └── run_test.go └── unix_socket.go ├── shell ├── bash.go ├── check_bash_setup.go ├── check_setup.go ├── check_zsh_setup.go ├── expansion │ ├── ignore.go │ └── ignore_test.go ├── fish.go ├── internal │ └── detect │ │ ├── detect.go │ │ └── detect_test.go ├── kind │ └── kind.go ├── spawn.go ├── supported.go └── zsh.go ├── slice └── slice.go ├── storage └── storage.go ├── tail ├── tail.go ├── tail_test.go └── testdata │ ├── data.txt │ └── empty.txt └── theme └── theme.go /.envrc: -------------------------------------------------------------------------------- 1 | source .env 2 | use flake 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "savvy-extension" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an Issue or Pull Request becomes stale 2 | daysUntilStale: 90 3 | # Number of days of inactivity before a stale Issue or Pull Request is closed 4 | daysUntilClose: 30 5 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking as stale 10 | staleLabel: stale 11 | # Comment to post when marking as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when removing the stale label. Set to `false` to disable 17 | unmarkComment: false 18 | # Comment to post when closing a stale Issue or Pull Request. Set to `false` to disable 19 | closeComment: true 20 | # Limit to only `issues` or `pulls` 21 | only: issues 22 | -------------------------------------------------------------------------------- /.github/workflows/build-zip.yml: -------------------------------------------------------------------------------- 1 | name: Build And Upload Extension Zip Via Artifact 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'savvy-extension/**' 8 | pull_request: 9 | paths: 10 | - 'savvy-extension/**' 11 | 12 | jobs: 13 | build: 14 | defaults: 15 | run: 16 | working-directory: savvy-extension 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | with: 24 | package_json_file: 'savvy-extension/package.json' 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version-file: 'savvy-extension/.nvmrc' 28 | cache-dependency-path: 'savvy-extension/pnpm-lock.yaml' 29 | cache: pnpm 30 | 31 | - run: pnpm install --frozen-lockfile --prefer-offline 32 | 33 | - run: pnpm build 34 | 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | path: dist/* 38 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: Run E2E Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'savvy-extension/**' 8 | pull_request: 9 | branches: [ main ] 10 | paths: 11 | - 'savvy-extension/**' 12 | 13 | jobs: 14 | chrome: 15 | defaults: 16 | run: 17 | working-directory: savvy-extension 18 | name: E2E tests for Chrome 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | with: 24 | package_json_file: savvy-extension/package.json 25 | - uses: actions/setup-node@v4 26 | with: 27 | node-version-file: 'savvy-extension/.nvmrc' 28 | cache: pnpm 29 | cache-dependency-path: 'savvy-extension/pnpm-lock.yaml' 30 | - run: pnpm install --frozen-lockfile --prefer-offline 31 | - run: pnpm e2e 32 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "*" ] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version-file: './go.mod' 22 | 23 | - name: Set CI ENV 24 | run: export ENV=CI 25 | 26 | - name: Build 27 | run: make cli 28 | 29 | - name: Test 30 | run: go test -v -count=1 ./... 31 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Check 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - 'savvy-extension/**' 8 | pull_request: 9 | branches: [ main ] 10 | paths: 11 | - 'savvy-extension/**' 12 | 13 | jobs: 14 | eslint: 15 | defaults: 16 | run: 17 | working-directory: savvy-extension 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: pnpm/action-setup@v4 24 | with: 25 | package_json_file: 'savvy-extension/package.json' 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version-file: 'savvy-extension/.nvmrc' 29 | cache-dependency-path: 'savvy-extension/pnpm-lock.yaml' 30 | cache: pnpm 31 | 32 | - run: pnpm install --frozen-lockfile --prefer-offline 33 | 34 | - run: pnpm lint 35 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Formating validation 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'savvy-extension/**' 7 | push: 8 | branches: [main] 9 | paths: 10 | - 'savvy-extension/**' 11 | 12 | jobs: 13 | prettier: 14 | defaults: 15 | run: 16 | working-directory: savvy-extension 17 | name: Prettier Check 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout Repository 21 | uses: actions/checkout@v2 22 | 23 | - name: Run Prettier 24 | id: prettier-run 25 | uses: rutajdash/prettier-cli-action@v1.0.0 26 | with: 27 | config_path: savvy-extension/.prettierrc 28 | file_pattern: "*.{js,jsx,ts,tsx,json}" 29 | 30 | # This step only runs if prettier finds errors causing the previous step to fail 31 | # This steps lists the files where errors were found 32 | - name: Prettier Output 33 | if: ${{ failure() }} 34 | shell: bash 35 | run: | 36 | echo "The following files aren't formatted properly:" 37 | echo "${{steps.prettier-run.outputs.prettier_output}}" 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.txt 2 | bin/* 3 | .direnv 4 | .env 5 | /savvy 6 | /savvy-dev 7 | *.log 8 | 9 | *.prof 10 | *.out 11 | *.tape 12 | 13 | ignored-gifs/*.gif 14 | 15 | .vscode/ 16 | 17 | .plandex/ 18 | dist/ 19 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # This is an example .goreleaser.yml file with some sensible defaults. 2 | # Make sure to check the documentation at https://goreleaser.com 3 | 4 | # The lines below are called `modelines`. See `:help modeline` 5 | # Feel free to remove those if you don't want/need to use them. 6 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 7 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 8 | 9 | version: 1 10 | 11 | before: 12 | hooks: 13 | # You may remove this if you don't use go modules. 14 | - go mod tidy 15 | # you may remove this if you don't need go generate 16 | - go generate ./... 17 | 18 | project_name: savvy 19 | builds: 20 | - id: savvy 21 | binary: savvy 22 | ldflags: 23 | - -s -w -X github.com/getsavvyinc/savvy-cli/config.version={{.Version}} 24 | env: 25 | - CGO_ENABLED=0 26 | goos: 27 | - linux 28 | - darwin 29 | 30 | archives: 31 | - format: binary 32 | # this name template makes the OS and Arch compatible with the results of `uname`. 33 | name_template: >- 34 | {{ .ProjectName }}_ 35 | {{- tolower .Os }}_ 36 | {{- if eq .Arch "amd64" }}x86_64 37 | {{- else if eq .Arch "386" }}i386 38 | {{- else }}{{ .Arch }}{{ end }} 39 | {{- if .Arm }}v{{ .Arm }}{{ end }} 40 | # use zip for windows archives 41 | format_overrides: 42 | - goos: windows 43 | format: zip 44 | 45 | changelog: 46 | sort: asc 47 | filters: 48 | exclude: 49 | - "^docs:" 50 | - "^test:" 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shantanu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | version := $(shell git rev-parse HEAD) 2 | 3 | cli: 4 | go build -ldflags "-X github.com/getsavvyinc/savvy-cli/config.version=$(version)" -o savvy . 5 | 6 | cli_race: 7 | go build -race -ldflags "-X github.com/getsavvyinc/savvy-cli/config.version=$(version)" -o savvy . 8 | cli_dev: 9 | go build -ldflags "-X github.com/getsavvyinc/savvy-cli/config.version=$(version)" -tags dev -o savvy-dev . 10 | 11 | cli_dev_debug: 12 | go build -race -ldflags "-X github.com/getsavvyinc/savvy-cli/config.version=$(version)" -gcflags="-N -l" -tags dev -o savvy-dev . 13 | 14 | cli_debug: 15 | go build -race -ldflags "-X github.com/getsavvyinc/savvy-cli/config.version=$(version)" -gcflags="-N -l" -o savvy . 16 | 17 | release: 18 | goreleaser release --clean 19 | 20 | build_all: 21 | goreleaser build --clean 22 | -------------------------------------------------------------------------------- /authz/roundtripper.go: -------------------------------------------------------------------------------- 1 | package authz 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | var ErrInvalidAuthzClient = errors.New("invalid authz client") 10 | 11 | type AuthorizedRoundTripper struct { 12 | token string 13 | savvyVersion string 14 | // wrap error returned by RoundTrip 15 | wrapErr error 16 | } 17 | 18 | // NewRoundTripper returns a new AuthorizedRoundTripper 19 | // 20 | // Caller must provide non nil err to wrap the error returned by RoundTrip 21 | func NewRoundTripper(token, savvyVersion string, err error) *AuthorizedRoundTripper { 22 | return &AuthorizedRoundTripper{token: token, savvyVersion: savvyVersion, wrapErr: err} 23 | } 24 | 25 | func (a *AuthorizedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 26 | // Clone the request to ensure thread safety 27 | clonedReq := req.Clone(req.Context()) 28 | clonedReq.Header.Set("Authorization", "Bearer "+a.token) 29 | clonedReq.Header.Set("X-Savvy-Version", a.savvyVersion) 30 | 31 | // Use the embedded Transport to perform the actual request 32 | res, err := http.DefaultTransport.RoundTrip(clonedReq) 33 | if err != nil { 34 | err = fmt.Errorf("%w: %v", a.wrapErr, err) 35 | return nil, err 36 | } 37 | 38 | // If we get a 401 Unauthorized, then the token is expired 39 | // and we need to refresh it 40 | if res.StatusCode == http.StatusUnauthorized { 41 | return nil, fmt.Errorf("%w: invalid token", a.wrapErr) 42 | } 43 | return res, err 44 | } 45 | -------------------------------------------------------------------------------- /client/local/local.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/getsavvyinc/savvy-cli/client" 9 | "github.com/getsavvyinc/savvy-cli/storage" 10 | ) 11 | 12 | func New() client.RunbookClient { 13 | return &local{} 14 | } 15 | 16 | type local struct{} 17 | 18 | var ErrNotFound = errors.New("not found") 19 | 20 | func (l *local) RunbookByID(ctx context.Context, id string) (*client.Runbook, error) { 21 | rbs, err := storage.Read() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | rb, ok := rbs[id] 27 | if !ok { 28 | err = fmt.Errorf("runbook %s: %w", id, ErrNotFound) 29 | return nil, err 30 | } 31 | return rb, nil 32 | } 33 | 34 | // Runbooks returns all runbooks stored in the local storage 35 | func (l *local) Runbooks(ctx context.Context, _ client.RunbooksOpt) ([]client.RunbookInfo, error) { 36 | 37 | rbs, err := storage.Read() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | var rbis []client.RunbookInfo 43 | for id, rb := range rbs { 44 | rbis = append(rbis, client.RunbookInfo{ 45 | RunbookID: id, 46 | Title: rb.Title, 47 | }) 48 | } 49 | return rbis, nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/browser/open.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "runtime" 7 | "strings" 8 | 9 | "github.com/getsavvyinc/savvy-cli/display" 10 | ) 11 | 12 | func OpenCmd(url string) *exec.Cmd { 13 | if strings.HasPrefix(url, "chrome-extension://") { 14 | switch runtime.GOOS { 15 | case "linux": 16 | return exec.Command("google-chrome", url) 17 | case "darwin": 18 | return exec.Command("open", "-a", "Google Chrome", url) 19 | default: 20 | return nil 21 | } 22 | } 23 | switch runtime.GOOS { 24 | case "linux": 25 | return exec.Command("xdg-open", url) 26 | case "windows": 27 | return exec.Command("rundll32", "url.dll,FileProtocolHandler", url) 28 | case "darwin": 29 | return exec.Command("open", url) 30 | default: 31 | } 32 | return nil 33 | } 34 | 35 | func Open(url string) { 36 | display.Info("Opening your default browser to " + url) 37 | 38 | cmd := OpenCmd(url) 39 | runOpenCmd(cmd, url) 40 | } 41 | 42 | func runOpenCmd(cmd *exec.Cmd, target string) { 43 | var browserOpenError = fmt.Errorf("Please visit %s in your browser", target) 44 | if cmd == nil { 45 | display.Error(browserOpenError) 46 | return 47 | } 48 | 49 | if err := cmd.Start(); err != nil { 50 | display.Error(browserOpenError) 51 | return 52 | } 53 | } 54 | 55 | func OpenExtensionSidePanel() { 56 | extensionURL := "chrome-extension://jocphfjphhfbdccjfjjnbcnejmbojjlh/side-panel/index.html" 57 | display.Info("Opening Savvy's extension on Chrome...") 58 | cmd := OpenCmd(extensionURL) 59 | runOpenCmd(cmd, extensionURL) 60 | } 61 | 62 | func InstallExtension() { 63 | extensionURL := "https://chrome.google.com/webstore/detail/savvy/jocphfjphhfbdccjfjjnbcnejmbojjlh" 64 | display.Info("Opening Chrome to install Savvy's extension...") 65 | cmd := OpenCmd(extensionURL) 66 | runOpenCmd(cmd, extensionURL) 67 | } 68 | -------------------------------------------------------------------------------- /cmd/component/fetch/fetch.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/charmbracelet/bubbles/spinner" 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | type Model struct { 12 | spinner spinner.Model 13 | waitMsg string 14 | 15 | done bool 16 | } 17 | 18 | var spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) 19 | 20 | // New creates a new fetch model. 21 | // waitMsg is the message to display while waiting for the fetch to complete. 22 | func New(waitMsg string) Model { 23 | m := Model{ 24 | spinner: newSpinner(), 25 | waitMsg: waitMsg, 26 | done: false, 27 | } 28 | return m 29 | } 30 | 31 | func newSpinner() spinner.Model { 32 | sp := spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(spinnerStyle)) 33 | mySpinner := new(spinner.Model) 34 | *mySpinner = sp 35 | mySpinner.Spinner.FPS = time.Second / 20 36 | return *mySpinner 37 | } 38 | 39 | type DoneMsg struct{} 40 | 41 | func (m Model) Done() DoneMsg { 42 | return DoneMsg{} 43 | } 44 | 45 | func (m Model) View() string { 46 | if m.done { 47 | return "" 48 | } 49 | return m.spinner.View() + " " + m.waitMsg 50 | } 51 | 52 | // Init initializes the model. 53 | func (m Model) Init() tea.Cmd { 54 | if m.done { 55 | return nil 56 | } 57 | return m.spinner.Tick 58 | } 59 | 60 | // Update updates the model. 61 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 62 | var cmd tea.Cmd 63 | switch msg.(type) { 64 | case DoneMsg: 65 | m.done = true 66 | m.resetSpinner() 67 | return m, nil 68 | } 69 | m.spinner, cmd = m.spinner.Update(msg) 70 | return m, cmd 71 | } 72 | 73 | // The pointer receiver is important here! 74 | func (m *Model) resetSpinner() { 75 | m.spinner = newSpinner() 76 | } 77 | -------------------------------------------------------------------------------- /cmd/file.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "strings" 8 | 9 | "github.com/getsavvyinc/savvy-cli/client" 10 | "github.com/getsavvyinc/savvy-cli/display" 11 | "github.com/getsavvyinc/savvy-cli/server" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // fileCmd represents the file command 16 | var fileCmd = &cobra.Command{ 17 | Use: "file", 18 | Short: "Record contents of a file given its path", 19 | Long: `Record contents and filename of a file given its path.`, 20 | Run: func(cmd *cobra.Command, args []string) { 21 | _, err := client.New() 22 | if err != nil && errors.Is(err, client.ErrInvalidClient) { 23 | display.Error(errors.New("You must be logged in to record a runbook. Please run `savvy login`")) 24 | os.Exit(1) 25 | } 26 | 27 | if len(args) == 0 { 28 | display.ErrorMsg("no file path provided") 29 | return 30 | } 31 | 32 | if len(args) > 1 { 33 | display.ErrorMsg("file only accepts one filepath at a time") 34 | return 35 | } 36 | 37 | filePath := strings.TrimSpace(args[0]) 38 | fi, err := os.Stat(filePath) 39 | if err != nil { 40 | display.Error(err) 41 | return 42 | } 43 | 44 | if fi.IsDir() { 45 | display.ErrorMsg("file path provided is a directory") 46 | return 47 | } 48 | 49 | if fi.Size() == 0 { 50 | display.ErrorMsg("file provided is empty") 51 | return 52 | } 53 | 54 | cl, err := server.NewDefaultClient(context.Background()) 55 | if err != nil { 56 | display.Error(err) 57 | return 58 | } 59 | 60 | if err := cl.SendFileInfo(filePath); err != nil { 61 | display.ErrorWithSupportCTA(err) 62 | return 63 | } 64 | }, 65 | } 66 | 67 | func init() { 68 | recordCmd.AddCommand(fileCmd) 69 | } 70 | -------------------------------------------------------------------------------- /cmd/init.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getsavvyinc/savvy-cli/cmd/setup" 5 | "github.com/getsavvyinc/savvy-cli/shell" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // initCmd represents the init command 10 | var initCmd = &cobra.Command{ 11 | Use: "init", 12 | Example: "savvy init zsh", 13 | 14 | Short: "Output shell setup", 15 | Long: `Output shell setup`, 16 | 17 | ValidArgs: shell.SupportedShells(), 18 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 19 | 20 | Run: func(cmd *cobra.Command, args []string) { 21 | }, 22 | } 23 | 24 | func init() { 25 | rootCmd.AddCommand(initCmd) 26 | initCmd.AddCommand(setup.ZshCmd) 27 | initCmd.AddCommand(setup.BashCmd) 28 | initCmd.AddCommand(setup.DashCmd) 29 | initCmd.AddCommand(setup.FishCmd) 30 | } 31 | -------------------------------------------------------------------------------- /cmd/internal/current.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsavvyinc/savvy-cli/display" 7 | "github.com/getsavvyinc/savvy-cli/server/run" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // currentCmd represents the current command 12 | var currentCmd = &cobra.Command{ 13 | Use: "current", 14 | Short: "Get the command to run", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | ctx := cmd.Context() 17 | cl, err := run.NewDefaultClient(ctx) 18 | if err != nil { 19 | display.ErrorWithSupportCTA(err) 20 | return 21 | } 22 | 23 | state, err := cl.CurrentState() 24 | if err != nil { 25 | display.ErrorWithSupportCTA(err) 26 | return 27 | } 28 | 29 | fmt.Printf("%s", state.CommandWithSetParams()) 30 | }, 31 | } 32 | 33 | func init() { 34 | InternalCmd.AddCommand(currentCmd) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/internal/internal.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // internalCmd represents the internal command 8 | var InternalCmd = &cobra.Command{ 9 | Use: "internal", 10 | Hidden: true, 11 | Short: "Internal commands not meant to be used by end users.", 12 | Run: func(cmd *cobra.Command, args []string) { 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /cmd/internal/next.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/getsavvyinc/savvy-cli/display" 9 | "github.com/getsavvyinc/savvy-cli/server/run" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // nextCmd represents the next command 14 | var nextCmd = &cobra.Command{ 15 | Use: "next", 16 | Hidden: true, 17 | Short: "Update runbook state to next step", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | ctx := cmd.Context() 20 | cl, err := run.NewDefaultClient(ctx) 21 | if err != nil { 22 | display.ErrorWithSupportCTA(err) 23 | return 24 | } 25 | 26 | state, err := cl.CurrentState() 27 | if err != nil { 28 | display.ErrorWithSupportCTA(err) 29 | os.Exit(1) 30 | } 31 | 32 | if forceNext || state.CommandWithSetParams() == executedCommand { 33 | updated, err := nextCommand(ctx, cl) 34 | if err != nil { 35 | display.ErrorWithSupportCTA(err) 36 | os.Exit(1) 37 | } 38 | fmt.Printf("%d", updated.Index) 39 | return 40 | } 41 | fmt.Printf("%d", state.Index) 42 | }, 43 | } 44 | 45 | func nextCommand(ctx context.Context, cl run.Client) (*run.State, error) { 46 | if err := cl.NextCommand(); err != nil { 47 | return nil, err 48 | } 49 | 50 | updatedState, err := cl.CurrentState() 51 | if err != nil { 52 | return nil, err 53 | } 54 | return updatedState, nil 55 | } 56 | 57 | var executedCommand string 58 | var forceNext bool 59 | 60 | func init() { 61 | InternalCmd.AddCommand(nextCmd) 62 | 63 | nextCmd.Flags().StringVarP(&executedCommand, "cmd", "c", "", "previously executed command") 64 | nextCmd.Flags().BoolVarP(&forceNext, "force", "f", false, "force next command regardless of current state") 65 | 66 | } 67 | -------------------------------------------------------------------------------- /cmd/internal/previous.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/getsavvyinc/savvy-cli/display" 9 | "github.com/getsavvyinc/savvy-cli/server/run" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | // previousCommand represents the next command 14 | var previousCmd = &cobra.Command{ 15 | Use: "previous", 16 | Hidden: true, 17 | Short: "Update runbook state to the previous step", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | ctx := cmd.Context() 20 | cl, err := run.NewDefaultClient(ctx) 21 | if err != nil { 22 | display.ErrorWithSupportCTA(err) 23 | return 24 | } 25 | 26 | state, err := cl.CurrentState() 27 | if err != nil { 28 | display.ErrorWithSupportCTA(err) 29 | os.Exit(1) 30 | } 31 | 32 | if forcePrevious { 33 | updated, err := previousCommand(ctx, cl) 34 | if err != nil { 35 | display.ErrorWithSupportCTA(err) 36 | os.Exit(1) 37 | } 38 | fmt.Printf("%d", updated.Index) 39 | return 40 | } 41 | fmt.Printf("%d", state.Index) 42 | }, 43 | } 44 | 45 | func previousCommand(ctx context.Context, cl run.Client) (*run.State, error) { 46 | if err := cl.PreviousCommand(); err != nil { 47 | return nil, err 48 | } 49 | 50 | updatedState, err := cl.CurrentState() 51 | if err != nil { 52 | return nil, err 53 | } 54 | return updatedState, nil 55 | } 56 | 57 | var forcePrevious bool 58 | 59 | func init() { 60 | InternalCmd.AddCommand(previousCmd) 61 | previousCmd.Flags().BoolVarP(&forcePrevious, "force", "f", false, "force previous command regardless of current state") 62 | } 63 | -------------------------------------------------------------------------------- /cmd/logger.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | type cmdLogger struct{} 10 | 11 | var cmdLoggerKey cmdLogger 12 | 13 | func loggerFromCtx(ctx context.Context) *slog.Logger { 14 | if logger, ok := ctx.Value(cmdLoggerKey).(*slog.Logger); ok && logger != nil { 15 | return logger 16 | } 17 | return defaultLogger 18 | } 19 | 20 | func ctxWithLogger(ctx context.Context, logger *slog.Logger) context.Context { 21 | return context.WithValue(ctx, cmdLoggerKey, logger) 22 | } 23 | 24 | var defaultLogger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) 25 | -------------------------------------------------------------------------------- /cmd/login.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/getsavvyinc/savvy-cli/client" 8 | "github.com/getsavvyinc/savvy-cli/display" 9 | "github.com/getsavvyinc/savvy-cli/login" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var loginCmd = &cobra.Command{ 14 | Use: "login", 15 | Short: "Login to savvy", 16 | Long: `Login allows users to use Google SSO to login to savvy.`, 17 | Run: runLoginCmd, 18 | } 19 | 20 | func runLoginCmd(cmd *cobra.Command, args []string) { 21 | force, err := cmd.Flags().GetBool(forceLoginFlag) 22 | if err != nil { 23 | display.ErrorWithSupportCTA(fmt.Errorf("error parsing flags: %w", err)) 24 | os.Exit(1) 25 | } 26 | 27 | if err := client.VerifyLogin(); err == nil && !force { 28 | display.Info("You are already logged in!") 29 | display.Info("Run `savvy login --force` to get a new token") 30 | return 31 | } 32 | 33 | login.Run(client.VerifyLogin) 34 | } 35 | 36 | const forceLoginFlag = "force" 37 | const forceLoginFlagShort = "f" 38 | 39 | func init() { 40 | loginCmd.Flags().BoolP(forceLoginFlag, forceLoginFlagShort, false, "Force new login flow") 41 | rootCmd.AddCommand(loginCmd) 42 | } 43 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "github.com/getsavvyinc/savvy-cli/cmd/internal" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | // rootCmd represents the base command when called without any subcommands 12 | var rootCmd = &cobra.Command{ 13 | Use: "savvy", 14 | Short: "Create, share and discover runbooks from the command line", 15 | Long: `Create, share and discover runbooks from the command line`, 16 | PersistentPreRun: func(cmd *cobra.Command, _ []string) { 17 | logLevel := slog.LevelInfo 18 | if debugFlag { 19 | logLevel = slog.LevelDebug 20 | } 21 | textHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 22 | AddSource: true, 23 | Level: logLevel, 24 | }) 25 | logger := slog.New(textHandler) 26 | slog.SetDefault(logger) 27 | cmd.SetContext(ctxWithLogger(cmd.Context(), logger)) 28 | }, 29 | // Uncomment the following line if your bare application 30 | // has an action associated with it: 31 | // Run: func(cmd *cobra.Command, args []string) { }, 32 | } 33 | 34 | var debugFlag bool 35 | 36 | // Execute adds all child commands to the root command and sets flags appropriately. 37 | // This is called by main.main(). It only needs to happen once to the rootCmd. 38 | func Execute() { 39 | err := rootCmd.Execute() 40 | if err != nil { 41 | os.Exit(1) 42 | } 43 | } 44 | 45 | func init() { 46 | // Here you will define your flags and configuration settings. 47 | // Cobra supports persistent flags, which, if defined here, 48 | // will be global for your application. 49 | 50 | // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.savvy.yaml)") 51 | rootCmd.PersistentFlags().BoolVarP(&debugFlag, "debug", "d", false, "Enable debug mode") 52 | rootCmd.AddCommand(internal.InternalCmd) 53 | 54 | // Cobra also supports local flags, which will only run 55 | // when this action is called directly. 56 | // rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") 57 | } 58 | -------------------------------------------------------------------------------- /cmd/setup/fish.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | //go:embed savvy.fish 11 | var fishSetupFiles embed.FS 12 | 13 | const fishSetupScriptName = "savvy.fish" 14 | 15 | // fishCmd represents the fish command 16 | var FishCmd = &cobra.Command{ 17 | Use: "fish", 18 | Short: "Output shell setup for fish", 19 | Long: `Output shell setup for bash`, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | content, err := fishSetupFiles.ReadFile(fishSetupScriptName) 22 | if err != nil { 23 | return err 24 | } 25 | fmt.Println(string(content)) 26 | return nil 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /cmd/setup/zsh.go: -------------------------------------------------------------------------------- 1 | package setup 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | //go:embed savvy.zsh 11 | var zshSetupScript embed.FS 12 | 13 | const zshSetupScriptName = "savvy.zsh" 14 | 15 | // initCmd represents the init command 16 | var ZshCmd = &cobra.Command{ 17 | Use: "zsh", 18 | Short: "Output shell setup for zsh", 19 | Long: `Output shell setup for zsh`, 20 | RunE: func(cmd *cobra.Command, args []string) error { 21 | content, err := zshSetupScript.ReadFile(zshSetupScriptName) 22 | if err != nil { 23 | return err 24 | } 25 | fmt.Println(string(content)) 26 | return nil 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /cmd/sync.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getsavvyinc/savvy-cli/client" 5 | "github.com/getsavvyinc/savvy-cli/storage" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // syncCmd represents the sync command 10 | var syncCmd = &cobra.Command{ 11 | Use: "sync", 12 | Short: "Create a local copy of all your Savvy Artifacts", 13 | Long: ` 14 | Create a local copy of all your Savvy Artifacts. 15 | 16 | This command will download all your artifacts from the Savvy API and store them in a local directory. 17 | You can access the artifacts in the local directory even when you are offline using savvy run --local. 18 | `, 19 | Run: syncRunbooks, 20 | } 21 | 22 | func syncRunbooks(cmd *cobra.Command, args []string) { 23 | store := map[string]*client.Runbook{} 24 | ctx := cmd.Context() 25 | logger := loggerFromCtx(ctx).With("command", "sync") 26 | 27 | var cl client.RunbookClient 28 | var err error 29 | cl, err = client.GetLoggedInClient() 30 | if err != nil { 31 | logger.Error(err.Error()) 32 | return 33 | } 34 | 35 | runbookInfo, err := cl.Runbooks(ctx, client.RunbooksOpt{ 36 | ExcludeTeamRunbooks: true, 37 | }) 38 | if err != nil { 39 | logger.Error("failed to fetch runbooks", "error", err) 40 | return 41 | } 42 | 43 | for _, rb := range runbookInfo { 44 | rb, err := cl.RunbookByID(ctx, rb.RunbookID) 45 | if err != nil { 46 | logger.Error("failed to fetch runbook", "runbook_id", rb.RunbookID, "error", err) 47 | return 48 | } 49 | 50 | store[rb.RunbookID] = rb 51 | } 52 | 53 | if err := storage.Write(store); err != nil { 54 | logger.Error("failed to write runbooks to local storage", "error", err) 55 | return 56 | } 57 | } 58 | 59 | func init() { 60 | rootCmd.AddCommand(syncCmd) 61 | } 62 | -------------------------------------------------------------------------------- /cmd/upgrade.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/getsavvyinc/savvy-cli/config" 8 | "github.com/getsavvyinc/savvy-cli/display" 9 | "github.com/getsavvyinc/upgrade-cli" 10 | "github.com/getsavvyinc/upgrade-cli/release/asset" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const owner = "getsavvyinc" 15 | const repo = "savvy-cli" 16 | 17 | // upgradeCmd represents the upgrade command 18 | var upgradeCmd = &cobra.Command{ 19 | Use: "upgrade", 20 | Short: "upgrade savvy to the latest version", 21 | Long: `upgrade savvy to the latest version`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | executablePath, err := os.Executable() 24 | if err != nil { 25 | display.Error(err) 26 | os.Exit(1) 27 | } 28 | version := config.Version() 29 | 30 | assetDownloader := asset.NewAssetDownloader(executablePath, asset.WithLookupArchFallback(map[string]string{ 31 | "amd64": "x86_64", 32 | "386": "i386", 33 | })) 34 | upgrader := upgrade.NewUpgrader(owner, repo, executablePath, upgrade.WithAssetDownloader(assetDownloader)) 35 | 36 | if ok, err := upgrader.IsNewVersionAvailable(context.Background(), version); err != nil { 37 | display.Error(err) 38 | return 39 | } else if !ok { 40 | display.Info("Savvy is already up to date") 41 | return 42 | } 43 | 44 | display.Info("Upgrading savvy...") 45 | if err := upgrader.Upgrade(context.Background(), version); err != nil { 46 | display.Error(err) 47 | os.Exit(1) 48 | } else { 49 | display.Success("Savvy has been upgraded to the latest version") 50 | } 51 | }, 52 | } 53 | 54 | func init() { 55 | rootCmd.AddCommand(upgradeCmd) 56 | } 57 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsavvyinc/savvy-cli/config" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // versionCmd represents the version command 11 | var versionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Shows the savvy cli version", 14 | Long: "Shows the savvy cli version", 15 | Run: func(_ *cobra.Command, _ []string) { 16 | fmt.Println("version:", config.Version()) 17 | }, 18 | } 19 | 20 | func init() { 21 | rootCmd.AddCommand(versionCmd) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/whoami.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/getsavvyinc/savvy-cli/client" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // whoamiCmd represents the whoami command 11 | var whoamiCmd = &cobra.Command{ 12 | Use: "whoami", 13 | Short: "Shows information about the current user", 14 | Long: "Shows information about the current user", 15 | Run: func(cmd *cobra.Command, _ []string) { 16 | cl, err := client.New() 17 | if err != nil { 18 | fmt.Println(err) 19 | return 20 | } 21 | whoami, err := cl.WhoAmI(cmd.Context()) 22 | if err != nil { 23 | cmd.PrintErrln(err) 24 | } 25 | cmd.Println(whoami) 26 | }, 27 | } 28 | 29 | func init() { 30 | rootCmd.AddCommand(whoamiCmd) 31 | } 32 | -------------------------------------------------------------------------------- /config/api_host.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var apiHost = "https://api.getsavvy.so" 4 | 5 | func APIHost() string { 6 | return apiHost 7 | } 8 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | const DefaultConfigFileName = "config.json" 11 | 12 | var ( 13 | DefaultConfigDir = os.ExpandEnv("$HOME/.config/savvy") 14 | DefaultConfigFilePath = filepath.Join(DefaultConfigDir, DefaultConfigFileName) 15 | ) 16 | 17 | type Config struct { 18 | Token string `json:"token"` 19 | LLMBaseURL string `json:"llm_base_url"` 20 | LLMModelName string `json:"llm_model_name"` 21 | LLMAPIKey string `json:"llm_api_key"` 22 | } 23 | 24 | func (c *Config) Save() error { 25 | if _, err := os.Stat(DefaultConfigDir); os.IsNotExist(err) { 26 | if err := os.MkdirAll(DefaultConfigDir, 0755); err != nil { 27 | return err 28 | } 29 | } 30 | 31 | f, err := os.Create(DefaultConfigFilePath) 32 | if err != nil { 33 | return err 34 | } 35 | defer f.Close() 36 | if err := json.NewEncoder(f).Encode(c); err != nil { 37 | return err 38 | } 39 | return nil 40 | } 41 | 42 | func LoadFromFile() (*Config, error) { 43 | f, err := os.Open(DefaultConfigFilePath) 44 | if err != nil { 45 | return nil, err 46 | } 47 | defer f.Close() 48 | 49 | var c Config 50 | if err := json.NewDecoder(f).Decode(&c); err != nil { 51 | return nil, err 52 | } 53 | 54 | if err := c.validate(); err != nil { 55 | return nil, err 56 | } 57 | 58 | return &c, nil 59 | } 60 | 61 | var ErrMissingToken = errors.New("missing or empty token") 62 | 63 | func (c *Config) validate() error { 64 | if c.Token == "" { 65 | return ErrMissingToken 66 | } 67 | 68 | if c.LLMBaseURL != "" && c.LLMModelName == "" { 69 | return errors.New("missing or empty llm_model_name") 70 | } 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /config/dashboard_host.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | var dashboardHost = "https://app.getsavvy.so" 4 | 5 | func DashboardHost() string { 6 | return dashboardHost 7 | } 8 | -------------------------------------------------------------------------------- /config/host_dev.go: -------------------------------------------------------------------------------- 1 | //go:build dev 2 | 3 | package config 4 | 5 | func init() { 6 | apiHost = "http://localhost:8080" 7 | dashboardHost = "http://localhost:5173" 8 | } 9 | -------------------------------------------------------------------------------- /config/version.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | // version is the version of the CLI 4 | // version is set via ldflags at build time 5 | var version string 6 | 7 | func Version() string { 8 | return version 9 | } 10 | -------------------------------------------------------------------------------- /demos/ask-command.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/ask-command.gif -------------------------------------------------------------------------------- /demos/ask-runbook.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/ask-runbook.gif -------------------------------------------------------------------------------- /demos/savvy-explain-errors.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-explain-errors.gif -------------------------------------------------------------------------------- /demos/savvy-explain-openssl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-explain-openssl.gif -------------------------------------------------------------------------------- /demos/savvy-history.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-history.gif -------------------------------------------------------------------------------- /demos/savvy-param-dashboard.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-param-dashboard.jpeg -------------------------------------------------------------------------------- /demos/savvy-param-run.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-param-run.jpeg -------------------------------------------------------------------------------- /demos/savvy-run.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-run.gif -------------------------------------------------------------------------------- /demos/savvy-sync.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/demos/savvy-sync.gif -------------------------------------------------------------------------------- /display/error.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | ) 9 | 10 | var style = lipgloss.NewStyle(). 11 | Bold(true). 12 | PaddingTop(1). 13 | Foreground(lipgloss.Color("9")) 14 | 15 | // Error prints the error and any additional messages to the terminal 16 | func Error(err error, msgs ...string) { 17 | // be defensive 18 | if err == nil { 19 | return 20 | } 21 | 22 | errMsg := err.Error() 23 | if errMsg == "" { 24 | return 25 | } 26 | 27 | ErrorMsg(err.Error()) 28 | if len(msgs) > 0 { 29 | ErrorMsg(msgs...) 30 | } 31 | } 32 | 33 | func ErrorMsg(msgs ...string) { 34 | for _, msg := range msgs { 35 | fmt.Println(style.Render(msg)) 36 | } 37 | } 38 | 39 | func FatalErr(err error, msgs ...string) { 40 | Error(err, msgs...) 41 | os.Exit(1) 42 | } 43 | 44 | func FatalErrWithSupportCTA(err error) { 45 | Error(err, supportCTA) 46 | os.Exit(1) 47 | } 48 | 49 | const supportCTA = `Stuck? We're here to make things easier for you. Just email us at support@getsavvy.so or join our friendly Discord community (https://getsavvy.so/discord) for a chat.` 50 | 51 | func ErrorWithSupportCTA(err error) { 52 | Error(err, supportCTA) 53 | } 54 | -------------------------------------------------------------------------------- /display/info.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var infoStyle = lipgloss.NewStyle(). 10 | Bold(false). 11 | PaddingTop(1). 12 | PaddingBottom(1) 13 | 14 | func Info(text string) { 15 | fmt.Println(infoStyle.Render(text)) 16 | } 17 | 18 | func Infof(format string, a ...interface{}) { 19 | Info(fmt.Sprintf(format, a...)) 20 | } 21 | -------------------------------------------------------------------------------- /display/success.go: -------------------------------------------------------------------------------- 1 | package display 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | var successStyle = lipgloss.NewStyle(). 10 | Bold(true). 11 | PaddingTop(1). 12 | Foreground(lipgloss.Color("2")) 13 | 14 | func Success(text string) { 15 | fmt.Println(successStyle.Render(text)) 16 | } 17 | 18 | func Successf(format string, args ...any) { 19 | text := fmt.Sprintf(format, args...) 20 | fmt.Println(successStyle.Render(text)) 21 | } 22 | -------------------------------------------------------------------------------- /extension/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/getsavvyinc/savvy-cli/cmd/browser" 11 | "github.com/getsavvyinc/savvy-cli/extension" 12 | ) 13 | 14 | func main() { 15 | 16 | browser.OpenExtensionSidePanel() 17 | // Create a context that we'll cancel on signal 18 | ctx, cancel := context.WithCancel(context.Background()) 19 | defer cancel() 20 | 21 | // Set up signal handling 22 | sigChan := make(chan os.Signal, 1) 23 | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) 24 | 25 | // Create and start the server 26 | processor := func(items []extension.HistoryItem) error { 27 | for _, item := range items { 28 | fmt.Printf("Processing item: %s\n", item.URL) 29 | } 30 | return nil 31 | } 32 | 33 | server := extension.New(processor) 34 | if err := server.Start(ctx); err != nil { 35 | fmt.Printf("Error starting server: %v\n", err) 36 | os.Exit(1) 37 | } 38 | 39 | // Wait for signal 40 | sig := <-sigChan 41 | fmt.Printf("\nReceived signal: %v\n", sig) 42 | fmt.Println("Shutting down server...") 43 | 44 | // Cancel context to trigger graceful shutdown 45 | cancel() 46 | 47 | // Wait for server to close 48 | if err := server.Close(); err != nil { 49 | fmt.Printf("Error during shutdown: %v\n", err) 50 | os.Exit(1) 51 | } 52 | 53 | fmt.Println("Server shutdown complete") 54 | } 55 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1705309234, 9 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1708477888, 24 | "narHash": "sha256-9KQw3zU3K49GjN66MgFB1FtIwtWWC6kpZ9xS9QmqvEs=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "4b701257ce81feec45c50a38054589ad3cce0c76", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "repo": "nixpkgs", 33 | "type": "github" 34 | } 35 | }, 36 | "root": { 37 | "inputs": { 38 | "flake-utils": "flake-utils", 39 | "nixpkgs": "nixpkgs" 40 | } 41 | }, 42 | "systems": { 43 | "locked": { 44 | "lastModified": 1681028828, 45 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 46 | "owner": "nix-systems", 47 | "repo": "default", 48 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "nix-systems", 53 | "repo": "default", 54 | "type": "github" 55 | } 56 | } 57 | }, 58 | "root": "root", 59 | "version": 7 60 | } 61 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Savvy"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem 11 | (system: 12 | let pkgs = nixpkgs.legacyPackages.${system}; in 13 | { 14 | devShells.default = pkgs.mkShell { 15 | buildInputs = [ 16 | #pkgs.go (doesn't install 1.21.6 yet) 17 | pkgs.gotools 18 | pkgs.gopls 19 | pkgs.go-outline 20 | pkgs.gopkgs 21 | pkgs.gocode-gomod 22 | pkgs.godef 23 | pkgs.golint 24 | pkgs.goose 25 | pkgs.cobra-cli 26 | pkgs.cowsay 27 | pkgs.git 28 | ]; 29 | 30 | shellHook = '' 31 | cowsay "Savvy CLI!" 32 | ''; 33 | }; 34 | } 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /idgen/gen.go: -------------------------------------------------------------------------------- 1 | package idgen 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | ) 7 | 8 | const ( 9 | CommandPrefix = "cmd-" 10 | FilePrefix = "f-" 11 | LLMTagPrefix = "llm-" 12 | ) 13 | 14 | func New(prefix string) string { 15 | bytes := make([]byte, 8) 16 | if _, err := rand.Read(bytes); err != nil { 17 | panic(err) 18 | } 19 | return prefix + hex.EncodeToString(bytes) 20 | } 21 | -------------------------------------------------------------------------------- /llm/llm.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | type Runbook struct { 4 | Title string 5 | Steps []RunbookStep 6 | } 7 | 8 | type StepTypeEnum string 9 | 10 | const ( 11 | StepTypeCode StepTypeEnum = "code" 12 | StepTypeFile StepTypeEnum = "file" 13 | ) 14 | 15 | type RunbookStep struct { 16 | Type StepTypeEnum `json:"type"` 17 | Description string `json:"description"` 18 | Command string `json:"command"` 19 | CommandID string `json:"command_id"` 20 | } 21 | -------------------------------------------------------------------------------- /llm/streamer.go: -------------------------------------------------------------------------------- 1 | package llm 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sashabaranov/go-openai" 7 | ) 8 | 9 | type ResponseStreamer interface { 10 | Recv() ([]byte, error) 11 | Close() error 12 | } 13 | 14 | type StreamData struct { 15 | Data string `json:"data"` 16 | } 17 | 18 | type streamer struct { 19 | stream *openai.ChatCompletionStream 20 | } 21 | 22 | // NewStreamer creates a new streamer. 23 | func NewStreamer(stream *openai.ChatCompletionStream) ResponseStreamer { 24 | return &streamer{stream: stream} 25 | } 26 | 27 | // Recv reads the next response from the stream. 28 | // Recv blocks until it receives a response or an error occurs. 29 | // Recv returns io.EOF when the stream has been closed. 30 | func (s *streamer) Recv() ([]byte, error) { 31 | completion, err := s.stream.Recv() 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | if len(completion.Choices) == 0 { 37 | return nil, errors.New("no completions returned") 38 | } 39 | 40 | data := completion.Choices[0].Delta.Content 41 | 42 | return []byte(data), nil 43 | } 44 | 45 | // Close closes the stream and releases any resources associated with it. 46 | // Close should be called when the caller is done with the stream. 47 | func (s *streamer) Close() error { 48 | if s.stream == nil { 49 | return nil 50 | } 51 | return s.stream.Close() 52 | } 53 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/getsavvyinc/savvy-cli/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /model/code_info.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type CodeInfo struct { 4 | Code string `json:"code"` 5 | Tags map[string]string `json:"tags,omitempty"` 6 | FileData []byte `json:"file_data,omitempty"` 7 | FileName string `json:"file_name,omitempty"` 8 | } 9 | -------------------------------------------------------------------------------- /model/command.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "io/fs" 4 | 5 | type RecordedCommand struct { 6 | Command string `json:"command"` 7 | Prompt string `json:"prompt,omitempty"` 8 | FileInfo *FileInfo `json:"file_info,omitempty"` 9 | } 10 | 11 | type FileInfo struct { 12 | Mode fs.FileMode `json:"mode,omitempty"` 13 | Content []byte `json:"content,omitempty"` 14 | Path string `json:"path,omitempty"` 15 | } 16 | -------------------------------------------------------------------------------- /model/question_info.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type QuestionInfo struct { 4 | Question string `json:"question"` 5 | Tags map[string]string `json:"tags,omitempty"` 6 | FileData []byte `json:"file_data,omitempty"` 7 | FileName string `json:"file_name,omitempty"` 8 | PreviousQuestions []string `json:"previous_questions,omitempty"` 9 | PreviousCommands []string `json:"previous_commands,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /param/param.go: -------------------------------------------------------------------------------- 1 | package param 2 | 3 | import "regexp" 4 | 5 | var paramRegex = regexp.MustCompile(`<([a-zA-Z0-9-_]+)>`) 6 | 7 | func Extract(input string) []string { 8 | // Define a regular expression to match parameters in the form of 9 | matches := paramRegex.FindAllStringSubmatch(input, -1) 10 | 11 | seen := make(map[string]struct{}) 12 | // Extract the matched parameters 13 | var params []string 14 | for _, match := range matches { 15 | if len(match) >= 1 { 16 | if _, ok := seen[match[0]]; ok { 17 | continue 18 | } 19 | seen[match[0]] = struct{}{} 20 | params = append(params, match[0]) 21 | } 22 | } 23 | return params 24 | } 25 | -------------------------------------------------------------------------------- /param/param_test.go: -------------------------------------------------------------------------------- 1 | package param_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/getsavvyinc/savvy-cli/param" 8 | ) 9 | 10 | func TestExtractParams(t *testing.T) { 11 | 12 | testCases := []struct { 13 | name string 14 | input string 15 | expected []string 16 | }{ 17 | { 18 | name: "no params", 19 | input: "No parameters here!", 20 | }, 21 | { 22 | name: "param with alphabets", 23 | input: `jobrunner.sh --file=""`, 24 | expected: []string{""}, 25 | }, 26 | { 27 | name: "param with numbers", 28 | input: `script --id="" --name=""`, 29 | expected: []string{"", ""}, 30 | }, 31 | { 32 | name: "incomplete param", 33 | input: "Edge case with incomplete ", 34 | expected: []string{""}, 35 | }, 36 | { 37 | name: "param with hyphen, underscore", 38 | input: `script --id="" --name=""`, 39 | expected: []string{"", ""}, 40 | }, 41 | { 42 | name: "dedupe params", 43 | input: `script --id="" --name=""`, 44 | expected: []string{""}, 45 | }, 46 | } 47 | 48 | for _, tc := range testCases { 49 | t.Run(tc.name, func(t *testing.T) { 50 | actual := param.Extract(tc.input) 51 | if !reflect.DeepEqual(actual, tc.expected) { 52 | t.Errorf("expected %v; got %v", tc.expected, actual) 53 | } 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /redact/redact.go: -------------------------------------------------------------------------------- 1 | package redact 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/charmbracelet/huh" 8 | "github.com/getsavvyinc/savvy-cli/server" 9 | "github.com/getsavvyinc/savvy-cli/slice" 10 | "github.com/getsavvyinc/savvy-cli/theme" 11 | ) 12 | 13 | // Commands allows users to redact one or more commands. 14 | // Commands returns a new slice of commands with the sensitive data redacted or removed. 15 | // 16 | // NOTE: It is possible for users to completely remove some commands. 17 | func Commands(cmds []*server.RecordedCommand) ([]*server.RecordedCommand, error) { 18 | var fs []huh.Field 19 | description := "Replace sensitive data with . To remove a command, simply delete the text." 20 | note := huh.NewNote().Title("Redact Secrets and PII").Description(description) 21 | fs = append(fs, note) 22 | 23 | for i, cmd := range cmds { 24 | fs = append(fs, RedactCommand(cmd.Command, strconv.Itoa(i))) 25 | } 26 | 27 | customTheme := theme.New() 28 | 29 | group := huh.NewGroup(fs...).Title("Redact Commands").WithTheme(customTheme) 30 | 31 | if err := huh.NewForm(group).WithTheme(customTheme).Run(); err != nil { 32 | err := fmt.Errorf("failed to run redaction form: %w", err) 33 | return nil, err 34 | } 35 | 36 | for _, f := range fs { 37 | in, ok := f.(*huh.Input) 38 | if !ok { 39 | continue 40 | } 41 | strVal, ok := in.GetValue().(string) 42 | if !ok { 43 | continue 44 | } 45 | 46 | idx, err := strconv.Atoi(in.GetKey()) 47 | if err != nil { 48 | continue 49 | } 50 | cmds[idx].Command = strVal 51 | } 52 | 53 | redacted := slice.Filter(cmds, func(cmd *server.RecordedCommand) bool { 54 | return cmd.Command != "" || cmd.FileInfo != nil 55 | }) 56 | return redacted, nil 57 | } 58 | 59 | func RedactCommand(cmd string, key string) huh.Field { 60 | return huh.NewInput().Value(&cmd).Key(key).WithTheme(theme.New()) 61 | } 62 | -------------------------------------------------------------------------------- /savvy-extension/.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /savvy-extension/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | tailwind.config.ts 4 | -------------------------------------------------------------------------------- /savvy-extension/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:react/recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended", 12 | "plugin:import/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "plugin:tailwindcss/recommended", 15 | "prettier", 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaFeatures": { 20 | "jsx": true, 21 | }, 22 | "ecmaVersion": "latest", 23 | "sourceType": "module", 24 | }, 25 | "plugins": ["react", "@typescript-eslint", "react-hooks", "import", "jsx-a11y", "prettier"], 26 | "settings": { 27 | "react": { 28 | "version": "detect", 29 | }, 30 | }, 31 | "rules": { 32 | "react/react-in-jsx-scope": "off", 33 | "import/no-unresolved": "off", 34 | "@typescript-eslint/consistent-type-imports": "error", 35 | }, 36 | "globals": { 37 | "chrome": "readonly", 38 | }, 39 | "ignorePatterns": ["watch.js", "dist/**", "**/components/ui/*.tsx"], 40 | } 41 | -------------------------------------------------------------------------------- /savvy-extension/.example.env: -------------------------------------------------------------------------------- 1 | VITE_EXAMPLE=example -------------------------------------------------------------------------------- /savvy-extension/.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | **/node_modules 3 | 4 | # nix 5 | .direnv 6 | 7 | # testing 8 | **/coverage 9 | 10 | # build 11 | **/dist 12 | **/build 13 | **/dist-zip 14 | 15 | # env 16 | **/.env.* 17 | **/.env 18 | 19 | # etc 20 | .DS_Store 21 | .idea 22 | **/.turbo 23 | 24 | # compiled 25 | chrome-extension/public/manifest.json 26 | **/tailwind-output.css 27 | -------------------------------------------------------------------------------- /savvy-extension/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm dlx lint-staged --allow-empty 2 | -------------------------------------------------------------------------------- /savvy-extension/.npmrc: -------------------------------------------------------------------------------- 1 | public-hoist-pattern[]=@testing-library/dom 2 | engine-strict=true -------------------------------------------------------------------------------- /savvy-extension/.nvmrc: -------------------------------------------------------------------------------- 1 | 22.12.0 2 | -------------------------------------------------------------------------------- /savvy-extension/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .gitignore 4 | .github 5 | .eslintignore 6 | .husky 7 | .nvmrc 8 | .prettierignore 9 | LICENSE 10 | *.md 11 | pnpm-lock.yaml -------------------------------------------------------------------------------- /savvy-extension/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "singleQuote": true, 5 | "arrowParens": "avoid", 6 | "printWidth": 120, 7 | "bracketSameLine": true, 8 | "htmlWhitespaceSensitivity": "strict" 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Seo Jong Hak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /savvy-extension/UPDATE-PACKAGE-VERSIONS.md: -------------------------------------------------------------------------------- 1 | For update package version in all ```package.json``` files use this command in root: 2 | 3 | FOR WINDOWS YOU NEED TO USE E.G ```GIT BASH``` CONSOLE OR OTHER WHICH SUPPORT UNIX COMMANDS 4 | ```bash 5 | pnpm update-version 6 | ``` 7 | 8 | If script was run successfully you will see ```Updated versions to ``` 9 | -------------------------------------------------------------------------------- /savvy-extension/assets/images/cli-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/assets/images/cli-extension.png -------------------------------------------------------------------------------- /savvy-extension/assets/images/export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/assets/images/export.png -------------------------------------------------------------------------------- /savvy-extension/assets/images/select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/assets/images/select.png -------------------------------------------------------------------------------- /savvy-extension/assets/images/time-range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/assets/images/time-range.png -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrome-extension", 3 | "version": "0.3.5", 4 | "description": "chrome extension - core settings", 5 | "type": "module", 6 | "scripts": { 7 | "clean:node_modules": "pnpx rimraf node_modules", 8 | "clean:turbo": "rimraf .turbo", 9 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 10 | "build": "vite build", 11 | "dev": "cross-env __DEV__=true vite build --mode development", 12 | "test": "vitest run", 13 | "lint": "eslint ./ --ext .ts,.js,.tsx,.jsx", 14 | "lint:fix": "pnpm lint --fix", 15 | "prettier": "prettier . --write --ignore-path ../.prettierignore", 16 | "type-check": "tsc --noEmit" 17 | }, 18 | "dependencies": { 19 | "webextension-polyfill": "^0.12.0", 20 | "@extension/shared": "workspace:*", 21 | "@extension/storage": "workspace:*" 22 | }, 23 | "devDependencies": { 24 | "@extension/dev-utils": "workspace:*", 25 | "@extension/hmr": "workspace:*", 26 | "@extension/tsconfig": "workspace:*", 27 | "@extension/vite-config": "workspace:*", 28 | "@laynezh/vite-plugin-lib-assets": "^0.6.1", 29 | "@types/ws": "^8.5.13", 30 | "magic-string": "^0.30.10", 31 | "ts-loader": "^9.5.1", 32 | "deepmerge": "^4.3.1", 33 | "cross-env": "^7.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/public/content.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/chrome-extension/public/content.css -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/chrome-extension/public/icon-128.png -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/public/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/chrome-extension/public/icon-34.png -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/public/icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/chrome-extension/public/icon-48.png -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/public/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/chrome-extension/public/icon-64.png -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/src/background/index.ts: -------------------------------------------------------------------------------- 1 | import 'webextension-polyfill'; 2 | 3 | // Handle extension icon click 4 | chrome.action.onClicked.addListener(async tab => { 5 | // Open the side panel 6 | await chrome.sidePanel.open({ windowId: tab.windowId }); 7 | }); 8 | 9 | // Make side panel persist across all sites 10 | chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }); 11 | 12 | // Set the side panel to be available on all URLs 13 | chrome.tabs.onUpdated.addListener((tabId, info) => { 14 | if (info.status === 'complete') { 15 | chrome.sidePanel.setOptions({ 16 | tabId, 17 | path: 'side-panel/index.html', 18 | enabled: true, 19 | }); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/app", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | } 8 | }, 9 | "include": ["src", "utils", "vite.config.mts", "../node_modules/@types"] 10 | } 11 | -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/utils/refresh.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | (function () { 3 | 'use strict'; 4 | // This is the custom ID for HMR (chrome-extension/vite.config.mts) 5 | const __HMR_ID = 'chrome-extension-hmr'; 6 | 7 | const LOCAL_RELOAD_SOCKET_PORT = 8081; 8 | const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 9 | 10 | const DO_UPDATE = 'do_update'; 11 | const DONE_UPDATE = 'done_update'; 12 | 13 | class MessageInterpreter { 14 | // eslint-disable-next-line @typescript-eslint/no-empty-function 15 | constructor() {} 16 | 17 | static send(message) { 18 | return JSON.stringify(message); 19 | } 20 | 21 | static receive(serializedMessage) { 22 | return JSON.parse(serializedMessage); 23 | } 24 | } 25 | 26 | function initClient({ id, onUpdate }) { 27 | const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 28 | 29 | ws.onopen = () => { 30 | ws.addEventListener('message', event => { 31 | const message = MessageInterpreter.receive(String(event.data)); 32 | 33 | if (message.type === DO_UPDATE && message.id === id) { 34 | onUpdate(); 35 | ws.send(MessageInterpreter.send({ type: DONE_UPDATE })); 36 | return; 37 | } 38 | }); 39 | }; 40 | } 41 | 42 | function addRefresh() { 43 | let pendingReload = false; 44 | 45 | initClient({ 46 | id: __HMR_ID, 47 | onUpdate: () => { 48 | // disable reload when tab is hidden 49 | if (document.hidden) { 50 | pendingReload = true; 51 | return; 52 | } 53 | reload(); 54 | }, 55 | }); 56 | 57 | // reload 58 | function reload() { 59 | pendingReload = false; 60 | window.location.reload(); 61 | } 62 | 63 | // reload when tab is visible 64 | function reloadWhenTabIsVisible() { 65 | !document.hidden && pendingReload && reload(); 66 | } 67 | 68 | document.addEventListener('visibilitychange', reloadWhenTabIsVisible); 69 | } 70 | 71 | addRefresh(); 72 | })(); 73 | -------------------------------------------------------------------------------- /savvy-extension/chrome-extension/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { defineConfig, type PluginOption } from "vite"; 3 | import libAssetsPlugin from '@laynezh/vite-plugin-lib-assets'; 4 | import makeManifestPlugin from './utils/plugins/make-manifest-plugin'; 5 | import { watchPublicPlugin, watchRebuildPlugin } from '@extension/hmr'; 6 | import { isDev, isProduction, watchOption } from '@extension/vite-config'; 7 | 8 | const rootDir = resolve(__dirname); 9 | const srcDir = resolve(rootDir, 'src'); 10 | 11 | const outDir = resolve(rootDir, '..', 'dist'); 12 | export default defineConfig({ 13 | resolve: { 14 | alias: { 15 | '@root': rootDir, 16 | '@src': srcDir, 17 | '@assets': resolve(srcDir, 'assets'), 18 | }, 19 | }, 20 | plugins: [ 21 | libAssetsPlugin({ 22 | outputPath: outDir, 23 | }) as PluginOption, 24 | watchPublicPlugin(), 25 | makeManifestPlugin({ outDir }), 26 | isDev && watchRebuildPlugin({ reload: true, id: 'chrome-extension-hmr' }), 27 | ], 28 | publicDir: resolve(rootDir, 'public'), 29 | build: { 30 | lib: { 31 | formats: ['iife'], 32 | entry: resolve(__dirname, 'src/background/index.ts'), 33 | name: 'BackgroundScript', 34 | fileName: 'background', 35 | }, 36 | outDir, 37 | emptyOutDir: false, 38 | sourcemap: isDev, 39 | minify: isProduction, 40 | reportCompressedSize: isProduction, 41 | watch: watchOption, 42 | rollupOptions: { 43 | external: ['chrome'], 44 | }, 45 | }, 46 | envDir: '../', 47 | }); 48 | -------------------------------------------------------------------------------- /savvy-extension/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1734988233, 24 | "narHash": "sha256-Ucfnxq1rF/GjNP3kTL+uTfgdoE9a3fxDftSfeLIS8mA=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "de1864217bfa9b5845f465e771e0ecb48b30e02d", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "repo": "nixpkgs", 33 | "rev": "de1864217bfa9b5845f465e771e0ecb48b30e02d", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /savvy-extension/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Chrome extension development environment"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/de1864217bfa9b5845f465e771e0ecb48b30e02d"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | pkgs = import nixpkgs { 13 | inherit system; 14 | }; 15 | in 16 | { 17 | devShells.default = pkgs.mkShell { 18 | buildInputs = with pkgs; [ 19 | # Node.js 23 20 | nodejs_23 21 | # TypeScript and development tools 22 | nodePackages.typescript 23 | nodePackages.typescript-language-server 24 | #nodePackages.vite 25 | 26 | # Additional useful tools 27 | nodePackages.npm 28 | ]; 29 | 30 | shellHook = '' 31 | # Only run the initialization once 32 | if [ -z "$IN_NIX_SHELL_INIT" ]; then 33 | export IN_NIX_SHELL_INIT=1 34 | 35 | echo "Chrome Extension Development Environment" 36 | echo "Available tools:" 37 | echo "- Node.js $(node --version)" 38 | echo "- npm $(npm --version)" 39 | echo "- TypeScript $(tsc --version)" 40 | echo "- Vite $(vite --version)" 41 | 42 | # Use system zsh with user's existing configuration 43 | if [ -x "$(command -v zsh)" ]; then 44 | exec zsh 45 | else 46 | echo "zsh not found, falling back to default shell" 47 | fi 48 | fi 49 | ''; 50 | }; 51 | } 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/manifest-parser'; 2 | export * from './lib/logger'; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import type { ValueOf } from '@extension/shared'; 2 | 3 | type ColorType = 'success' | 'info' | 'error' | 'warning' | keyof typeof COLORS; 4 | 5 | export function colorLog(message: string, type: ColorType) { 6 | let color: ValueOf; 7 | 8 | switch (type) { 9 | case 'success': 10 | color = COLORS.FgGreen; 11 | break; 12 | case 'info': 13 | color = COLORS.FgBlue; 14 | break; 15 | case 'error': 16 | color = COLORS.FgRed; 17 | break; 18 | case 'warning': 19 | color = COLORS.FgYellow; 20 | break; 21 | default: 22 | color = COLORS[type]; 23 | break; 24 | } 25 | 26 | console.log(color, message); 27 | } 28 | 29 | const COLORS = { 30 | Reset: '\x1b[0m', 31 | Bright: '\x1b[1m', 32 | Dim: '\x1b[2m', 33 | Underscore: '\x1b[4m', 34 | Blink: '\x1b[5m', 35 | Reverse: '\x1b[7m', 36 | Hidden: '\x1b[8m', 37 | FgBlack: '\x1b[30m', 38 | FgRed: '\x1b[31m', 39 | FgGreen: '\x1b[32m', 40 | FgYellow: '\x1b[33m', 41 | FgBlue: '\x1b[34m', 42 | FgMagenta: '\x1b[35m', 43 | FgCyan: '\x1b[36m', 44 | FgWhite: '\x1b[37m', 45 | BgBlack: '\x1b[40m', 46 | BgRed: '\x1b[41m', 47 | BgGreen: '\x1b[42m', 48 | BgYellow: '\x1b[43m', 49 | BgBlue: '\x1b[44m', 50 | BgMagenta: '\x1b[45m', 51 | BgCyan: '\x1b[46m', 52 | BgWhite: '\x1b[47m', 53 | } as const; 54 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/lib/manifest-parser/impl.ts: -------------------------------------------------------------------------------- 1 | import type { ManifestParserInterface, Manifest } from './type'; 2 | 3 | export const ManifestParserImpl: ManifestParserInterface = { 4 | convertManifestToString: (manifest, env) => { 5 | if (env === 'firefox') { 6 | manifest = convertToFirefoxCompatibleManifest(manifest); 7 | } 8 | return JSON.stringify(manifest, null, 2); 9 | }, 10 | }; 11 | 12 | function convertToFirefoxCompatibleManifest(manifest: Manifest) { 13 | const manifestCopy = { 14 | ...manifest, 15 | } as { [key: string]: unknown }; 16 | 17 | manifestCopy.background = { 18 | scripts: [manifest.background?.service_worker], 19 | type: 'module', 20 | }; 21 | manifestCopy.options_ui = { 22 | page: manifest.options_page, 23 | browser_style: false, 24 | }; 25 | manifestCopy.content_security_policy = { 26 | extension_pages: "script-src 'self'; object-src 'self'", 27 | }; 28 | manifestCopy.browser_specific_settings = { 29 | gecko: { 30 | id: 'example@example.com', 31 | strict_min_version: '109.0', 32 | }, 33 | }; 34 | delete manifestCopy.options_page; 35 | return manifestCopy as Manifest; 36 | } 37 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/lib/manifest-parser/index.ts: -------------------------------------------------------------------------------- 1 | import { ManifestParserImpl } from './impl'; 2 | export const ManifestParser = ManifestParserImpl; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/lib/manifest-parser/type.ts: -------------------------------------------------------------------------------- 1 | export type Manifest = chrome.runtime.ManifestV3; 2 | 3 | export interface ManifestParserInterface { 4 | convertManifestToString: (manifest: Manifest, env: 'chrome' | 'firefox') => string; 5 | } 6 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/dev-utils", 3 | "version": "0.3.5", 4 | "description": "chrome extension - dev utils", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "ready": "tsc", 19 | "lint": "eslint . --ext .ts,.tsx", 20 | "lint:fix": "pnpm lint --fix", 21 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 22 | "type-check": "tsc --noEmit" 23 | }, 24 | "devDependencies": { 25 | "@extension/tsconfig": "workspace:*", 26 | "@extension/shared": "workspace:*" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /savvy-extension/packages/dev-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome", "node"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/plugins'; 2 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/constant.ts: -------------------------------------------------------------------------------- 1 | export const LOCAL_RELOAD_SOCKET_PORT = 8081; 2 | export const LOCAL_RELOAD_SOCKET_URL = `ws://localhost:${LOCAL_RELOAD_SOCKET_PORT}`; 3 | 4 | export const DO_UPDATE = 'do_update'; 5 | export const DONE_UPDATE = 'done_update'; 6 | export const BUILD_COMPLETE = 'build_complete'; 7 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/initializers/initClient.ts: -------------------------------------------------------------------------------- 1 | import { DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_URL } from '../constant'; 2 | import MessageInterpreter from '../interpreter'; 3 | 4 | export default function initClient({ id, onUpdate }: { id: string; onUpdate: () => void }) { 5 | const ws = new WebSocket(LOCAL_RELOAD_SOCKET_URL); 6 | 7 | ws.onopen = () => { 8 | ws.addEventListener('message', event => { 9 | const message = MessageInterpreter.receive(String(event.data)); 10 | 11 | if (message.type === DO_UPDATE && message.id === id) { 12 | onUpdate(); 13 | ws.send(MessageInterpreter.send({ type: DONE_UPDATE })); 14 | return; 15 | } 16 | }); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/initializers/initReloadServer.ts: -------------------------------------------------------------------------------- 1 | import type { WebSocket } from 'ws'; 2 | import { WebSocketServer } from 'ws'; 3 | import { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE, LOCAL_RELOAD_SOCKET_PORT, LOCAL_RELOAD_SOCKET_URL } from '../constant'; 4 | import MessageInterpreter from '../interpreter'; 5 | 6 | const clientsThatNeedToUpdate: Set = new Set(); 7 | 8 | function initReloadServer() { 9 | const wss = new WebSocketServer({ port: LOCAL_RELOAD_SOCKET_PORT }); 10 | 11 | wss.on('listening', () => { 12 | console.log(`[HMR] Server listening at ${LOCAL_RELOAD_SOCKET_URL}`); 13 | }); 14 | 15 | wss.on('connection', ws => { 16 | clientsThatNeedToUpdate.add(ws); 17 | 18 | ws.addEventListener('close', () => { 19 | clientsThatNeedToUpdate.delete(ws); 20 | }); 21 | 22 | ws.addEventListener('message', event => { 23 | if (typeof event.data !== 'string') return; 24 | 25 | const message = MessageInterpreter.receive(event.data); 26 | 27 | if (message.type === DONE_UPDATE) { 28 | ws.close(); 29 | } 30 | 31 | if (message.type === BUILD_COMPLETE) { 32 | clientsThatNeedToUpdate.forEach((ws: WebSocket) => 33 | ws.send(MessageInterpreter.send({ type: DO_UPDATE, id: message.id })), 34 | ); 35 | } 36 | }); 37 | }); 38 | 39 | wss.on('error', error => { 40 | console.error(`[HMR] Failed to start server at ${LOCAL_RELOAD_SOCKET_URL}`); 41 | throw error; 42 | }); 43 | } 44 | 45 | initReloadServer(); 46 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/injections/refresh.ts: -------------------------------------------------------------------------------- 1 | import initClient from '../initializers/initClient'; 2 | 3 | function addRefresh() { 4 | let pendingReload = false; 5 | 6 | initClient({ 7 | // @ts-expect-error That's because of the dynamic code loading 8 | id: __HMR_ID, 9 | onUpdate: () => { 10 | // disable reload when tab is hidden 11 | if (document.hidden) { 12 | pendingReload = true; 13 | return; 14 | } 15 | reload(); 16 | }, 17 | }); 18 | 19 | // reload 20 | function reload(): void { 21 | pendingReload = false; 22 | window.location.reload(); 23 | } 24 | 25 | // reload when tab is visible 26 | function reloadWhenTabIsVisible(): void { 27 | !document.hidden && pendingReload && reload(); 28 | } 29 | 30 | document.addEventListener('visibilitychange', reloadWhenTabIsVisible); 31 | } 32 | 33 | addRefresh(); 34 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/injections/reload.ts: -------------------------------------------------------------------------------- 1 | import initClient from '../initializers/initClient'; 2 | 3 | function addReload() { 4 | const reload = () => { 5 | chrome.runtime.reload(); 6 | }; 7 | 8 | initClient({ 9 | // @ts-expect-error That's because of the dynamic code loading 10 | id: __HMR_ID, 11 | onUpdate: reload, 12 | }); 13 | } 14 | 15 | addReload(); 16 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/interpreter/index.ts: -------------------------------------------------------------------------------- 1 | import type { SerializedMessage, WebSocketMessage } from '../types'; 2 | 3 | export default class MessageInterpreter { 4 | // eslint-disable-next-line @typescript-eslint/no-empty-function 5 | private constructor() {} 6 | 7 | static send(message: WebSocketMessage): SerializedMessage { 8 | return JSON.stringify(message); 9 | } 10 | 11 | static receive(serializedMessage: SerializedMessage): WebSocketMessage { 12 | return JSON.parse(serializedMessage); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './watch-rebuild-plugin'; 2 | export * from './make-entry-point-plugin'; 3 | export * from './watch-public-plugin'; 4 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/plugins/watch-public-plugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOption } from 'vite'; 2 | import fg from 'fast-glob'; 3 | 4 | export function watchPublicPlugin(): PluginOption { 5 | return { 6 | name: 'watch-public-plugin', 7 | async buildStart() { 8 | const files = await fg(['public/**/*']); 9 | 10 | for (const file of files) { 11 | this.addWatchFile(file); 12 | } 13 | }, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { BUILD_COMPLETE, DO_UPDATE, DONE_UPDATE } from './constant'; 2 | 3 | type UpdateRequestMessage = { 4 | type: typeof DO_UPDATE; 5 | id: string; 6 | }; 7 | 8 | type UpdateCompleteMessage = { type: typeof DONE_UPDATE }; 9 | type BuildCompletionMessage = { type: typeof BUILD_COMPLETE; id: string }; 10 | 11 | export type SerializedMessage = string; 12 | 13 | export type WebSocketMessage = UpdateCompleteMessage | UpdateRequestMessage | BuildCompletionMessage; 14 | 15 | export type PluginConfig = { 16 | onStart?: () => void; 17 | reload?: boolean; 18 | refresh?: boolean; 19 | id?: string; 20 | }; 21 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/hmr", 3 | "version": "0.3.5", 4 | "description": "chrome extension - hot module reload/refresh", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist && pnpx rimraf build", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "build:tsc": "tsc -b tsconfig.build.json", 19 | "build:rollup": "rollup --config rollup.config.mjs", 20 | "ready": "pnpm run build:tsc && pnpm run build:rollup", 21 | "dev": "node dist/lib/initializers/initReloadServer.js", 22 | "lint": "eslint . --ext .ts,.tsx", 23 | "lint:fix": "pnpm lint --fix", 24 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 25 | "type-check": "tsc --noEmit" 26 | }, 27 | "devDependencies": { 28 | "@extension/tsconfig": "workspace:*", 29 | "@rollup/plugin-sucrase": "^5.0.2", 30 | "@types/ws": "^8.5.13", 31 | "esm": "^3.2.25", 32 | "fast-glob": "^3.3.2", 33 | "rollup": "^4.24.0", 34 | "ts-node": "^10.9.2", 35 | "ws": "8.18.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import sucrase from '@rollup/plugin-sucrase'; 2 | 3 | const plugins = [ 4 | sucrase({ 5 | exclude: ['node_modules/**'], 6 | transforms: ['typescript'], 7 | }), 8 | ]; 9 | 10 | /** 11 | * @type {import("rollup").RollupOptions[]} 12 | */ 13 | export default [ 14 | { 15 | plugins, 16 | input: 'lib/injections/reload.ts', 17 | output: { 18 | format: 'iife', 19 | file: 'build/injections/reload.js', 20 | }, 21 | }, 22 | { 23 | plugins, 24 | input: 'lib/injections/refresh.ts', 25 | output: { 26 | format: 'iife', 27 | file: 'build/injections/refresh.js', 28 | }, 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "exclude": ["lib/injections/**/*"], 9 | "include": ["lib", "index.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /savvy-extension/packages/hmr/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["lib", "index.ts", "rollup.config.mjs"] 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/.gitignore: -------------------------------------------------------------------------------- 1 | lib/i18n.ts 2 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/build.dev.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { build } from './build.mjs'; 3 | 4 | const i18nPath = path.resolve('lib', 'i18n-dev.ts'); 5 | 6 | void build(i18nPath); 7 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/build.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import esbuild from 'esbuild'; 4 | import { rimraf } from 'rimraf'; 5 | 6 | /** 7 | * @param i18nPath {string} 8 | */ 9 | export async function build(i18nPath) { 10 | fs.cpSync(i18nPath, path.resolve('lib', 'i18n.ts')); 11 | 12 | await esbuild.build({ 13 | entryPoints: ['./index.ts'], 14 | tsconfig: './tsconfig.json', 15 | bundle: true, 16 | packages: 'bundle', 17 | target: 'es6', 18 | outdir: './dist', 19 | sourcemap: true, 20 | format: 'esm', 21 | }); 22 | 23 | const outDir = path.resolve('..', '..', 'dist'); 24 | const localePath = path.resolve(outDir, '_locales'); 25 | rimraf.sync(localePath); 26 | fs.cpSync(path.resolve('locales'), localePath, { recursive: true }); 27 | 28 | console.log('I18n build complete'); 29 | } 30 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/build.prod.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { build } from './build.mjs'; 3 | 4 | const i18nPath = path.resolve('lib', 'i18n-prod.ts'); 5 | 6 | void build(i18nPath); 7 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 2 | // @ts-ignore 3 | import { t as t_dev_or_prod } from './lib/i18n'; 4 | import type { t as t_dev } from './lib/i18n-dev'; 5 | 6 | export const t = t_dev_or_prod as unknown as typeof t_dev; 7 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/lib/getMessageFromLocale.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is generated by generate-i18n.mjs 3 | * Do not edit this file directly 4 | */ 5 | import enMessage from '../locales/en/messages.json'; 6 | 7 | export function getMessageFromLocale(locale: string) { 8 | switch (locale) { 9 | case 'en': 10 | return enMessage; 11 | default: 12 | throw new Error('Unsupported locale'); 13 | } 14 | } 15 | 16 | export const defaultLocale = (() => { 17 | const locales = ['en']; 18 | const firstLocale = locales[0]; 19 | const defaultLocale = Intl.DateTimeFormat().resolvedOptions().locale.replace('-', '_'); 20 | if (locales.includes(defaultLocale)) { 21 | return defaultLocale; 22 | } 23 | const defaultLocaleWithoutRegion = defaultLocale.split('_')[0]; 24 | if (locales.includes(defaultLocaleWithoutRegion)) { 25 | return defaultLocaleWithoutRegion; 26 | } 27 | return firstLocale; 28 | })(); 29 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/lib/i18n-dev.ts: -------------------------------------------------------------------------------- 1 | import type { DevLocale, MessageKey } from './type'; 2 | import { defaultLocale, getMessageFromLocale } from './getMessageFromLocale'; 3 | 4 | type I18nValue = { 5 | message: string; 6 | placeholders?: Record; 7 | }; 8 | 9 | function translate(key: MessageKey, substitutions?: string | string[]) { 10 | const value = getMessageFromLocale(t.devLocale)[key] as I18nValue; 11 | let message = value.message; 12 | /** 13 | * This is a placeholder replacement logic. But it's not perfect. 14 | * It just imitates the behavior of the Chrome extension i18n API. 15 | * Please check the official document for more information And double-check the behavior on production build. 16 | * 17 | * @url https://developer.chrome.com/docs/extensions/how-to/ui/localization-message-formats#placeholders 18 | */ 19 | if (value.placeholders) { 20 | Object.entries(value.placeholders).forEach(([key, { content }]) => { 21 | if (!content) { 22 | return; 23 | } 24 | message = message.replace(new RegExp(`\\$${key}\\$`, 'gi'), content); 25 | }); 26 | } 27 | if (!substitutions) { 28 | return message; 29 | } 30 | if (Array.isArray(substitutions)) { 31 | return substitutions.reduce((acc, cur, idx) => acc.replace(`$${idx + 1}`, cur), message); 32 | } 33 | return message.replace(/\$(\d+)/, substitutions); 34 | } 35 | 36 | function removePlaceholder(message: string) { 37 | return message.replace(/\$\d+/g, ''); 38 | } 39 | 40 | export const t = (...args: Parameters) => { 41 | return removePlaceholder(translate(...args)); 42 | }; 43 | 44 | t.devLocale = defaultLocale as DevLocale; 45 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/lib/i18n-prod.ts: -------------------------------------------------------------------------------- 1 | import type { DevLocale, MessageKey } from './type'; 2 | 3 | export function t(key: MessageKey, substitutions?: string | string[]) { 4 | return chrome.i18n.getMessage(key, substitutions); 5 | } 6 | 7 | t.devLocale = '' as DevLocale; // for type consistency with i18n-dev.ts 8 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/lib/type.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is generated by generate-i18n.mjs 3 | * Do not edit this file directly 4 | */ 5 | import type enMessage from '../locales/en/messages.json'; 6 | 7 | export type MessageKey = keyof typeof enMessage; 8 | 9 | export type DevLocale = 'en'; 10 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionDescription": { 3 | "description": "Extension description", 4 | "message": "Chrome extension boilerplate developed with Vite, React and Typescript" 5 | }, 6 | "extensionName": { 7 | "description": "Extension name", 8 | "message": "Chrome extension boilerplate" 9 | }, 10 | "toggleTheme": { 11 | "message": "Toggle theme" 12 | }, 13 | "loading": { 14 | "message": "Loading..." 15 | }, 16 | "greeting": { 17 | "description": "Greeting message", 18 | "message": "Hello, My name is $NAME$", 19 | "placeholders": { 20 | "name": { 21 | "content": "$1", 22 | "example": "John Doe" 23 | } 24 | } 25 | }, 26 | "hello": { 27 | "description": "Placeholder example", 28 | "message": "Hello $1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/i18n", 3 | "version": "0.3.5", 4 | "description": "chrome extension - internationalization", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "types": "index.ts", 11 | "main": "./dist/index.js", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "genenrate-i8n": "node genenrate-i18n.mjs", 18 | "ready": "pnpm genenrate-i8n && node build.dev.mjs", 19 | "build": "pnpm genenrate-i8n && node build.prod.mjs", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "@extension/hmr": "workspace:*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /savvy-extension/packages/i18n/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib", "locales"] 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/README.md: -------------------------------------------------------------------------------- 1 | # Shared Package 2 | 3 | This package contains code shared with other packages. 4 | To use the code in the package, you need to add the following to the package.json file. 5 | 6 | ```json 7 | { 8 | "dependencies": { 9 | "@extension/shared": "workspace:*" 10 | } 11 | } 12 | ``` 13 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | /** 4 | * @type { import('esbuild').BuildOptions } 5 | */ 6 | const buildOptions = { 7 | entryPoints: ['./index.ts', './lib/**/*.ts', './lib/**/*.tsx'], 8 | tsconfig: './tsconfig.json', 9 | bundle: false, 10 | target: 'es6', 11 | outdir: './dist', 12 | sourcemap: true, 13 | }; 14 | 15 | await esbuild.build(buildOptions); 16 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/hooks'; 2 | export * from './lib/hoc'; 3 | export * from './lib/utils'; 4 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hoc/index.ts: -------------------------------------------------------------------------------- 1 | import { withSuspense } from './withSuspense'; 2 | import { withErrorBoundary } from './withErrorBoundary'; 3 | 4 | export { withSuspense, withErrorBoundary }; 5 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hoc/withErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, ErrorInfo, ReactElement } from 'react'; 2 | import { Component } from 'react'; 3 | 4 | class ErrorBoundary extends Component< 5 | { 6 | children: ReactElement; 7 | fallback: ReactElement; 8 | }, 9 | { 10 | hasError: boolean; 11 | } 12 | > { 13 | state = { hasError: false }; 14 | 15 | static getDerivedStateFromError() { 16 | return { hasError: true }; 17 | } 18 | 19 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 20 | console.error(error, errorInfo); 21 | } 22 | 23 | render() { 24 | if (this.state.hasError) { 25 | return this.props.fallback; 26 | } 27 | 28 | return this.props.children; 29 | } 30 | } 31 | 32 | export function withErrorBoundary>( 33 | Component: ComponentType, 34 | ErrorComponent: ReactElement, 35 | ) { 36 | return function WithErrorBoundary(props: T) { 37 | return ( 38 | 39 | 40 | 41 | ); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hoc/withSuspense.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentType, ReactElement } from 'react'; 2 | import { Suspense } from 'react'; 3 | 4 | export function withSuspense>( 5 | Component: ComponentType, 6 | SuspenseComponent: ReactElement, 7 | ) { 8 | return function WithSuspense(props: T) { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useStorage'; 2 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hooks/useAPI.tsx: -------------------------------------------------------------------------------- 1 | // src/shared/api/hooks.ts 2 | import { useMemo } from 'react'; 3 | import type { AxiosInstance } from 'axios'; 4 | import axios from 'axios'; 5 | interface LocalApiHookResult { 6 | client: AxiosInstance; 7 | } 8 | 9 | export function useLocalClient(): LocalApiHookResult { 10 | const axiosInstance = useMemo( 11 | () => 12 | axios.create({ 13 | baseURL: 'http://localhost:8765', 14 | headers: { 15 | 'Content-Type': '*/*', 16 | }, 17 | }), 18 | [], 19 | ); // Empty dependency array means this will only be created once 20 | 21 | return { 22 | client: axiosInstance, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/hooks/useStorage.tsx: -------------------------------------------------------------------------------- 1 | import { useSyncExternalStore } from 'react'; 2 | import type { BaseStorage } from '@extension/storage'; 3 | 4 | type WrappedPromise = ReturnType; 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | const storageMap: Map, WrappedPromise> = new Map(); 7 | 8 | export function useStorage< 9 | Storage extends BaseStorage, 10 | Data = Storage extends BaseStorage ? Data : unknown, 11 | >(storage: Storage) { 12 | const _data = useSyncExternalStore(storage.subscribe, storage.getSnapshot); 13 | 14 | if (!storageMap.has(storage)) { 15 | storageMap.set(storage, wrapPromise(storage.get())); 16 | } 17 | if (_data !== null) { 18 | storageMap.set(storage, { read: () => _data }); 19 | } 20 | 21 | return (_data ?? storageMap.get(storage)!.read()) as Exclude>; 22 | } 23 | 24 | function wrapPromise(promise: Promise) { 25 | let status = 'pending'; 26 | let result: R; 27 | const suspender = promise.then( 28 | r => { 29 | status = 'success'; 30 | result = r; 31 | }, 32 | e => { 33 | status = 'error'; 34 | result = e; 35 | }, 36 | ); 37 | 38 | return { 39 | read() { 40 | switch (status) { 41 | case 'pending': 42 | throw suspender; 43 | case 'error': 44 | throw result; 45 | default: 46 | return result; 47 | } 48 | }, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/utils/api_config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from './shared-types'; 2 | 3 | const isDevelopment = 4 | typeof process !== 'undefined' && (process.env.NODE_ENV === 'development' || process.env.__DEV__ === 'true'); 5 | console.log('isDevelopment logLine', isDevelopment); 6 | 7 | export const config: Config = { 8 | dashboardURL: isDevelopment ? 'http://localhost:5173' : 'https://app.getsavvy.so', 9 | apiURL: isDevelopment ? 'http://localhost:8080' : 'https://api.getsavvy.so', 10 | tokenKey: '', 11 | }; 12 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './shared-types'; 2 | export * from './api_config'; 3 | export * from './storage'; 4 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/utils/shared-types.ts: -------------------------------------------------------------------------------- 1 | export type ValueOf = T[keyof T]; 2 | 3 | export interface ApiError { 4 | status: number; 5 | message: string; 6 | code?: string; 7 | } 8 | 9 | export interface Config { 10 | dashboardURL: string; 11 | apiURL: string; 12 | tokenKey: string; 13 | } 14 | 15 | export interface BaseStorage { 16 | get: () => Promise; 17 | set: (value: T) => Promise; 18 | subscribe: (callback: () => void) => () => void; 19 | getSnapshot: () => T; 20 | } 21 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/lib/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import type { BaseStorage } from './shared-types'; 2 | 3 | export function createStorage(key: string, defaultValue: T, options?: { liveUpdate?: boolean }): BaseStorage { 4 | return { 5 | get: async () => { 6 | const result = await chrome.storage.local.get(key); 7 | return result[key] ?? defaultValue; 8 | }, 9 | set: async (value: T) => { 10 | await chrome.storage.local.set({ [key]: value }); 11 | }, 12 | subscribe: callback => { 13 | if (options?.liveUpdate) { 14 | const listener = (changes: { [key: string]: chrome.storage.StorageChange }) => { 15 | if (key in changes) { 16 | callback(); 17 | } 18 | }; 19 | chrome.storage.local.onChanged.addListener(listener); 20 | return () => chrome.storage.local.onChanged.removeListener(listener); 21 | } 22 | return () => {}; 23 | }, 24 | getSnapshot: () => { 25 | const value = chrome.storage.local.get(key); 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | return (value as any)[key] ?? defaultValue; 28 | }, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/shared", 3 | "version": "0.3.5", 4 | "description": "chrome extension - shared code", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "types": "index.ts", 11 | "main": "./dist/index.js", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "ready": "node build.mjs", 18 | "lint": "eslint . --ext .ts,.tsx", 19 | "lint:fix": "pnpm lint --fix", 20 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 21 | "type-check": "tsc --noEmit" 22 | }, 23 | "devDependencies": { 24 | "@extension/storage": "workspace:*", 25 | "@extension/tsconfig": "workspace:*" 26 | }, 27 | "dependencies": { 28 | "axios": "^1.7.9" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /savvy-extension/packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome", "node"], 7 | "paths": { 8 | "@lib/*": ["lib/*"] 9 | } 10 | }, 11 | "include": ["index.ts", "lib"] 12 | } 13 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/build.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | 3 | /** 4 | * @type { import('esbuild').BuildOptions } 5 | */ 6 | const buildOptions = { 7 | entryPoints: ['./index.ts', './lib/**/*.ts'], 8 | tsconfig: './tsconfig.json', 9 | bundle: false, 10 | target: 'es6', 11 | outdir: './dist', 12 | sourcemap: true, 13 | }; 14 | 15 | await esbuild.build(buildOptions); 16 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib'; 2 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/base/enums.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage area type for persisting and exchanging data. 3 | * @see https://developer.chrome.com/docs/extensions/reference/storage/#overview 4 | */ 5 | export enum StorageEnum { 6 | /** 7 | * Persist data locally against browser restarts. Will be deleted by uninstalling the extension. 8 | * @default 9 | */ 10 | Local = 'local', 11 | /** 12 | * Uploads data to the users account in the cloud and syncs to the users browsers on other devices. Limits apply. 13 | */ 14 | Sync = 'sync', 15 | /** 16 | * Requires an [enterprise policy](https://www.chromium.org/administrators/configuring-policy-for-extensions) with a 17 | * json schema for company wide config. 18 | */ 19 | Managed = 'managed', 20 | /** 21 | * Only persist data until the browser is closed. Recommended for service workers which can shutdown anytime and 22 | * therefore need to restore their state. Set {@link SessionAccessLevelEnum} for permitting content scripts access. 23 | * @implements Chromes [Session Storage](https://developer.chrome.com/docs/extensions/reference/storage/#property-session) 24 | */ 25 | Session = 'session', 26 | } 27 | 28 | /** 29 | * Global access level requirement for the {@link StorageEnum.Session} Storage Area. 30 | * @implements Chromes [Session Access Level](https://developer.chrome.com/docs/extensions/reference/storage/#method-StorageArea-setAccessLevel) 31 | */ 32 | export enum SessionAccessLevelEnum { 33 | /** 34 | * Storage can only be accessed by Extension pages (not Content scripts). 35 | * @default 36 | */ 37 | ExtensionPagesOnly = 'TRUSTED_CONTEXTS', 38 | /** 39 | * Storage can be accessed by both Extension pages and Content scripts. 40 | */ 41 | ExtensionPagesAndContentScripts = 'TRUSTED_AND_UNTRUSTED_CONTEXTS', 42 | } 43 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/base/types.ts: -------------------------------------------------------------------------------- 1 | import type { StorageEnum } from './enums'; 2 | 3 | export type ValueOrUpdate = D | ((prev: D) => Promise | D); 4 | 5 | export type BaseStorage = { 6 | get: () => Promise; 7 | set: (value: ValueOrUpdate) => Promise; 8 | getSnapshot: () => D | null; 9 | subscribe: (listener: () => void) => () => void; 10 | }; 11 | 12 | export type StorageConfig = { 13 | /** 14 | * Assign the {@link StorageEnum} to use. 15 | * @default Local 16 | */ 17 | storageEnum?: StorageEnum; 18 | /** 19 | * Only for {@link StorageEnum.Session}: Grant Content scripts access to storage area? 20 | * @default false 21 | */ 22 | sessionAccessForContentScripts?: boolean; 23 | /** 24 | * Keeps state live in sync between all instances of the extension. Like between popup, side panel and content scripts. 25 | * To allow chrome background scripts to stay in sync as well, use {@link StorageEnum.Session} storage area with 26 | * {@link StorageConfig.sessionAccessForContentScripts} potentially also set to true. 27 | * @see https://stackoverflow.com/a/75637138/2763239 28 | * @default false 29 | */ 30 | liveUpdate?: boolean; 31 | /** 32 | * An optional props for converting values from storage and into it. 33 | * @default undefined 34 | */ 35 | serialization?: { 36 | /** 37 | * convert non-native values to string to be saved in storage 38 | */ 39 | serialize: (value: D) => string; 40 | /** 41 | * convert string value from storage to non-native values 42 | */ 43 | deserialize: (text: string) => D; 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/impl/exampleThemeStorage.ts: -------------------------------------------------------------------------------- 1 | import { StorageEnum } from '../base/enums'; 2 | import { createStorage } from '../base/base'; 3 | import type { BaseStorage } from '../base/types'; 4 | 5 | type Theme = 'light' | 'dark'; 6 | 7 | type ThemeStorage = BaseStorage & { 8 | toggle: () => Promise; 9 | }; 10 | 11 | const storage = createStorage('theme-storage-key', 'light', { 12 | storageEnum: StorageEnum.Local, 13 | liveUpdate: true, 14 | }); 15 | 16 | // You can extend it with your own methods 17 | export const exampleThemeStorage: ThemeStorage = { 18 | ...storage, 19 | toggle: async () => { 20 | await storage.set(currentTheme => { 21 | return currentTheme === 'light' ? 'dark' : 'light'; 22 | }); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/impl/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exampleThemeStorage'; 2 | export * from './tokenStorage'; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/impl/tokenStorage.ts: -------------------------------------------------------------------------------- 1 | import { createStorage } from '../base/base'; 2 | 3 | export const tokenStorage = createStorage('savvy_user_key', '', { 4 | liveUpdate: true, 5 | }); 6 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/lib/index.ts: -------------------------------------------------------------------------------- 1 | export type { BaseStorage } from './base/types'; 2 | export * from './impl'; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/storage", 3 | "version": "0.3.5", 4 | "description": "chrome extension - storage", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "./dist/index.js", 11 | "types": "index.ts", 12 | "scripts": { 13 | "clean:bundle": "rimraf dist", 14 | "clean:node_modules": "pnpx rimraf node_modules", 15 | "clean:turbo": "rimraf .turbo", 16 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 17 | "ready": "node build.mjs", 18 | "lint": "eslint . --ext .ts,.tsx", 19 | "lint:fix": "pnpm lint --fix", 20 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 21 | "type-check": "tsc --noEmit" 22 | }, 23 | "devDependencies": { 24 | "@extension/tsconfig": "workspace:*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /savvy-extension/packages/storage/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/packages/tailwind-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/tailwindcss-config", 3 | "version": "0.3.5", 4 | "description": "chrome extension - tailwindcss configuration", 5 | "main": "tailwind.config.ts", 6 | "private": true 7 | } 8 | -------------------------------------------------------------------------------- /savvy-extension/packages/tailwind-config/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss/types/config'; 2 | 3 | export default { 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } as Omit; 9 | -------------------------------------------------------------------------------- /savvy-extension/packages/tsconfig/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Chrome Extension App", 4 | "extends": "./base.json" 5 | } 6 | -------------------------------------------------------------------------------- /savvy-extension/packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Base", 4 | "compilerOptions": { 5 | "allowJs": true, 6 | "noEmit": true, 7 | "module": "esnext", 8 | "downlevelIteration": true, 9 | "isolatedModules": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "moduleResolution": "node", 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "noImplicitReturns": true, 20 | "jsx": "react-jsx", 21 | "lib": ["DOM", "ESNext"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /savvy-extension/packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/tsconfig", 3 | "version": "0.3.5", 4 | "description": "chrome extension - tsconfig", 5 | "private": true 6 | } 7 | -------------------------------------------------------------------------------- /savvy-extension/packages/tsconfig/utils.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Chrome Extension Utils", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "noEmit": false, 7 | "declaration": true, 8 | "module": "CommonJS", 9 | "moduleResolution": "Node", 10 | "target": "ES6", 11 | "types": ["node"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/build.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { replaceTscAliasPaths } from 'tsc-alias'; 3 | import { resolve } from 'node:path'; 4 | import esbuild from 'esbuild'; 5 | 6 | /** 7 | * @type { import('esbuild').BuildOptions } 8 | */ 9 | const buildOptions = { 10 | entryPoints: ['./index.ts', './lib/**/*.ts', './lib/**/*.tsx'], 11 | tsconfig: './tsconfig.json', 12 | bundle: false, 13 | target: 'es6', 14 | outdir: './dist', 15 | sourcemap: true, 16 | }; 17 | 18 | await esbuild.build(buildOptions); 19 | 20 | /** 21 | * Post build paths resolve since ESBuild only natively 22 | * support paths resolution for bundling scenario 23 | * @url https://github.com/evanw/esbuild/issues/394#issuecomment-1537247216 24 | */ 25 | await replaceTscAliasPaths({ 26 | configFile: 'tsconfig.json', 27 | watch: false, 28 | outDir: 'dist', 29 | declarationDir: 'dist', 30 | }); 31 | 32 | fs.copyFileSync(resolve('lib', 'global.css'), resolve('dist', 'global.css')); 33 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/components'; 2 | export * from './lib/utils'; 3 | export * from './lib/withUI'; 4 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/lib/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | import { cn } from '../utils'; 3 | 4 | export type ButtonProps = { 5 | theme?: 'light' | 'dark'; 6 | } & ComponentPropsWithoutRef<'button'>; 7 | 8 | export function Button({ theme, className, children, ...props }: ButtonProps) { 9 | return ( 10 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/lib/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Button'; 2 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/lib/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ClassValue } from 'clsx'; 2 | import { clsx } from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | export const cn = (...inputs: ClassValue[]) => { 6 | return twMerge(clsx(inputs)); 7 | }; 8 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/lib/withUI.ts: -------------------------------------------------------------------------------- 1 | import deepmerge from 'deepmerge'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export function withUI(tailwindConfig: Config): Config { 5 | return deepmerge(tailwindConfig, { 6 | content: ['./node_modules/@extension/ui/lib/**/*.{tsx,ts,js,jsx}'], 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/ui", 3 | "version": "0.3.5", 4 | "description": "chrome extension - ui components", 5 | "private": true, 6 | "sideEffects": false, 7 | "type": "module", 8 | "files": [ 9 | "dist/**", 10 | "dist/global.css" 11 | ], 12 | "types": "index.ts", 13 | "main": "./dist/index.js", 14 | "scripts": { 15 | "clean:bundle": "rimraf dist", 16 | "clean:node_modules": "pnpx rimraf node_modules", 17 | "clean:turbo": "rimraf .turbo", 18 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 19 | "ready": "node build.mjs", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "deepmerge": "^4.3.1", 28 | "tsc-alias": "^1.8.10" 29 | }, 30 | "dependencies": { 31 | "clsx": "^2.1.1", 32 | "tailwind-merge": "^2.4.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /savvy-extension/packages/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "baseUrl": ".", 6 | "types": ["chrome"], 7 | "paths": { 8 | "@/*": ["./*"] 9 | } 10 | }, 11 | "include": ["index.ts", "lib"] 12 | } 13 | -------------------------------------------------------------------------------- /savvy-extension/packages/vite-config/index.mjs: -------------------------------------------------------------------------------- 1 | export * from './lib/env.mjs'; 2 | export * from './lib/withPageConfig.mjs'; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/vite-config/lib/env.mjs: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.__DEV__ === 'true'; 2 | export const isProduction = !isDev; 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/vite-config/lib/withPageConfig.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { watchRebuildPlugin } from '@extension/hmr'; 3 | import react from '@vitejs/plugin-react-swc'; 4 | import deepmerge from 'deepmerge'; 5 | import { isDev, isProduction } from './env.mjs'; 6 | 7 | export const watchOption = isDev ? { 8 | buildDelay: 100, 9 | chokidar: { 10 | ignored:[ 11 | /\/packages\/.*\.(ts|tsx|map)$/, 12 | ] 13 | } 14 | }: undefined; 15 | 16 | /** 17 | * @typedef {import('vite').UserConfig} UserConfig 18 | * @param {UserConfig} config 19 | * @returns {UserConfig} 20 | */ 21 | export function withPageConfig(config) { 22 | return defineConfig( 23 | deepmerge( 24 | { 25 | base: '', 26 | plugins: [react(), isDev && watchRebuildPlugin({ refresh: true })], 27 | build: { 28 | sourcemap: isDev, 29 | minify: isProduction, 30 | reportCompressedSize: isProduction, 31 | emptyOutDir: isProduction, 32 | watch: watchOption, 33 | rollupOptions: { 34 | external: ['chrome'], 35 | }, 36 | }, 37 | define: { 38 | 'process.env.NODE_ENV': isDev ? `"development"` : `"production"`, 39 | }, 40 | envDir: '../..' 41 | }, 42 | config, 43 | ), 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /savvy-extension/packages/vite-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/vite-config", 3 | "version": "0.3.5", 4 | "description": "chrome extension - vite base configuration", 5 | "main": "index.mjs", 6 | "type": "module", 7 | "private": true, 8 | "scripts": { 9 | "clean:node_modules": "pnpx rimraf node_modules", 10 | "clean": "pnpm clean:node_modules" 11 | }, 12 | "devDependencies": { 13 | "@extension/hmr": "workspace:*", 14 | "@extension/tsconfig": "workspace:*", 15 | "@vitejs/plugin-react-swc": "^3.7.2", 16 | "deepmerge": "^4.3.1" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /savvy-extension/packages/zipper/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /savvy-extension/packages/zipper/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { zipBundle } from './lib/zip-bundle'; 3 | 4 | const YYYYMMDD = new Date().toISOString().slice(0, 10).replace(/-/g, ''); 5 | const HHmmss = new Date().toISOString().slice(11, 19).replace(/:/g, ''); 6 | const fileName = `extension-${YYYYMMDD}-${HHmmss}`; 7 | 8 | // package the root dist file 9 | zipBundle({ 10 | distDirectory: resolve(__dirname, '../../dist'), 11 | buildDirectory: resolve(__dirname, '../../dist-zip'), 12 | archiveName: process.env.__FIREFOX__ ? `${fileName}.xpi` : `${fileName}.zip`, 13 | }); 14 | -------------------------------------------------------------------------------- /savvy-extension/packages/zipper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/zipper", 3 | "version": "0.3.5", 4 | "description": "chrome extension - zipper", 5 | "private": true, 6 | "sideEffects": false, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "types": "index.ts", 13 | "scripts": { 14 | "clean:bundle": "rimraf dist", 15 | "clean:node_modules": "pnpx rimraf node_modules", 16 | "clean:turbo": "rimraf .turbo", 17 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 18 | "zip": "tsx index.ts", 19 | "ready": "tsc", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "devDependencies": { 26 | "@extension/tsconfig": "workspace:*", 27 | "fast-glob": "^3.3.2", 28 | "fflate": "^0.8.2", 29 | "tsx": "^4.19.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /savvy-extension/packages/zipper/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "types": ["chrome", "node"] 7 | }, 8 | "include": ["index.ts", "lib"] 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-runtime-script", 3 | "version": "0.3.5", 4 | "description": "chrome extension - content runtime script", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "devDependencies": { 22 | "@extension/tsconfig": "workspace:*", 23 | "@extension/hmr": "workspace:*", 24 | "@extension/vite-config": "workspace:*" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default function App() { 4 | useEffect(() => { 5 | console.log('runtime content view loaded'); 6 | }, []); 7 | 8 | return
runtime content view
; 9 | } 10 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/src/Root.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from '@src/App'; 3 | // eslint-disable-next-line 4 | // @ts-ignore 5 | import injectedStyle from '@src/index.css?inline'; 6 | 7 | export function mount() { 8 | const root = document.createElement('div'); 9 | root.id = 'chrome-extension-boilerplate-react-vite-runtime-content-view-root'; 10 | 11 | document.body.append(root); 12 | 13 | const rootIntoShadow = document.createElement('div'); 14 | rootIntoShadow.id = 'shadow-root'; 15 | 16 | const shadowRoot = root.attachShadow({ mode: 'open' }); 17 | 18 | if (navigator.userAgent.includes('Firefox')) { 19 | /** 20 | * In the firefox environment, adoptedStyleSheets cannot be used due to the bug 21 | * @url https://bugzilla.mozilla.org/show_bug.cgi?id=1770592 22 | * 23 | * Injecting styles into the document, this may cause style conflicts with the host page 24 | */ 25 | const styleElement = document.createElement('style'); 26 | styleElement.innerHTML = injectedStyle; 27 | shadowRoot.appendChild(styleElement); 28 | } else { 29 | /** Inject styles into shadow dom */ 30 | const globalStyleSheet = new CSSStyleSheet(); 31 | globalStyleSheet.replaceSync(injectedStyle); 32 | shadowRoot.adoptedStyleSheets = [globalStyleSheet]; 33 | } 34 | 35 | shadowRoot.appendChild(rootIntoShadow); 36 | createRoot(rootIntoShadow).render(); 37 | } 38 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/src/index.css: -------------------------------------------------------------------------------- 1 | .runtime-content-view-text { 2 | font-size: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/src/index.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@src/Root'; 2 | 3 | mount(); 4 | console.log('runtime script loaded'); 5 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-runtime/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | lib: { 16 | formats: ['iife'], 17 | entry: resolve(__dirname, 'src/index.ts'), 18 | name: 'ContentRuntimeScript', 19 | fileName: 'index', 20 | }, 21 | outDir: resolve(rootDir, '..', '..', 'dist', 'content-runtime'), 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-ui", 3 | "version": "0.3.5", 4 | "description": "chrome extension - content ui", 5 | "type": "module", 6 | "private": true, 7 | "sideEffects": true, 8 | "files": [ 9 | "dist/**" 10 | ], 11 | "scripts": { 12 | "clean:bundle": "rimraf dist", 13 | "clean:node_modules": "pnpx rimraf node_modules", 14 | "clean:turbo": "rimraf .turbo", 15 | "clean": "pnpm clean:bundle && pnpm clean:node_modules && pnpm clean:turbo", 16 | "build:tailwindcss": "pnpm tailwindcss -i ./src/tailwind-input.css -o ./dist/tailwind-output.css -m", 17 | "build": "pnpm build:tailwindcss && vite build", 18 | "build:watch": "concurrently \"cross-env __DEV__=true vite build --mode development\" \"pnpm build:tailwindcss -- -w\"", 19 | "dev": "pnpm build:tailwindcss && pnpm build:watch", 20 | "lint": "eslint . --ext .ts,.tsx", 21 | "lint:fix": "pnpm lint --fix", 22 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 23 | "type-check": "tsc --noEmit" 24 | }, 25 | "dependencies": { 26 | "@extension/shared": "workspace:*", 27 | "@extension/storage": "workspace:*", 28 | "@extension/ui": "workspace:*" 29 | }, 30 | "devDependencies": { 31 | "@extension/tailwindcss-config": "workspace:*", 32 | "@extension/tsconfig": "workspace:*", 33 | "@extension/hmr": "workspace:*", 34 | "@extension/vite-config": "workspace:*", 35 | "concurrently": "^9.0.1", 36 | "cross-env": "^7.0.3" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { Button } from '@extension/ui'; 3 | import { useStorage } from '@extension/shared'; 4 | import { exampleThemeStorage } from '@extension/storage'; 5 | 6 | export default function App() { 7 | const theme = useStorage(exampleThemeStorage); 8 | 9 | useEffect(() => { 10 | console.log('content ui loaded'); 11 | }, []); 12 | 13 | return ( 14 |
15 |
16 | Edit pages/content-ui/src/app.tsx and save to reload. 17 |
18 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import App from '@src/App'; 3 | import tailwindcssOutput from '../dist/tailwind-output.css?inline'; 4 | 5 | const root = document.createElement('div'); 6 | root.id = 'chrome-extension-boilerplate-react-vite-content-view-root'; 7 | 8 | document.body.append(root); 9 | 10 | const rootIntoShadow = document.createElement('div'); 11 | rootIntoShadow.id = 'shadow-root'; 12 | 13 | const shadowRoot = root.attachShadow({ mode: 'open' }); 14 | 15 | if (navigator.userAgent.includes('Firefox')) { 16 | /** 17 | * In the firefox environment, adoptedStyleSheets cannot be used due to the bug 18 | * @url https://bugzilla.mozilla.org/show_bug.cgi?id=1770592 19 | * 20 | * Injecting styles into the document, this may cause style conflicts with the host page 21 | */ 22 | const styleElement = document.createElement('style'); 23 | styleElement.innerHTML = tailwindcssOutput; 24 | shadowRoot.appendChild(styleElement); 25 | } else { 26 | /** Inject styles into shadow dom */ 27 | const globalStyleSheet = new CSSStyleSheet(); 28 | globalStyleSheet.replaceSync(tailwindcssOutput); 29 | shadowRoot.adoptedStyleSheets = [globalStyleSheet]; 30 | } 31 | 32 | shadowRoot.appendChild(rootIntoShadow); 33 | createRoot(rootIntoShadow).render(); 34 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/src/tailwind-input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['src/**/*.{ts,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/content-ui/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { makeEntryPointPlugin } from '@extension/hmr'; 3 | import { isDev, withPageConfig } from '@extension/vite-config'; 4 | 5 | const rootDir = resolve(__dirname); 6 | const srcDir = resolve(rootDir, 'src'); 7 | 8 | export default withPageConfig({ 9 | resolve: { 10 | alias: { 11 | '@src': srcDir, 12 | }, 13 | }, 14 | plugins: [isDev && makeEntryPointPlugin()], 15 | publicDir: resolve(rootDir, 'public'), 16 | build: { 17 | lib: { 18 | entry: resolve(srcDir, 'index.tsx'), 19 | name: 'contentUI', 20 | formats: ['iife'], 21 | fileName: 'index', 22 | }, 23 | outDir: resolve(rootDir, '..', '..', 'dist', 'content-ui'), 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /savvy-extension/pages/content/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/content-script", 3 | "version": "0.3.5", 4 | "description": "chrome extension - content script", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*" 24 | }, 25 | "devDependencies": { 26 | "@extension/hmr": "workspace:*", 27 | "@extension/tsconfig": "workspace:*", 28 | "@extension/vite-config": "workspace:*", 29 | "cross-env": "^7.0.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /savvy-extension/pages/content/src/index.ts: -------------------------------------------------------------------------------- 1 | console.log('content script loaded'); 2 | -------------------------------------------------------------------------------- /savvy-extension/pages/content/src/toggleTheme.ts: -------------------------------------------------------------------------------- 1 | import { exampleThemeStorage } from '@extension/storage'; 2 | 3 | export async function toggleTheme() { 4 | console.log('initial theme:', await exampleThemeStorage.get()); 5 | await exampleThemeStorage.toggle(); 6 | console.log('toggled theme:', await exampleThemeStorage.get()); 7 | } 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/content/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/content/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { makeEntryPointPlugin } from '@extension/hmr'; 3 | import { isDev, withPageConfig } from '@extension/vite-config'; 4 | 5 | const rootDir = resolve(__dirname); 6 | const srcDir = resolve(rootDir, 'src'); 7 | 8 | export default withPageConfig({ 9 | resolve: { 10 | alias: { 11 | '@src': srcDir, 12 | }, 13 | }, 14 | publicDir: resolve(rootDir, 'public'), 15 | plugins: [isDev && makeEntryPointPlugin()], 16 | build: { 17 | lib: { 18 | entry: resolve(__dirname, 'src/index.ts'), 19 | formats: ['iife'], 20 | name: 'ContentScript', 21 | fileName: 'index', 22 | }, 23 | outDir: resolve(rootDir, '..', '..', 'dist', 'content'), 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Devtools Panel 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/devtools-panel", 3 | "version": "0.3.5", 4 | "description": "chrome extension - devtools panel", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*" 24 | }, 25 | "devDependencies": { 26 | "@extension/tailwindcss-config": "workspace:*", 27 | "@extension/tsconfig": "workspace:*", 28 | "@extension/vite-config": "workspace:*", 29 | "postcss-load-config": "^6.0.1", 30 | "cross-env": "^7.0.3" 31 | }, 32 | "postcss": { 33 | "plugins": { 34 | "tailwindcss": {}, 35 | "autoprefixer": {} 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/src/Panel.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | height: 100vh; 4 | width: 100%; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 2rem; 9 | } 10 | 11 | .App-logo { 12 | height: 40vmin; 13 | } 14 | 15 | .App-header { 16 | height: 100%; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: center; 20 | justify-content: center; 21 | font-size: calc(10px + 2vmin); 22 | } 23 | 24 | code { 25 | background: rgba(148, 163, 184, 0.5); 26 | border-radius: 0.25rem; 27 | padding: 0.2rem 0.5rem; 28 | } 29 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/src/Panel.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Panel.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import type { ComponentPropsWithoutRef } from 'react'; 5 | 6 | const Panel = () => { 7 | const theme = useStorage(exampleThemeStorage); 8 | const isLight = theme === 'light'; 9 | const logo = isLight ? 'devtools-panel/logo_horizontal.svg' : 'devtools-panel/logo_horizontal_dark.svg'; 10 | const goGithubSite = () => 11 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 12 | 13 | return ( 14 |
15 |
16 | 19 |

20 | Edit pages/devtools-panel/src/Panel.tsx 21 |

22 | Toggle theme 23 |
24 |
25 | ); 26 | }; 27 | 28 | const ToggleButton = (props: ComponentPropsWithoutRef<'button'>) => { 29 | const theme = useStorage(exampleThemeStorage); 30 | return ( 31 | 41 | ); 42 | }; 43 | 44 | export default withErrorBoundary(withSuspense(Panel,
Loading ...
),
Error Occur
); 45 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | position: relative; 13 | } 14 | 15 | code { 16 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 17 | } 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import Panel from '@src/Panel'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export default { 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | } as Config; 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools-panel/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'devtools-panel'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Devtools 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/devtools", 3 | "version": "0.3.5", 4 | "description": "chrome extension - devtools", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*" 23 | }, 24 | "devDependencies": { 25 | "@extension/tsconfig": "workspace:*", 26 | "@extension/vite-config": "workspace:*", 27 | "cross-env": "^7.0.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools/src/index.ts: -------------------------------------------------------------------------------- 1 | try { 2 | console.log("Edit 'pages/devtools/src/index.ts' and save to reload."); 3 | chrome.devtools.panels.create('Dev Tools', '/icon-34.png', '/devtools-panel/index.html'); 4 | } catch (e) { 5 | console.error(e); 6 | } 7 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/devtools/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'devtools'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | New Tab 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/new-tab", 3 | "version": "0.3.5", 4 | "description": "chrome extension - new tab", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/ui": "workspace:*", 25 | "@extension/i18n": "workspace:*" 26 | }, 27 | "devDependencies": { 28 | "@extension/tailwindcss-config": "workspace:*", 29 | "@extension/tsconfig": "workspace:*", 30 | "@extension/vite-config": "workspace:*", 31 | "sass": "1.79.4", 32 | "postcss-load-config": "^6.0.1", 33 | "cross-env": "^7.0.3" 34 | }, 35 | "postcss": { 36 | "plugins": { 37 | "tailwindcss": {}, 38 | "autoprefixer": {} 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/src/NewTab.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | } 8 | 9 | .App-header { 10 | min-height: 100vh; 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | justify-content: center; 15 | font-size: calc(10px + 2vmin); 16 | } 17 | 18 | code { 19 | background: rgba(148, 163, 184, 0.5); 20 | border-radius: 0.25rem; 21 | padding: 0.2rem 0.5rem; 22 | } 23 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/src/NewTab.scss: -------------------------------------------------------------------------------- 1 | $myColor: red; 2 | 3 | h1, 4 | h2, 5 | h3, 6 | h4, 7 | h5, 8 | h6 { 9 | color: $myColor; 10 | } 11 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/src/NewTab.tsx: -------------------------------------------------------------------------------- 1 | import '@src/NewTab.css'; 2 | import '@src/NewTab.scss'; 3 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 4 | import { exampleThemeStorage } from '@extension/storage'; 5 | import { Button } from '@extension/ui'; 6 | import { t } from '@extension/i18n'; 7 | 8 | const NewTab = () => { 9 | const theme = useStorage(exampleThemeStorage); 10 | const isLight = theme === 'light'; 11 | const logo = isLight ? 'new-tab/logo_horizontal.svg' : 'new-tab/logo_horizontal_dark.svg'; 12 | const goGithubSite = () => 13 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 14 | 15 | return ( 16 |
17 |
18 | 21 |

22 | Edit pages/new-tab/src/NewTab.tsx 23 |

24 |
The color of this paragraph is defined using SASS.
25 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default withErrorBoundary(withSuspense(NewTab,
{t('loading')}
),
Error Occur
); 34 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | code { 14 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 15 | } 16 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import '@extension/ui/lib/global.css'; 4 | import NewTab from '@src/NewTab'; 5 | 6 | function init() { 7 | const appContainer = document.querySelector('#app-container'); 8 | if (!appContainer) { 9 | throw new Error('Can not find #app-container'); 10 | } 11 | const root = createRoot(appContainer); 12 | 13 | root.render(); 14 | } 15 | 16 | init(); 17 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/new-tab/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'new-tab'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Options 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/options", 3 | "version": "0.3.5", 4 | "description": "chrome extension - options", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/ui": "workspace:*" 25 | }, 26 | "devDependencies": { 27 | "@extension/tailwindcss-config": "workspace:*", 28 | "@extension/tsconfig": "workspace:*", 29 | "@extension/vite-config": "workspace:*", 30 | "postcss-load-config": "^6.0.1", 31 | "cross-env": "^7.0.3" 32 | }, 33 | "postcss": { 34 | "plugins": { 35 | "tailwindcss": {}, 36 | "autoprefixer": {} 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/src/Options.css: -------------------------------------------------------------------------------- 1 | #app-container { 2 | text-align: center; 3 | width: 100vw; 4 | height: 100vh; 5 | } 6 | 7 | .App-logo { 8 | height: 40vmin; 9 | pointer-events: none; 10 | } 11 | 12 | .App { 13 | width: 100vw; 14 | height: 100vh; 15 | font-size: calc(10px + 2vmin); 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | justify-content: center; 20 | } 21 | 22 | code { 23 | background: rgba(148, 163, 184, 0.5); 24 | border-radius: 0.25rem; 25 | padding: 0.2rem 0.5rem; 26 | } 27 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/src/Options.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Options.css'; 2 | import { useStorage, withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { exampleThemeStorage } from '@extension/storage'; 4 | import { Button } from '@extension/ui'; 5 | 6 | const Options = () => { 7 | const theme = useStorage(exampleThemeStorage); 8 | const isLight = theme === 'light'; 9 | const logo = isLight ? 'options/logo_horizontal.svg' : 'options/logo_horizontal_dark.svg'; 10 | const goGithubSite = () => 11 | chrome.tabs.create({ url: 'https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite' }); 12 | 13 | return ( 14 |
15 | 18 |

19 | Edit pages/options/src/Options.tsx 20 |

21 | 24 |
25 | ); 26 | }; 27 | 28 | export default withErrorBoundary(withSuspense(Options,
Loading ...
),
Error Occur
); 29 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 8 | 'Droid Sans', 'Helvetica Neue', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import '@extension/ui/dist/global.css'; 4 | import Options from '@src/Options'; 5 | 6 | function init() { 7 | const appContainer = document.querySelector('#app-container'); 8 | if (!appContainer) { 9 | throw new Error('Can not find #app-container'); 10 | } 11 | const root = createRoot(appContainer); 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import { withUI } from '@extension/ui'; 3 | 4 | export default withUI({ 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | }); 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/options/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'options'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Popup 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/popup", 3 | "version": "0.3.5", 4 | "description": "chrome extension - popup", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@extension/content-runtime-script": "workspace:*" 25 | }, 26 | "devDependencies": { 27 | "@extension/tailwindcss-config": "workspace:*", 28 | "@extension/tsconfig": "workspace:*", 29 | "@extension/vite-config": "workspace:*", 30 | "postcss-load-config": "^6.0.1", 31 | "cross-env": "^7.0.3" 32 | }, 33 | "postcss": { 34 | "plugins": { 35 | "tailwindcss": {}, 36 | "autoprefixer": {} 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/src/Popup.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | text-align: center; 8 | height: 100%; 9 | padding: 1rem; 10 | } 11 | 12 | .App-logo { 13 | height: 50vmin; 14 | margin-bottom: 1rem; 15 | } 16 | 17 | .App-header { 18 | height: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: flex-end; 23 | font-size: 0.75rem; 24 | } 25 | 26 | code { 27 | background: rgba(148, 163, 184, 0.5); 28 | border-radius: 0.25rem; 29 | padding: 0.2rem 0.5rem; 30 | } 31 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/src/Popup.tsx: -------------------------------------------------------------------------------- 1 | import '@src/Popup.css'; 2 | import { withSuspense } from '@extension/shared'; 3 | 4 | const Popup = () => { 5 | return ( 6 |
7 |
Logged in
8 |
9 | ); 10 | }; 11 | 12 | export default withSuspense(Popup,
Loading ...
); 13 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | width: 300px; 7 | height: 260px; 8 | margin: 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 10 | 'Droid Sans', 'Helvetica Neue', sans-serif; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | 14 | position: relative; 15 | } 16 | 17 | code { 18 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 19 | } 20 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import Popup from '@src/Popup'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | 12 | root.render(); 13 | } 14 | 15 | init(); 16 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import baseConfig from '@extension/tailwindcss-config'; 2 | import type { Config } from 'tailwindcss/types/config'; 3 | 4 | export default { 5 | ...baseConfig, 6 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 | } as Config; 8 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/popup/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'popup'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@src/components", 15 | "utils": "@src/lib/utils", 16 | "ui": "@src/components/ui", 17 | "lib": "@src/lib", 18 | "hooks": "@src/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Savvy 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/sidepanel", 3 | "version": "0.3.5", 4 | "description": "chrome extension - side panel", 5 | "private": true, 6 | "sideEffects": true, 7 | "files": [ 8 | "dist/**" 9 | ], 10 | "scripts": { 11 | "clean:node_modules": "pnpx rimraf node_modules", 12 | "clean:turbo": "rimraf .turbo", 13 | "clean": "pnpm clean:turbo && pnpm clean:node_modules", 14 | "build": "vite build", 15 | "dev": "cross-env __DEV__=true vite build --mode development", 16 | "lint": "eslint . --ext .ts,.tsx", 17 | "lint:fix": "pnpm lint --fix", 18 | "prettier": "prettier . --write --ignore-path ../../.prettierignore", 19 | "type-check": "tsc --noEmit" 20 | }, 21 | "dependencies": { 22 | "@extension/shared": "workspace:*", 23 | "@extension/storage": "workspace:*", 24 | "@radix-ui/react-checkbox": "^1.1.3", 25 | "@radix-ui/react-dialog": "^1.1.5", 26 | "@radix-ui/react-label": "^2.1.1", 27 | "@radix-ui/react-scroll-area": "^1.2.2", 28 | "@radix-ui/react-select": "^2.1.4", 29 | "@radix-ui/react-slot": "^1.1.1", 30 | "@radix-ui/react-switch": "^1.1.2", 31 | "@radix-ui/react-tabs": "^1.1.2", 32 | "@radix-ui/react-toast": "^1.2.4", 33 | "@radix-ui/react-tooltip": "^1.1.6", 34 | "axios": "^1.7.9", 35 | "class-variance-authority": "^0.7.1", 36 | "clsx": "^2.1.1", 37 | "lucide-react": "^0.471.1", 38 | "next-themes": "^0.4.4", 39 | "sonner": "^1.7.2", 40 | "tailwind-merge": "^2.4.0", 41 | "tailwindcss-animate": "^1.0.7" 42 | }, 43 | "devDependencies": { 44 | "@extension/tailwindcss-config": "workspace:*", 45 | "@extension/tsconfig": "workspace:*", 46 | "@extension/vite-config": "workspace:*", 47 | "cross-env": "^7.0.3", 48 | "postcss-load-config": "^6.0.1" 49 | }, 50 | "postcss": { 51 | "plugins": { 52 | "tailwindcss": {}, 53 | "autoprefixer": {} 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/SidePanel.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | text-align: center; 8 | height: 100%; 9 | padding: 1rem; 10 | } 11 | 12 | .App-logo { 13 | height: 50vmin; 14 | margin-bottom: 1rem; 15 | } 16 | 17 | .App-header { 18 | height: 100%; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | } 25 | 26 | code { 27 | background: rgba(148, 163, 184, 0.5); 28 | border-radius: 0.25rem; 29 | padding: 0.2rem 0.5rem; 30 | } 31 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/SidePanel.tsx: -------------------------------------------------------------------------------- 1 | import '@src/SidePanel.css'; 2 | import { withErrorBoundary, withSuspense } from '@extension/shared'; 3 | import { HistoryViewer } from './components/HistoryViewer'; 4 | 5 | const SidePanel = () => { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | }; 12 | 13 | export default withErrorBoundary(withSuspense(SidePanel,
Loading ...
),
Error Occur
); 14 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/assets/fonts/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/savvy-extension/pages/side-panel/src/assets/fonts/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cva, type VariantProps } from 'class-variance-authority'; 3 | 4 | import { cn } from '@src/lib/utils'; 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', 12 | secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 13 | destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', 14 | outline: 'text-foreground', 15 | }, 16 | }, 17 | defaultVariants: { 18 | variant: 'default', 19 | }, 20 | }, 21 | ); 22 | 23 | export interface BadgeProps extends React.HTMLAttributes, VariantProps {} 24 | 25 | function Badge({ className, variant, ...props }: BadgeProps) { 26 | return
; 27 | } 28 | 29 | export { Badge, badgeVariants }; 30 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@src/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 14 | outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | }, 19 | size: { 20 | default: 'h-9 px-4 py-2', 21 | sm: 'h-8 rounded-md px-3 text-xs', 22 | lg: 'h-10 rounded-md px-8', 23 | icon: 'h-9 w-9', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | }, 31 | ); 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean; 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : 'button'; 42 | return ; 43 | }, 44 | ); 45 | Button.displayName = 'Button'; 46 | 47 | export { Button, buttonVariants }; 48 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; 3 | import { Check } from 'lucide-react'; 4 | 5 | import { cn } from '@src/lib/utils'; 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 18 | 19 | 20 | 21 | 22 | )); 23 | Checkbox.displayName = CheckboxPrimitive.Root.displayName; 24 | 25 | export { Checkbox }; 26 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@src/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 3 | 4 | import { cn } from '@src/lib/utils'; 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 11 | {children} 12 | 13 | 14 | 15 | )); 16 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; 17 | 18 | const ScrollBar = React.forwardRef< 19 | React.ElementRef, 20 | React.ComponentPropsWithoutRef 21 | >(({ className, orientation = 'vertical', ...props }, ref) => ( 22 | 32 | 33 | 34 | )); 35 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; 36 | 37 | export { ScrollArea, ScrollBar }; 38 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes'; 2 | import { Toaster as Sonner } from 'sonner'; 3 | 4 | type ToasterProps = React.ComponentProps; 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = 'system' } = useTheme(); 8 | 9 | return ( 10 | 24 | ); 25 | }; 26 | 27 | export { Toaster }; 28 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@src/lib/utils" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@src/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from '@src/hooks/use-toast'; 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from '@src/components/ui/toast'; 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast(); 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && {description}} 22 |
23 | {action} 24 | 25 |
26 | ); 27 | })} 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 3 | 4 | import { cn } from '@src/lib/utils'; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = TooltipPrimitive.Root; 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger; 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 17 | 26 | 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 31 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import '@src/index.css'; 3 | import SidePanel from '@src/SidePanel'; 4 | 5 | function init() { 6 | const appContainer = document.querySelector('#app-container'); 7 | if (!appContainer) { 8 | throw new Error('Can not find #app-container'); 9 | } 10 | const root = createRoot(appContainer); 11 | root.render(); 12 | } 13 | 14 | init(); 15 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@src/*": ["src/*"] 7 | }, 8 | "types": ["chrome", "../../vite-env.d.ts"] 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /savvy-extension/pages/side-panel/vite.config.mts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { withPageConfig } from '@extension/vite-config'; 3 | 4 | const rootDir = resolve(__dirname); 5 | const srcDir = resolve(rootDir, 'src'); 6 | 7 | export default withPageConfig({ 8 | resolve: { 9 | alias: { 10 | '@src': srcDir, 11 | }, 12 | }, 13 | publicDir: resolve(rootDir, 'public'), 14 | build: { 15 | outDir: resolve(rootDir, '..', '..', 'dist', 'side-panel'), 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "chrome-extension" 3 | - "pages/*" 4 | - "packages/*" 5 | - "tests/*" 6 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/config/wdio.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace WebdriverIO { 2 | interface Browser extends WebdriverIO.Browser { 3 | getExtensionPath: () => Promise; 4 | installAddOn: (extension: string, temporary: boolean) => Promise; 5 | addCommand: (name: string, func: () => Promise) => void; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/helpers/theme.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper method to check if user can click on theme button and toggle theme color 3 | */ 4 | export const canSwitchTheme = async () => { 5 | const LIGHT_THEME_CLASS = 'bg-slate-50'; 6 | const DARK_THEME_CLASS = 'bg-gray-800'; 7 | const TOGGLE_BUTTON_TEXT = 'Toggle theme'; 8 | 9 | const app = await $('.App').getElement(); 10 | const toggleThemeButton = await $(`button=${TOGGLE_BUTTON_TEXT}`).getElement(); 11 | 12 | await expect(app).toBeExisting(); 13 | await expect(toggleThemeButton).toBeExisting(); 14 | 15 | const appClasses = await app.getAttribute('class'); 16 | const initialThemeClass = appClasses.includes(LIGHT_THEME_CLASS) ? LIGHT_THEME_CLASS : DARK_THEME_CLASS; 17 | const afterClickThemeClass = appClasses.includes(LIGHT_THEME_CLASS) ? DARK_THEME_CLASS : LIGHT_THEME_CLASS; 18 | 19 | // Toggle theme 20 | await toggleThemeButton.click(); 21 | await expect(app).toHaveElementClass(afterClickThemeClass); 22 | 23 | // Toggle back to initial theme 24 | await toggleThemeButton.click(); 25 | await expect(app).toHaveElementClass(initialThemeClass); 26 | }; 27 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@extension/e2e", 3 | "version": "0.3.5", 4 | "description": "E2e tests configuration boilerplate", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "e2e": "wdio run ./config/wdio.browser.conf.ts", 9 | "clean:node_modules": "pnpx rimraf node_modules", 10 | "clean:turbo": "pnpx rimraf .turbo", 11 | "clean": "pnpm clean:turbo && pnpm clean:node_modules" 12 | }, 13 | "devDependencies": { 14 | "@extension/tsconfig": "workspace:*", 15 | "@wdio/cli": "^9.4.5", 16 | "@wdio/globals": "^9.4.5", 17 | "@wdio/local-runner": "^9.1.2", 18 | "@wdio/mocha-framework": "^9.1.2", 19 | "@wdio/spec-reporter": "^9.2.14", 20 | "@wdio/types": "^9.1.2", 21 | "tsx": "^4.19.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/specs/page-content-runtime.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Content Runtime Script', () => { 2 | before(function () { 3 | if ((browser.capabilities as WebdriverIO.Capabilities).browserName === 'chrome') { 4 | // Chrome doesn't allow content scripts on the extension pages 5 | this.skip(); 6 | } 7 | }); 8 | 9 | it('should create runtime element on the page', async () => { 10 | // Open the popup 11 | const extensionPath = await browser.getExtensionPath(); 12 | const popupUrl = `${extensionPath}/popup/index.html`; 13 | await browser.url(popupUrl); 14 | 15 | await expect(browser).toHaveTitle('Popup'); 16 | 17 | // Trigger the content script on the popup 18 | // button contains "Content Script" text 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/specs/page-content-ui.test.ts: -------------------------------------------------------------------------------- 1 | // We don't have any content UI to test yet, so this test is commented out. 2 | //describe('Content UI Injection', () => { 3 | // it('should locate the injected content UI div', async () => { 4 | // await browser.url('https://www.example.com'); 5 | // 6 | // const contentDiv = await $('#chrome-extension-boilerplate-react-vite-content-view-root').getElement(); 7 | // await expect(contentDiv).toBeDisplayed(); 8 | // }); 9 | //}); 10 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/specs/page-content.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Content Script', () => { 2 | it('should log "content script loaded" in console', async () => { 3 | await browser.sessionSubscribe({ events: ['log.entryAdded'] }); 4 | const logs: (string | null)[] = []; 5 | 6 | browser.on('log.entryAdded', logEntry => { 7 | logs.push(logEntry.text); 8 | }); 9 | 10 | await browser.url('https://www.example.com'); 11 | 12 | const EXPECTED_LOG_MESSAGE = 'content script loaded'; 13 | await browser.waitUntil(() => logs.includes(EXPECTED_LOG_MESSAGE)); 14 | 15 | expect(logs).toContain(EXPECTED_LOG_MESSAGE); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/specs/page-popup.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Popup', () => { 2 | it('should open the popup successfully', async () => { 3 | const extensionPath = await browser.getExtensionPath(); 4 | const popupUrl = `${extensionPath}/popup/index.html`; 5 | await browser.url(popupUrl); 6 | 7 | await expect(browser).toHaveTitle('Popup'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/specs/page-side-panel.test.ts: -------------------------------------------------------------------------------- 1 | describe('Webextension Side Panel', () => { 2 | it('should make side panel accessible', async () => { 3 | const extensionPath = await browser.getExtensionPath(); 4 | const sidePanelUrl = `${extensionPath}/side-panel/index.html`; 5 | 6 | await browser.url(sidePanelUrl); 7 | await expect(browser).toHaveTitle('Savvy'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@extension/tsconfig/utils", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "ESNext", 6 | "target": "es2022", 7 | "lib": ["es2022", "dom"], 8 | "types": ["node", "@wdio/globals/types", "@wdio/mocha-framework"], 9 | "resolveJsonModule": true, 10 | "isolatedModules": true, 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noFallthroughCasesInSwitch": true 15 | }, 16 | "include": ["specs", "config", "helpers"] 17 | } 18 | -------------------------------------------------------------------------------- /savvy-extension/tests/e2e/utils/extension-path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns the Chrome extension path. 3 | * @param browser 4 | * @returns path to the Chrome extension 5 | */ 6 | export const getChromeExtensionPath = async (browser: WebdriverIO.Browser) => { 7 | await browser.url('chrome://extensions/'); 8 | /** 9 | * https://webdriver.io/docs/extension-testing/web-extensions/#test-popup-modal-in-chrome 10 | * ```ts 11 | * const extensionItem = await $('extensions-item').getElement(); 12 | * ``` 13 | * The above code is not working. I guess it's because the shadow root is not accessible. 14 | * So I used the following code to access the shadow root manually. 15 | * 16 | * @url https://github.com/webdriverio/webdriverio/issues/13521 17 | * @url https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite/issues/786 18 | */ 19 | const extensionItem = await (async () => { 20 | const extensionsManager = await $('extensions-manager').getElement(); 21 | const itemList = await extensionsManager.shadow$('#container > #viewManager > extensions-item-list'); 22 | return await itemList.shadow$('extensions-item'); 23 | })(); 24 | 25 | const extensionId = await extensionItem.getAttribute('id'); 26 | 27 | if (!extensionId) { 28 | throw new Error('Extension ID not found'); 29 | } 30 | 31 | return `chrome-extension://${extensionId}`; 32 | }; 33 | 34 | /** 35 | * Returns the Firefox extension path. 36 | * @param browser 37 | * @returns path to the Firefox extension 38 | */ 39 | export const getFirefoxExtensionPath = async (browser: WebdriverIO.Browser) => { 40 | await browser.url('about:debugging#/runtime/this-firefox'); 41 | const uuidElement = await browser.$('//dt[contains(text(), "Internal UUID")]/following-sibling::dd').getElement(); 42 | const internalUUID = await uuidElement.getText(); 43 | 44 | if (!internalUUID) { 45 | throw new Error('Internal UUID not found'); 46 | } 47 | 48 | return `moz-extension://${internalUUID}`; 49 | }; 50 | -------------------------------------------------------------------------------- /savvy-extension/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "globalEnv": ["__FIREFOX__"], 5 | "daemon": false, 6 | "tasks": { 7 | "ready": { 8 | "dependsOn": ["^ready"], 9 | "outputs": ["dist/**", "build/**"] 10 | }, 11 | "dev": { 12 | "dependsOn": ["ready"], 13 | "outputs": ["dist/**", "build/**", "i18n/locales/**"], 14 | "cache": false, 15 | "persistent": true 16 | }, 17 | "build": { 18 | "dependsOn": ["^build"], 19 | "outputs": ["../../dist/**", "dist/**", "build/**"], 20 | "cache": false 21 | }, 22 | "e2e": { 23 | "cache": false 24 | }, 25 | "type-check": { 26 | "cache": false 27 | }, 28 | "lint": { 29 | "cache": false 30 | }, 31 | "lint:fix": { 32 | "cache": false 33 | }, 34 | "prettier": { 35 | "cache": false 36 | }, 37 | "clean:node_modules": { 38 | "dependsOn": ["^clean:node_modules"], 39 | "cache": false 40 | }, 41 | "clean:turbo": { 42 | "dependsOn": ["^clean:turbo"], 43 | "cache": false 44 | }, 45 | "clean:bundle": { 46 | "dependsOn": ["^clean:bundle"], 47 | "cache": false 48 | }, 49 | "clean": { 50 | "dependsOn": ["^clean"], 51 | "cache": false 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /savvy-extension/update_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Usage: ./update_version.sh 3 | # FORMAT IS <0.0.0> 4 | 5 | if [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 6 | find . -name 'package.json' -not -path '*/node_modules/*' -exec bash -c ' 7 | # Parse the version from package.json 8 | current_version=$(grep -o "\"version\": \"[^\"]*" "$0" | cut -d"\"" -f4) 9 | 10 | # Update the version 11 | perl -i -pe"s/$current_version/'$1'/" "$0" 12 | ' {} \; 13 | 14 | echo "Updated versions to $1"; 15 | else 16 | echo "Version format <$1> isn't correct, proper format is <0.0.0>"; 17 | fi 18 | -------------------------------------------------------------------------------- /savvy-extension/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_EXAMPLE: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /server/cleanup/permission.go: -------------------------------------------------------------------------------- 1 | package cleanup 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/charmbracelet/huh" 7 | "github.com/getsavvyinc/savvy-cli/server/mode" 8 | ) 9 | 10 | func GetPermission(m mode.Mode) (bool, error) { 11 | var confirmation bool 12 | confirmCleanup := huh.NewConfirm(). 13 | Title(fmt.Sprintf("Multiple %s sessions detected", m)). 14 | Affirmative("Continue here and kill other sessions"). 15 | Negative("Quit this session"). 16 | Value(&confirmation) 17 | if err := huh.NewForm(huh.NewGroup(confirmCleanup)).Run(); err != nil { 18 | return false, err 19 | } 20 | return confirmation, nil 21 | } 22 | -------------------------------------------------------------------------------- /server/mode/mode.go: -------------------------------------------------------------------------------- 1 | package mode 2 | 3 | type Mode int 4 | 5 | const ( 6 | Record Mode = iota 7 | Run 8 | ) 9 | 10 | func (mode Mode) String() string { 11 | switch mode { 12 | case Record: 13 | return "recording" 14 | case Run: 15 | return "run" 16 | default: 17 | return "" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shell/check_bash_setup.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | ) 9 | 10 | type bashSetupChecker struct{} 11 | 12 | var _ SetupChecker = (*bashSetupChecker)(nil) 13 | 14 | func (b *bashSetupChecker) CheckSetup() error { 15 | executablePath, _ := os.Executable() 16 | 17 | if _, err := exec.LookPath(filepath.Base(executablePath)); err != nil { 18 | return errors.New(b.pathInstruction()) 19 | } 20 | return nil 21 | } 22 | 23 | func (b *bashSetupChecker) pathInstruction() string { 24 | return ` 25 | Please add savvy to your $PATH by running the following command: 26 | 27 | echo 'export PATH="$HOME/bin:$PATH"' >> ~/.bashrc 28 | source ~/.bashrc 29 | ` 30 | } 31 | -------------------------------------------------------------------------------- /shell/check_setup.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "github.com/getsavvyinc/savvy-cli/shell/internal/detect" 5 | "github.com/getsavvyinc/savvy-cli/shell/kind" 6 | ) 7 | 8 | type SetupChecker interface { 9 | // CheckSetup returns an non nil error if the shell setup is not correct 10 | // The error message should contain instructions on how to fix the setup and is safe to display to the user 11 | CheckSetup() error 12 | } 13 | 14 | func NewSetupChecker() SetupChecker { 15 | shell := detect.DetectWithDefault() 16 | switch shell { 17 | case kind.Zsh: 18 | return &zshSetupChecker{} 19 | case kind.Bash: 20 | return &bashSetupChecker{} 21 | default: 22 | return &nopSetupChecker{} 23 | } 24 | } 25 | 26 | type nopSetupChecker struct{} 27 | 28 | var _ SetupChecker = (*nopSetupChecker)(nil) 29 | 30 | func (n *nopSetupChecker) CheckSetup() error { 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /shell/check_zsh_setup.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | ) 9 | 10 | type zshSetupChecker struct{} 11 | 12 | var _ SetupChecker = (*zshSetupChecker)(nil) 13 | 14 | func (z *zshSetupChecker) CheckSetup() error { 15 | executablePath, _ := os.Executable() 16 | 17 | if _, err := exec.LookPath(filepath.Base(executablePath)); err != nil { 18 | return errors.New(z.pathInstruction()) 19 | } 20 | return nil 21 | } 22 | 23 | func (z *zshSetupChecker) pathInstruction() string { 24 | return ` 25 | Please add savvy to your $PATH by running the following commands: 26 | 27 | echo 'export PATH="$HOME/bin:$PATH"' >> ~/.zshrc 28 | source ~/.zshrc 29 | ` 30 | } 31 | -------------------------------------------------------------------------------- /shell/expansion/ignore.go: -------------------------------------------------------------------------------- 1 | package expansion 2 | 3 | import "regexp" 4 | 5 | var grepColorExcludeDirRegexPattern = regexp.MustCompile(`grep --color=auto --exclude-dir={[\w,.]+}`) 6 | 7 | func IgnoreGrep(message string) string { 8 | // grep expansions are not useful for the user with the addition of --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} by default. 9 | // This is a workaround to ignore the expansions in the command. 10 | if grepColorExcludeDirRegexPattern.MatchString(message) { 11 | return grepColorExcludeDirRegexPattern.ReplaceAllString(message, "grep") 12 | } 13 | return message 14 | } 15 | -------------------------------------------------------------------------------- /shell/expansion/ignore_test.go: -------------------------------------------------------------------------------- 1 | package expansion_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/getsavvyinc/savvy-cli/shell/expansion" 7 | ) 8 | 9 | func TestIgnoreGrep(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | message string 13 | expected string 14 | }{ 15 | { 16 | name: "no grep", 17 | message: "echo hello world", 18 | expected: "echo hello world", 19 | }, 20 | { 21 | name: "grep", 22 | message: `grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} "pattern"`, 23 | expected: `grep "pattern"`, 24 | }, 25 | { 26 | name: "grep with flags", 27 | message: `grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} -i "pattern"`, 28 | expected: `grep -i "pattern"`, 29 | }, 30 | { 31 | name: "grep with multiple flags", 32 | message: `grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} -i -n "pattern"`, 33 | expected: `grep -i -n "pattern"`, 34 | }, 35 | { 36 | name: "multiple grep instances", 37 | message: `grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} -i -n "pattern1" | grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} "pattern2"`, 38 | expected: `grep -i -n "pattern1" | grep "pattern2"`, 39 | }, 40 | { 41 | name: "grep with other commands", 42 | message: `echo "hello world" | grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn} "pattern"`, 43 | expected: `echo "hello world" | grep "pattern"`, 44 | }, 45 | } 46 | 47 | for _, tc := range testCases { 48 | t.Run(tc.name, func(t *testing.T) { 49 | actual := expansion.IgnoreGrep(tc.message) 50 | if actual != tc.expected { 51 | t.Errorf("expected %q, got %q", tc.expected, actual) 52 | } 53 | }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /shell/internal/detect/detect_test.go: -------------------------------------------------------------------------------- 1 | package detect 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseCommand(t *testing.T) { 8 | testCases := []struct { 9 | rawCommandAndArgs string 10 | expected string 11 | }{ 12 | { 13 | rawCommandAndArgs: "-zsh", 14 | expected: "zsh", 15 | }, 16 | {rawCommandAndArgs: "tmux attach -t savvy", 17 | expected: "tmux", 18 | }, 19 | { 20 | rawCommandAndArgs: "/bin/zsh -il", 21 | expected: "zsh", 22 | }, 23 | {rawCommandAndArgs: "/nix/store/2gs1bzkbap7r4nr7vvyi575mdrj26nby-nodejs-18.18.2/bin/node /nix/store/9kl1b483qxmq4if81i6affypgcrxpmsh-yarn-1.22.19/bin/yarn dev", 24 | expected: "node", 25 | }, 26 | {rawCommandAndArgs: "/Users/shantanu/src/github.com/savvy-prototype/www/node_modules/@esbuild/darwin-arm64/bin/esbuild --service=0.19.9 --ping", 27 | expected: "esbuild", 28 | }, 29 | { 30 | rawCommandAndArgs: "nvim main.go", 31 | expected: "nvim", 32 | }, 33 | {rawCommandAndArgs: "sh -c 'fzf' --border '--color=bg+:#3b4252,bg:#2e3440,spinner:#81a1c1,hl:#616e88,fg:#d8dee9,header:#616e88,info:#81a1c1,pointer:#81a1c1,marker:#81a1c1,fg+:#d8dee9,prompt:#81a1c1,hl+:#81a1c1' +m --ansi --tiebreak=begin --header-lines=1 -d\240 '--preview' '", 34 | expected: "sh", 35 | }, 36 | } 37 | 38 | for _, tc := range testCases { 39 | got := parseCommand(tc.rawCommandAndArgs) 40 | if got != tc.expected { 41 | t.Errorf("wrong output for commandOf(%s). expected %s, got %s", tc.rawCommandAndArgs, tc.expected, got) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shell/kind/kind.go: -------------------------------------------------------------------------------- 1 | package kind 2 | 3 | // Kind represents the type of shell. 4 | type Kind string 5 | 6 | // Define constants for ShellKind here, based on your needs, e.g., Bash, Zsh, etc. 7 | const ( 8 | Bash Kind = "bash" 9 | Zsh Kind = "zsh" 10 | Dash Kind = "dash" 11 | Fish Kind = "fish" 12 | Unknown Kind = "unknown" 13 | ) 14 | 15 | // ShellKindFromString tries to match a string to a shell Kind. 16 | func ShellKindFromString(name string) (Kind, bool) { 17 | switch name { 18 | case "bash": 19 | return Bash, true 20 | case "zsh": 21 | return Zsh, true 22 | case "dash": 23 | return Dash, true 24 | case "fish": 25 | return Fish, true 26 | } 27 | return Unknown, false 28 | } 29 | -------------------------------------------------------------------------------- /shell/spawn.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os/exec" 7 | 8 | "github.com/getsavvyinc/savvy-cli/client" 9 | "github.com/getsavvyinc/savvy-cli/shell/internal/detect" 10 | "github.com/getsavvyinc/savvy-cli/shell/kind" 11 | ) 12 | 13 | type Shell interface { 14 | Spawn(ctx context.Context) (*exec.Cmd, error) 15 | TailHistory(ctx context.Context) ([]string, error) 16 | SpawnHistoryExpander(ctx context.Context) (*exec.Cmd, error) 17 | SpawnRunbookRunner(ctx context.Context, runbook *client.Runbook) (*exec.Cmd, error) 18 | DefaultStartingArrayIndex() int 19 | } 20 | 21 | func New(logTarget string) Shell { 22 | shell := detect.DetectWithDefault() 23 | switch shell { 24 | case kind.Zsh: 25 | return &zsh{ 26 | shellCmd: "zsh", 27 | SocketPath: logTarget, 28 | } 29 | case kind.Dash: 30 | fallthrough 31 | case kind.Bash: 32 | return &bash{ 33 | shellCmd: "bash", 34 | SocketPath: logTarget, 35 | } 36 | 37 | case kind.Fish: 38 | return &fish{ 39 | shellCmd: "fish", 40 | SocketPath: logTarget, 41 | } 42 | default: 43 | return &todo{} 44 | } 45 | } 46 | 47 | type todo struct{} 48 | 49 | func (t *todo) Spawn(ctx context.Context) (*exec.Cmd, error) { 50 | return nil, errors.New("savvy doesn't support your current shell") 51 | } 52 | 53 | func (t *todo) TailHistory(ctx context.Context) ([]string, error) { 54 | return nil, errors.New("savvy doesn't support your current shell") 55 | } 56 | 57 | func (t *todo) SpawnHistoryExpander(ctx context.Context) (*exec.Cmd, error) { 58 | return nil, errors.New("savvy doesn't support your current shell") 59 | } 60 | 61 | func (t *todo) SpawnRunbookRunner(ctx context.Context, runbook *client.Runbook) (*exec.Cmd, error) { 62 | return nil, errors.New("savvy doesn't support your current shell") 63 | } 64 | 65 | func (t *todo) DefaultStartingArrayIndex() int { 66 | return 0 67 | } 68 | -------------------------------------------------------------------------------- /shell/supported.go: -------------------------------------------------------------------------------- 1 | package shell 2 | 3 | import "github.com/getsavvyinc/savvy-cli/shell/kind" 4 | 5 | func SupportedShells() []string { 6 | return []string{string(kind.Bash), string(kind.Zsh), string(kind.Dash)} 7 | } 8 | -------------------------------------------------------------------------------- /slice/slice.go: -------------------------------------------------------------------------------- 1 | package slice 2 | 3 | func Map[T, U any](s []T, f func(T) U) []U { 4 | var result []U 5 | for _, v := range s { 6 | result = append(result, f(v)) 7 | } 8 | return result 9 | } 10 | 11 | func Has[T comparable](s []T, v T) bool { 12 | for _, x := range s { 13 | if x == v { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | 20 | func Filter[T any](s []T, f func(T) bool) []T { 21 | var result []T 22 | for _, v := range s { 23 | if f(v) { 24 | result = append(result, v) 25 | } 26 | } 27 | return result 28 | } 29 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/gob" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/getsavvyinc/savvy-cli/client" 9 | "github.com/getsavvyinc/savvy-cli/config" 10 | ) 11 | 12 | const defaultDBFilename = "savvy.local" 13 | 14 | var defaultLocalDBPath = filepath.Join(config.DefaultConfigDir, defaultDBFilename) 15 | 16 | func Write(store map[string]*client.Runbook) error { 17 | f, err := openStore() 18 | if err != nil { 19 | return err 20 | } 21 | defer f.Close() 22 | 23 | if err := f.Truncate(0); err != nil { 24 | return err 25 | } 26 | 27 | encoder := gob.NewEncoder(f) 28 | if err := encoder.Encode(store); err != nil { 29 | return err 30 | } 31 | 32 | return nil 33 | } 34 | 35 | func Read() (map[string]*client.Runbook, error) { 36 | // Read the store from disk 37 | f, err := openStore() 38 | if err != nil { 39 | return nil, err 40 | } 41 | defer f.Close() 42 | 43 | if _, err := f.Stat(); err != nil { 44 | return nil, err 45 | } 46 | 47 | store := make(map[string]*client.Runbook) 48 | 49 | decoder := gob.NewDecoder(f) 50 | if err := decoder.Decode(&store); err != nil { 51 | return nil, err 52 | } 53 | return store, nil 54 | } 55 | 56 | func openStore() (*os.File, error) { 57 | return os.OpenFile(defaultLocalDBPath, os.O_RDWR|os.O_CREATE, 0666) 58 | } 59 | -------------------------------------------------------------------------------- /tail/tail_test.go: -------------------------------------------------------------------------------- 1 | package tail_test 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/getsavvyinc/savvy-cli/tail" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | const ( 15 | testDataDir = "testdata" 16 | ) 17 | 18 | var ( 19 | testDataFile = filepath.Join(testDataDir, "data.txt") 20 | testEmptyFile = filepath.Join(testDataDir, "empty.txt") 21 | ) 22 | 23 | func TestTail(t *testing.T) { 24 | t.Run("FailsOnNonExistentFile", func(t *testing.T) { 25 | _, err := tail.Tail("nonexistentfile", 10) 26 | require.Error(t, err) 27 | require.ErrorIs(t, err, os.ErrNotExist) 28 | }) 29 | t.Run("FailsOnDirectory", func(t *testing.T) { 30 | _, err := tail.Tail(testDataDir, 10) 31 | require.Error(t, err) 32 | }) 33 | t.Run("FailsOnEmptyFile", func(t *testing.T) { 34 | _, err := tail.Tail(testEmptyFile, 10) 35 | assert.Error(t, err) 36 | assert.ErrorIs(t, err, tail.ErrEmptyFile) 37 | }) 38 | t.Run("FailsOnNegativeLines", func(t *testing.T) { 39 | _, err := tail.Tail(testDataFile, -1) 40 | assert.Error(t, err) 41 | assert.ErrorIs(t, err, tail.ErrInvalidN) 42 | }) 43 | t.Run("ReturnsEntireFileWhenNIsGreaterThanFileLength", func(t *testing.T) { 44 | f, err := tail.Tail(testDataFile, 1000) 45 | require.NoError(t, err) 46 | defer f.Close() 47 | 48 | got, err := io.ReadAll(f) 49 | assert.NoError(t, err) 50 | 51 | expected, err := os.ReadFile(testDataFile) 52 | require.NoError(t, err) 53 | assert.Equal(t, string(expected), string(got)) 54 | }) 55 | t.Run("ReturnsLastNLines", func(t *testing.T) { 56 | f, err := tail.Tail(testDataFile, 3) 57 | require.NoError(t, err) 58 | defer f.Close() 59 | 60 | expected := `right from their terminal. 61 | Savvy's ClI also allows developers to create runbooks from their shell history 62 | and share them with their team. 63 | ` 64 | 65 | got, err := io.ReadAll(f) 66 | assert.NoError(t, err) 67 | assert.Equal(t, expected, string(got)) 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /tail/testdata/data.txt: -------------------------------------------------------------------------------- 1 | Savvy's AI-generated runbooks help developers resolve user-facing issues faster. 2 | Savvy’s CLI and browser extension provide developers with the knowledge they need 3 | to resolve user facing incidents faster, with fewer escalations. 4 | Savvy's CLI allows developers to record their shell and create runbooks right 5 | right from their terminal. 6 | Savvy's ClI also allows developers to create runbooks from their shell history 7 | and share them with their team. 8 | -------------------------------------------------------------------------------- /tail/testdata/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsavvyinc/savvy-cli/61c88a4818c8fcc53624da47ecda95f9bc477d3f/tail/testdata/empty.txt -------------------------------------------------------------------------------- /theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | catppuccin "github.com/catppuccin/go" 5 | "github.com/charmbracelet/huh" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | func New() *huh.Theme { 10 | t := huh.ThemeDracula() 11 | 12 | light := catppuccin.Latte 13 | dark := catppuccin.Mocha 14 | var ( 15 | subtext0 = lipgloss.AdaptiveColor{Light: light.Subtext0().Hex, Dark: dark.Subtext0().Hex} 16 | overlay1 = lipgloss.AdaptiveColor{Light: light.Overlay1().Hex, Dark: dark.Overlay1().Hex} 17 | ) 18 | 19 | f := &t.Focused 20 | f.SelectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#02CF92", Dark: "#02A877"}).SetString("✓ ") 21 | f.UnselectedPrefix = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "", Dark: "243"}).SetString("• ") 22 | 23 | // light := catppuccin.Latte 24 | // dark := catppuccin.Mocha 25 | // green = lipgloss.AdaptiveColor{Light: light.Green().Hex, Dark: dark.Green().Hex} 26 | 27 | t.Help.Ellipsis.Foreground(subtext0) 28 | t.Help.ShortKey.Foreground(subtext0) 29 | t.Help.ShortDesc.Foreground(overlay1) 30 | t.Help.ShortSeparator.Foreground(subtext0) 31 | t.Help.FullKey.Foreground(subtext0) 32 | t.Help.FullDesc.Foreground(overlay1) 33 | t.Help.FullSeparator.Foreground(subtext0) 34 | 35 | return t 36 | } 37 | --------------------------------------------------------------------------------