diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..489fb04 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,7 @@ +# Changesets + +Use `pnpm changeset` in feature branches to describe user-facing package changes. + +When changes land on `main`, the release workflow opens or updates a release PR. +Merging that release PR publishes changed packages to npm and creates GitHub +Releases from the generated changelogs. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..3671d8f --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "beeper/pickle" }], + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml deleted file mode 100644 index 480f40f..0000000 --- a/.github/actions/setup-go/action.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Setup Go -description: 'Sets up Go environment with private modules' -inputs: - stainless-api-key: - required: false - description: the value of the STAINLESS_API_KEY secret -runs: - using: composite - steps: - - uses: stainless-api/retrieve-github-access-token@v1 - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - id: get_token - with: - repo: stainless-sdks/beeper-desktop-api-go - stainless-api-key: ${{ inputs.stainless-api-key }} - - - name: Configure Git for access to the Go SDK's staging repo - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - shell: bash - run: git config --global url."https://x-access-token:${{ steps.get_token.outputs.github_access_token }}@github.com/stainless-sdks/beeper-desktop-api-go".insteadOf "https://github.com/stainless-sdks/beeper-desktop-api-go" - - - name: Setup go - uses: actions/setup-go@v5 - with: - go-version-file: ./go.mod - - - name: Bootstrap - shell: bash - run: ./scripts/bootstrap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2742613..b142f26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,115 +2,42 @@ name: CI on: push: branches: - - '**' - - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' + - "**" + - "!integrated/**" + - "!stl-preview-head/**" + - "!stl-preview-base/**" + - "!generated" + - "!codegen/**" + - "codegen/stl/**" pull_request: branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' - -env: - GOPRIVATE: github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go + - "stl-preview-head/**" + - "stl-preview-base/**" jobs: - lint: - timeout-minutes: 10 - name: lint - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run lints - run: ./scripts/lint - - build: - timeout-minutes: 10 - name: build - permissions: - contents: read - id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run goreleaser - uses: goreleaser/goreleaser-action@v6.1.0 - with: - version: latest - args: release --snapshot --clean --skip=publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get GitHub OIDC Token - if: |- - github.repository == 'stainless-sdks/beeper-desktop-api-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - id: github-oidc - uses: actions/github-script@v8 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: |- - github.repository == 'stainless-sdks/beeper-desktop-api-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh - test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-go + - uses: actions/setup-node@v6 with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} + node-version: 24 + cache: pnpm + + - name: Enable pnpm + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true + - name: Typecheck + run: pnpm typecheck - - name: Bootstrap - run: ./scripts/bootstrap + - name: Test + run: pnpm test - - name: Run tests - run: ./scripts/test + - name: Package Homebrew archive + run: pnpm --filter beeper-cli run pack:homebrew diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4e556fb..208adad 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,27 +11,37 @@ on: tags: - "v*" jobs: - goreleaser: + publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 + - name: Set up Node + uses: actions/setup-node@v6 with: - go-version-file: "go.mod" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.1.0 - with: - version: latest - args: release --clean + node-version: 24 + cache: pnpm + - name: Enable pnpm + run: corepack enable + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Test + run: pnpm test + - name: Build Homebrew archive + run: pnpm --filter beeper-cli run pack:homebrew + - name: Publish GitHub release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + if ! gh release view "${tag}" >/dev/null 2>&1; then + gh release create "${tag}" --title "${tag}" --generate-notes --verify-tag + fi + gh release upload "${tag}" packages/cli/dist/release/*.tar.gz packages/cli/dist/release/homebrew.json --clobber + - name: Publish Homebrew formula env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} \ No newline at end of file + run: node scripts/publish-homebrew-formula.mjs diff --git a/.gitignore b/.gitignore index 268ede0..cadd9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .prism.log .stdy.log dist/ +node_modules/ /beeper-desktop-cli *.exe diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index b418408..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,110 +0,0 @@ -project_name: beeper-desktop-cli -version: 2 - -before: - hooks: - - mkdir -p completions - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion bash > completions/beeper-desktop-cli.bash" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion zsh > completions/beeper-desktop-cli.zsh" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion fish > completions/beeper-desktop-cli.fish" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @manpages -o man" - -builds: - - id: macos - goos: [darwin] - goarch: [amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: linux - goos: [linux] - goarch: ['386', arm, amd64, arm64] - env: - - CGO_ENABLED=0 - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: windows - goos: [windows] - goarch: ['386', amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - -archives: - - id: linux-archive - ids: [linux] - name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [tar.gz] - files: - - completions/* - - man/*/* - - id: macos-archive - ids: [macos] - name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - - id: windows-archive - ids: [windows] - name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - -snapshot: - version_template: '{{ .Tag }}-next' - -nfpms: - - license: MIT - maintainer: help@beeper.com - bindir: /usr - formats: - - apk - - deb - - rpm - - termux.deb - - archlinux - contents: - - src: man/man1/*.1.gz - dst: /usr/share/man/man1/ -homebrew_casks: - - name: beeper-desktop-cli - repository: - owner: beeper - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - homepage: https://developers.beeper.com/desktop-api/ - description: CLI for Beeper Desktop API - license: MIT - binary: "beeper-desktop-cli" - completions: - bash: "completions/beeper-desktop-cli.bash" - zsh: "completions/beeper-desktop-cli.zsh" - fish: "completions/beeper-desktop-cli.fish" - manpages: - - man/man1/beeper-desktop-cli.1.gz - -notarize: - macos: - - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' - ids: [macos] - - sign: - certificate: "{{.Env.MACOS_SIGN_P12}}" - password: "{{.Env.MACOS_SIGN_PASSWORD}}" - - notarize: - issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" - key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" - key: "{{.Env.MACOS_NOTARY_KEY}}" diff --git a/README.md b/README.md deleted file mode 100644 index 09e97fb..0000000 --- a/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# Beeper Desktop CLI - -The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com/desktop-api/). - -It is generated with [Stainless](https://www.stainless.com/). - - - -## Installation - -### Installing with Homebrew - -```sh -brew install beeper/tap/beeper-desktop-cli -``` - -### Installing with Go - -To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed. - -```sh -go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-cli@latest' -``` - -Once you have run `go install`, the binary is placed in your Go bin directory: - -- **Default location**: `$HOME/go/bin` (or `$GOPATH/bin` if GOPATH is set) -- **Check your path**: Run `go env GOPATH` to see the base directory - -If commands aren't found after installation, add the Go bin directory to your PATH: - -```sh -# Add to your shell profile (.zshrc, .bashrc, etc.) -export PATH="$PATH:$(go env GOPATH)/bin" -``` - - - -### Running Locally - -After cloning the git repository for this project, you can use the -`scripts/run` script to run the tool locally: - -```sh -./scripts/run args... -``` - -## Usage - -The CLI follows a resource-based command structure: - -```sh -beeper-desktop-cli [resource] [flags...] -``` - -```sh -beeper-desktop-cli chats search \ - --include-muted \ - --limit 3 \ - --type single -``` - -For details about specific commands, use the `--help` flag. - -### Environment variables - -| Environment variable | Description | Required | -| --------------------- | ----------------------------------------------------------------------------------------------------- | -------- | -| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. | yes | - -### Global flags - -- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. (can also be set with `BEEPER_ACCESS_TOKEN` env var) -- `--help` - Show command line usage -- `--debug` - Enable debug logging (includes HTTP request/response details) -- `--version`, `-v` - Show the CLI version -- `--base-url` - Use a custom API backend URL -- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) -- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) - -### Passing files as arguments - -To pass files to your API, you can use the `@myfile.ext` syntax: - -```bash -beeper-desktop-cli --arg @abe.jpg -``` - -Files can also be passed inside JSON or YAML blobs: - -```bash -beeper-desktop-cli --arg '{image: "@abe.jpg"}' -# Equivalent: -beeper-desktop-cli < --username '\@abe' -``` - -#### Explicit encoding - -For JSON endpoints, the CLI tool does filetype sniffing to determine whether the -file contents should be sent as a string literal (for plain text files) or as a -base64-encoded string literal (for binary files). If you need to explicitly send -the file as either plain text or base64-encoded data, you can use -`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for -base64-encoding). Note that absolute paths will begin with `@file://` or -`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`). - -```bash -beeper-desktop-cli --arg @data://file.txt -``` diff --git a/bin/check-release-environment b/bin/check-release-environment deleted file mode 100644 index 1e951e9..0000000 --- a/bin/check-release-environment +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -errors=() - -lenErrors=${#errors[@]} - -if [[ lenErrors -gt 0 ]]; then - echo -e "Found the following errors in the release environment:\n" - - for error in "${errors[@]}"; do - echo -e "- $error\n" - done - - exit 1 -fi - -echo "The environment is ready to push releases!" diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go deleted file mode 100644 index e27225a..0000000 --- a/cmd/beeper-desktop-cli/main.go +++ /dev/null @@ -1,62 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package main - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "slices" - - "github.com/beeper/desktop-api-cli/pkg/cmd" - "github.com/beeper/desktop-api-go" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -func main() { - app := cmd.Command - - if slices.Contains(os.Args, "__complete") { - prepareForAutocomplete(app) - } - - if err := app.Run(context.Background(), os.Args); err != nil { - exitCode := 1 - - // Check if error has a custom exit code - if exitErr, ok := err.(cli.ExitCoder); ok { - exitCode = exitErr.ExitCode() - } - - var apierr *beeperdesktopapi.Error - if errors.As(err, &apierr) { - fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) - format := app.String("format-error") - json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(os.Stdout, "Error", json, format, app.String("transform-error")) - if show_err != nil { - // Just print the original error: - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } else { - if cmd.CommandErrorBuffer.Len() > 0 { - os.Stderr.Write(cmd.CommandErrorBuffer.Bytes()) - } else { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } - os.Exit(exitCode) - } -} - -func prepareForAutocomplete(cmd *cli.Command) { - // urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags. - // This skips that sort of validation - cmd.SkipFlagParsing = true - for _, child := range cmd.Commands { - prepareForAutocomplete(child) - } -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 4d42343..0000000 --- a/go.mod +++ /dev/null @@ -1,46 +0,0 @@ -module github.com/beeper/desktop-api-cli - -go 1.25 - -require ( - github.com/beeper/desktop-api-go v0.5.0 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/term v0.2.1 - github.com/goccy/go-yaml v1.18.0 - github.com/itchyny/json2yaml v0.1.4 - github.com/muesli/reflow v0.3.0 - github.com/stretchr/testify v1.10.0 - github.com/tidwall/gjson v1.18.0 - github.com/tidwall/pretty v1.2.1 - github.com/urfave/cli-docs/v3 v3.0.0-alpha6 - github.com/urfave/cli/v3 v3.3.2 - golang.org/x/sys v0.38.0 -) - -require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index c6fed8f..0000000 --- a/go.sum +++ /dev/null @@ -1,89 +0,0 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/beeper/desktop-api-go v0.5.0 h1:0Myrz8eop5dC3/QseUrbYVIyWkHPGLyU47/lffw/kT4= -github.com/beeper/desktop-api-go v0.5.0/go.mod h1:y9Mk83OdQWo6ldLTcPyaUPrwjkmvy/3QkhHqZLhU/mA= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= -github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= -github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= -github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go deleted file mode 100644 index 857fe14..0000000 --- a/internal/apiform/encoder.go +++ /dev/null @@ -1,236 +0,0 @@ -package apiform - -import ( - "fmt" - "io" - "mime/multipart" - "net/textproto" - "path" - "reflect" - "sort" - "strconv" - "strings" -) - -// Marshal encodes a value as multipart form data using default settings -func Marshal(value any, writer *multipart.Writer) error { - e := &encoder{ - format: FormatRepeat, - } - return e.marshal(value, writer) -} - -// MarshalWithSettings encodes a value with custom array format -func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error { - e := &encoder{ - format: arrayFormat, - } - return e.marshal(value, writer) -} - -type encoder struct { - format FormFormat -} - -func (e *encoder) marshal(value any, writer *multipart.Writer) error { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil - } - return e.encodeValue("", val, writer) -} - -func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error { - if !val.IsValid() { - return writer.WriteField(key, "") - } - - t := val.Type() - - if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { - return e.encodeReader(key, val, writer) - } - - switch t.Kind() { - case reflect.Pointer: - if val.IsNil() || !val.IsValid() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.Slice, reflect.Array: - return e.encodeArray(key, val, writer) - - case reflect.Map: - return e.encodeMap(key, val, writer) - - case reflect.Interface: - if val.IsNil() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.String: - return writer.WriteField(key, val.String()) - - case reflect.Bool: - if val.Bool() { - return writer.WriteField(key, "true") - } - return writer.WriteField(key, "false") - - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return writer.WriteField(key, strconv.FormatInt(val.Int(), 10)) - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) - - case reflect.Float32: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) - - case reflect.Float64: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) - - default: - return fmt.Errorf("unknown type: %s", t.String()) - } -} - -func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error { - if e.format == FormatComma { - var values []string - for i := 0; i < val.Len(); i++ { - item := val.Index(i) - if (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && item.IsNil() { - // Null values are sent as an empty string - values = append(values, "") - continue - } - // If item is an interface, reduce it to the concrete type - if item.Kind() == reflect.Interface { - item = item.Elem() - } - var strValue string - switch item.Kind() { - case reflect.String: - strValue = item.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - strValue = strconv.FormatInt(item.Int(), 10) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - strValue = strconv.FormatUint(item.Uint(), 10) - case reflect.Float32, reflect.Float64: - strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) - case reflect.Bool: - strValue = strconv.FormatBool(item.Bool()) - default: - return fmt.Errorf("comma format not supported for complex array elements") - } - values = append(values, strValue) - } - return writer.WriteField(key, strings.Join(values, ",")) - } - - for i := 0; i < val.Len(); i++ { - var formattedKey string - switch e.format { - case FormatRepeat: - formattedKey = key - case FormatBrackets: - formattedKey = key + "[]" - case FormatIndicesDots: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "." + strconv.Itoa(i) - } - case FormatIndicesBrackets: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "[" + strconv.Itoa(i) + "]" - } - default: - return fmt.Errorf("apiform: unsupported array format") - } - - if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil { - return err - } - } - return nil -} - -var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") - -func escapeQuotes(s string) string { - return quoteEscaper.Replace(s) -} - -func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error { - reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) - if !ok { - return nil - } - - // Set defaults - filename := "anonymous_file" - contentType := "application/octet-stream" - - // Get filename if available - if named, ok := reader.(interface{ Filename() string }); ok { - filename = named.Filename() - } else if named, ok := reader.(interface{ Name() string }); ok { - filename = path.Base(named.Name()) - } - - // Get content type if available - if typed, ok := reader.(interface{ ContentType() string }); ok { - contentType = typed.ContentType() - } - - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, - escapeQuotes(key), escapeQuotes(filename))) - h.Set("Content-Type", contentType) - - filewriter, err := writer.CreatePart(h) - if err != nil { - return err - } - _, err = io.Copy(filewriter, reader) - return err -} - -func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error { - type mapPair struct { - key string - value reflect.Value - } - - if key != "" { - key = key + "." - } - - // Collect and sort map entries for deterministic output - pairs := []mapPair{} - iter := val.MapRange() - for iter.Next() { - if iter.Key().Type().Kind() != reflect.String { - return fmt.Errorf("cannot encode a map with a non string key") - } - pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) - } - - sort.Slice(pairs, func(i, j int) bool { - return pairs[i].key < pairs[j].key - }) - - // Process sorted pairs - for _, p := range pairs { - if err := e.encodeValue(key+p.key, p.value, writer); err != nil { - return err - } - } - - return nil -} diff --git a/internal/apiform/form.go b/internal/apiform/form.go deleted file mode 100644 index 024de27..0000000 --- a/internal/apiform/form.go +++ /dev/null @@ -1,20 +0,0 @@ -package apiform - -type Marshaler interface { - MarshalMultipart() ([]byte, string, error) -} - -type FormFormat int - -const ( - // FormatRepeat represents arrays as repeated keys with the same value - FormatRepeat FormFormat = iota - // Comma-separated values 1,2,3 - FormatComma - // FormatBrackets uses the key[] notation for arrays - FormatBrackets - // FormatIndicesDots uses key.0, key.1, etc. notation - FormatIndicesDots - // FormatIndicesBrackets uses key[0], key[1], etc. notation - FormatIndicesBrackets -) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go deleted file mode 100644 index 2cf5bdd..0000000 --- a/internal/apiform/form_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package apiform - -import ( - "bytes" - "mime/multipart" - "testing" -) - -// Define test cases -var tests = map[string]struct { - value any - format FormFormat - expected string -}{ - "nil": { - value: nil, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n", - }, - "string": { - value: "hello", - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n", - }, - "int": { - value: 42, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n", - }, - "float": { - value: 3.14, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n", - }, - "bool": { - value: true, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", - }, - "empty slice": { - value: []string{}, - expected: "\r\n--xxx--\r\n", - }, - "nil slice": { - value: []string(nil), - expected: "\r\n--xxx--\r\n", - }, - "slice with dot indices": { - value: []string{"a", "b", "c"}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n", - }, - "slice with bracket indices": { - value: []int{10, 20, 30}, - format: FormatIndicesBrackets, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with repeat": { - value: []int{10, 20, 30}, - format: FormatRepeat, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with commas": { - value: []int{10, 20, 30}, - format: FormatComma, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n", - }, - "empty map": { - value: map[string]any{}, - expected: "\r\n--xxx--\r\n", - }, - "nil map": { - value: map[string]any(nil), - expected: "\r\n--xxx--\r\n", - }, - "map": { - value: map[string]any{"key1": "value1", "key2": "value2"}, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n", - }, - "nested_map": { - value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n", - }, - "mixed_map": { - value: map[string]any{"name": "John", "ages": []int{25, 30, 35}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n", - }, -} - -func TestEncode(t *testing.T) { - for name, test := range tests { - t.Run(name, func(t *testing.T) { - buf := bytes.NewBuffer(nil) - writer := multipart.NewWriter(buf) - writer.SetBoundary("xxx") - - form := map[string]any{"foo": test.value} - err := MarshalWithSettings(form, writer, test.format) - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - err = writer.Close() - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - result := buf.String() - if result != test.expected { - t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result) - } - }) - } -} diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go deleted file mode 100644 index 0d09dee..0000000 --- a/internal/apiquery/encoder.go +++ /dev/null @@ -1,166 +0,0 @@ -package apiquery - -import ( - "fmt" - "reflect" - "strconv" - "strings" -) - -type encoder struct { - settings QuerySettings -} - -type Pair struct { - key string - value string -} - -func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) { - t := value.Type() - switch t.Kind() { - case reflect.Pointer: - if value.IsNil() || !value.IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - case reflect.Array, reflect.Slice: - return e.encodeArray(key, value) - - case reflect.Map: - return e.encodeMap(key, value) - - case reflect.Interface: - if !value.Elem().IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - default: - return e.encodePrimitive(key, value) - } -} - -func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) { - var pairs []Pair - iter := value.MapRange() - for iter.Next() { - subkey := iter.Key().String() - keyPath := subkey - if len(key) > 0 { - if e.settings.NestedFormat == NestedQueryFormatDots { - keyPath = fmt.Sprintf("%s.%s", key, subkey) - } else { - keyPath = fmt.Sprintf("%s[%s]", key, subkey) - } - } - - subpairs, err := e.Encode(keyPath, iter.Value()) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil -} - -func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) { - switch e.settings.ArrayFormat { - case ArrayQueryFormatComma: - elements := []string{} - for i := 0; i < value.Len(); i++ { - innerPairs, err := e.Encode("", value.Index(i)) - if err != nil { - return nil, err - } - for _, pair := range innerPairs { - elements = append(elements, pair.value) - } - } - return []Pair{{key, strings.Join(elements, ",")}}, nil - - case ArrayQueryFormatRepeat: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key, value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatIndices: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatBrackets: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key+"[]", value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - default: - panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) - } -} - -func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) { - switch value.Kind() { - case reflect.Pointer: - if !value.IsValid() || value.IsNil() { - return nil, nil - } - return e.encodePrimitive(key, value.Elem()) - - case reflect.String: - return []Pair{{key, value.String()}}, nil - - case reflect.Bool: - if value.Bool() { - return []Pair{{key, "true"}}, nil - } - return []Pair{{key, "false"}}, nil - - case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: - return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil - - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil - - case reflect.Float32, reflect.Float64: - return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil - - default: - return nil, nil - } -} - -func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return nil, fmt.Errorf("apiquery: field cannot be null") - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.Encode(key, raw) - } - return e.Encode(key, value.FieldByName("Value")) -} diff --git a/internal/apiquery/query.go b/internal/apiquery/query.go deleted file mode 100644 index fd07a2f..0000000 --- a/internal/apiquery/query.go +++ /dev/null @@ -1,53 +0,0 @@ -package apiquery - -import ( - "net/url" - "reflect" -) - -func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil, nil - } - - e := encoder{settings} - pairs, err := e.Encode("", val) - if err != nil { - return nil, err - } - - kv := url.Values{} - for _, pair := range pairs { - kv.Add(pair.key, pair.value) - } - return kv, nil -} -func Marshal(value any) (url.Values, error) { - return MarshalWithSettings(value, QuerySettings{}) -} - -type Queryer interface { - URLQuery() (url.Values, error) -} - -type NestedQueryFormat int - -const ( - NestedQueryFormatBrackets NestedQueryFormat = iota - NestedQueryFormatDots -) - -type ArrayQueryFormat int - -const ( - ArrayQueryFormatComma ArrayQueryFormat = iota - ArrayQueryFormatRepeat - ArrayQueryFormatIndices - ArrayQueryFormatBrackets -) - -type QuerySettings struct { - NestedFormat NestedQueryFormat - ArrayFormat ArrayQueryFormat -} diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go deleted file mode 100644 index 8bee784..0000000 --- a/internal/apiquery/query_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package apiquery - -import ( - "net/url" - "testing" -) - -func TestEncode(t *testing.T) { - tests := map[string]struct { - val any - settings QuerySettings - enc string - }{ - "null": { - val: nil, - enc: "query=", - }, - "string": { - val: "hello world", - enc: "query=hello world", - }, - "int": { - val: 42, - enc: "query=42", - }, - "float": { - val: 3.14, - enc: "query=3.14", - }, - "bool": { - val: true, - enc: "query=true", - }, - "empty_slice": { - val: []any{}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "nil_slice": { - val: []any(nil), - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "slice_of_ints": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=10,20,30", - }, - "slice_of_ints_repeat": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, - enc: "query=10&query=20&query=30", - }, - "slice_of_ints_indices": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices}, - enc: "query[0]=10&query[1]=20&query[2]=30", - }, - "slice_of_ints_brackets": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, - enc: "query[]=10&query[]=20&query[]=30", - }, - "slice_of_strings": { - val: []any{"a", "b", "c"}, - settings: QuerySettings{}, - enc: "query=a,b,c", - }, - "empty_map": { - val: map[string]any{}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "nil_map": { - val: map[string]any(nil), - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "map_string_to_int_brackets": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "query[one]=1&query[two]=2", - }, - "map_string_to_int_dots": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatDots}, - enc: "query.one=1&query.two=2", - }, - "map_string_to_slice": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{}, - enc: "query[nums]=10,20,30", - }, - "map_string_to_slice_repeat_dots": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots}, - enc: "query.nums=10&query.nums=20&query.nums=30", - }, - "map_with_empties": { - val: map[string]any{ - "empty-array": []any{}, - "nil-array": []any(nil), - "null": nil, - }, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots}, - enc: "query.empty-array=&query.nil-array=&query.null=", - }, - "nested_map": { - val: map[string]map[string]any{"outer": {"inner": 42}}, - settings: QuerySettings{}, - enc: "query[outer][inner]=42", - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - query := map[string]any{"query": test.val} - values, err := MarshalWithSettings(query, test.settings) - if err != nil { - t.Fatalf("failed to marshal url %s", err) - } - str, _ := url.QueryUnescape(values.Encode()) - if str != test.enc { - t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str) - } - }) - } -} diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go deleted file mode 100644 index 97fe1a8..0000000 --- a/internal/autocomplete/autocomplete.go +++ /dev/null @@ -1,361 +0,0 @@ -package autocomplete - -import ( - "context" - "embed" - "fmt" - "os" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -type CompletionStyle string - -const ( - CompletionStyleZsh CompletionStyle = "zsh" - CompletionStyleBash CompletionStyle = "bash" - CompletionStylePowershell CompletionStyle = "pwsh" - CompletionStyleFish CompletionStyle = "fish" -) - -type renderCompletion func(cmd *cli.Command, appName string) (string, error) - -var ( - //go:embed shellscripts - autoCompleteFS embed.FS - - shellCompletions = map[CompletionStyle]renderCompletion{ - "bash": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "fish": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "pwsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "zsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - } -) - -func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { - shells := make([]CompletionStyle, 0, len(shellCompletions)) - for k := range shellCompletions { - shells = append(shells, k) - } - - if cmd.Args().Len() == 0 { - return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1) - } - s := CompletionStyle(cmd.Args().First()) - - renderCompletion, ok := shellCompletions[s] - if !ok { - return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) - } - - completionScript, err := renderCompletion(cmd, cmd.Root().Name) - if err != nil { - return cli.Exit(err, 1) - } - - _, err = cmd.Writer.Write([]byte(completionScript)) - if err != nil { - return cli.Exit(err, 1) - } - - return nil -} - -type ShellCompletion struct { - Name string - Usage string -} - -func NewShellCompletion(name string, usage string) ShellCompletion { - return ShellCompletion{Name: name, Usage: usage} -} - -type ShellCompletionBehavior int - -const ( - ShellCompletionBehaviorDefault ShellCompletionBehavior = iota - ShellCompletionBehaviorFile = 10 - ShellCompletionBehaviorNoComplete -) - -type CompletionResult struct { - Completions []ShellCompletion - Behavior ShellCompletionBehavior -} - -func isFlag(arg string) bool { - return strings.HasPrefix(arg, "-") -} - -func findFlag(cmd *cli.Command, arg string) *cli.Flag { - name := strings.TrimLeft(arg, "-") - for _, flag := range cmd.Flags { - if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() { - continue - } - - if slices.Contains(flag.Names(), name) { - return &flag - } - } - return nil -} - -func findChild(cmd *cli.Command, name string) *cli.Command { - for _, c := range cmd.Commands { - if !c.Hidden && c.Name == name { - return c - } - } - return nil -} - -type shellCompletionBuilder struct { - completionStyle CompletionStyle -} - -func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len(command.Names())) - - for _, name := range command.Names() { - if strings.HasPrefix(name, input) { - matchingNames = append(matchingNames, name) - } - } - - if scb.completionStyle == CompletionStyleBash { - index := strings.LastIndex(input, ":") + 1 - if index > 0 { - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name[index:], command.Usage)) - } - return result - } - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, command.Usage)) - } - return result -} - -func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len((*flag).Names())) - - for _, name := range (*flag).Names() { - withPrefix := "" - if len(name) == 1 { - withPrefix = "-" + name - } else { - withPrefix = "--" + name - } - - if strings.HasPrefix(withPrefix, input) { - matchingNames = append(matchingNames, withPrefix) - } - } - - usage := "" - if dgf, ok := (*flag).(cli.DocGenerationFlag); ok { - usage = dgf.GetUsage() - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, usage)) - } - - return result -} - -func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - result := getAllPossibleCompletions(completionStyle, root, args) - - // If the user has not put in a colon, filter out colon commands - if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") { - // Nothing with anything after a colon. Create a single entry for groups with the same colon subset - foundNames := make([]string, 0, len(result.Completions)) - filteredCompletions := make([]ShellCompletion, 0, len(result.Completions)) - - for _, completion := range result.Completions { - name := completion.Name - firstColonIndex := strings.Index(name, ":") - if firstColonIndex > -1 { - name = name[0:firstColonIndex] - completion.Name = name - completion.Usage = "" - } - - if !slices.Contains(foundNames, name) { - foundNames = append(foundNames, name) - filteredCompletions = append(filteredCompletions, completion) - } - } - - result.Completions = filteredCompletions - } - - return result -} - -func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - builder := shellCompletionBuilder{completionStyle: completionStyle} - completions := make([]ShellCompletion, 0) - if len(args) == 0 { - for _, child := range root.Commands { - completions = builder.createFromCommand("", child, completions) - } - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault} - } - - current := args[len(args)-1] - preceding := args[0 : len(args)-1] - cmd := root - i := 0 - for i < len(preceding) { - arg := preceding[i] - - if isFlag(arg) { - flag := findFlag(cmd, arg) - if flag == nil { - i++ - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - // All flags except for bool flags take values - i += 2 - } else { - i++ - } - } else { - child := findChild(cmd, arg) - if child != nil { - cmd = child - } - i++ - } - } - - // Check if the previous arg was a flag expecting a value - if len(preceding) > 0 { - prev := preceding[len(preceding)-1] - if isFlag(prev) { - flag := findFlag(cmd, prev) - if flag != nil { - if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile} - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete} - } - } - } - } - - // Completing a flag name - if isFlag(current) { - for _, flag := range cmd.Flags { - completions = builder.createFromFlag(current, &flag, completions) - } - } - - for _, child := range cmd.Commands { - if !child.Hidden { - completions = builder.createFromCommand(current, child, completions) - } - } - - return CompletionResult{ - Completions: completions, - Behavior: ShellCompletionBehaviorDefault, - } -} - -func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { - root := cmd.Root() - args := rebuildColonSeparatedArgs(root.Args().Slice()[1:]) - - var completionStyle CompletionStyle - if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok { - switch style { - case "bash": - completionStyle = CompletionStyleBash - case "zsh": - completionStyle = CompletionStyleZsh - case "pwsh": - completionStyle = CompletionStylePowershell - case "fish": - completionStyle = CompletionStyleFish - default: - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1) - } - } else { - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1) - } - - result := GetCompletions(completionStyle, root, args) - - for _, completion := range result.Completions { - name := completion.Name - if completionStyle == CompletionStyleZsh { - name = strings.ReplaceAll(name, ":", "\\:") - } - if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage) - } else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage) - } else { - _, _ = fmt.Fprintf(cmd.Writer, "%s\n", name) - } - } - return cli.Exit("", int(result.Behavior)) -} - -// When CLI arguments are passed in, they are separated on word barriers. -// Most commonly this is whitespace but in some cases that may also be colons. -// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring -// arguments. -// -// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]` -func rebuildColonSeparatedArgs(args []string) []string { - if len(args) == 0 { - return args - } - - result := []string{} - i := 0 - - for i < len(args) { - current := args[i] - - // Keep joining while the next element is ":" or the current element ends with ":" - for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { - if args[i+1] == ":" { - current += ":" - i++ - // Check if there's a following element after the ":" - if i+1 < len(args) && args[i+1] != ":" { - current += args[i+1] - i++ - } - } else { - break - } - } - - result = append(result, current) - i++ - } - - return result -} diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go deleted file mode 100644 index 3e8aa33..0000000 --- a/internal/autocomplete/autocomplete_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package autocomplete - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestGetCompletions_EmptyArgs(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 3) - assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"}) -} - -func TestGetCompletions_SubcommandPrefix(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"ge"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "generate", result.Completions[0].Name) - assert.Equal(t, "Generate SDK", result.Completions[0].Usage) -} - -func TestGetCompletions_HiddenCommand(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "visible", Usage: "Visible command"}, - {Name: "hidden", Usage: "Hidden command", Hidden: true}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "visible", result.Completions[0].Name) -} - -func TestGetCompletions_NestedSubcommand(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "config", - Usage: "Configuration commands", - Commands: []*cli.Command{ - {Name: "get", Usage: "Get config value"}, - {Name: "set", Usage: "Set config value"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "set", result.Completions[0].Name) - assert.Equal(t, "Set config value", result.Completions[0].Usage) -} - -func TestGetCompletions_FlagCompletion(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "--output", result.Completions[0].Name) - assert.Equal(t, "Output directory", result.Completions[0].Usage) -} - -func TestGetCompletions_ShortFlagCompletion(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "-v", result.Completions[0].Name) -} - -func TestGetCompletions_FileFlagBehavior(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_NonBoolFlagValue(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "Generate TypeScript SDK"}, - {Name: "python", Usage: "Generate Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"co"}) - - // Should collapse to single "config" entry without usage - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config", result.Completions[0].Name) - assert.Equal(t, "", result.Completions[0].Usage) -} - -func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:"}) - - // For bash, should show suffixes only - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "get") - assert.Contains(t, names, "set") - assert.Contains(t, names, "list") -} - -func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:"}) - - // For zsh, should show full names - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "config:get") - assert.Contains(t, names, "config:set") - assert.Contains(t, names, "config:list") -} - -func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:g"}) - - // For bash, should return suffix from after the colon in the input - // Input "config:g" has colon at index 6, so we take name[7:] from matched commands - assert.Len(t, result.Completions, 1) - assert.Equal(t, "get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"other:g"}) - - // No matches - assert.Len(t, result.Completions, 0) -} - -func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"}) - - // For zsh, should return full name - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config:get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - // Should show "generate" and "config" (collapsed) - assert.Len(t, result.Completions, 2) - names := []string{result.Completions[0].Name, result.Completions[1].Name} - assert.Contains(t, names, "generate") - assert.Contains(t, names, "config") -} - -func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - }, - }, - }, - } - - // Bool flag should not consume the next arg as a value - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - {Name: "python", Usage: "Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "python", result.Completions[0].Name) -} - -func TestGetCompletions_CommandAliases(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"g"}) - - // Should match all aliases that start with "g" - assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too - names := []string{} - for _, c := range result.Completions { - names = append(names, c.Name) - } - assert.Contains(t, names, "generate") - assert.Contains(t, names, "gen") -} - -func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "format", Aliases: []string{"f"}}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"}) - - // Should show all flag variations - assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format -} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash deleted file mode 100755 index 8fb7b0b..0000000 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -____APPNAME___bash_autocomplete() { - if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur completions exit_code - local IFS=$'\n' - cur="${COMP_WORDS[COMP_CWORD]}" - - completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null) - exit_code=$? - - local last_token="$cur" - - # If the last token has been split apart by a ':', join it back together. - # Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b' - if [[ $COMP_CWORD -ge 2 ]]; then - local prev2="${COMP_WORDS[COMP_CWORD - 2]}" - local prev1="${COMP_WORDS[COMP_CWORD - 1]}" - if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then - last_token="$prev2:$cur" - fi - fi - - # Check for custom file completion patterns - local prefix="" - local file_part="$cur" - local force_file_completion=false - if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then - local before_at="${BASH_REMATCH[1]}" - local protocol="${BASH_REMATCH[2]}" - file_part="${BASH_REMATCH[3]}" - - if [[ "$protocol" == "" ]]; then - prefix="$before_at@" - else - if [[ "$before_at" == "" ]]; then - prefix="//" - else - prefix="$before_at@$protocol" - fi - fi - - force_file_completion=true - fi - - if [[ "$force_file_completion" == true ]]; then - mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|") - else - case $exit_code in - 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion - 11) COMPREPLY=() ;; # no completion - 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions - esac - fi - return 0 - fi -} - -complete -F ____APPNAME___bash_autocomplete __APPNAME__ diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish deleted file mode 100644 index b853057..0000000 --- a/internal/autocomplete/shellscripts/fish_autocomplete.fish +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env fish - -function ____APPNAME___fish_autocomplete - set -l tokens (commandline -xpc) - set -l current (commandline -ct) - - set -l cmd $tokens[1] - set -l args $tokens[2..-1] - - set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log) - set -l exit_code $status - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - set -l prefix "" - set -l file_part "$current" - set -l force_file_completion 0 - - if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current - if string match -qr '^[\'"]' -- $before - # Ensures we don't insert an extra quote when the user is building an argument in quotes - set before (string sub -s 2 -- $before) - end - - set prefix "$before@$protocol" - set force_file_completion 1 - end - - if test $force_file_completion -eq 1 - for path in (__fish_complete_path "$file_part") - echo $prefix$path - end - else - switch $exit_code - case 10 - # File completion - __fish_complete_path "$current" - case 11 - # No completion - return 0 - case 0 - # Use returned completions - for completion in $completions - echo $completion - end - end - end -end - -complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)' - diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 deleted file mode 100644 index 7cd6e62..0000000 --- a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 +++ /dev/null @@ -1,97 +0,0 @@ -Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock { - param($wordToComplete, $commandAst, $cursorPosition) - - $elements = $commandAst.CommandElements - $completionArgs = @() - - # Extract each of the arguments - for ($i = 0; $i -lt $elements.Count; $i++) { - $completionArgs += $elements[$i].Extent.Text - } - - # Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space) - # Necessary for differentiating between getting completions for namespaced commands vs. subcommands - if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) { - $completionArgs += "" - } - - $output = & { - $env:COMPLETION_STYLE = 'pwsh' - __APPNAME__ __complete @completionArgs 2>&1 - } - $exitCode = $LASTEXITCODE - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - $prefix = "" - $filePart = $wordToComplete - $forceFileCompletion = $false - - # PowerShell includes quotes in $wordToComplete - strip them for pattern matching - # but preserve them in the prefix for the completion result - $wordContent = $wordToComplete - $leadingQuote = "" - if ($wordToComplete -match '^([''"])(.*)(\1)$') { - # Fully quoted: "content" or 'content' - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } elseif ($wordToComplete -match '^([''"])(.*)$') { - # Opening quote only: "content or 'content - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } - - if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') { - $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2] - $filePart = $Matches[3] - $forceFileCompletion = $true - } - - if ($forceFileCompletion) { - # Handle empty filePart (e.g., "@" or "@file://") by listing current directory - $items = if ([string]::IsNullOrEmpty($filePart)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } else { - switch ($exitCode) { - 10 { - # File completion behavior - $items = if ([string]::IsNullOrEmpty($wordToComplete)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } - 11 { - # No reasonable suggestions - [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ') - } - default { - # Default behavior - show command completions - $output | ForEach-Object { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } - } - } -} diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh deleted file mode 100644 index 4d4bdcd..0000000 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/zsh -compdef ____APPNAME___zsh_autocomplete __APPNAME__ - -____APPNAME___zsh_autocomplete() { - - local -a opts - local temp - local exit_code - - temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}") - exit_code=$? - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - local cur="${words[CURRENT]}" - - if [[ "$cur" = *'@'* ]]; then - # Extract everything after the last @ - local after_last_at="${cur##*@}" - - if [[ $after_last_at =~ ^(file://|data://) ]]; then - compset -P "*$MATCH" - _files - else - compset -P '*@' - _files - fi - return - fi - - case $exit_code in - 10) - # File completion behavior - _files - ;; - 11) - # No completion behavior - return nothing - return 1 - ;; - 0) - # Default behavior - show command completions - opts=("${(@f)temp}") - _describe 'values' opts - ;; - esac -} diff --git a/internal/binaryparam/binary_param.go b/internal/binaryparam/binary_param.go deleted file mode 100644 index 40d4ecf..0000000 --- a/internal/binaryparam/binary_param.go +++ /dev/null @@ -1,30 +0,0 @@ -package binaryparam - -import ( - "io" - "os" -) - -const stdinGlyph = "-" - -// FileOrStdin opens the file at the given path for reading. If the path is "-", stdin is returned instead. -// -// It's the caller's responsibility to close the returned ReadCloser (usually with `defer`). -// -// Returns a boolean indicating whether stdin is being used. If true, no other components of the calling -// program should attempt to read from stdin for anything else. -func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) { - // When the special glyph "-" is used, read from stdin. Although probably less necessary, also support - // special Unix files that refer to stdin. - switch path { - case "", stdinGlyph, "/dev/fd/0", "/dev/stdin": - return stdin, true, nil - } - - readCloser, err := os.Open(path) - if err != nil { - return nil, false, err - } - - return readCloser, false, err -} diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go deleted file mode 100644 index 7a66682..0000000 --- a/internal/binaryparam/binary_param_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package binaryparam - -import ( - "io" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFileOrStdin(t *testing.T) { - t.Parallel() - - const expectedContents = "test file contents" - - t.Run("WithFile", func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - readCloser, stdinInUse, err := FileOrStdin(os.Stdin, tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, readCloser.Close()) }) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.False(t, stdinInUse) - }) - - stdinTests := []struct { - testName string - path string - }{ - {"TestEmptyString", ""}, - {"TestDash", "-"}, - {"TestDevStdin", "/dev/stdin"}, - {"TestDevFD0", "/dev/fd/0"}, - } - for _, test := range stdinTests { - t.Run(test.testName, func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, test.path) - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) - } -} diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go deleted file mode 100644 index f07b93b..0000000 --- a/internal/debugmiddleware/debug_middleware.go +++ /dev/null @@ -1,127 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httputil" - "reflect" - "strings" -) - -// For the time being these type definitions are duplicated here so that we can -// test this file in a non-generated context. -type ( - Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) - MiddlewareNext = func(*http.Request) (*http.Response, error) -) - -const redactedPlaceholder = "" - -// Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`, -// which is handled specially in `redactRequest` below. -var sensitiveHeaders = []string{} - -// RequestLogger is a middleware that logs HTTP requests and responses. -type RequestLogger struct { - logger interface{ Printf(string, ...any) } // field for testability; usually log.Default() - sensitiveHeaders []string // field for testability; usually sensitiveHeaders -} - -// NewRequestLogger returns a new RequestLogger instance with default options. -func NewRequestLogger() *RequestLogger { - return &RequestLogger{ - logger: log.Default(), - sensitiveHeaders: sensitiveHeaders, - } -} - -func (m *RequestLogger) Middleware() Middleware { - return func(req *http.Request, mn MiddlewareNext) (*http.Response, error) { - redacted, err := m.redactRequest(req) - if err != nil { - return nil, err - } - if reqBytes, err := httputil.DumpRequest(redacted, true); err == nil { - m.logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(req) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - m.logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - } -} - -// redactRequest redacts sensitive information from the request for logging -// purposes. If redaction is necessary, the request is cloned before mutating -// the original and that clone is returned. As a small optimization, the -// original is request is returned unchanged if no redaction is necessary. -func (m *RequestLogger) redactRequest(req *http.Request) (*http.Request, error) { - redactedHeaders := req.Header.Clone() - - // Notably, the clauses below are written so they can redact multiple - // headers of the same name if necessary. - if values := redactedHeaders.Values("Authorization"); len(values) > 0 { - redactedHeaders.Del("Authorization") - - for _, value := range values { - // In case we're using something like a bearer token (e.g. `Bearer - // `), keep the `Bearer` part for more debugging - // information. - if authKind, _, ok := strings.Cut(value, " "); ok { - redactedHeaders.Add("Authorization", authKind+" "+redactedPlaceholder) - } else { - redactedHeaders.Add("Authorization", redactedPlaceholder) - } - } - } - - for _, header := range m.sensitiveHeaders { - values := redactedHeaders.Values(header) - if len(values) == 0 { - continue - } - - redactedHeaders.Del(header) - - for range values { - redactedHeaders.Add(header, redactedPlaceholder) - } - } - - if reflect.DeepEqual(req.Header, redactedHeaders) { - return req, nil - } - - redacted := req.Clone(req.Context()) - redacted.Header = redactedHeaders - var err error - redacted.Body, req.Body, err = cloneBody(req.Body) - return redacted, err -} - -// This function returns two copies of an HTTP request body that can each be -// read independently without affecting the other. -// This logic is taken from `drainBody` in net/http/httputil. -func cloneBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { - if b == nil || b == http.NoBody { - // No copying needed. Preserve the magic sentinel meaning of NoBody. - return http.NoBody, http.NoBody, nil - } - var buf bytes.Buffer - if _, err = buf.ReadFrom(b); err != nil { - return nil, b, err - } - if err = b.Close(); err != nil { - return nil, b, err - } - return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil -} diff --git a/internal/debugmiddleware/debug_middleware_test.go b/internal/debugmiddleware/debug_middleware_test.go deleted file mode 100644 index 4e46fbc..0000000 --- a/internal/debugmiddleware/debug_middleware_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDebugMiddleware(t *testing.T) { - t.Parallel() - - setup := func() (*RequestLogger, *bytes.Buffer) { - var ( - logBuf bytes.Buffer - middleware = NewRequestLogger() - ) - middleware.logger = log.New(&logBuf, "", 0) - return middleware, &logBuf - } - - t.Run("DoesNotRedactMostHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - const stainlessUserAgent = "Stainless" - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("User-Agent", stainlessUserAgent) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, stainlessUserAgent, req.Header.Get("User-Agent")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "User-Agent: "+stainlessUserAgent) - }) - - const secretToken = "secret-token" - - t.Run("RedactsAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) - - t.Run("RedactsOnlySecretInAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", "Bearer "+secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: Bearer "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleAuthorizationHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add("Authorization", secretToken+"1") - req.Header.Add("Authorization", secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - - if strings.Count(logBuf.String(), "Authorization: "+redactedPlaceholder) != 2 { - t.Error("expected exactly two redacted placeholders in authorization headers") - } - }) - - const customAPIKeyHeader = "X-My-Api-Key" - - t.Run("RedactsSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set(customAPIKeyHeader, secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add(customAPIKeyHeader, secretToken+"1") - req.Header.Add(customAPIKeyHeader, secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Equal(t, 2, strings.Count(logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder)) - }) - - t.Run("DoesNotConsumeRequestBodyWhenIoReader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - const bodyContent = "test request body content" - bodyReader := strings.NewReader(bodyContent) - - req := httptest.NewRequest("POST", "https://example.com", bodyReader) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request body should still be fully readable after the middleware runs - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - require.Equal(t, bodyContent, string(body)) - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) -} diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go deleted file mode 100644 index 055541e..0000000 --- a/internal/jsonview/explorer.go +++ /dev/null @@ -1,775 +0,0 @@ -package jsonview - -import ( - "encoding/json" - "errors" - "fmt" - "math" - "os" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/muesli/reflow/wordwrap" - "github.com/tidwall/gjson" -) - -const ( - // UI layout constants - borderPadding = 2 - heightOffset = 5 - tableMinHeight = 2 - titlePaddingLeft = 2 - titlePaddingTop = 0 - footerPaddingLeft = 1 - - // Column width constants - defaultColumnWidth = 10 - keyColumnWidth = 3 - valueColumnWidth = 5 - - // String formatting constants - maxStringLength = 100 - maxPreviewLength = 24 - - arrayColor = lipgloss.Color("1") - stringColor = lipgloss.Color("5") - objectColor = lipgloss.Color("4") -) - -type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - PrintValue key.Binding - Raw key.Binding - Quit key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -var keys = keyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Back: key.NewBinding( - key.WithKeys("left", "h", "backspace"), - key.WithHelp("←/h", "go back"), - ), - Enter: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "expand"), - ), - PrintValue: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "print and exit"), - ), - Raw: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "toggle raw JSON"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c", "enter"), - key.WithHelp("q/enter", "quit"), - ), -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) - arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) - stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) - objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) - stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) -) - -type JSONView interface { - GetPath() string - GetData() gjson.Result - Update(tea.Msg, bool) tea.Cmd - View() string - Resize(width, height int) -} - -type TableView struct { - width int - height int - path string - data gjson.Result - table table.Model - rowData []gjson.Result - iterator AnyIterator - isLoading bool - columns []table.Column -} - -func (tv *TableView) GetPath() string { return tv.path } -func (tv *TableView) GetData() gjson.Result { return tv.data } -func (tv *TableView) View() string { return tv.table.View() } - -func (tv *TableView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.table, cmd = tv.table.Update(msg) - - // Check if we need to load more data - if tv.iterator != nil && !tv.isLoading && tv.data.IsArray() { - cursor := tv.table.Cursor() - totalRows := len(tv.table.Rows()) - - // Load more when we're at the last row - if cursor == totalRows-1 { - tv.isLoading = true - return tv.loadMoreData(raw) - } - } - - return cmd -} - -func (tv *TableView) loadMoreData(raw bool) tea.Cmd { - return func() tea.Msg { - if tv.iterator == nil { - return nil - } - - if !tv.iterator.Next() { - tv.isLoading = false - return tv.iterator.Err() - } - - obj := tv.iterator.Current() - var result gjson.Result - if jsonBytes, err := json.Marshal(obj); err != nil { - return err - } else { - result = gjson.ParseBytes(jsonBytes) - } - - if !result.Exists() { - tv.isLoading = false - return nil - } - - // Add the new item to our data - tv.rowData = append(tv.rowData, result) - - // Add new row to the table - newRow := table.Row{formatValue(result, raw)} - - // For array of objects, we need to format according to columns - if len(tv.columns) > 1 && result.IsObject() { - newRow = make(table.Row, len(tv.columns)) - for i, col := range tv.columns { - newRow[i] = formatValue(result.Get(col.Title), raw) - } - } - - rows := tv.table.Rows() - rows = append(rows, newRow) - tv.table.SetRows(rows) - - // Resize columns to accommodate the new data - tv.Resize(tv.width, tv.height) - - tv.isLoading = false - return nil - } -} - -func (tv *TableView) Resize(width, height int) { - tv.width = width - tv.height = height - tv.updateColumnWidths(width) - tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) -} - -func (tv *TableView) updateColumnWidths(width int) { - columns := tv.table.Columns() - widths := make([]int, len(columns)) - - // Calculate required widths from headers and content - for i, col := range columns { - widths[i] = lipgloss.Width(col.Title) - } - - for _, row := range tv.table.Rows() { - for i, cell := range row { - if i < len(widths) { - widths[i] = max(widths[i], lipgloss.Width(cell)) - } - } - } - - totalWidth := sum(widths) - available := width - borderPadding*len(columns) - - if totalWidth <= available { - for i, w := range widths { - columns[i].Width = w - } - return - } - - fairShare := float64(available) / float64(len(columns)) - shrinkable := 0.0 - - for _, w := range widths { - if float64(w) > fairShare { - shrinkable += float64(w) - fairShare - } - } - - if shrinkable > 0 { - excess := float64(totalWidth - available) - for i, w := range widths { - if float64(w) > fairShare { - reduction := (float64(w) - fairShare) * (excess / shrinkable) - widths[i] = int(math.Round(float64(w) - reduction)) - } - } - } - - for i, w := range widths { - columns[i].Width = w - } - - tv.table.SetColumns(columns) -} - -type TextView struct { - path string - data gjson.Result - viewport viewport.Model - ready bool -} - -func (tv *TextView) GetPath() string { return tv.path } -func (tv *TextView) GetData() gjson.Result { return tv.data } -func (tv *TextView) View() string { return tv.viewport.View() } - -func (tv *TextView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.viewport, cmd = tv.viewport.Update(msg) - return cmd -} - -func (tv *TextView) Resize(width, height int) { - h := height - heightOffset - if !tv.ready { - tv.viewport = viewport.New(width, h) - tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) - tv.ready = true - return - } - tv.viewport.Width = width - tv.viewport.Height = h -} - -type JSONViewer struct { - stack []JSONView - root string - width int - height int - rawMode bool - message string - help help.Model -} - -// ExploreJSON explores a single JSON value known ahead of time -func ExploreJSON(title string, json gjson.Result) error { - view, err := newView("", json, false) - if err != nil { - return err - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -// ExploreJSONStream explores JSON data loaded incrementally via an iterator -func ExploreJSONStream[T any](title string, it Iterator[T]) error { - anyIt := genericToAnyIterator(it) - - preloadCount := 20 - if termHeight, _, err := term.GetSize(os.Stdout.Fd()); err == nil { - preloadCount = termHeight - } - - items := make([]any, 0, preloadCount) - for i := 0; i < preloadCount && anyIt.Next(); i++ { - items = append(items, anyIt.Current()) - } - - if err := anyIt.Err(); err != nil { - return err - } - - // Convert items to JSON array - jsonBytes, err := json.Marshal(items) - if err != nil { - return err - } - arrayJSON := gjson.ParseBytes(jsonBytes) - view, err := newTableView("", arrayJSON, false) - if err != nil { - return err - } - - // Set iterator if there might be more data - if len(items) == preloadCount { - view.iterator = anyIt - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } -func (v *JSONViewer) Init() tea.Cmd { return nil } - -func (v *JSONViewer) resize(width, height int) { - v.width, v.height = width, height - v.help.Width = width - for i := range v.stack { - v.stack[i].Resize(width, height) - } -} - -func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.resize(msg.Width-borderPadding, msg.Height) - return v, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, keys.Quit): - return v, tea.Quit - case key.Matches(msg, keys.Enter): - return v.navigateForward() - case key.Matches(msg, keys.Back): - return v.navigateBack() - case key.Matches(msg, keys.Raw): - return v.toggleRaw() - case key.Matches(msg, keys.PrintValue): - v.message = v.getSelectedContent() - return v, tea.Quit - } - } - - return v, v.current().Update(msg, v.rawMode) -} - -func (v *JSONViewer) getSelectedContent() string { - tableView, ok := v.current().(*TableView) - if !ok { - return v.current().GetData().Raw - } - - selected := tableView.rowData[tableView.table.Cursor()] - if selected.Type == gjson.String { - return selected.String() - } - return selected.Raw -} - -func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { - tableView, ok := v.current().(*TableView) - if !ok { - return v, nil - } - - cursor := tableView.table.Cursor() - selected := tableView.rowData[cursor] - if !v.canNavigateInto(selected) { - return v, nil - } - - path := v.buildNavigationPath(tableView, cursor) - forwardView, err := newView(path, selected, v.rawMode) - if err != nil { - return v, nil - } - - v.stack = append(v.stack, forwardView) - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { - if tableView.data.IsArray() { - return fmt.Sprintf("%s[%d]", tableView.path, cursor) - } - key := tableView.data.Get("@keys").Array()[cursor].Str - return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) -} - -func quoteString(s string) string { - // Replace backslashes and quotes with escaped versions - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return stringLiteralStyle.Render("\"" + s + "\"") -} - -func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { - switch { - case data.IsArray(): - return len(data.Array()) > 0 - case data.IsObject(): - return len(data.Map()) > 0 - case data.Type == gjson.String: - str := data.String() - return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength - } - return false -} - -func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { - if len(v.stack) > 1 { - v.stack = v.stack[:len(v.stack)-1] - } - return v, nil -} - -func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { - v.rawMode = !v.rawMode - - for i, view := range v.stack { - viewWithRaw, err := newView(view.GetPath(), view.GetData(), v.rawMode) - if err != nil { - return v, tea.Printf("Error: %s", err) - } - if newTV, ok := viewWithRaw.(*TableView); ok { - if tv, ok := view.(*TableView); ok && tv.iterator != nil { - newTV.iterator = tv.iterator - } - } - v.stack[i] = viewWithRaw - } - - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) View() string { - view := v.current() - title := v.buildTitle(view) - content := titleStyle.Render(title) - style := v.getStyleForData(view.GetData()) - content += "\n" + style.Render(view.View()) - content += "\n" + v.help.View(keys) - return content -} - -func (v *JSONViewer) buildTitle(view JSONView) string { - title := v.root - if len(view.GetPath()) > 0 { - title += " → " + view.GetPath() - } - if v.rawMode { - title += " (JSON)" - } - return title -} - -func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { - switch { - case data.Type == gjson.String: - return stringStyle - case data.IsArray(): - return arrayStyle - default: - return objectStyle - } -} - -func newView(path string, data gjson.Result, raw bool) (JSONView, error) { - if data.Type == gjson.String { - return newTextView(path, data) - } - return newTableView(path, data, raw) -} - -func newTextView(path string, data gjson.Result) (*TextView, error) { - if !data.Exists() || data.Type != gjson.String { - return nil, fmt.Errorf("invalid text JSON") - } - return &TextView{path: path, data: data}, nil -} - -func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { - if !data.Exists() || data.Type != gjson.JSON { - return nil, fmt.Errorf("invalid table JSON") - } - - switch { - case data.IsArray(): - array := data.Array() - if isArrayOfObjects(array) { - return newArrayOfObjectsTableView(path, data, array, raw), nil - } else { - return newArrayTableView(path, data, array, raw), nil - } - case data.IsObject(): - return newObjectTableView(path, data, raw), nil - default: - return nil, fmt.Errorf("unsupported JSON type") - } -} - -func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - rows = append(rows, table.Row{formatValue(item, raw)}) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - // Collect unique keys - keySet := make(map[string]struct{}) - var columns []table.Column - - for _, item := range array { - for _, key := range item.Get("@keys").Array() { - if _, exists := keySet[key.Str]; !exists { - keySet[key.Str] = struct{}{} - title := key.Str - columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) - } - } - } - - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - row := make(table.Row, len(columns)) - for i, col := range columns { - row[i] = formatValue(item.Get(col.Title), raw) - } - rows = append(rows, row) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Object"}, {}} - - keys := data.Get("@keys").Array() - rows := make([]table.Row, 0, len(keys)) - rowData := make([]gjson.Result, 0, len(keys)) - - for _, key := range keys { - value := data.Get(key.Str) - title := key.Str - rows = append(rows, table.Row{title, formatValue(value, raw)}) - rowData = append(rowData, value) - } - - // Adjust column widths based on content - for _, row := range rows { - for i, cell := range row { - if i < len(columns) { - columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) - } - } - } - - t := createTable(columns, rows, objectColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - - // Set common table styles - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(bgColor). - Bold(false) - t.SetStyles(s) - - return t -} - -func formatValue(value gjson.Result, raw bool) string { - if raw { - return value.Get("@ugly").Raw - } - - switch { - case value.IsObject(): - return formatObject(value) - case value.IsArray(): - return formatArray(value) - case value.Type == gjson.String: - return value.Str - default: - return value.Raw - } -} - -func formatObject(value gjson.Result) string { - keys := value.Get("@keys").Array() - keyStrs := make([]string, len(keys)) - - for i, key := range keys { - val := value.Get(key.Str) - keyStrs[i] = formatObjectKey(key.Str, val) - } - - return "{" + strings.Join(keyStrs, ", ") + "}" -} - -func formatObjectKey(key string, val gjson.Result) string { - switch { - case val.IsObject(): - return key + ":{…}" - case val.IsArray(): - return key + ":[…]" - case val.Type == gjson.String: - str := val.Str - if lipgloss.Width(str) <= maxPreviewLength { - return fmt.Sprintf(`%s:"%s"`, key, str) - } - return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) - default: - return key + ":" + val.Raw - } -} - -func formatArray(value gjson.Result) string { - switch count := len(value.Array()); count { - case 0: - return "[]" - case 1: - return "[...1 item...]" - default: - return fmt.Sprintf("[...%d items...]", count) - } -} - -func isArrayOfObjects(array []gjson.Result) bool { - for _, item := range array { - if !item.IsObject() { - return false - } - } - return len(array) > 0 -} - -func sum(ints []int) int { - total := 0 - for _, n := range ints { - total += n - } - return total -} - -// An iterator over `any` values -type AnyIterator interface { - Next() bool - Err() error - Current() any -} - -// A generic iterator interface that is used by the `genericIterator` struct -// below to convert iterators over specific types to an AnyIterator -type Iterator[T any] interface { - Next() bool - Err() error - Current() T -} - -// genericIterator adapts a generic Iterator[T] to an AnyIterator. -type genericIterator[T any] struct { - iterator Iterator[T] - current any -} - -func (g *genericIterator[T]) Next() bool { - if !g.iterator.Next() { - return false - } - g.current = g.iterator.Current() - return true -} - -func (g *genericIterator[T]) Err() error { - return g.iterator.Err() -} - -func (g *genericIterator[T]) Current() any { - return g.current -} - -func genericToAnyIterator[T any](it Iterator[T]) AnyIterator { - return &genericIterator[T]{ - iterator: it, - } -} diff --git a/internal/jsonview/staticdisplay.go b/internal/jsonview/staticdisplay.go deleted file mode 100644 index 768ea34..0000000 --- a/internal/jsonview/staticdisplay.go +++ /dev/null @@ -1,135 +0,0 @@ -package jsonview - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/tidwall/gjson" -) - -const ( - tabWidth = 2 -) - -var ( - keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) - stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) - numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) - boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) - nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) - bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) - containerStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("63")). - Padding(0, 1) -) - -func formatJSON(json gjson.Result, width int) string { - if !json.Exists() { - return nullValueStyle.Render("Invalid JSON") - } - return formatResult(json, 0, width) -} - -func formatResult(result gjson.Result, indent, width int) string { - switch result.Type { - case gjson.String: - str := result.Str - if str == "" { - return nullValueStyle.Render("(empty)") - } - if lipgloss.Width(str) > width { - str = truncate.String(str, uint(width-1)) + "…" - } - return stringValueStyle.Render(str) - case gjson.Number: - return numberValueStyle.Render(result.Raw) - case gjson.True: - return boolValueStyle.Render("yes") - case gjson.False: - return boolValueStyle.Render("no") - case gjson.Null: - return nullValueStyle.Render("null") - case gjson.JSON: - if result.IsArray() { - return formatJSONArray(result, indent, width) - } - return formatJSONObject(result, indent, width) - default: - return stringValueStyle.Render(result.String()) - } -} - -func isSingleLine(result gjson.Result, indent int) bool { - return !(result.IsObject() || result.IsArray()) -} - -func formatJSONArray(result gjson.Result, indent, width int) string { - items := result.Array() - if len(items) == 0 { - return nullValueStyle.Render(" (none)") - } - - numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) - - var formattedItems []string - for i, item := range items { - number := fmt.Sprintf("%d.", i+1) - numbering := getIndent(indent) + bulletStyle.Render(number) - - // If the item will be a one-liner, put it inline after the numbering, - // otherwise it starts with a newline and goes below the numbering. - itemWidth := width - if isSingleLine(item, indent+1) { - // Add right-padding: - numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) - itemWidth = width - lipgloss.Width(numbering) - } - value := formatResult(item, indent+1, itemWidth) - formattedItems = append(formattedItems, numbering+value) - } - return "\n" + strings.Join(formattedItems, "\n") -} - -func formatJSONObject(result gjson.Result, indent, width int) string { - keys := result.Get("@keys").Array() - if len(keys) == 0 { - return nullValueStyle.Render("(empty)") - } - - var items []string - for _, key := range keys { - value := result.Get(key.String()) - keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") - // If item will be a one-liner, put it inline after the key, otherwise - // it starts with a newline and goes below the key. - itemWidth := width - if isSingleLine(value, indent+1) { - keyStr += " " - itemWidth = width - lipgloss.Width(keyStr) - } - formattedValue := formatResult(value, indent+1, itemWidth) - items = append(items, keyStr+formattedValue) - } - - return "\n" + strings.Join(items, "\n") -} - -func getIndent(indent int) string { - return strings.Repeat(" ", indent*tabWidth) -} - -func RenderJSON(title string, json gjson.Result) string { - width, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - width = 80 - } - width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + - containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() - content := strings.TrimLeft(formatJSON(json, width), "\n") - return titleStyle.Render(title) + "\n" + containerStyle.Render(content) -} diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go deleted file mode 100644 index e51ceb5..0000000 --- a/internal/mocktest/mocktest.go +++ /dev/null @@ -1,101 +0,0 @@ -package mocktest - -import ( - "bytes" - "context" - "fmt" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var mockServerURL *url.URL - -func init() { - mockServerURL, _ = url.Parse("http://localhost:4010") - if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" { - if parsed, err := url.Parse(testURL); err == nil { - mockServerURL = parsed - } - } -} - -// OnlyMockServerDialer only allows network connections to the mock server -type OnlyMockServerDialer struct{} - -func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - if address == mockServerURL.Host { - return (&net.Dialer{}).DialContext(ctx, network, address) - } - - return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host) -} - -func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) { - restricted := &http.Transport{ - DialContext: (&OnlyMockServerDialer{}).DialContext, - } - - origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport - http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted - return origClient, origDefault -} - -func restoreNetwork(origClient, origDefault http.RoundTripper) { - http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault -} - -// TestRunMockTestWithFlags runs a test against a mock server with the provided -// CLI args and ensures it succeeds -func TestRunMockTestWithFlags(t *testing.T, args ...string) { - TestRunMockTestWithPipeAndFlags(t, nil, args...) -} - -// TestRunMockTestWithPipeAndFlags runs a test against a mock server with the provided -// data piped over stdin and CLI args and ensures it succeeds -func TestRunMockTestWithPipeAndFlags(t *testing.T, pipeData []byte, args ...string) { - origClient, origDefault := blockNetworkExceptMockServer() - defer restoreNetwork(origClient, origDefault) - - // Check if mock server is running - conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second) - if err != nil { - require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.") - } else { - conn.Close() - } - - // Get the path to the main command - _, filename, _, ok := runtime.Caller(0) - require.True(t, ok, "Could not get current file path") - dirPath := filepath.Dir(filename) - project := filepath.Join(dirPath, "..", "..", "cmd", "beeper-desktop-cli") - - args = append([]string{"run", project, "--base-url", mockServerURL.String()}, args...) - - t.Logf("Testing command: go run ./cmd/beeper-desktop-cli %s", strings.Join(args[2:], " ")) - - cmd := exec.Command("go", args...) - cmd.Stdin = bytes.NewReader(pipeData) - output, err := cmd.CombinedOutput() - assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output) - - t.Logf("Test passed successfully\nOutput:\n%s", string(output)) -} - -func TestFile(t *testing.T, contents string) string { - tmpDir := t.TempDir() - filename := filepath.Join(tmpDir, "file.txt") - require.NoError(t, os.WriteFile(filename, []byte(contents), 0644)) - return filename -} diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go deleted file mode 100644 index 102624f..0000000 --- a/internal/requestflag/innerflag.go +++ /dev/null @@ -1,260 +0,0 @@ -package requestflag - -import ( - "fmt" - "reflect" - "strings" - - "github.com/urfave/cli/v3" -) - -// InnerFlag[T] represents a CLI flag for the urfave/cli package that allows setting -// nested fields within other flags. For example, using `--foo.baz` will set the "baz" -// field on a parent flag named `--foo`. -type InnerFlag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, -] struct { - Name string // name of the flag - DefaultText string // default text of the flag for usage purposes - Usage string // usage string for help output - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - OuterFlag cli.Flag // The flag on which this inner flag will set values - InnerField string // The inner field which this flag will set -} - -type HasOuterFlag interface { - cli.Flag - SetOuterFlag(cli.Flag) - GetOuterFlag() cli.Flag -} - -func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { - f.OuterFlag = flag -} - -func (f *InnerFlag[T]) GetOuterFlag() cli.Flag { - return f.OuterFlag -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) PreParse() error { - return nil -} - -func (f *InnerFlag[T]) PostParse() error { - return nil -} - -func (f *InnerFlag[T]) Set(name string, rawVal string) error { - if parsedValue, err := parseCLIArg[T](rawVal); err != nil { - return err - } else { - if f.Validator != nil { - if err := f.Validator(parsedValue); err != nil { - return err - } - } - - if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { - settableInnerField.SetInnerField(f.InnerField, parsedValue) - } else { - return fmt.Errorf("Cannot set inner field on %v", f.OuterFlag) - } - return nil - } -} - -func (f *InnerFlag[T]) Get() any { - var zeroValue T - return zeroValue -} - -func (f *InnerFlag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *InnerFlag[T]) IsSet() bool { - return false -} - -func (f *InnerFlag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *InnerFlag[T]) GetUsage() string { - return f.Usage -} - -func (f *InnerFlag[T]) GetValue() string { - return "" -} - -func (f *InnerFlag[T]) GetDefaultText() string { - return f.DefaultText -} - -func (f *InnerFlag[T]) GetEnvVars() []string { - return nil -} - -func (f *InnerFlag[T]) IsDefaultVisible() bool { - return false -} - -func (f *InnerFlag[T]) TypeName() string { - var zeroValue T - ty := reflect.TypeOf(zeroValue) - if ty == nil { - return "" - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) IsMultiValueFlag() bool { - return false -} - -func (f *InnerFlag[T]) IsBoolFlag() bool { - var zeroValue T - _, isBool := any(zeroValue).(bool) - return isBool -} - -// WithInnerFlags takes a command and a map of flag names to inner flags, -// and returns a modified command with the appropriate inner flags set. -func WithInnerFlags(cmd cli.Command, innerFlagMap map[string][]HasOuterFlag) cli.Command { - if len(innerFlagMap) == 0 { - return cmd - } - - // If any keys are unused by the end, we know that they were not valid - unusedInnerFlagKeys := make(map[string]struct{}) - for name := range innerFlagMap { - unusedInnerFlagKeys[name] = struct{}{} - } - - updatedFlags := make([]cli.Flag, 0, len(cmd.Flags)) - for _, flag := range cmd.Flags { - updatedFlags = append(updatedFlags, flag) - for _, name := range flag.Names() { - // Check if this flag has inner flags in our map - innerFlags, hasInnerFlags := innerFlagMap[name] - if !hasInnerFlags { - continue - } - - // Mark this inner flag key as used - delete(unusedInnerFlagKeys, name) - - for _, innerFlag := range innerFlags { - innerFlag.SetOuterFlag(flag) - updatedFlags = append(updatedFlags, innerFlag) - } - } - } - - // If there are inner flags that don't correspond to any valid outer flag - // names, then panic because the user probably made a typo or forgot to - // delete inner flags that correspond to missing outer flags. - if len(unusedInnerFlagKeys) > 0 { - unusedKeys := make([]string, 0, len(unusedInnerFlagKeys)) - for key := range unusedInnerFlagKeys { - unusedKeys = append(unusedKeys, key) - } - panic(fmt.Sprintf("Missing outer flags to use with inner flags: %v", unusedKeys)) - } - - result := cmd - result.Flags = updatedFlags - return result -} - -// Helper function to verify that all inner flags have an outer flag set and -// follow the --foo.baz prefix format -func CheckInnerFlags(cmd cli.Command) error { - var errors []string - for _, flag := range cmd.Flags { - if innerFlag, ok := flag.(HasOuterFlag); ok { - outerFlag := innerFlag.GetOuterFlag() - if outerFlag == nil { - errors = append(errors, fmt.Sprintf("inner flag %s is missing an outer flag", flag.Names())) - continue - } - - innerFlagName := flag.Names()[0] - valid := false - for _, outerName := range outerFlag.Names() { - if strings.HasPrefix(innerFlagName, outerName+".") { - valid = true - break - } - } - - if !valid { - errors = append(errors, fmt.Sprintf("inner flag %s must start with one of its outer flag's names followed by a dot", innerFlagName)) - } - } - } - - if len(errors) > 0 { - return fmt.Errorf("%s", strings.Join(errors, "; ")) - } - return nil -} diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go deleted file mode 100644 index 3f204c9..0000000 --- a/internal/requestflag/innerflag_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package requestflag - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestInnerFlagSet(t *testing.T) { - tests := []struct { - name string - flagType string - inputVal string - expected any - expectErr bool - }{ - {"string", "string", "hello", "hello", false}, - {"int64", "int64", "42", int64(42), false}, - {"float64", "float64", "3.14", float64(3.14), false}, - {"bool", "bool", "true", true, false}, - {"invalid int", "int64", "not-a-number", nil, true}, - {"invalid float", "float64", "not-a-float", nil, true}, - {"invalid bool", "bool", "not-a-bool", nil, true}, - {"yaml map", "map", "key: value", map[string]any{"key": "value"}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - outerFlag := &Flag[map[string]any]{ - Name: "test-flag", - } - - var innerFlag cli.Flag - switch tt.flagType { - case "string": - innerFlag = &InnerFlag[string]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "int64": - innerFlag = &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "float64": - innerFlag = &InnerFlag[float64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "bool": - innerFlag = &InnerFlag[bool]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "map": - innerFlag = &InnerFlag[map[string]any]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - } - - err := innerFlag.Set(innerFlag.Names()[0], tt.inputVal) - - if tt.expectErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - actual, ok := outerFlag.Get().(map[string]any)["test_field"] - assert.True(t, ok, "Field 'test_field' should exist in the map") - assert.Equal(t, tt.expected, actual, "Expected %v (%T), got %v (%T)", tt.expected, tt.expected, actual, actual) - }) - } -} - -func TestInnerFlagValidator(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "test-flag"} - - innerFlag := &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - Validator: func(val int64) error { - if val < 0 { - return cli.Exit("Value must be non-negative", 1) - } - return nil - }, - } - - // Valid case - err := innerFlag.Set(innerFlag.Name, "42") - assert.NoError(t, err, "Expected no error for valid value, got: %v", err) - - // Should trigger validator error - err = innerFlag.Set(innerFlag.Name, "-5") - assert.Error(t, err, "Expected error for invalid value, got none") -} - -func TestWithInnerFlags(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[string]{ - Name: "outer.baz", - InnerField: "baz", - } - - cmd := WithInnerFlags(cli.Command{ - Name: "test-command", - Flags: []cli.Flag{outerFlag}, - }, map[string][]HasOuterFlag{ - "outer": {innerFlag}, - }) - - // Verify that the command now has both the original flag and inner flag - assert.Len(t, cmd.Flags, 2, "Expected 2 flags, got %d", len(cmd.Flags)) - assert.Equal(t, outerFlag, cmd.Flags[0], "First flag should be outerFlag") - assert.Equal(t, innerFlag, cmd.Flags[1], "Second flag should be innerFlag") - assert.Same(t, outerFlag, innerFlag.OuterFlag, "innerFlag.OuterFlag should point to outerFlag") -} - -func TestInnerFlagTypeNames(t *testing.T) { - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &InnerFlag[string]{}, "string"}, - {"int64", &InnerFlag[int64]{}, "int"}, - {"float64", &InnerFlag[float64]{}, "float"}, - {"bool", &InnerFlag[bool]{}, "boolean"}, - {"string slice", &InnerFlag[[]string]{}, "string"}, - {"date", &InnerFlag[DateValue]{}, "date"}, - {"datetime", &InnerFlag[DateTimeValue]{}, "datetime"}, - {"time", &InnerFlag[TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} - -func TestInnerYamlHandling(t *testing.T) { - // Test with map value - t.Run("Parse YAML to map", func(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - err := innerFlag.Set(innerFlag.Name, "{name: test, value: 42}") - assert.NoError(t, err) - - // Retrieve and check the parsed YAML map - result, ok := outerFlag.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any from outerFlag.Get()") - yamlField, ok := result["baz"].(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", result["baz"]) - val := yamlField - - if ok { - assert.Equal(t, map[string]any{"name": "test", "value": uint64(42)}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - invalidYaml := `[not closed` - err := innerFlag.Set(innerFlag.Name, invalidYaml) - assert.Error(t, err) - }) - - // Test setting inner flags on a map multiple times - t.Run("Set inner flags on map multiple times", func(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // Set first inner flag - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.first-flag", - OuterFlag: outerFlag, - InnerField: "first_field", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "first-value") - assert.NoError(t, err) - - // Set second inner flag - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.second-flag", - OuterFlag: outerFlag, - InnerField: "second_field", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Verify both fields are set correctly - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{"first_field": "first-value", "second_field": int64(42)}, result) - }) - - // Test setting YAML and then an inner flag - t.Run("Set YAML and then inner flag", func(t *testing.T) { - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // First set the outer flag with YAML - err := outerFlag.Set(outerFlag.Name, `{existing: value, another: field}`) - assert.NoError(t, err) - - // Then set an inner flag - innerFlag := &InnerFlag[string]{ - Name: "outer.inner-flag", - OuterFlag: outerFlag, - InnerField: "new_field", - } - - err = innerFlag.Set(innerFlag.Name, "inner-value") - assert.NoError(t, err) - - // Verify both the YAML content and inner flag value - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{ - "existing": "value", - "another": "field", - "new_field": "inner-value", - }, result) - }) -} - -func TestInnerFlagWithSliceType(t *testing.T) { - t.Run("Setting inner flags on slice of maps", func(t *testing.T) { - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - - // Set first inner flag (should create first item) - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "item1") - assert.NoError(t, err) - - // Set second inner flag (should modify first item) - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.count-flag", - OuterFlag: outerFlag, - InnerField: "count", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Set name flag again (should create second item) - err = firstInnerFlag.Set(firstInnerFlag.Name, "item2") - assert.NoError(t, err) - - // Verify the slice has two items with correct values - result := outerFlag.Get().([]map[string]any) - - assert.Equal(t, []map[string]any{ - {"name": "item1", "count": int64(42)}, - {"name": "item2"}, - }, result) - assert.Nil(t, result[1]["count"], "Second item should not have count field") - }) - - t.Run("Appending to existing slice", func(t *testing.T) { - // Initialize with existing items - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - err := outerFlag.Set(outerFlag.Name, `{name: initial}`) - assert.NoError(t, err) - - // Set inner flag to modify existing item - modifyFlag := &InnerFlag[string]{ - Name: "outer.value-flag", - OuterFlag: outerFlag, - InnerField: "value", - } - - err = modifyFlag.Set(modifyFlag.Name, "updated") - assert.NoError(t, err) - - // Set inner flag to create new item - newItemFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err = newItemFlag.Set(newItemFlag.Name, "second") - assert.NoError(t, err) - - // Verify both items - result := outerFlag.Get().([]map[string]any) - assert.Equal(t, []map[string]any{ - {"name": "initial", "value": "updated"}, - {"name": "second"}, - }, result) - }) -} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go deleted file mode 100644 index 21a8a69..0000000 --- a/internal/requestflag/requestflag.go +++ /dev/null @@ -1,726 +0,0 @@ -package requestflag - -import ( - "fmt" - "reflect" - "strconv" - "strings" - "time" - "unicode" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -// Flag [T] is a generic flag base which can be used to implement the most -// common interfaces used by urfave/cli. Additionally, it allows specifying -// where in an HTTP request the flag values should be placed (e.g. query, body, etc.). -type Flag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool, -] struct { - Name string // name of the flag - Category string // category of the flag, if any - DefaultText string // default text of the flag for usage purposes - HideDefault bool // whether to hide the default value in output - Usage string // usage string for help output - Sources cli.ValueSourceChain // sources to load flag value from - Required bool // whether the flag is required or not - Hidden bool // whether to hide the flag in help output - Default T // default value for this flag if not set by from any source - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - QueryPath string // location in the request query string to put this flag's value - HeaderPath string // location in the request header to put this flag's value - BodyPath string // location in the request body to put this flag's value - BodyRoot bool // if true, then use this value as the entire request body - - // unexported fields for internal use - count int // number of times the flag has been set - hasBeenSet bool // whether the flag has been set from env or file - applied bool // whether the flag has been applied to a flag set already - value cli.Value // value representing this flag's value -} - -// Type assertions to verify we implement the relevant urfave/cli interfaces -var _ cli.CategorizableFlag = (*Flag[any])(nil) - -// InRequest interface for flags that should be included in HTTP requests -type InRequest interface { - GetQueryPath() string - GetHeaderPath() string - GetBodyPath() string - IsBodyRoot() bool -} - -func (f Flag[T]) GetQueryPath() string { - return f.QueryPath -} - -func (f Flag[T]) GetHeaderPath() string { - return f.HeaderPath -} - -func (f Flag[T]) GetBodyPath() string { - return f.BodyPath -} - -func (f Flag[T]) IsBodyRoot() bool { - return f.BodyRoot -} - -// The values that will be sent in different parts of a request. -type RequestContents struct { - Queries map[string]any - Headers map[string]any - Body any -} - -// Extract query parameters, headers, and body values from command flags. -func ExtractRequestContents(cmd *cli.Command) RequestContents { - bodyMap := make(map[string]any) - res := RequestContents{ - Queries: make(map[string]any), - Headers: make(map[string]any), - Body: bodyMap, - } - - for _, flag := range cmd.Flags { - if !flag.IsSet() { - continue - } - - value := flag.Get() - if toSend, ok := flag.(InRequest); ok { - if queryPath := toSend.GetQueryPath(); queryPath != "" { - res.Queries[queryPath] = value - } - if headerPath := toSend.GetHeaderPath(); headerPath != "" { - res.Headers[headerPath] = value - } - if toSend.IsBodyRoot() { - res.Body = value - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - bodyMap[bodyPath] = value - } - } - } - return res -} - -func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag { - missing := []cli.Flag{} - for _, flag := range cmd.Flags { - if flag.IsSet() { - continue - } - - if required, ok := flag.(cli.RequiredFlag); ok && required.IsRequired() { - missing = append(missing, flag) - continue - } - - if r, ok := flag.(RequiredFlagOrStdin); !ok || !r.IsRequiredAsFlagOrStdin() { - continue - } - - if toSend, ok := flag.(InRequest); ok { - if toSend.IsBodyRoot() { - if body != nil { - continue - } - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - if bodyMap, ok := body.(map[string]any); ok { - if _, found := bodyMap[bodyPath]; found { - continue - } - } - } - } - missing = append(missing, flag) - } - return missing -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) PreParse() error { - newVal := f.Default - f.value = &cliValue[T]{newVal} - - // Validate the given default or values set from external sources as well - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - f.applied = true - return nil -} - -func (f *Flag[T]) PostParse() error { - if !f.hasBeenSet { - if val, source, found := f.Sources.LookupWithSource(); found { - if val != "" || reflect.TypeOf(f.value).Kind() == reflect.String { - if err := f.Set(f.Name, val); err != nil { - return fmt.Errorf( - "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", - val, f.value, source, f.Name, err, - ) - } - } else if val == "" && reflect.TypeOf(f.value).Kind() == reflect.Bool { - _ = f.Set(f.Name, "false") - } - - f.hasBeenSet = true - } - } - return nil -} - -func (f *Flag[T]) Set(name string, val string) error { - // Initialize flag if needed - if !f.applied { - if err := f.PreParse(); err != nil { - return err - } - f.applied = true - } - - f.count++ - - // If this is the first time setting a slice type, reset it to empty - // to avoid appending to the default value - if f.count == 1 && f.value != nil { - typ := reflect.TypeOf(f.Default) - if typ != nil && typ.Kind() == reflect.Slice { - // Create a new empty slice of the same type and set it - emptySlice := reflect.MakeSlice(typ, 0, 0).Interface() - f.value = &cliValue[T]{emptySlice.(T)} - } - } - - if err := f.value.Set(val); err != nil { - return err - } - - f.hasBeenSet = true - - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - return nil -} - -func (f *Flag[T]) Get() any { - if f.value != nil { - return f.value.Get() - } - return f.Default -} - -func (f *Flag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *Flag[T]) IsSet() bool { - return f.hasBeenSet -} - -func (f *Flag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.VisibleFlag interface -var _ cli.VisibleFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsVisible() bool { - return !f.Hidden -} - -func (f *Flag[T]) GetCategory() string { - return f.Category -} - -func (f *Flag[T]) SetCategory(c string) { - f.Category = c -} - -// Implementation for the cli.RequiredFlag interface -var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsRequired() bool { - // Intentionally don't use `f.Required`, because request flags may be passed - // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot { - return false - } - return f.Required -} - -type RequiredFlagOrStdin interface { - IsRequiredAsFlagOrStdin() bool -} - -func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { - return f.Required -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *Flag[T]) GetUsage() string { - return f.Usage -} - -func (f *Flag[T]) GetValue() string { - if f.value == nil { - return "" - } - return f.value.String() -} - -func (f *Flag[T]) GetDefaultText() string { - return f.DefaultText -} - -// GetEnvVars returns the env vars for this flag -func (f *Flag[T]) GetEnvVars() []string { - return f.Sources.EnvKeys() -} - -func (f *Flag[T]) IsDefaultVisible() bool { - return !f.HideDefault -} - -func (f *Flag[T]) TypeName() string { - ty := reflect.TypeOf(f.Default) - if ty == nil { - return "" - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsMultiValueFlag() bool { - if reflect.TypeOf(f.Default) == nil { - return false - } - kind := reflect.TypeOf(f.Default).Kind() - return kind == reflect.Slice || kind == reflect.Map -} - -func (f *Flag[T]) IsBoolFlag() bool { - _, isBool := any(f.Default).(bool) - return isBool -} - -// Implementation for the cli.Countable interface -var _ cli.Countable = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) Count() int { - return f.count -} - -// Implementation for the cli.LocalFlag interface -var _ cli.LocalFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f Flag[T]) IsLocal() bool { - // By default, all request flags are local, i.e. can be provided at any part of the CLI command. - return true -} - -// cliValue is a generic implementation of cli.Value for common types -type cliValue[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, -] struct { - value T -} - -// Take an argument string for a single argument and convert it into a typed -// value for one of the supported CLI argument types -func parseCLIArg[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool, -](value string) (T, error) { - var parsedValue any - var err error - - var empty T - switch any(empty).(type) { - case string: - parsedValue = value - case int64: - parsedValue, err = strconv.ParseInt(value, 0, 64) - case float64: - parsedValue, err = strconv.ParseFloat(value, 64) - case bool: - parsedValue, err = strconv.ParseBool(value) - case DateTimeValue: - var dt DateTimeValue - err = (&dt).Parse(value) - if err == nil { - parsedValue = dt - } - - case DateValue: - var d DateValue - err = (&d).Parse(value) - if err == nil { - parsedValue = d - } - - case TimeValue: - var t TimeValue - err = (&t).Parse(value) - if err == nil { - parsedValue = t - } - - default: - if strings.HasPrefix(value, "@") { - // File literals like @file.txt should work here - parsedValue = value - } else { - var yamlValue T - err = yaml.Unmarshal([]byte(value), &yamlValue) - if err == nil { - parsedValue = yamlValue - } else if allowAsLiteralString(value) { - parsedValue = value - } else { - parsedValue = nil - err = fmt.Errorf("failed to parse as YAML: %w", err) - } - } - } - - // Nil needs to be handled specially because unmarshalling a YAML `null` - // causes problems when doing type assertions. - if parsedValue == nil { - parsedValue = (*struct{})(nil) - } - - if err == nil { - if typedValue, ok := parsedValue.(T); ok { - return typedValue, nil - } else { - expectedType := reflect.TypeFor[T]() - err = fmt.Errorf("Couldn't convert %q (%v) to expected type %v", value, parsedValue, expectedType) - } - } - return empty, err - -} - -// Assuming this string failed to parse as valid YAML, this function will -// return true for strings that can reasonably be interpreted as a string literal, -// like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`), -// base64 (`aGVsbG8=`), and qualified identifiers (`color.Red`). This should -// not include strings that look like mistyped YAML (e.g. `{key:`) -func allowAsLiteralString(s string) bool { - for _, c := range s { - if !unicode.IsLetter(c) && !unicode.IsDigit(c) && - c != '_' && c != '-' && c != '.' && c != '=' { - return false - } - } - return true -} - -// Parse the input string and set result as the cliValue's value -func (c *cliValue[T]) Set(value string) error { - valueType := reflect.TypeOf(c.value) - // When setting slice values, we append to the existing values - // e.g. --foo 10 --foo 20 --foo 30 => [10, 20, 30] - if valueType != nil && valueType.Kind() == reflect.Slice { - elemType := valueType.Elem() - - var singleElem any - var err error - switch elemType.Kind() { - case reflect.String: - singleElem, err = parseCLIArg[string](value) - case reflect.Int64: - singleElem, err = parseCLIArg[int64](value) - case reflect.Float64: - singleElem, err = parseCLIArg[float64](value) - case reflect.Bool: - singleElem, err = parseCLIArg[bool](value) - default: - // Check for special types by name - switch elemType.Name() { - case "DateTimeValue": - singleElem, err = parseCLIArg[DateTimeValue](value) - case "DateValue": - singleElem, err = parseCLIArg[DateValue](value) - case "TimeValue": - singleElem, err = parseCLIArg[TimeValue](value) - default: - // This handles []map[string]any - if elemType.Kind() == reflect.Map && elemType.Key().Kind() == reflect.String { - singleElem, err = parseCLIArg[map[string]any](value) - } else { - singleElem, err = parseCLIArg[any](value) - } - } - } - - if err != nil { - return err - } - - // Append the new element to the slice - sliceValue := reflect.ValueOf(c.value) - if !sliceValue.IsValid() || sliceValue.IsNil() { - // Create a new slice if the current one is nil - sliceValue = reflect.MakeSlice(valueType, 0, 1) - } - - // Append the new element - newElem := reflect.ValueOf(singleElem) - sliceValue = reflect.Append(sliceValue, newElem) - - // Set the updated slice back to c.value - c.value = sliceValue.Interface().(T) - } else { - // For non-slice types, simply parse and set the value - if parsedValue, err := parseCLIArg[T](value); err != nil { - return err - } else { - c.value = parsedValue - } - } - - return nil -} - -func (c *cliValue[T]) Get() any { - return c.value -} - -func (c *cliValue[T]) String() string { - switch v := any(c.value).(type) { - case string, int, int64, float64, bool, DateTimeValue, DateValue, TimeValue, - []string, []int, []int64, []float64, []bool, []DateTimeValue, []DateValue, []TimeValue: - // For basic types, use standard string representation - return fmt.Sprintf("%v", v) - - default: - // For complex types, convert to YAML - yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) - if err != nil { - // Fall back to standard format if YAML conversion fails - return fmt.Sprintf("%v", c.value) - } - return string(yamlBytes) - } -} - -func (c *cliValue[T]) IsBoolFlag() bool { - _, ok := any(c.value).(bool) - return ok -} - -// Time-related value types -type DateValue string -type DateTimeValue string -type TimeValue string - -// String methods for time-related types -func (d DateValue) String() string { - return string(d) -} - -func (d DateTimeValue) String() string { - return string(d) -} - -func (t TimeValue) String() string { - return string(t) -} - -// parseTimeWithFormats attempts to parse a string using multiple formats -func parseTimeWithFormats(s string, formats []string) (time.Time, error) { - var lastErr error - for _, format := range formats { - t, err := time.Parse(format, s) - if err == nil { - return t, nil - } - lastErr = err - } - return time.Time{}, lastErr -} - -// Parse methods for time-related types -func (d *DateValue) Parse(s string) error { - formats := []string{ - "2006-01-02", - "01/02/2006", - "Jan 2, 2006", - "January 2, 2006", - "2-Jan-2006", - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse date: %v", err) - } - - *d = DateValue(t.Format("2006-01-02")) - return nil -} - -func (d *DateTimeValue) Parse(s string) error { - formats := []string{ - time.RFC3339, - time.RFC3339Nano, - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - time.RFC1123, - time.RFC822, - time.ANSIC, - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse datetime: %v", err) - } - - *d = DateTimeValue(t.Format(time.RFC3339)) - return nil -} - -func (t *TimeValue) Parse(s string) error { - formats := []string{ - "15:04:05", - "15:04:05.999999999Z07:00", - "3:04:05PM", - "3:04 PM", - "15:04", - time.Kitchen, - } - - parsedTime, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse time: %v", err) - } - - *t = TimeValue(parsedTime.Format("15:04:05")) - return nil -} - -// Allow setting inner fields on other flags (e.g. --foo.baz can set the "baz" -// field on the --foo flag) -type SettableInnerField interface { - SetInnerField(string, any) -} - -func (f *Flag[T]) SetInnerField(field string, val any) { - if f.value == nil { - f.value = &cliValue[T]{} - } - - if settableInnerField, ok := f.value.(SettableInnerField); ok { - settableInnerField.SetInnerField(field, val) - f.hasBeenSet = true - } else { - panic(fmt.Sprintf("Cannot set inner field: %v", f.value)) - } -} - -func (c *cliValue[T]) SetInnerField(field string, val any) { - flagVal := c.value - flagValReflect := reflect.ValueOf(flagVal) - switch flagValReflect.Kind() { - case reflect.Slice: - if flagValReflect.Type().Elem().Kind() != reflect.Map { - return - } - - sliceLen := flagValReflect.Len() - if sliceLen > 0 { - // Check if the last element already has the InnerField - lastElement := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any) - if _, hasInnerField := lastElement[field]; !hasInnerField { - // Last element doesn't have the field, set it - lastElement[field] = val - return - } - } - - // Create a new map and append it to the slice - newMap := map[string]any{field: val} - switch sliceVal := any(c.value).(type) { - case []map[string]any: - c.value = any(append(sliceVal, newMap)).(T) - case []any: - c.value = any(append(sliceVal, newMap)).(T) - } - - case reflect.Map: - mapVal, ok := any(flagVal).(map[string]any) - if !ok || mapVal == nil { - mapVal = map[string]any{field: val} - c.value = any(mapVal).(T) - } else { - mapVal[field] = val - } - } -} diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go deleted file mode 100644 index 9751904..0000000 --- a/internal/requestflag/requestflag_test.go +++ /dev/null @@ -1,590 +0,0 @@ -package requestflag - -import ( - "fmt" - "testing" - "time" - - "github.com/goccy/go-yaml" - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestDateValueParse(t *testing.T) { - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "ISO format", - input: "2023-05-15", - want: "2023-05-15", - wantErr: false, - }, - { - name: "US format", - input: "05/15/2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Short month format", - input: "May 15, 2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Long month format", - input: "January 15, 2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "British format", - input: "15-Jan-2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a date", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var d DateValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, d.String()) - } - }) - } -} - -func TestDateTimeValueParse(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "RFC3339", - input: "2023-05-15T14:30:45Z", - wantErr: false, - }, - { - name: "ISO with timezone", - input: "2023-05-15T14:30:45+02:00", - wantErr: false, - }, - { - name: "ISO without timezone", - input: "2023-05-15T14:30:45", - wantErr: false, - }, - { - name: "Space separated", - input: "2023-05-15 14:30:45", - wantErr: false, - }, - { - name: "RFC1123", - input: "Mon, 15 May 2023 14:30:45 GMT", - wantErr: false, - }, - { - name: "RFC822", - input: "15 May 23 14:30 GMT", - wantErr: false, - }, - { - name: "ANSIC", - input: "Mon Jan 2 15:04:05 2006", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a datetime", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var d DateTimeValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - // Parse the string back to ensure it's valid RFC3339 - _, parseErr := time.Parse(time.RFC3339, d.String()) - assert.NoError(t, parseErr) - } - }) - } -} - -func TestTimeValueParse(t *testing.T) { - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "24-hour format", - input: "14:30:45", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format with seconds", - input: "2:30:45PM", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format without seconds", - input: "2:30 PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "24-hour without seconds", - input: "14:30", - want: "14:30:00", - wantErr: false, - }, - { - name: "Kitchen format", - input: "2:30PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a time", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var tv TimeValue - err := tv.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, tv.String()) - } - }) - } -} - -func TestRequestParams(t *testing.T) { - t.Run("map body type", func(t *testing.T) { - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - } - - // Create string flag with body path - stringFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - BodyPath: "string_field", - value: &cliValue[string]{value: "test-value"}, - hasBeenSet: true, - } - - // Create int flag with header path - intFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - HeaderPath: "X-Int-Value", - value: &cliValue[int64]{value: 99}, - hasBeenSet: true, - } - - // Create bool flag with query path - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - QueryPath: "include_details", - value: &cliValue[bool]{value: true}, - hasBeenSet: true, - } - - // Create date flag with multiple paths - dateFlag := &Flag[DateValue]{ - Name: "date-flag", - Default: DateValue("2023-01-01"), - BodyPath: "effective_date", - HeaderPath: "X-Effective-Date", - QueryPath: "as_of_date", - value: &cliValue[DateValue]{value: DateValue("2023-05-15")}, - hasBeenSet: true, - } - - // Create flag with no path - noPathFlag := &Flag[string]{ - Name: "no-path-flag", - Default: "no-path", - value: &cliValue[string]{value: "no-path-value"}, - hasBeenSet: true, - } - - // Create unset flag - unsetFlag := &Flag[string]{ - Name: "unset-flag", - Default: "unset", - BodyPath: "should_not_appear", - value: &cliValue[string]{value: "unset-value"}, - hasBeenSet: false, - } - - cmd.Flags = []cli.Flag{stringFlag, intFlag, boolFlag, dateFlag, noPathFlag, unsetFlag} - - // Test the RequestParams function - contents := ExtractRequestContents(cmd) - - // Verify query parameters - assert.Equal(t, true, contents.Queries["include_details"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Queries["as_of_date"]) - assert.Len(t, contents.Queries, 2) - - // Verify headers - assert.Equal(t, int64(99), contents.Headers["X-Int-Value"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Headers["X-Effective-Date"]) - assert.Len(t, contents.Headers, 2) - - // Verify body - bodyMap, ok := contents.Body.(map[string]any) - assert.True(t, ok, "Expected body to be map[string]any, got %T", contents.Body) - assert.Equal(t, "test-value", bodyMap["string_field"]) - assert.Equal(t, DateValue("2023-05-15"), bodyMap["effective_date"]) - assert.Len(t, bodyMap, 2) - - // Verify the unset flag didn't make it into the maps - assert.NotContains(t, contents.Body, "should_not_appear") - }) - - t.Run("non-map body type", func(t *testing.T) { - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - Flags: []cli.Flag{ - &Flag[int64]{ - Name: "int-body-flag", - Default: 0, - BodyRoot: true, - }, - }, - } - cmd.Set("int-body-flag", "42") - - contents := ExtractRequestContents(cmd) - intBody, ok := contents.Body.(int64) - assert.True(t, ok, "Expected body to be int64, got %T", contents.Body) - assert.Equal(t, int64(42), intBody) - }) -} - -func TestFlagSet(t *testing.T) { - strFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - } - - superstitiousIntFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - Validator: func(val int64) error { - if val == 13 { - return fmt.Errorf("Unlucky number!") - } - return nil - }, - } - - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - } - - // Test initialization and setting - t.Run("PreParse initialization", func(t *testing.T) { - assert.NoError(t, strFlag.PreParse()) - assert.True(t, strFlag.applied) - assert.Equal(t, "default-string", strFlag.Get()) - }) - - t.Run("Set string flag", func(t *testing.T) { - assert.NoError(t, strFlag.Set("string-flag", "new-value")) - assert.Equal(t, "new-value", strFlag.Get()) - assert.True(t, strFlag.IsSet()) - }) - - t.Run("Set int flag with valid value", func(t *testing.T) { - assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) - assert.Equal(t, int64(100), superstitiousIntFlag.Get()) - assert.True(t, superstitiousIntFlag.IsSet()) - }) - - t.Run("Set int flag with invalid value", func(t *testing.T) { - assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) - }) - - t.Run("Set int flag with validator failing", func(t *testing.T) { - assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) - }) - - t.Run("Set bool flag", func(t *testing.T) { - assert.NoError(t, boolFlag.Set("bool-flag", "true")) - assert.Equal(t, true, boolFlag.Get()) - assert.True(t, boolFlag.IsSet()) - }) - - t.Run("Set slice flag with multiple values", func(t *testing.T) { - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{}, - } - - // Initialize the flag - assert.NoError(t, sliceFlag.PreParse()) - - // First set - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - - // Subsequent setting should append, not replace - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have both values in the slice - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) - - t.Run("Set slice flag with a nonempty default", func(t *testing.T) { - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{99, 100}, - } - - assert.NoError(t, sliceFlag.PreParse()) - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have clobbered the default value instead of appending - // to it. - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) -} - -func TestParseTimeWithFormats(t *testing.T) { - tests := []struct { - name string - input string - formats []string - wantTime time.Time - wantErr bool - }{ - { - name: "RFC3339 format", - input: "2023-05-15T14:30:45Z", - formats: []string{time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 14, 30, 45, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - first matches", - input: "2023-05-15", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - second matches", - input: "15/05/2023", - formats: []string{"2006-01-02", "02/01/2006"}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "No matching format", - input: "not a date", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Time{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseTimeWithFormats(tt.input, tt.formats) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.True(t, tt.wantTime.Equal(got), "Expected %v, got %v", tt.wantTime, got) - } - }) - } -} - -func TestYamlHandling(t *testing.T) { - // Test with any value - t.Run("Parse YAML to any", func(t *testing.T) { - cv := &cliValue[any]{} - err := cv.Set("name: test\nvalue: 42\n") - assert.NoError(t, err) - - // The value should be a map - val, ok := cv.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", cv.Get()) - - if ok { - assert.Equal(t, "test", val["name"]) - assert.Equal(t, uint64(42), val["value"]) - } - - // The string representation should be valid YAML - strVal := cv.String() - var parsed map[string]any - err = yaml.Unmarshal([]byte(strVal), &parsed) - assert.NoError(t, err) - assert.Equal(t, "test", parsed["name"]) - assert.Equal(t, uint64(42), parsed["value"]) - }) - - // Test with array - t.Run("Parse YAML array", func(t *testing.T) { - cv := &cliValue[any]{} - err := cv.Set("- item1\n- item2\n- item3\n") - assert.NoError(t, err) - - // The value should be a slice - val, ok := cv.Get().([]any) - assert.True(t, ok, "Expected []any, got %T", cv.Get()) - - if ok { - assert.Len(t, val, 3) - assert.Equal(t, "item1", val[0]) - assert.Equal(t, "item2", val[1]) - assert.Equal(t, "item3", val[2]) - } - }) - - t.Run("Parse @file.txt as YAML", func(t *testing.T) { - flag := &Flag[any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file.txt")) - - val := flag.Get() - assert.Equal(t, "@file.txt", val) - }) - - t.Run("Parse @file.txt list as YAML", func(t *testing.T) { - flag := &Flag[[]any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file1.txt")) - assert.NoError(t, flag.Set("file-flag", "@file2.txt")) - - val := flag.Get() - assert.Equal(t, []any{"@file1.txt", "@file2.txt"}, val) - }) - - t.Run("Parse identifiers as YAML", func(t *testing.T) { - tests := []string{ - "hello", - "e4e355fa-b03b-4c57-a73d-25c9733eec79", - "foo_bar", - "Color.Red", - "aGVsbG8=", - } - for _, test := range tests { - flag := &Flag[any]{ - Name: "flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("flag", test)) - - val := flag.Get() - assert.Equal(t, test, val) - } - - for _, test := range tests { - flag := &Flag[[]any]{ - Name: "identifier", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("identifier", test)) - assert.NoError(t, flag.Set("identifier", test)) - - val := flag.Get() - assert.Equal(t, []any{test, test}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - invalidYaml := `[not closed` - cv := &cliValue[any]{} - err := cv.Set(invalidYaml) - assert.Error(t, err) - }) -} - -func TestFlagTypeNames(t *testing.T) { - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &Flag[string]{}, "string"}, - {"int64", &Flag[int64]{}, "int"}, - {"float64", &Flag[float64]{}, "float"}, - {"bool", &Flag[bool]{}, "boolean"}, - {"string slice", &Flag[[]string]{}, "string"}, - {"date", &Flag[DateValue]{}, "date"}, - {"datetime", &Flag[DateTimeValue]{}, "datetime"}, - {"time", &Flag[TimeValue]{}, "time"}, - {"date slice", &Flag[[]DateValue]{}, "date"}, - {"datetime slice", &Flag[[]DateTimeValue]{}, "datetime"}, - {"time slice", &Flag[[]TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} diff --git a/package.json b/package.json new file mode 100644 index 0000000..060a848 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "desktop-api-cli-monorepo", + "private": true, + "type": "module", + "packageManager": "pnpm@10.25.0", + "scripts": { + "build": "pnpm -r build", + "check": "pnpm typecheck && pnpm test && pnpm build && pnpm pack:packages", + "clean": "pnpm -r clean", + "changeset": "changeset", + "pack:packages": "mkdir -p .packs && pnpm -r --filter './packages/*' pack --pack-destination ./.packs", + "release": "pnpm check && pnpm changeset publish", + "test": "pnpm -r test", + "typecheck": "pnpm -r typecheck", + "version-packages": "changeset version" + }, + "devDependencies": { + "@changesets/changelog-github": "^0.6.0", + "@changesets/cli": "^2.31.0", + "@types/node": "^20.0.0", + "tsdown": "^0.21.10", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + } +} diff --git a/CHANGELOG.md b/packages/cli/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to packages/cli/CHANGELOG.md diff --git a/LICENSE b/packages/cli/LICENSE similarity index 100% rename from LICENSE rename to packages/cli/LICENSE diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..b7e62b7 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,2237 @@ +# Beeper CLI + +Command-line access to the [Beeper Desktop API](https://developers.beeper.com/desktop-api/). + +The CLI is built with TypeScript, oclif, and the official `@beeper/desktop-api` +SDK. The command reference below is generated from the oclif command metadata in +the built CLI. + +## Inspiration + +This CLI is shamelessly inspired by [wacli](https://wacli.sh/), a WhatsApp CLI +that gets the command-line product shape right. The Beeper CLI borrows the same +basic taste: workflow-first commands, human-readable output by default, exact +`--json` for scripts, `--events` for long-running automation, `--read-only` +for safe agent/tool use, and command names that optimize for what people are +trying to do rather than for raw API resource names. + +When in doubt, the model is simple: make the default output pleasant to read, +make machine output boring and stable, keep write commands explicit, and expose +one obvious command for each job. + +## Install + +Beeper CLI is distributed through Homebrew as a built release archive: + +```sh +brew install beeper/tap/beeper-cli +``` + +The installed command is `beeper`. + +## Plugins + +Beeper CLI supports oclif plugins for third-party commands: + +```sh +beeper plugins +beeper plugins install beeper-cli-plugin-example +beeper plugins uninstall beeper-cli-plugin-example +``` + +Plugin authors should depend on the stable SDK entrypoint instead of importing +CLI internals: + +```ts +import { BeeperCommand, createBeeperClient, ensureWritable } from 'beeper-cli/plugin-sdk' +``` + +Plugins should use namespaced commands such as `beeper github issue create` and +must treat installed plugins as trusted code, because plugins run arbitrary Node.js. + +## Local Development + +```sh +npm install +npm run build +node ./bin/run.js --help +``` + +Run commands directly from TypeScript: + +```sh +npm run dev -- --help +``` + +Regenerate this README after command, flag, or argument changes: + +```sh +npm run readme +``` + +## Authenticate + +```sh +beeper chats +beeper auth status +``` + +On first use, authenticated commands look for a local Beeper Desktop API on the +default port range. If Beeper Desktop is already signed in, the CLI immediately +uses OAuth2 Authorization Code with PKCE and stores the server URL and bearer +token in `~/.config/beeper/config.json`. After that, commands reuse the +remembered server URL. + +If the local Desktop app is not authenticated, the CLI exits with an error +instead of starting another login flow. You can explicitly sign in the app +itself with: + +```sh +beeper login --app-login --email you@example.com +``` + +For non-interactive use, pass a token through the environment: + +```sh +BEEPER_ACCESS_TOKEN=... beeper chats --json +``` + +## Common Workflows + +```sh +beeper doctor +beeper status +beeper accounts +beeper chats +beeper messages "Family" +beeper send text "Family" "on my way" --wait +beeper send file "Family" ./photo.jpg "from today" +beeper export --out ./beeper-export +beeper api get /v1/info +``` + +## Input Resolution + +- Chat arguments accept Beeper chat IDs, local chat IDs, exact titles, or search text. +- Ambiguous chat matches return numbered choices; pass `--pick N` to select one. +- Account arguments accept account IDs, network names, bridge type/id, or account user identity. +- Account filters can expand a network name to multiple matching accounts. +- `contacts search` and `start-chat` can search across all accounts when `--account` is omitted. +- `contacts list` accepts the same account selectors as other account-scoped commands. + +## Output + +Most commands support: + +- app-like text by default, optimized for scanning chats, messages, contacts, accounts, and assets +- `--json` for exact API-shaped structured output +- `--events` for NDJSON lifecycle events on stderr from long-running commands +- `--read-only` to reject commands that modify Beeper or local CLI state +- `--debug` for SDK debug logging +- `--base-url` to point at a different local Desktop API server + +Use `beeper login --server-url URL` to remember a Desktop API server URL for +future commands. + +`beeper commands --json` prints a compact command manifest for tools and agents. +`beeper llm` prints a concise human-readable command guide. + +## Environment + +| Environment variable | Description | +| --- | --- | +| `BEEPER_ACCESS_TOKEN` | Bearer token. Overrides stored OAuth login. | +| `BEEPER_DESKTOP_BASE_URL` | Beeper Desktop API base URL. Defaults to `http://localhost:23373`. | +| `BEEPER_BASE_URL` | SDK-compatible base URL fallback. | +| `BEEPER_CLI_CONFIG_DIR` | Override config directory for testing or isolated profiles. | + +## Command Summary + +| Command | Summary | +| --- | --- | +| `accounts` | List Chat Accounts connected to this Beeper Desktop instance, including bridge metadata and network identity. | +| `accounts add` | Add a Beeper account | +| `accounts add.d` | | +| `accounts.d` | | +| `api get` | Call a raw Desktop API GET path | +| `api get.d` | | +| `api post` | Call a raw Desktop API POST path with a JSON body | +| `api post.d` | | +| `app e2ee recovery-code mark-backed-up` | Mark the recovery key as saved | +| `app e2ee recovery-code mark-backed-up.d` | | +| `app e2ee recovery-code reset begin` | Create a new recovery key | +| `app e2ee recovery-code reset begin.d` | | +| `app e2ee recovery-code reset confirm` | Confirm a newly created recovery key | +| `app e2ee recovery-code reset confirm.d` | | +| `app e2ee recovery-code verify` | Unlock encrypted messages with a recovery key | +| `app e2ee recovery-code verify.d` | | +| `app e2ee verification accept` | Accept a device verification request | +| `app e2ee verification accept.d` | | +| `app e2ee verification cancel` | Cancel device verification | +| `app e2ee verification cancel.d` | | +| `app e2ee verification qr confirm-scanned` | Confirm another device scanned this QR code | +| `app e2ee verification qr confirm-scanned.d` | | +| `app e2ee verification qr scan` | Submit a scanned verification QR payload | +| `app e2ee verification qr scan.d` | | +| `app e2ee verification sas confirm` | Confirm matching emoji verification | +| `app e2ee verification sas confirm.d` | | +| `app e2ee verification sas start` | Start emoji verification | +| `app e2ee verification sas start.d` | | +| `app e2ee verification start` | Start device verification | +| `app e2ee verification start.d` | | +| `app status` | Show Beeper app login and encrypted messaging state | +| `app status.d` | | +| `archive` | Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox | +| `archive.d` | | +| `assets download` | Download a Matrix file using its mxc:// or localmxc:// URL to the device running Beeper Desktop and return the local file URL. | +| `assets download.d` | | +| `assets upload` | Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending a message or materializing a draft attachment. | +| `assets upload.d` | | +| `auth status` | Show local auth status and token metadata | +| `auth status.d` | | +| `autocomplete` | Display autocomplete installation instructions. | +| `avatar` | Set or clear a group chat avatar | +| `avatar.d` | | +| `chat` | Retrieve chat details including metadata, participants, and latest message | +| `chat.d` | | +| `chats` | List all chats sorted by last activity (most recent first). Combines all accounts into a single paginated list. | +| `chats index.d` | | +| `chats search` | Search chats by title, network, or participant names. | +| `chats search.d` | | +| `clear-draft` | Clear a chat draft | +| `clear-draft.d` | | +| `commands` | Print the Beeper CLI command manifest | +| `commands.d` | | +| `config get` | Print CLI configuration | +| `config get.d` | | +| `config path` | Print the CLI config path | +| `config path.d` | | +| `config reset` | Reset CLI configuration | +| `config reset.d` | | +| `config set` | Set a CLI configuration value | +| `config set.d` | | +| `contacts list` | List merged contacts for a specific account with cursor-based pagination. | +| `contacts list.d` | | +| `contacts search` | Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup. | +| `contacts search.d` | | +| `create-chat` | Create a direct or group chat from participant IDs. Returns the created chat. | +| `create-chat.d` | | +| `current-user` | Show the authenticated Desktop API user | +| `current-user.d` | | +| `delete-message` | Delete a message by final message ID. Pending message IDs are not accepted because messages cannot be deleted while sending. | +| `delete-message.d` | | +| `description` | Set or clear a group chat description | +| `description.d` | | +| `doctor` | Verify Desktop API reachability and authentication | +| `doctor.d` | | +| `draft` | Set a chat draft | +| `draft.d` | | +| `edit` | Edit the text content of an existing message. Messages with attachments cannot be edited. | +| `edit.d` | | +| `export` | Export accounts, chats, messages, Markdown transcripts, and attachments. | +| `export.d` | | +| `focus` | Focus Beeper Desktop, optionally opening a chat or message | +| `focus.d` | | +| `help` | Display help for beeper. | +| `inbox` | Move a chat to the primary inbox | +| `inbox.d` | | +| `llm` | Print compact CLI help for agents | +| `llm.d` | | +| `login` | Authenticate with local Beeper Desktop | +| `login.d` | | +| `logout` | Remove the locally stored Beeper Desktop token | +| `logout.d` | | +| `low-priority` | Move a chat to Low Priority | +| `low-priority.d` | | +| `message` | Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. Chat ID may be a Beeper chat ID or local chat ID. | +| `message-expiry` | Set or clear disappearing-message expiry | +| `message-expiry.d` | | +| `message.d` | | +| `messages` | List all messages in a chat with cursor-based pagination. Sorted by timestamp. | +| `messages index.d` | | +| `messages search` | Search messages across chats. | +| `messages search.d` | | +| `mute` | Mute a chat | +| `mute.d` | | +| `notify-anyway` | Force a delivery notification when supported by the underlying network. Currently intended for iMessage on macOS; unsupported networks return an error. | +| `notify-anyway.d` | | +| `pin` | Pin a chat | +| `pin.d` | | +| `react` | Add a reaction to an existing message. | +| `react.d` | | +| `read` | Mark a chat as read, optionally through a specific message ID. | +| `read.d` | | +| `remind` | Set a reminder for a chat at a specific time | +| `remind.d` | | +| `rpc` | Run newline-delimited JSON command RPC | +| `rpc.d` | | +| `search` | Search chats and messages | +| `search.d` | | +| `send file` | Send a file to a chat | +| `send file.d` | | +| `send text` | Send a text message to a specific chat. Supports replying to existing messages. Returns a pending message ID. | +| `send text.d` | | +| `shell` | Run an interactive Beeper CLI shell | +| `shell.d` | | +| `start-chat` | Resolve a user/contact and open a direct chat. Reuses and returns an existing direct chat when one is found. Available in Beeper Desktop v4.2.808+. | +| `start-chat.d` | | +| `status` | Check Beeper Desktop API status | +| `status.d` | | +| `title` | Set a custom chat title | +| `title.d` | | +| `unarchive` | Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox | +| `unarchive.d` | | +| `unmute` | Unmute a chat | +| `unmute.d` | | +| `unpin` | Unpin a chat | +| `unpin.d` | | +| `unreact` | Remove the reaction added by the authenticated user from an existing message. | +| `unreact.d` | | +| `unread` | Mark a chat as unread, optionally from a specific message ID. | +| `unread.d` | | +| `unremind` | Clear an existing reminder from a chat | +| `unremind.d` | | +| `watch` | Stream Desktop API WebSocket events | +| `watch.d` | | + +## Command Reference + +### `beeper accounts` +List Chat Accounts connected to this Beeper Desktop instance, including bridge metadata and network identity. + +```sh +beeper accounts +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper accounts add` +Add a Beeper account + +```sh +beeper accounts add [account] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `account` | no | Account type to add, for example WhatsApp, Discord, or local-whatsapp | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--cookie=...` | option | Cookie value for non-interactive login, in name=value form. Repeat for multiple cookies. | +| `--field=...` | option | Field value for non-interactive login, in id=value form. Repeat for multiple fields. | +| `--flow=` | option | Login flow ID. If omitted, Desktop chooses the default flow. | +| `--guided` | boolean | Prompt through login steps until completion | +| `--login-id=` | option | Existing login ID to re-login as | +| `--non-interactive` | boolean | Do not prompt; require --flow, --field, and --cookie values when needed. | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper accounts add.d` + +```sh +beeper accounts add.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper accounts.d` + +```sh +beeper accounts.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper api get` +Call a raw Desktop API GET path + +```sh +beeper api get +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `path` | yes | API path, for example /v1/info | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper api get.d` + +```sh +beeper api get.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper api post` +Call a raw Desktop API POST path with a JSON body + +```sh +beeper api post +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `path` | yes | API path, for example /v1/messages/{chatID}/send | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--body=` | option | JSON request body Default: {} | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper api post.d` + +```sh +beeper api post.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code mark-backed-up` +Mark the recovery key as saved + +```sh +beeper app e2ee recovery-code mark-backed-up +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code mark-backed-up.d` + +```sh +beeper app e2ee recovery-code mark-backed-up.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code reset begin` +Create a new recovery key + +```sh +beeper app e2ee recovery-code reset begin +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--recovery-code=` | option | Existing recovery key, if available | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code reset begin.d` + +```sh +beeper app e2ee recovery-code reset begin.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code reset confirm` +Confirm a newly created recovery key + +```sh +beeper app e2ee recovery-code reset confirm +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `recoveryCode` | yes | New recovery key returned by reset begin | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code reset confirm.d` + +```sh +beeper app e2ee recovery-code reset confirm.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code verify` +Unlock encrypted messages with a recovery key + +```sh +beeper app e2ee recovery-code verify +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `recoveryCode` | yes | Beeper recovery key | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee recovery-code verify.d` + +```sh +beeper app e2ee recovery-code verify.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification accept` +Accept a device verification request + +```sh +beeper app e2ee verification accept +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `txnID` | yes | Verification transaction ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification accept.d` + +```sh +beeper app e2ee verification accept.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification cancel` +Cancel device verification + +```sh +beeper app e2ee verification cancel +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `txnID` | yes | Verification transaction ID | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--code=` | option | Optional cancellation code | +| `--reason=` | option | Optional cancellation reason | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification cancel.d` + +```sh +beeper app e2ee verification cancel.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification qr confirm-scanned` +Confirm another device scanned this QR code + +```sh +beeper app e2ee verification qr confirm-scanned +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `txnID` | yes | Verification transaction ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification qr confirm-scanned.d` + +```sh +beeper app e2ee verification qr confirm-scanned.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification qr scan` +Submit a scanned verification QR payload + +```sh +beeper app e2ee verification qr scan +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `data` | yes | QR code payload | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification qr scan.d` + +```sh +beeper app e2ee verification qr scan.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification sas confirm` +Confirm matching emoji verification + +```sh +beeper app e2ee verification sas confirm +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `txnID` | yes | Verification transaction ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification sas confirm.d` + +```sh +beeper app e2ee verification sas confirm.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification sas start` +Start emoji verification + +```sh +beeper app e2ee verification sas start +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `txnID` | yes | Verification transaction ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification sas start.d` + +```sh +beeper app e2ee verification sas start.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification start` +Start device verification + +```sh +beeper app e2ee verification start +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--user-id=` | option | User ID to verify. Defaults to the signed-in user. | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app e2ee verification start.d` + +```sh +beeper app e2ee verification start.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app status` +Show Beeper app login and encrypted messaging state + +```sh +beeper app status +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper app status.d` + +```sh +beeper app status.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper archive` +Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox + +```sh +beeper archive +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper archive.d` + +```sh +beeper archive.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper assets download` +Download a Matrix file using its mxc:// or localmxc:// URL to the device running Beeper Desktop and return the local file URL. + +```sh +beeper assets download +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `url` | yes | Asset URL | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper assets download.d` + +```sh +beeper assets download.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper assets upload` +Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending a message or materializing a draft attachment. + +```sh +beeper assets upload +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `file` | yes | The file to upload (max 500 MB). | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--file-name=` | option | Original filename. Defaults to the uploaded file name if omitted | +| `--mime-type=` | option | MIME type. Auto-detected from magic bytes if omitted | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper assets upload.d` + +```sh +beeper assets upload.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper auth status` +Show local auth status and token metadata + +```sh +beeper auth status +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper auth status.d` + +```sh +beeper auth status.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper autocomplete` +Display autocomplete installation instructions. + +```sh +beeper autocomplete [shell] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `shell` | no | Shell type | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `-r, --refresh-cache` | boolean | Refresh cache (ignores displaying instructions) | + +### `beeper avatar` +Set or clear a group chat avatar + +```sh +beeper avatar [path] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `path` | no | Local avatar image path. Omit with --clear to remove it. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--clear` | boolean | Clear the current avatar | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper avatar.d` + +```sh +beeper avatar.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chat` +Retrieve chat details including metadata, participants, and latest message + +```sh +beeper chat +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--max-participants=` | option | Maximum participants to return | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chat.d` + +```sh +beeper chat.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chats` +List all chats sorted by last activity (most recent first). Combines all accounts into a single paginated list. + +```sh +beeper chats +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Limit to Account ID, network, bridge, or account user | +| `--ids` | boolean | Print only chat IDs | +| `--limit=` | option | Maximum chats to print Default: 20 | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chats index.d` + +```sh +beeper chats index.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chats search` +Search chats by title, network, or participant names. + +```sh +beeper chats search +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `query` | yes | User-typed search text. Literal word matching (non-semantic). | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Limit to Account ID, network, bridge, or account user | +| `--ids` | boolean | Print only chat IDs | +| `--inbox=` | option | | +| `--include-muted` | boolean | Include muted chats. Use --no-include-muted for a tighter search. | +| `--last-activity-after=` | option | Only chats with last activity after this ISO timestamp | +| `--last-activity-before=` | option | Only chats with last activity before this ISO timestamp | +| `--limit=` | option | Maximum chats to print Default: 20 | +| `--scope=` | option | | +| `--type=` | option | | +| `--unread` | boolean | Only unread chats | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper chats search.d` + +```sh +beeper chats search.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper clear-draft` +Clear a chat draft + +```sh +beeper clear-draft +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID, local chat ID, title, or search text | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper clear-draft.d` + +```sh +beeper clear-draft.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper commands` +Print the Beeper CLI command manifest + +```sh +beeper commands +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper commands.d` + +```sh +beeper commands.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config get` +Print CLI configuration + +```sh +beeper config get [key] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `key` | no | Optional config key to print | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config get.d` + +```sh +beeper config get.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config path` +Print the CLI config path + +```sh +beeper config path +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config path.d` + +```sh +beeper config path.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config reset` +Reset CLI configuration + +```sh +beeper config reset +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config reset.d` + +```sh +beeper config reset.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config set` +Set a CLI configuration value + +```sh +beeper config set +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `key` | yes | Config key to set | +| `value` | yes | Config value | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper config set.d` + +```sh +beeper config set.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper contacts list` +List merged contacts for a specific account with cursor-based pagination. + +```sh +beeper contacts list +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `account` | yes | Account ID, network, bridge, or account user | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--ids` | boolean | Print only contact user IDs | +| `--limit=` | option | Maximum contacts to print Default: 50 | +| `--query=` | option | Optional blended contact lookup query | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper contacts list.d` + +```sh +beeper contacts list.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper contacts search` +Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup. + +```sh +beeper contacts search +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `query` | yes | Contact search query | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Account ID, network, bridge, or account user. Omit to search every account. | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper contacts search.d` + +```sh +beeper contacts search.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper create-chat` +Create a direct or group chat from participant IDs. Returns the created chat. + +```sh +beeper create-chat +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=` | option | Account ID, network, bridge, or account user Required. | +| `--message=` | option | Optional first message | +| `--participant=...` | option | Participant user ID Required. | +| `--title=` | option | Group title | +| `--type=` | option | Chat type Default: single | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper create-chat.d` + +```sh +beeper create-chat.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper current-user` +Show the authenticated Desktop API user + +```sh +beeper current-user +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper current-user.d` + +```sh +beeper current-user.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper delete-message` +Delete a message by final message ID. Pending message IDs are not accepted because messages cannot be deleted while sending. + +```sh +beeper delete-message +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `message` | yes | Message ID. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--for-everyone` | boolean | True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported. | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper delete-message.d` + +```sh +beeper delete-message.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper description` +Set or clear a group chat description + +```sh +beeper description [description] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `description` | no | New description. Omit with --clear to remove it. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--clear` | boolean | Clear the current description | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper description.d` + +```sh +beeper description.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper doctor` +Verify Desktop API reachability and authentication + +```sh +beeper doctor +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper doctor.d` + +```sh +beeper doctor.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper draft` +Set a chat draft + +```sh +beeper draft +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID, local chat ID, title, or search text | +| `text` | yes | Draft text | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--file=` | option | Draft attachment file | +| `--file-name=` | option | Attachment display filename | +| `--mime-type=` | option | Attachment MIME type | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper draft.d` + +```sh +beeper draft.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper edit` +Edit the text content of an existing message. Messages with attachments cannot be edited. + +```sh +beeper edit +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `message` | yes | Message ID. | +| `text` | yes | Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper edit.d` + +```sh +beeper edit.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper export` +Export accounts, chats, messages, Markdown transcripts, and attachments. + +```sh +beeper export +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Limit to an account selector. Repeat to include more accounts. | +| `--chat=...` | option | Limit to a chat selector. Repeat to include more chats. | +| `--force` | boolean | Re-export chats even if checkpoint state says they are complete. | +| `--limit-chats=` | option | Maximum chats to export. Intended for testing large exports. | +| `--limit-messages=` | option | Maximum messages per chat. Intended for testing large exports. | +| `--max-participants=` | option | Maximum participants to include in each chat.json. Default: 500 | +| `--no-attachments` | boolean | Skip downloading message attachments. | +| `-o, --out=` | option | Export directory. Default: beeper-export | +| `--pick=` | option | Pick the Nth chat when a --chat selector is ambiguous. | +| `--quiet` | boolean | Suppress progress output. | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper export.d` + +```sh +beeper export.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper focus` +Focus Beeper Desktop, optionally opening a chat or message + +```sh +beeper focus [chat] [message] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | no | Chat ID, local chat ID, title, or search text | +| `message` | no | Message ID | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--attachment=` | option | Draft attachment path | +| `--draft=` | option | Draft text | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper focus.d` + +```sh +beeper focus.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper help` +Display help for beeper. + +```sh +beeper help [command] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `command` | no | Command to show help for. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `-n, --nested-commands` | boolean | Include all nested commands in the output. | + +### `beeper inbox` +Move a chat to the primary inbox + +```sh +beeper inbox +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper inbox.d` + +```sh +beeper inbox.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper llm` +Print compact CLI help for agents + +```sh +beeper llm +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper llm.d` + +```sh +beeper llm.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper login` +Authenticate with local Beeper Desktop + +```sh +beeper login +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--accept-terms` | boolean | Accept the Terms of Use and acknowledge the Privacy Policy when creating an account | +| `--app-login` | boolean | Sign in the local Beeper Desktop app itself instead of requesting a Desktop API token from an already-signed-in app | +| `--client-name=` | option | OAuth client name shown in Beeper Desktop Default: Beeper CLI | +| `--code=` | option | Email sign-in code | +| `--email=` | option | Email address to send a sign-in code to | +| `--no-open` | boolean | Print the authorization URL instead of opening a browser | +| `--no-save` | boolean | Do not store the returned Desktop API token | +| `--oauth` | boolean | Use the OAuth2 PKCE Desktop API authorization flow | +| `--scope=` | option | Space-separated OAuth scopes Default: read write | +| `--server-url=` | option | Beeper Desktop API server URL | +| `--username=` | option | Username to create if registration is required | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper login.d` + +```sh +beeper login.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper logout` +Remove the locally stored Beeper Desktop token + +```sh +beeper logout +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper logout.d` + +```sh +beeper logout.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper low-priority` +Move a chat to Low Priority + +```sh +beeper low-priority +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper low-priority.d` + +```sh +beeper low-priority.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper message` +Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. Chat ID may be a Beeper chat ID or local chat ID. + +```sh +beeper message +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `message` | yes | Message ID. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper message-expiry` +Set or clear disappearing-message expiry + +```sh +beeper message-expiry +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `seconds` | yes | Expiry in seconds, or "off" | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper message-expiry.d` + +```sh +beeper message-expiry.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper message.d` + +```sh +beeper message.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper messages` +List all messages in a chat with cursor-based pagination. Sorted by timestamp. + +```sh +beeper messages +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--after=` | option | Fetch messages after cursor | +| `--before=` | option | Fetch messages before cursor | +| `--ids` | boolean | Print only message IDs | +| `--limit=` | option | Maximum messages to print Default: 50 | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper messages index.d` + +```sh +beeper messages index.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper messages search` +Search messages across chats. + +```sh +beeper messages search [query] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `query` | no | User-typed search text. Literal word matching (non-semantic). | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Limit to Account ID, network, bridge, or account user | +| `--chat=...` | option | Limit to Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `--chat-type=` | option | Limit to group chats or direct messages | +| `--date-after=` | option | Only messages after this ISO timestamp | +| `--date-before=` | option | Only messages before this ISO timestamp | +| `--exclude-low-priority` | boolean | Exclude low-priority chats. Use --no-exclude-low-priority to include all. | +| `--ids` | boolean | Print only message IDs | +| `--include-muted` | boolean | Include muted chats. Use --no-include-muted for a tighter search. | +| `--limit=` | option | Maximum messages to print Default: 50 | +| `--media=...` | option | Filter by media type. Repeat for more types. | +| `--sender=` | option | me, others, or a user ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper messages search.d` + +```sh +beeper messages search.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper mute` +Mute a chat + +```sh +beeper mute +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID, local chat ID, title, or search text | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper mute.d` + +```sh +beeper mute.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper notify-anyway` +Force a delivery notification when supported by the underlying network. Currently intended for iMessage on macOS; unsupported networks return an error. + +```sh +beeper notify-anyway +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper notify-anyway.d` + +```sh +beeper notify-anyway.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper pin` +Pin a chat + +```sh +beeper pin +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper pin.d` + +```sh +beeper pin.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper react` +Add a reaction to an existing message. + +```sh +beeper react +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `message` | yes | Message ID. | +| `reaction` | yes | Reaction key to add (emoji, shortcode, or custom emoji key) | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | +| `--transaction=` | option | Optional transaction ID | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper react.d` + +```sh +beeper react.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper read` +Mark a chat as read, optionally through a specific message ID. + +```sh +beeper read +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--message=` | option | Message ID. | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper read.d` + +```sh +beeper read.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper remind` +Set a reminder for a chat at a specific time + +```sh +beeper remind +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `when` | yes | Timestamp when the reminder should trigger. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--dismiss-on-message` | boolean | Cancel if someone messages in the chat | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper remind.d` + +```sh +beeper remind.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper rpc` +Run newline-delimited JSON command RPC + +```sh +beeper rpc +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper rpc.d` + +```sh +beeper rpc.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper search` +Search chats and messages + +```sh +beeper search +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `query` | yes | Literal search query | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper search.d` + +```sh +beeper search.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper send file` +Send a file to a chat + +```sh +beeper send file [text] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `file` | yes | The file to upload (max 500 MB). | +| `text` | no | Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--file-name=` | option | Original filename. Defaults to the uploaded file name if omitted | +| `--mime-type=` | option | MIME type. Auto-detected from magic bytes if omitted | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | +| `--reply-to=` | option | Provide a message ID to send this as a reply to an existing message | +| `--wait` | boolean | Wait for the pending message to resolve | +| `--wait-interval=` | option | Milliseconds between message status checks Default: 750 | +| `--wait-timeout=` | option | Milliseconds to wait for message resolution Default: 30000 | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper send file.d` + +```sh +beeper send file.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper send text` +Send a text message to a specific chat. Supports replying to existing messages. Returns a pending message ID. + +```sh +beeper send text +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `text` | yes | Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--file=` | option | The file to upload (max 500 MB). | +| `--file-name=` | option | Original filename. Defaults to the uploaded file name if omitted | +| `--mime-type=` | option | MIME type. Auto-detected from magic bytes if omitted | +| `--pick=` | option | Pick the Nth chat when the input is ambiguous | +| `--reply-to=` | option | Provide a message ID to send this as a reply to an existing message | +| `--wait` | boolean | Wait for the pending message to resolve | +| `--wait-interval=` | option | Milliseconds between message status checks Default: 750 | +| `--wait-timeout=` | option | Milliseconds to wait for message resolution Default: 30000 | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper send text.d` + +```sh +beeper send text.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper shell` +Run an interactive Beeper CLI shell + +```sh +beeper shell +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper shell.d` + +```sh +beeper shell.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper start-chat` +Resolve a user/contact and open a direct chat. Reuses and returns an existing direct chat when one is found. Available in Beeper Desktop v4.2.808+. + +```sh +beeper start-chat [query] +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `query` | no | Phone, email, username, user ID, or name | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--account=...` | option | Account ID, network, bridge, or account user. Omit to try every account. | +| `--allow-invite` | boolean | Allow invite-based DM creation when required | +| `--email=` | option | Email address | +| `--id=` | option | Known user ID | +| `--message=` | option | Optional first message | +| `--name=` | option | Display name hint | +| `--phone=` | option | Phone number | +| `--username=` | option | Username | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper start-chat.d` + +```sh +beeper start-chat.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper status` +Check Beeper Desktop API status + +```sh +beeper status +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper status.d` + +```sh +beeper status.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper title` +Set a custom chat title + +```sh +beeper title +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `title` | yes | New chat title | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper title.d` + +```sh +beeper title.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unarchive` +Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox + +```sh +beeper unarchive <chat> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unarchive.d` + +```sh +beeper unarchive.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unmute` +Unmute a chat + +```sh +beeper unmute <chat> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID, local chat ID, title, or search text | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unmute.d` + +```sh +beeper unmute.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unpin` +Unpin a chat + +```sh +beeper unpin <chat> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unpin.d` + +```sh +beeper unpin.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unreact` +Remove the reaction added by the authenticated user from an existing message. + +```sh +beeper unreact <chat> <message> <reaction> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | +| `message` | yes | Message ID. | +| `reaction` | yes | Reaction key to add (emoji, shortcode, or custom emoji key) | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unreact.d` + +```sh +beeper unreact.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unread` +Mark a chat as unread, optionally from a specific message ID. + +```sh +beeper unread <chat> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--message=<value>` | option | Message ID. | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unread.d` + +```sh +beeper unread.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unremind` +Clear an existing reminder from a chat + +```sh +beeper unremind <chat> +``` + +Arguments: + +| Name | Required | Description | +| --- | --- | --- | +| `chat` | yes | Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available. Also accepts exact chat titles or search text. | + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `--pick=<value>` | option | Pick the Nth chat when the input is ambiguous | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper unremind.d` + +```sh +beeper unremind.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper watch` +Stream Desktop API WebSocket events + +```sh +beeper watch +``` + +Flags: + +| Flag | Type | Description | +| --- | --- | --- | +| `-c, --chat=<value>...` | option | Chat ID to subscribe to. Defaults to all chats. | + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +### `beeper watch.d` + +```sh +beeper watch.d +``` + +Global flags: `--base-url`, `--debug`, `--events`, `--json`, `--read-only`. + +## Publishing + +Beeper CLI releases are built as Homebrew archives and uploaded to GitHub +Releases. Push a `v*` tag to run `.github/workflows/publish-release.yml`. + +The release workflow: + +- runs the TypeScript test suite +- builds a Homebrew archive containing the compiled CLI and production dependencies +- uploads the archive to the GitHub release +- updates `beeper/homebrew-tap` with the pinned archive SHA + +Required repository secrets: + +- `HOMEBREW_TAP_GITHUB_TOKEN` diff --git a/SECURITY.md b/packages/cli/SECURITY.md similarity index 100% rename from SECURITY.md rename to packages/cli/SECURITY.md diff --git a/packages/cli/bin/check-release-environment b/packages/cli/bin/check-release-environment new file mode 100644 index 0000000..0a2b720 --- /dev/null +++ b/packages/cli/bin/check-release-environment @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +errors=() + +for path in package.json package-lock.json scripts/build-homebrew-archive.mjs scripts/publish-homebrew-formula.mjs .github/workflows/publish-release.yml; do + if [[ ! -f "${path}" ]]; then + errors+=("Missing required release file: ${path}") + fi +done + +for command in node npm git gh tar; do + if ! command -v "${command}" >/dev/null 2>&1; then + errors+=("Missing required release command: ${command}") + fi +done + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" diff --git a/packages/cli/bin/dev.js b/packages/cli/bin/dev.js new file mode 100755 index 0000000..e91fe4c --- /dev/null +++ b/packages/cli/bin/dev.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core' + +await execute({ development: true, dir: import.meta.url }) diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js new file mode 100755 index 0000000..3492285 --- /dev/null +++ b/packages/cli/bin/run.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core' + +await execute({ dir: import.meta.url }) diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..a3b7209 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,115 @@ +{ + "name": "beeper-cli", + "version": "0.0.0", + "description": "Beeper CLI", + "license": "MIT", + "type": "module", + "bin": { + "beeper": "./bin/run.js" + }, + "exports": { + "./plugin-sdk": { + "types": "./dist/plugin-sdk.d.ts", + "import": "./dist/plugin-sdk.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "bin", + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "pnpm clean && tsc -p tsconfig.json", + "check:api-copy": "pnpm build && node scripts/check-api-copy.mjs", + "check:readme": "pnpm build && node scripts/generate-readme.mjs --check", + "clean": "rm -rf dist", + "dev": "node --import tsx ./bin/dev.js", + "e2e:staging": "pnpm build && node test/e2e-staging.mjs", + "pack:homebrew": "pnpm build && node scripts/build-homebrew-archive.mjs", + "readme": "pnpm build && node scripts/generate-readme.mjs", + "test": "pnpm build && node scripts/generate-readme.mjs --check && node scripts/check-api-copy.mjs && node ./test/cli-smoke.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "oclif": { + "additionalHelpFlags": [ + "-h" + ], + "commands": { + "strategy": "pattern", + "target": "./dist/commands", + "globPatterns": [ + "**/*.{js,ts,tsx}" + ] + }, + "bin": "beeper", + "dirname": "beeper", + "flexibleTaxonomy": true, + "helpOptions": { + "maxWidth": 100 + }, + "plugins": [ + "@oclif/plugin-help", + "@oclif/plugin-autocomplete", + "@oclif/plugin-not-found", + "@oclif/plugin-plugins" + ], + "topicSeparator": " ", + "topics": { + "api": { + "description": "Call raw Desktop API endpoints." + }, + "app": { + "description": "Manage app login and encrypted messaging setup." + }, + "accounts": { + "description": "Manage Beeper accounts." + }, + "assets": { + "description": "Upload and download message assets." + }, + "auth": { + "description": "Authenticate with local Beeper Desktop." + }, + "chat": { + "description": "Open and inspect one chat." + }, + "chats": { + "description": "List and search chats." + }, + "config": { + "description": "Manage local CLI configuration." + }, + "contacts": { + "description": "Search contacts across accounts." + }, + "messages": { + "description": "List and search chat messages." + }, + "send": { + "description": "Send messages." + } + } + }, + "dependencies": { + "@beeper/desktop-api": "^5.0.0", + "@oclif/core": "^4.11.2", + "@oclif/plugin-autocomplete": "^3.2.49", + "@oclif/plugin-help": "^6.2.48", + "@oclif/plugin-not-found": "^3.2.85", + "@oclif/plugin-plugins": "^5.4.67", + "figures": "^6.1.0", + "ink": "^7.0.3", + "ink-spinner": "^5.0.0", + "react": "^19.2.6", + "ws": "^8.20.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^19.2.14", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + } +} diff --git a/release-please-config.json b/packages/cli/release-please-config.json similarity index 97% rename from release-please-config.json rename to packages/cli/release-please-config.json index 53619de..0eec94e 100644 --- a/release-please-config.json +++ b/packages/cli/release-please-config.json @@ -61,7 +61,7 @@ ], "release-type": "simple", "extra-files": [ - "pkg/cmd/version.go", + "package.json", "README.md" ] -} \ No newline at end of file +} diff --git a/scripts/bootstrap b/packages/cli/scripts/bootstrap similarity index 69% rename from scripts/bootstrap rename to packages/cli/scripts/bootstrap index 9ebb7d3..2baee40 100755 --- a/scripts/bootstrap +++ b/packages/cli/scripts/bootstrap @@ -18,5 +18,8 @@ if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] echo } fi -echo "==> Installing Go dependencies…" -go mod tidy -e || true +echo "==> Checking Node dependencies" +if [ ! -d node_modules ]; then + echo "node_modules is missing. Run npm install after approving dependency installation." + exit 1 +fi diff --git a/packages/cli/scripts/build b/packages/cli/scripts/build new file mode 100755 index 0000000..a1c560f --- /dev/null +++ b/packages/cli/scripts/build @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Building Beeper CLI" +npm run build diff --git a/packages/cli/scripts/build-homebrew-archive.mjs b/packages/cli/scripts/build-homebrew-archive.mjs new file mode 100644 index 0000000..e85072e --- /dev/null +++ b/packages/cli/scripts/build-homebrew-archive.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +import {createHash} from 'node:crypto'; +import {existsSync} from 'node:fs'; +import {cp, mkdir, mkdtemp, readFile, rm, stat, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {basename, join, resolve} from 'node:path'; +import {spawn} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const workspaceRoot = resolve(root, '../..'); +const packageJsonPath = join(root, 'package.json'); +const pnpmLockPath = join(workspaceRoot, 'pnpm-lock.yaml'); +const distPath = join(root, 'dist'); +const outDir = join(root, 'dist', 'release'); + +const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8')); +const packageName = 'beeper-cli'; +const commandName = 'beeper'; +const version = process.env.PACKAGE_VERSION || pkg.version; +const archiveName = `${packageName}_${version}_any.tar.gz`; +const archivePath = join(outDir, archiveName); +const metadataPath = join(outDir, 'homebrew.json'); +const workDir = await mkdtemp(join(tmpdir(), 'beeper-cli-homebrew-')); + +await ensureBuilt(); +await mkdir(join(workDir, 'bin'), {recursive: true}); +await mkdir(join(workDir, 'libexec'), {recursive: true}); +await mkdir(outDir, {recursive: true}); + +await cp(packageJsonPath, join(workDir, 'libexec', 'package.json')); +await cp(pnpmLockPath, join(workDir, 'libexec', 'pnpm-lock.yaml')); +await writeFile(join(workDir, 'libexec', 'pnpm-workspace.yaml'), 'packages:\n - .\n'); +await cp(join(root, 'bin'), join(workDir, 'libexec', 'bin'), {recursive: true}); +await cp(distPath, join(workDir, 'libexec', 'dist'), { + recursive: true, + filter: source => !source.startsWith(outDir), +}); +await writeFile( + join(workDir, 'bin', commandName), + `#!/bin/sh +set -e +prefix="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +exec node "$prefix/libexec/bin/run.js" "$@" +`, + {mode: 0o755}, +); + +await run('pnpm', ['install', '--prod', '--frozen-lockfile', '--ignore-scripts'], { + cwd: join(workDir, 'libexec'), +}); +await rm(archivePath, {force: true}); +await run('tar', ['-czf', archivePath, '-C', workDir, '.'], {cwd: root}); + +const sha256 = await hashFile(archivePath); +await writeFile( + metadataPath, + `${JSON.stringify( + { + archive: basename(archivePath), + command: commandName, + displayName: 'Beeper CLI', + package: packageName, + path: archivePath, + sha256, + version, + }, + null, + 2, + )}\n`, +); + +console.log(`${archivePath}`); +console.log(`sha256 ${sha256}`); +await rm(workDir, {recursive: true, force: true}); + +async function ensureBuilt() { + if (!existsSync(distPath)) { + throw new Error('dist/ does not exist. Run pnpm build before packaging.'); + } + + const distStats = await stat(distPath); + if (!distStats.isDirectory()) { + throw new Error('dist/ exists but is not a directory.'); + } + + if (!existsSync(join(distPath, 'commands'))) { + throw new Error('dist/commands does not exist. Run pnpm build before packaging.'); + } +} + +async function hashFile(path) { + const hash = createHash('sha256'); + hash.update(await readFile(path)); + return hash.digest('hex'); +} + +async function run(command, args, options = {}) { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd || root, + env: process.env, + stdio: 'inherit', + }); + + child.on('error', reject); + child.on('exit', code => { + if (code === 0) { + resolvePromise(); + return; + } + + reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)); + }); + }); +} diff --git a/packages/cli/scripts/check-api-copy.mjs b/packages/cli/scripts/check-api-copy.mjs new file mode 100644 index 0000000..4538857 --- /dev/null +++ b/packages/cli/scripts/check-api-copy.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import {readFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import {join, resolve} from 'node:path'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const {apiCopy} = await import('../dist/lib/copy.js'); + +const checks = [ + ['accounts.list', 'resources/accounts/accounts.d.ts', 'list'], + ['assets.download', 'resources/assets.d.ts', 'download'], + ['assets.upload', 'resources/assets.d.ts', 'upload'], + ['chats.archive', 'resources/chats/chats.d.ts', 'archive'], + ['chats.create', 'resources/chats/chats.d.ts', 'create'], + ['chats.list', 'resources/chats/chats.d.ts', 'list'], + ['chats.markRead', 'resources/chats/chats.d.ts', 'markRead'], + ['chats.markUnread', 'resources/chats/chats.d.ts', 'markUnread'], + ['chats.notifyAnyway', 'resources/chats/chats.d.ts', 'notifyAnyway'], + ['chats.retrieve', 'resources/chats/chats.d.ts', 'retrieve'], + ['chats.search', 'resources/chats/chats.d.ts', 'search'], + ['chats.start', 'resources/chats/chats.d.ts', 'start'], + ['contacts.search', 'resources/accounts/contacts.d.ts', 'search'], + ['messages.delete', 'resources/messages.d.ts', 'delete'], + ['messages.list', 'resources/messages.d.ts', 'list'], + ['messages.retrieve', 'resources/messages.d.ts', 'retrieve'], + ['messages.search', 'resources/messages.d.ts', 'search'], + ['messages.send', 'resources/messages.d.ts', 'send'], + ['messages.update', 'resources/messages.d.ts', 'update'], + ['reactions.add', 'resources/chats/messages/reactions.d.ts', 'add'], + ['reactions.delete', 'resources/chats/messages/reactions.d.ts', 'delete'], + ['reminders.create', 'resources/chats/reminders.d.ts', 'create'], + ['reminders.delete', 'resources/chats/reminders.d.ts', 'delete'], +]; + +const failures = []; + +for (const [copyPath, sdkPath, method] of checks) { + const expected = getPath(apiCopy, copyPath); + const actual = await sdkMethodDescription(sdkPath, method); + if (expected !== actual) { + failures.push(`${copyPath}\n expected: ${expected}\n actual: ${actual}`); + } +} + +if (failures.length > 0) { + console.error(`API copy drifted from @beeper/desktop-api:\n\n${failures.join('\n\n')}`); + process.exit(1); +} + +console.log(`api-copy: ${checks.length} SDK descriptions verified`); + +function getPath(object, path) { + return path.split('.').reduce((value, key) => value?.[key], object); +} + +async function sdkMethodDescription(relativePath, method) { + const source = await readFile(join(root, 'node_modules', '@beeper', 'desktop-api', relativePath), 'utf8'); + const methodMatch = source.match(new RegExp(String.raw`^\s*${method}\(`, 'm')); + const methodIndex = methodMatch?.index ?? -1; + if (methodIndex === -1) throw new Error(`Could not find SDK method ${relativePath}#${method}`); + + const comments = [...source.slice(0, methodIndex).matchAll(/\/\*\*([\s\S]*?)\*\//g)]; + const match = comments.at(-1); + if (!match) throw new Error(`Could not find SDK docs for ${relativePath}#${method}`); + + const lines = match[1] + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '').trimEnd()) + + const exampleIndex = lines.findIndex(line => line.startsWith('@example')); + return lines + .slice(0, exampleIndex === -1 ? undefined : exampleIndex) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/scripts/format b/packages/cli/scripts/format similarity index 100% rename from scripts/format rename to packages/cli/scripts/format diff --git a/packages/cli/scripts/generate-readme.mjs b/packages/cli/scripts/generate-readme.mjs new file mode 100644 index 0000000..d293232 --- /dev/null +++ b/packages/cli/scripts/generate-readme.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +import {readFile, writeFile} from 'node:fs/promises'; +import {Config} from '@oclif/core/config'; + +const config = await Config.load({root: process.cwd()}); +const check = process.argv.includes('--check'); +const commands = [...config.commands] + .filter(command => !command.hidden) + .sort((a, b) => displayID(a.id).localeCompare(displayID(b.id))); + +const globalFlags = new Set(['base-url', 'debug', 'events', 'json', 'read-only']); +const commandList = commands.map(command => { + const id = displayID(command.id); + return `| \`${id}\` | ${escapeTable(text(command.summary || command.description || ''))} |`; +}); + +const commandSections = commands.map(command => commandSection(command)).join('\n\n'); + +const readme = `# Beeper CLI + +Command-line access to the [Beeper Desktop API](https://developers.beeper.com/desktop-api/). + +The CLI is built with TypeScript, oclif, and the official \`@beeper/desktop-api\` +SDK. The command reference below is generated from the oclif command metadata in +the built CLI. + +## Inspiration + +This CLI is shamelessly inspired by [wacli](https://wacli.sh/), a WhatsApp CLI +that gets the command-line product shape right. The Beeper CLI borrows the same +basic taste: workflow-first commands, human-readable output by default, exact +\`--json\` for scripts, \`--events\` for long-running automation, \`--read-only\` +for safe agent/tool use, and command names that optimize for what people are +trying to do rather than for raw API resource names. + +When in doubt, the model is simple: make the default output pleasant to read, +make machine output boring and stable, keep write commands explicit, and expose +one obvious command for each job. + +## Install + +Beeper CLI is distributed through Homebrew as a built release archive: + +\`\`\`sh +brew install beeper/tap/beeper-cli +\`\`\` + +The installed command is \`beeper\`. + +## Local Development + +\`\`\`sh +npm install +npm run build +node ./bin/run.js --help +\`\`\` + +Run commands directly from TypeScript: + +\`\`\`sh +npm run dev -- --help +\`\`\` + +Regenerate this README after command, flag, or argument changes: + +\`\`\`sh +npm run readme +\`\`\` + +## Authenticate + +\`\`\`sh +beeper chats +beeper auth status +\`\`\` + +On first use, authenticated commands look for a local Beeper Desktop API on the +default port range. If Beeper Desktop is already signed in, the CLI immediately +uses OAuth2 Authorization Code with PKCE and stores the server URL and bearer +token in \`~/.config/beeper/config.json\`. After that, commands reuse the +remembered server URL. + +If the local Desktop app is not authenticated, the CLI exits with an error +instead of starting another login flow. You can explicitly sign in the app +itself with: + +\`\`\`sh +beeper login --app-login --email you@example.com +\`\`\` + +For non-interactive use, pass a token through the environment: + +\`\`\`sh +BEEPER_ACCESS_TOKEN=... beeper chats --json +\`\`\` + +## Common Workflows + +\`\`\`sh +beeper doctor +beeper status +beeper accounts +beeper chats +beeper messages "Family" +beeper send text "Family" "on my way" --wait +beeper send file "Family" ./photo.jpg "from today" +beeper export --out ./beeper-export +beeper api get /v1/info +\`\`\` + +## Input Resolution + +- Chat arguments accept Beeper chat IDs, local chat IDs, exact titles, or search text. +- Ambiguous chat matches return numbered choices; pass \`--pick N\` to select one. +- Account arguments accept account IDs, network names, bridge type/id, or account user identity. +- Account filters can expand a network name to multiple matching accounts. +- \`contacts search\` and \`start-chat\` can search across all accounts when \`--account\` is omitted. +- \`contacts list\` accepts the same account selectors as other account-scoped commands. + +## Output + +Most commands support: + +- app-like text by default, optimized for scanning chats, messages, contacts, accounts, and assets +- \`--json\` for exact API-shaped structured output +- \`--events\` for NDJSON lifecycle events on stderr from long-running commands +- \`--read-only\` to reject commands that modify Beeper or local CLI state +- \`--debug\` for SDK debug logging +- \`--base-url\` to point at a different local Desktop API server + +Use \`beeper login --server-url URL\` to remember a Desktop API server URL for +future commands. + +\`commands --json\` prints a compact command manifest for tools and agents. +\`llm\` prints a concise human-readable command guide. + +## Environment + +| Environment variable | Description | +| --- | --- | +| \`BEEPER_ACCESS_TOKEN\` | Bearer token. Overrides stored OAuth login. | +| \`BEEPER_DESKTOP_BASE_URL\` | Beeper Desktop API base URL. Defaults to \`http://localhost:23373\`. | +| \`BEEPER_BASE_URL\` | SDK-compatible base URL fallback. | +| \`BEEPER_CLI_CONFIG_DIR\` | Override config directory for testing or isolated profiles. | + +## Command Summary + +| Command | Summary | +| --- | --- | +${commandList.join('\n')} + +## Command Reference + +${commandSections} + +## Publishing + +Beeper CLI releases are built as Homebrew archives and uploaded to GitHub +Releases. Push a \`v*\` tag to run \`.github/workflows/publish-release.yml\`. + +The release workflow: + +- runs the TypeScript test suite +- builds a Homebrew archive containing the compiled CLI and production dependencies +- uploads the archive to the GitHub release +- updates \`beeper/homebrew-tap\` with the pinned archive SHA + +Required repository secrets: + +- \`HOMEBREW_TAP_GITHUB_TOKEN\` +`; + +if (check) { + const current = await readFile('README.md', 'utf8'); + if (current !== readme) { + console.error('README.md is out of date. Run npm run readme.'); + process.exit(1); + } +} else { + await writeFile('README.md', readme); +} + +function commandSection(command) { + const id = displayID(command.id); + const usage = usageFor(command); + const parts = [ + `### \`beeper ${id}\``, + text(command.summary || command.description || ''), + '', + '```sh', + usage, + '```', + ]; + + const args = Object.values(command.args || {}); + if (args.length > 0) { + parts.push('', 'Arguments:', '', '| Name | Required | Description |', '| --- | --- | --- |'); + for (const arg of args) { + parts.push(`| \`${arg.name}\` | ${arg.required ? 'yes' : 'no'} | ${escapeTable(arg.description || '')} |`); + } + } + + const flags = Object.values(command.flags || {}).filter(flag => !globalFlags.has(flag.name)); + if (flags.length > 0) { + parts.push('', 'Flags:', '', '| Flag | Type | Description |', '| --- | --- | --- |'); + for (const flag of flags.sort((a, b) => a.name.localeCompare(b.name))) { + parts.push(`| \`${flagLabel(flag)}\` | ${flag.type || 'boolean'} | ${escapeTable(flagDescription(flag))} |`); + } + } + + const inherited = Object.values(command.flags || {}).filter(flag => globalFlags.has(flag.name)); + if (inherited.length > 0) { + parts.push('', `Global flags: ${inherited.map(flag => `\`--${flag.name}\``).join(', ')}.`); + } + + return parts.filter((part, index, array) => part !== '' || array[index - 1] !== '').join('\n'); +} + +function displayID(id) { + return id.replaceAll(':', ' '); +} + +function usageFor(command) { + const args = Object.values(command.args || {}).map(arg => arg.required ? `<${arg.name}>` : `[${arg.name}]`); + return ['beeper', displayID(command.id), ...args].join(' '); +} + +function flagLabel(flag) { + const prefix = flag.char ? `-${flag.char}, --${flag.name}` : `--${flag.name}`; + if (flag.type === 'boolean') return prefix; + const value = flag.options?.length ? `<${flag.options.join('|')}>` : '<value>'; + return `${prefix}=${value}${flag.multiple ? '...' : ''}`; +} + +function flagDescription(flag) { + const details = []; + if (flag.description) details.push(text(flag.description)); + if (flag.default !== undefined) details.push(`Default: ${String(flag.default)}`); + if (flag.required) details.push('Required.'); + return details.join(' '); +} + +function escapeTable(value) { + return text(value).replaceAll('|', '\\|').replace(/\s+/g, ' ').trim(); +} + +function text(value) { + return String(value) + .replaceAll('<%= config.bin %>', config.bin) + .replaceAll('<%= command.id %>', '') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/packages/cli/scripts/link b/packages/cli/scripts/link new file mode 100755 index 0000000..b8e388f --- /dev/null +++ b/packages/cli/scripts/link @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Linking Beeper CLI" +npm link diff --git a/packages/cli/scripts/lint b/packages/cli/scripts/lint new file mode 100755 index 0000000..91d312a --- /dev/null +++ b/packages/cli/scripts/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Typechecking Beeper CLI" +npm run typecheck diff --git a/scripts/mock b/packages/cli/scripts/mock similarity index 100% rename from scripts/mock rename to packages/cli/scripts/mock diff --git a/packages/cli/scripts/publish-homebrew-formula.mjs b/packages/cli/scripts/publish-homebrew-formula.mjs new file mode 100644 index 0000000..f621fd8 --- /dev/null +++ b/packages/cli/scripts/publish-homebrew-formula.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import {existsSync} from 'node:fs'; +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {spawn} from 'node:child_process'; + +const root = new URL('..', import.meta.url).pathname; +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const metadata = JSON.parse(await readFile(new URL('../dist/release/homebrew.json', import.meta.url), 'utf8')); + +const token = process.env.HOMEBREW_TAP_GITHUB_TOKEN; +const tapRepository = process.env.HOMEBREW_TAP_REPOSITORY || 'beeper/homebrew-tap'; +const sourceRepository = process.env.GITHUB_REPOSITORY || 'beeper/desktop-api-cli'; +const version = process.env.PACKAGE_VERSION || metadata.version || packageJson.version; +const formulaName = process.env.HOMEBREW_FORMULA_NAME || 'beeper-cli'; +const commandName = process.env.HOMEBREW_COMMAND_NAME || metadata.command || 'beeper'; +const formulaClass = formulaName + .split(/[-_]/) + .map(part => `${part[0].toUpperCase()}${part.slice(1)}`) + .join(''); +const tag = process.env.GITHUB_REF_NAME || `v${version}`; + +if (!token) { + throw new Error('HOMEBREW_TAP_GITHUB_TOKEN is required to publish the Homebrew formula.'); +} + +const cloneRoot = await mkdtemp(join(tmpdir(), 'beeper-cli-homebrew-')); +const tapPath = join(cloneRoot, 'tap'); +const remote = `https://x-access-token:${token}@github.com/${tapRepository}.git`; + +try { + await run('git', ['clone', '--depth', '1', remote, tapPath], {cwd: cloneRoot, scrub: token}); + await run('git', ['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'beeper-release-bot'], {cwd: tapPath}); + await run('git', ['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'help@beeper.com'], {cwd: tapPath}); + + const formulaDir = join(tapPath, 'Formula'); + const formulaPath = join(formulaDir, `${formulaName}.rb`); + if (!existsSync(formulaDir)) { + await mkdir(formulaDir, {recursive: true}); + } + + await writeFile( + formulaPath, + formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}), + ); + await run('git', ['add', formulaPath], {cwd: tapPath}); + + const changed = await output('git', ['diff', '--cached', '--quiet'], {cwd: tapPath, allowFailure: true}); + if (changed.code === 0) { + console.log('Homebrew formula is already current.'); + } else { + await run('git', ['commit', '-m', `${formulaName} ${version}`], {cwd: tapPath}); + await run('git', ['push', 'origin', 'HEAD'], {cwd: tapPath, scrub: token}); + } +} finally { + await rm(cloneRoot, {recursive: true, force: true}); +} + +function formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}) { + return `class ${formulaClass} < Formula + desc "Beeper CLI" + homepage "https://developers.beeper.com/desktop-api/" + url "https://github.com/${sourceRepository}/releases/download/${tag}/${metadata.archive}" + sha256 "${metadata.sha256}" + license "MIT" + version "${version}" + + depends_on "node" + + def install + libexec.install Dir["libexec/*"] + bin.install "bin/${commandName}" + bin.install_symlink bin/"${commandName}" => "${formulaName}" + end + + test do + assert_match version.to_s, shell_output("#{bin}/${commandName} --version") + end +end +`; +} + +async function run(command, args, options = {}) { + const result = await output(command, args, options); + if (result.code !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.code}`); + } +} + +async function output(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd || root, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + const text = chunk.toString(); + stdout += text; + process.stdout.write(options.scrub ? text.replaceAll(options.scrub, '[token]') : text); + }); + child.stderr.on('data', chunk => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(options.scrub ? text.replaceAll(options.scrub, '[token]') : text); + }); + child.on('error', reject); + child.on('exit', code => { + if (code !== 0 && !options.allowFailure) { + resolvePromise({code, stdout, stderr}); + return; + } + + resolvePromise({code, stdout, stderr}); + }); + }); +} diff --git a/packages/cli/scripts/run b/packages/cli/scripts/run new file mode 100755 index 0000000..e3954af --- /dev/null +++ b/packages/cli/scripts/run @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +npm run dev -- "$@" diff --git a/scripts/test b/packages/cli/scripts/test similarity index 79% rename from scripts/test rename to packages/cli/scripts/test index 9f448ce..9f5362b 100755 --- a/scripts/test +++ b/packages/cli/scripts/test @@ -4,9 +4,6 @@ set -euo pipefail cd "$(dirname "$0")/.." -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" - RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' @@ -56,9 +53,4 @@ else fi echo "==> Running tests" -go test ./... "$@" - -echo "==> Checking tests on Windows" -GOARCH=amd64 GOOS=windows go test -c ./... "$@" -# `go test -c` produces a bunch of .exe files; make sure to clean those up -find . -name "*.test.exe" -exec rm {} \; +npm test -- "$@" diff --git a/packages/cli/scripts/unlink b/packages/cli/scripts/unlink new file mode 100755 index 0000000..b9a8f83 --- /dev/null +++ b/packages/cli/scripts/unlink @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Unlinking Beeper CLI" +npm unlink -g beeper-cli || true diff --git a/packages/cli/src/commands/accounts.ts b/packages/cli/src/commands/accounts.ts new file mode 100644 index 0000000..f3b32f0 --- /dev/null +++ b/packages/cli/src/commands/accounts.ts @@ -0,0 +1,42 @@ +import { BeeperCommand } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy } from '../lib/copy.js' +import { printData, printList } from '../lib/output.js' +import { withInkSpinner as withSpinner } from '../lib/ink/spinner.js' + +function accountItems(accounts: unknown): unknown[] { + if (Array.isArray(accounts)) return accounts + return (accounts as { items?: unknown[] }).items ?? [] +} + +export default class Accounts extends BeeperCommand { + static override summary = apiCopy.accounts.list + + async run(): Promise<void> { + const { flags } = await this.parse(Accounts) + const client = await createClient(flags) + const useSpinner = !flags.json + const accounts = useSpinner + ? await withSpinner('Loading accounts…', () => client.accounts.list(), { + done: value => { + const count = accountItems(value).length + return `${count} account${count === 1 ? '' : 's'}` + }, + }) + : await client.accounts.list() + if (flags.json) { + await printData(accounts, 'json') + return + } + const items = accountItems(accounts) + await printList(items, 'human', { + title: 'No accounts connected', + subtitle: 'Add an account to start chatting from the CLI.', + suggestions: [ + { command: 'beeper accounts add', hint: 'browse and connect a network' }, + { command: 'beeper accounts add WhatsApp', hint: 'connect a specific network' }, + { command: 'beeper login', hint: 'sign in to Beeper Desktop first' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts new file mode 100644 index 0000000..5617b5b --- /dev/null +++ b/packages/cli/src/commands/accounts/add.ts @@ -0,0 +1,166 @@ +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import type { BridgeAvailability } from '@beeper/desktop-api/resources/bridges.js' +import type { AuthListFlowsResponse } from '@beeper/desktop-api/resources/matrix/bridges/auth.js' +import { createClient } from '../../lib/client.js' +import { printAccountLoginStep, runGuidedAccountLogin } from '../../lib/account-login.js' +import { printData } from '../../lib/output.js' + +type AccountType = BridgeAvailability + +export default class AccountsAdd extends BeeperCommand { + static override summary = 'Add a Beeper account' + static override args = { + account: Args.string({ description: 'Account type to add, for example WhatsApp, Discord, or local-whatsapp' }), + } + static override flags = { + cookie: Flags.string({ description: 'Cookie value for non-interactive login, in name=value form. Repeat for multiple cookies.', multiple: true }), + field: Flags.string({ description: 'Field value for non-interactive login, in id=value form. Repeat for multiple fields.', multiple: true }), + flow: Flags.string({ description: 'Login flow ID. If omitted, Desktop chooses the default flow.' }), + guided: Flags.boolean({ default: true, allowNo: true, description: 'Prompt through login steps until completion' }), + 'login-id': Flags.string({ description: 'Existing login ID to re-login as' }), + 'non-interactive': Flags.boolean({ default: false, description: 'Do not prompt; require --flow, --field, and --cookie values when needed.' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AccountsAdd) + ensureWritable(flags) + const client = await createClient(flags) + + if (!args.account) { + const bridges = await client.bridges.list() + if (flags.json) { + await printData(bridges, 'json') + return + } + + printAvailableAccounts(bridges.items) + return + } + + const bridges = await client.bridges.list() + const accountType = resolveAccountType(bridges.items, args.account) + if (accountType.status !== 'available') { + const suffix = accountType.statusText ? `: ${accountType.statusText}` : '' + throw new Error(`${accountType.displayName} is not available${suffix}`) + } + + let flowID = flags.flow + if (!flowID) { + const flows = await client.matrix.bridges.auth.listFlows(accountType.bridgeID) + const loginFlows = flows.flows ?? [] + if (loginFlows.length > 1) { + if (flags.guided && !flags.json && !flags['non-interactive']) flowID = await chooseLoginFlow(loginFlows) + else throw new Error(`Multiple sign-in methods are available for ${accountType.displayName}. Pass --flow.`) + } else { + flowID = loginFlows[0]?.id + } + if (!flowID) throw new Error(`No login flows returned for ${accountType.displayName}.`) + if (!flags.json && loginFlows.length > 1) this.log(`Using flow ${flowID}`) + } + + const step = await client.matrix.bridges.auth.startLogin(flowID, { + bridgeID: accountType.bridgeID, + login_id: flags['login-id'], + }) + const result = flags.guided ? await runGuidedAccountLogin(client, accountType.bridgeID, step, { + cookies: parseKeyValueFlags(flags.cookie, '--cookie'), + fields: parseKeyValueFlags(flags.field, '--field'), + nonInteractive: flags['non-interactive'], + }) : step + if (flags.json) await printData(result, 'json') + else printAccountLoginStep(result) + } +} + +function printAvailableAccounts(items: AccountType[]): void { + const sections: Array<[string, AccountType[]]> = [ + ['On-Device Accounts', items.filter(item => item.bridgeProvider === 'local')], + ['Beeper Cloud Accounts', items.filter(item => item.bridgeProvider === 'cloud')], + ['Self-Hosted Accounts', items.filter(item => item.bridgeProvider === 'self-hosted')], + ] + + for (const [title, accounts] of sections) { + if (!accounts.length) continue + process.stdout.write(`${title}\n`) + for (const account of accounts) { + const status = account.statusText ?? statusLabel(account) + process.stdout.write(` ${account.displayName} (${account.bridgeID})${status ? ` - ${status}` : ''}\n`) + } + process.stdout.write('\n') + } +} + +function resolveAccountType(items: AccountType[], input: string): AccountType { + const normalizedInput = normalize(input) + const exact = items.filter(item => [ + item.bridgeID, + item.displayName, + item.network, + item.bridgeType, + ].some(value => normalize(value) === normalizedInput)) + + if (exact.length === 1) return exact[0]! + if (exact.length > 1) throw ambiguousAccountType(input, exact) + + const partial = items.filter(item => [ + item.bridgeID, + item.displayName, + item.network, + item.bridgeType, + ].some(value => normalize(value).includes(normalizedInput))) + + if (partial.length === 1) return partial[0]! + if (partial.length > 1) throw ambiguousAccountType(input, partial) + throw new Error(`Unknown account type ${input}. Run \`beeper accounts add\` to list available account types.`) +} + +function ambiguousAccountType(input: string, matches: AccountType[]): Error { + const options = matches.map(item => `${item.displayName} (${item.bridgeID})`).join(', ') + return new Error(`Account type ${input} is ambiguous. Use one of: ${options}`) +} + +function statusLabel(account: AccountType): string | undefined { + if (account.status === 'available') return undefined + if (account.status === 'connected') return `${account.displayName} Connected` + return account.status.replaceAll('_', ' ') +} + +function normalize(value: string | undefined): string { + return (value ?? '').toLowerCase().replaceAll(/[^a-z0-9]+/g, '') +} + +function parseKeyValueFlags(values: string[] | undefined, flagName: string): Record<string, string> { + const parsed: Record<string, string> = {} + for (const value of values ?? []) { + const equalsIndex = value.indexOf('=') + if (equalsIndex <= 0) throw new Error(`${flagName} must use name=value form.`) + parsed[value.slice(0, equalsIndex)] = value.slice(equalsIndex + 1) + } + + return parsed +} + +async function chooseLoginFlow(flows: NonNullable<AuthListFlowsResponse['flows']>): Promise<string> { + process.stdout.write('Choose how you want to sign in:\n') + flows.forEach((flow, index) => { + const description = flow.description ? ` - ${flow.description}` : '' + process.stdout.write(` ${index + 1}. ${flow.name}${description}\n`) + }) + + const rl = createInterface({ input, output }) + try { + for (;;) { + const answer = (await rl.question('Select a sign-in method: ')).trim() + const selected = Number.parseInt(answer, 10) + if (Number.isInteger(selected) && selected >= 1 && selected <= flows.length) return flows[selected - 1]!.id + const byID = flows.find(flow => flow.id === answer) + if (byID) return byID.id + process.stdout.write('Choose one of the listed sign-in methods.\n') + } + } finally { + rl.close() + } +} diff --git a/packages/cli/src/commands/api/get.ts b/packages/cli/src/commands/api/get.ts new file mode 100644 index 0000000..287b3b6 --- /dev/null +++ b/packages/cli/src/commands/api/get.ts @@ -0,0 +1,20 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class ApiGet extends BeeperCommand { + static override summary = 'Call a raw Desktop API GET path' + static override args = { + path: Args.string({ description: 'API path, for example /v1/info', required: true }), + } + static override flags = { + json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ApiGet) + const client = await createClient(flags) + await printData(await client.get(args.path), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/api/post.ts b/packages/cli/src/commands/api/post.ts new file mode 100644 index 0000000..bf78e01 --- /dev/null +++ b/packages/cli/src/commands/api/post.ts @@ -0,0 +1,23 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class ApiPost extends BeeperCommand { + static override summary = 'Call a raw Desktop API POST path with a JSON body' + static override args = { + path: Args.string({ description: 'API path, for example /v1/messages/{chatID}/send', required: true }), + } + static override flags = { + body: Flags.string({ default: '{}', description: 'JSON request body' }), + json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ApiPost) + ensureWritable(flags) + const client = await createClient(flags) + const body = JSON.parse(flags.body) as Record<string, unknown> + await printData(await client.post(args.path, { body }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/recovery-code/mark-backed-up.ts b/packages/cli/src/commands/app/e2ee/recovery-code/mark-backed-up.ts new file mode 100644 index 0000000..d686786 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/recovery-code/mark-backed-up.ts @@ -0,0 +1,17 @@ +import { BeeperCommand, ensureWritable } from '../../../../lib/command.js' +import type { RecoveryCodeMarkBackedUpResponse } from '@beeper/desktop-api/resources/app/e2ee/recovery-code/recovery-code.js' +import { appRequest } from '../../../../lib/app-api.js' +import { printData } from '../../../../lib/output.js' + +export default class AppE2EERecoveryCodeMarkBackedUp extends BeeperCommand { + static override summary = 'Mark the recovery key as saved' + + async run(): Promise<void> { + const { flags } = await this.parse(AppE2EERecoveryCodeMarkBackedUp) + ensureWritable(flags) + const result = await appRequest<RecoveryCodeMarkBackedUpResponse>('POST', '/v1/app/e2ee/recovery-code/mark-backed-up', { + baseURL: flags['base-url'], + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/recovery-code/reset/begin.ts b/packages/cli/src/commands/app/e2ee/recovery-code/reset/begin.ts new file mode 100644 index 0000000..09c8ec8 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/recovery-code/reset/begin.ts @@ -0,0 +1,38 @@ +import { createInterface } from 'node:readline/promises' +import { execFileSync } from 'node:child_process' +import { stdin as input, stderr as output } from 'node:process' +import { Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { ResetBeginResponse } from '@beeper/desktop-api/resources/app/e2ee/recovery-code/reset.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EERecoveryCodeResetBegin extends BeeperCommand { + static override summary = 'Create a new recovery key' + static override flags = { + 'recovery-code': Flags.string({ description: 'Existing recovery key, if available' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(AppE2EERecoveryCodeResetBegin) + ensureWritable(flags) + const recoveryCode = flags['recovery-code'] ?? (!flags.json && input.isTTY ? await promptSecret('Existing recovery key (optional): ') : undefined) + const result = await appRequest<ResetBeginResponse>('POST', '/v1/app/e2ee/recovery-code/reset', { + baseURL: flags['base-url'], + body: recoveryCode ? { recoveryCode } : {}, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} + +async function promptSecret(label: string): Promise<string> { + const rl = createInterface({ input, output }) + try { + execFileSync('stty', ['-echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) + return (await rl.question(label)).trim() + } finally { + rl.close() + execFileSync('stty', ['echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) + output.write('\n') + } +} diff --git a/packages/cli/src/commands/app/e2ee/recovery-code/reset/confirm.ts b/packages/cli/src/commands/app/e2ee/recovery-code/reset/confirm.ts new file mode 100644 index 0000000..f4a3e7c --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/recovery-code/reset/confirm.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { ResetConfirmResponse } from '@beeper/desktop-api/resources/app/e2ee/recovery-code/reset.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EERecoveryCodeResetConfirm extends BeeperCommand { + static override summary = 'Confirm a newly created recovery key' + static override args = { + recoveryCode: Args.string({ description: 'New recovery key returned by reset begin', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EERecoveryCodeResetConfirm) + ensureWritable(flags) + const result = await appRequest<ResetConfirmResponse>('POST', '/v1/app/e2ee/recovery-code/reset/confirm', { + baseURL: flags['base-url'], + body: { recoveryCode: args.recoveryCode }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/recovery-code/verify.ts b/packages/cli/src/commands/app/e2ee/recovery-code/verify.ts new file mode 100644 index 0000000..852f86b --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/recovery-code/verify.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../lib/command.js' +import type { RecoveryCodeVerifyResponse } from '@beeper/desktop-api/resources/app/e2ee/recovery-code/recovery-code.js' +import { appRequest } from '../../../../lib/app-api.js' +import { printData } from '../../../../lib/output.js' + +export default class AppE2EERecoveryCodeVerify extends BeeperCommand { + static override summary = 'Unlock encrypted messages with a recovery key' + static override args = { + recoveryCode: Args.string({ description: 'Beeper recovery key', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EERecoveryCodeVerify) + ensureWritable(flags) + const result = await appRequest<RecoveryCodeVerifyResponse>('POST', '/v1/app/e2ee/recovery-code/verify', { + baseURL: flags['base-url'], + body: { recoveryCode: args.recoveryCode }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/accept.ts b/packages/cli/src/commands/app/e2ee/verification/accept.ts new file mode 100644 index 0000000..180f209 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/accept.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../lib/command.js' +import type { VerificationAcceptResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/verification.js' +import { appRequest } from '../../../../lib/app-api.js' +import { printData } from '../../../../lib/output.js' + +export default class AppE2EEVerificationAccept extends BeeperCommand { + static override summary = 'Accept a device verification request' + static override args = { + txnID: Args.string({ description: 'Verification transaction ID', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationAccept) + ensureWritable(flags) + const result = await appRequest<VerificationAcceptResponse>('POST', '/v1/app/e2ee/verification/accept', { + baseURL: flags['base-url'], + body: { txnID: args.txnID }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/cancel.ts b/packages/cli/src/commands/app/e2ee/verification/cancel.ts new file mode 100644 index 0000000..d84b3d6 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/cancel.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../lib/command.js' +import type { VerificationCancelResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/verification.js' +import { appRequest } from '../../../../lib/app-api.js' +import { printData } from '../../../../lib/output.js' + +export default class AppE2EEVerificationCancel extends BeeperCommand { + static override summary = 'Cancel device verification' + static override args = { + txnID: Args.string({ description: 'Verification transaction ID', required: true }), + } + static override flags = { + code: Flags.string({ description: 'Optional cancellation code' }), + reason: Flags.string({ description: 'Optional cancellation reason' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationCancel) + ensureWritable(flags) + const result = await appRequest<VerificationCancelResponse>('POST', '/v1/app/e2ee/verification/cancel', { + baseURL: flags['base-url'], + body: { txnID: args.txnID, code: flags.code, reason: flags.reason }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/qr/confirm-scanned.ts b/packages/cli/src/commands/app/e2ee/verification/qr/confirm-scanned.ts new file mode 100644 index 0000000..f6224e9 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/qr/confirm-scanned.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { QrConfirmScannedResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/qr.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EEVerificationQRConfirmScanned extends BeeperCommand { + static override summary = 'Confirm another device scanned this QR code' + static override args = { + txnID: Args.string({ description: 'Verification transaction ID', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationQRConfirmScanned) + ensureWritable(flags) + const result = await appRequest<QrConfirmScannedResponse>('POST', '/v1/app/e2ee/verification/qr/confirm-scanned', { + baseURL: flags['base-url'], + body: { txnID: args.txnID }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/qr/scan.ts b/packages/cli/src/commands/app/e2ee/verification/qr/scan.ts new file mode 100644 index 0000000..3c4dc5d --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/qr/scan.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { QrScanResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/qr.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EEVerificationQRScan extends BeeperCommand { + static override summary = 'Submit a scanned verification QR payload' + static override args = { + data: Args.string({ description: 'QR code payload', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationQRScan) + ensureWritable(flags) + const result = await appRequest<QrScanResponse>('POST', '/v1/app/e2ee/verification/qr/scan', { + baseURL: flags['base-url'], + body: { data: args.data }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/sas/confirm.ts b/packages/cli/src/commands/app/e2ee/verification/sas/confirm.ts new file mode 100644 index 0000000..7fd0d4c --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/sas/confirm.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { SaConfirmResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/sas.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EEVerificationSASConfirm extends BeeperCommand { + static override summary = 'Confirm matching emoji verification' + static override args = { + txnID: Args.string({ description: 'Verification transaction ID', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationSASConfirm) + ensureWritable(flags) + const result = await appRequest<SaConfirmResponse>('POST', '/v1/app/e2ee/verification/sas/confirm', { + baseURL: flags['base-url'], + body: { txnID: args.txnID }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/sas/start.ts b/packages/cli/src/commands/app/e2ee/verification/sas/start.ts new file mode 100644 index 0000000..84835ec --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/sas/start.ts @@ -0,0 +1,22 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../../lib/command.js' +import type { SaStartResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/sas.js' +import { appRequest } from '../../../../../lib/app-api.js' +import { printData } from '../../../../../lib/output.js' + +export default class AppE2EEVerificationSASStart extends BeeperCommand { + static override summary = 'Start emoji verification' + static override args = { + txnID: Args.string({ description: 'Verification transaction ID', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AppE2EEVerificationSASStart) + ensureWritable(flags) + const result = await appRequest<SaStartResponse>('POST', '/v1/app/e2ee/verification/sas/start', { + baseURL: flags['base-url'], + body: { txnID: args.txnID }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/e2ee/verification/start.ts b/packages/cli/src/commands/app/e2ee/verification/start.ts new file mode 100644 index 0000000..5dca012 --- /dev/null +++ b/packages/cli/src/commands/app/e2ee/verification/start.ts @@ -0,0 +1,22 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../../../lib/command.js' +import type { VerificationStartResponse } from '@beeper/desktop-api/resources/app/e2ee/verification/verification.js' +import { appRequest } from '../../../../lib/app-api.js' +import { printData } from '../../../../lib/output.js' + +export default class AppE2EEVerificationStart extends BeeperCommand { + static override summary = 'Start device verification' + static override flags = { + 'user-id': Flags.string({ description: 'User ID to verify. Defaults to the signed-in user.' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(AppE2EEVerificationStart) + ensureWritable(flags) + const result = await appRequest<VerificationStartResponse>('POST', '/v1/app/e2ee/verification', { + baseURL: flags['base-url'], + body: flags['user-id'] ? { userID: flags['user-id'] } : {}, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/app/status.ts b/packages/cli/src/commands/app/status.ts new file mode 100644 index 0000000..b64f2ee --- /dev/null +++ b/packages/cli/src/commands/app/status.ts @@ -0,0 +1,14 @@ +import { BeeperCommand } from '../../lib/command.js' +import type { AppStatusResponse } from '@beeper/desktop-api/resources/app/app.js' +import { appRequest } from '../../lib/app-api.js' +import { printData } from '../../lib/output.js' + +export default class AppStatus extends BeeperCommand { + static override summary = 'Show Beeper app login and encrypted messaging state' + + async run(): Promise<void> { + const { flags } = await this.parse(AppStatus) + const state = await appRequest<AppStatusResponse>('GET', '/v1/app/status', { baseURL: flags['base-url'] }) + await printData(state, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/archive.ts b/packages/cli/src/commands/archive.ts new file mode 100644 index 0000000..1c9085a --- /dev/null +++ b/packages/cli/src/commands/archive.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printSuccess } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Archive extends BeeperCommand { + static override summary = apiCopy.chats.archive + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Archive) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.archive(chatID, { archived: true }) + await printSuccess({ message: 'Archived', detail: chatID, data: { chatID } }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/assets/download.ts b/packages/cli/src/commands/assets/download.ts new file mode 100644 index 0000000..3c24d35 --- /dev/null +++ b/packages/cli/src/commands/assets/download.ts @@ -0,0 +1,20 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' +import { printData } from '../../lib/output.js' + +export default class AssetsDownload extends BeeperCommand { + static override summary = apiCopy.assets.download + static override args = { + url: Args.string({ description: 'Asset URL', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AssetsDownload) + ensureWritable(flags) + const client = await createClient(flags) + const result = await client.assets.download({ url: args.url }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/assets/upload.ts b/packages/cli/src/commands/assets/upload.ts new file mode 100644 index 0000000..78d13a6 --- /dev/null +++ b/packages/cli/src/commands/assets/upload.ts @@ -0,0 +1,29 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { createReadStream } from 'node:fs' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' +import { printData } from '../../lib/output.js' + +export default class AssetsUpload extends BeeperCommand { + static override summary = apiCopy.assets.upload + static override args = { + file: Args.string({ description: sdkParamCopy.attachmentFile, required: true }), + } + static override flags = { + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(AssetsUpload) + ensureWritable(flags) + const client = await createClient(flags) + const result = await client.assets.upload({ + file: createReadStream(args.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts new file mode 100644 index 0000000..0cd2feb --- /dev/null +++ b/packages/cli/src/commands/auth/status.ts @@ -0,0 +1,22 @@ +import { BeeperCommand } from '../../lib/command.js' +import { readConfig } from '../../lib/config.js' +import { printData } from '../../lib/output.js' + +export default class AuthStatus extends BeeperCommand { + static override summary = 'Show local auth status and token metadata' + + async run(): Promise<void> { + const { flags } = await this.parse(AuthStatus) + const config = await readConfig() + const authenticated = Boolean(process.env.BEEPER_ACCESS_TOKEN || config.auth?.accessToken) + const data = { + authenticated, + baseURL: config.baseURL, + source: process.env.BEEPER_ACCESS_TOKEN ? 'env' : config.auth?.accessToken ? 'config' : 'none', + clientID: config.auth?.clientID, + expiresAt: config.auth?.expiresAt, + scope: config.auth?.scope, + } + await printData(data, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/avatar.ts b/packages/cli/src/commands/avatar.ts new file mode 100644 index 0000000..92354ef --- /dev/null +++ b/packages/cli/src/commands/avatar.ts @@ -0,0 +1,28 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Avatar extends BeeperCommand { + static override summary = 'Set or clear a group chat avatar' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + path: Args.string({ description: 'Local avatar image path. Omit with --clear to remove it.', required: false }), + } + + static override flags = { + clear: Flags.boolean({ default: false, description: 'Clear the current avatar' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Avatar) + ensureWritable(flags) + if (!flags.clear && !args.path) throw new Error('Provide PATH or pass --clear') + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { imgURL: flags.clear ? null : args.path }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts new file mode 100644 index 0000000..d98ef03 --- /dev/null +++ b/packages/cli/src/commands/chat.ts @@ -0,0 +1,27 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Chat extends BeeperCommand { + static override summary = apiCopy.chats.retrieve + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + 'max-participants': Flags.integer({ description: 'Maximum participants to return' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Chat) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const chat = await client.chats.retrieve(chatID, { + maxParticipantCount: flags['max-participants'], + }) + await printData(chat, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/chats/index.ts b/packages/cli/src/commands/chats/index.ts new file mode 100644 index 0000000..1da1238 --- /dev/null +++ b/packages/cli/src/commands/chats/index.ts @@ -0,0 +1,41 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' +import { collectPage, printIDs, printList } from '../../lib/output.js' +import { resolveAccountIDs } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class ChatsIndex extends BeeperCommand { + static override summary = apiCopy.chats.list + static override flags = { + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), + limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(ChatsIndex) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const useSpinner = !flags.json && !flags.ids + const items = useSpinner + ? await withSpinner('Loading chats…', () => collectPage(client.chats.list({ accountIDs }), flags.limit), { + done: value => `${value.length} chat${value.length === 1 ? '' : 's'}`, + }) + : await collectPage(client.chats.list({ accountIDs }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + await printList(items, flags.json ? 'json' : 'human', { + title: 'No chats yet', + subtitle: accountIDs?.length ? 'Try another account, or check your filters.' : 'Connect an account or sync your existing ones.', + suggestions: [ + { command: 'beeper accounts', hint: 'list connected accounts' }, + { command: 'beeper accounts add', hint: 'add a new account' }, + { command: 'beeper status', hint: 'verify Desktop is reachable' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/chats/search.ts b/packages/cli/src/commands/chats/search.ts new file mode 100644 index 0000000..1a236b4 --- /dev/null +++ b/packages/cli/src/commands/chats/search.ts @@ -0,0 +1,62 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' +import { collectPage, printIDs, printList } from '../../lib/output.js' +import { resolveAccountIDs } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class ChatsSearch extends BeeperCommand { + static override summary = apiCopy.chats.search + static override args = { + query: Args.string({ description: sdkParamCopy.searchQuery, required: true }), + } + static override flags = { + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), + inbox: Flags.string({ options: ['primary', 'low-priority', 'archive'] }), + 'include-muted': Flags.boolean({ allowNo: true, default: true, description: 'Include muted chats. Use --no-include-muted for a tighter search.' }), + 'last-activity-after': Flags.string({ description: 'Only chats with last activity after this ISO timestamp' }), + 'last-activity-before': Flags.string({ description: 'Only chats with last activity before this ISO timestamp' }), + limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), + scope: Flags.string({ options: ['titles', 'participants'] }), + type: Flags.string({ options: ['single', 'group', 'any'] }), + unread: Flags.boolean({ default: false, description: 'Only unread chats' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ChatsSearch) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const params = { + accountIDs, + inbox: flags.inbox as 'primary' | 'low-priority' | 'archive' | undefined, + includeMuted: flags['include-muted'], + lastActivityAfter: flags['last-activity-after'], + lastActivityBefore: flags['last-activity-before'], + query: args.query, + scope: flags.scope as 'titles' | 'participants' | undefined, + type: flags.type as 'single' | 'group' | 'any' | undefined, + unreadOnly: flags.unread || undefined, + } + const useSpinner = !flags.json && !flags.ids + const items = useSpinner + ? await withSpinner(`Searching chats for "${args.query}"…`, () => collectPage(client.chats.search(params), flags.limit), { + done: value => `${value.length} match${value.length === 1 ? '' : 'es'}`, + }) + : await collectPage(client.chats.search(params), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + await printList(items, flags.json ? 'json' : 'human', { + title: 'No chats matched', + subtitle: `Nothing found for "${args.query}".`, + suggestions: [ + { command: 'beeper chats', hint: 'see everything' }, + { command: 'beeper search "<term>"', hint: 'search messages too' }, + { command: 'beeper contacts <accountID> <query>', hint: 'find contacts to start a chat' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/clear-draft.ts b/packages/cli/src/commands/clear-draft.ts new file mode 100644 index 0000000..dd3332c --- /dev/null +++ b/packages/cli/src/commands/clear-draft.ts @@ -0,0 +1,24 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class ClearDraft extends BeeperCommand { + static override summary = 'Clear a chat draft' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ClearDraft) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { draft: null }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts new file mode 100644 index 0000000..aadabf2 --- /dev/null +++ b/packages/cli/src/commands/commands.ts @@ -0,0 +1,12 @@ +import { BeeperCommand } from '../lib/command.js' +import { commandManifest } from '../lib/manifest.js' +import { printData } from '../lib/output.js' + +export default class Commands extends BeeperCommand { + static override summary = 'Print the Beeper CLI command manifest' + + async run(): Promise<void> { + const { flags } = await this.parse(Commands) + await printData(commandManifest, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/config/get.ts b/packages/cli/src/commands/config/get.ts new file mode 100644 index 0000000..8ac3646 --- /dev/null +++ b/packages/cli/src/commands/config/get.ts @@ -0,0 +1,26 @@ +import { Args } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { readConfig } from '../../lib/config.js' +import { printConfig, printData } from '../../lib/output.js' + +export default class ConfigGet extends BeeperCommand { + static override summary = 'Print CLI configuration' + static override args = { + key: Args.string({ description: 'Optional config key to print', options: ['baseURL', 'auth'], required: false }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ConfigGet) + const config = await readConfig() + const safeConfig = { + ...config, + auth: config.auth ? { ...config.auth, accessToken: '[redacted]' } : config.auth, + } + const format = flags.json ? 'json' : 'human' + if (args.key) { + await printData(safeConfig[args.key as 'baseURL' | 'auth'], format) + return + } + await printConfig(safeConfig as unknown as Record<string, unknown>, format) + } +} diff --git a/packages/cli/src/commands/config/path.ts b/packages/cli/src/commands/config/path.ts new file mode 100644 index 0000000..b92ba30 --- /dev/null +++ b/packages/cli/src/commands/config/path.ts @@ -0,0 +1,17 @@ +import { BeeperCommand } from '../../lib/command.js' +import { configPath } from '../../lib/config.js' + +export default class ConfigPath extends BeeperCommand { + static override summary = 'Print the CLI config path' + + async run(): Promise<void> { + const { flags } = await this.parse(ConfigPath) + const path = configPath() + if (flags.json) { + process.stdout.write(`${JSON.stringify({ path }, null, 2)}\n`) + return + } + // Plain path so it's pipeable (xargs / cat / cd). + process.stdout.write(`${path}\n`) + } +} diff --git a/packages/cli/src/commands/config/reset.ts b/packages/cli/src/commands/config/reset.ts new file mode 100644 index 0000000..4560feb --- /dev/null +++ b/packages/cli/src/commands/config/reset.ts @@ -0,0 +1,14 @@ +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { resetConfig } from '../../lib/config.js' +import { printSuccess } from '../../lib/output.js' + +export default class ConfigReset extends BeeperCommand { + static override summary = 'Reset CLI configuration' + + async run(): Promise<void> { + const { flags } = await this.parse(ConfigReset) + ensureWritable(flags) + await resetConfig() + await printSuccess({ message: 'Config reset' }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts new file mode 100644 index 0000000..cbbec29 --- /dev/null +++ b/packages/cli/src/commands/config/set.ts @@ -0,0 +1,23 @@ +import { Args } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { updateConfig } from '../../lib/config.js' +import { printSuccess } from '../../lib/output.js' + +export default class ConfigSet extends BeeperCommand { + static override summary = 'Set a CLI configuration value' + static override args = { + key: Args.string({ description: 'Config key to set', options: ['baseURL'], required: true }), + value: Args.string({ description: 'Config value', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ConfigSet) + ensureWritable(flags) + await updateConfig(config => ({ ...config, [args.key]: args.value })) + await printSuccess({ + message: `Set ${args.key}`, + detail: args.value, + data: { [args.key]: args.value }, + }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/contacts/list.ts b/packages/cli/src/commands/contacts/list.ts new file mode 100644 index 0000000..ce10675 --- /dev/null +++ b/packages/cli/src/commands/contacts/list.ts @@ -0,0 +1,62 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' +import { collectPage, printData, printList } from '../../lib/output.js' +import { resolveAccountIDs } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class ContactsList extends BeeperCommand { + static override summary = apiCopy.contacts.list + static override args = { + account: Args.string({ description: cliCopy.args.accountSelector, required: true }), + } + + static override flags = { + ids: Flags.boolean({ default: false, description: 'Print only contact user IDs' }), + limit: Flags.integer({ default: 50, description: 'Maximum contacts to print' }), + query: Flags.string({ description: 'Optional blended contact lookup query' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ContactsList) + const client = await createClient(flags) + const accountIDs = (await resolveAccountIDs(client, [args.account], { allowMultiplePerInput: true }))! + const useSpinner = !flags.json && !flags.ids + const load = async (): Promise<Array<Record<string, unknown>>> => { + const collected: Array<Record<string, unknown>> = [] + for (const accountID of accountIDs) { + const remaining = flags.limit - collected.length + if (remaining <= 0) break + const contacts = await collectPage(client.accounts.contacts.list(accountID, { query: flags.query }), remaining) + collected.push(...contacts.map(item => ({ ...(item as unknown as Record<string, unknown>), accountID }))) + if (collected.length >= flags.limit) break + } + return collected + } + const items = useSpinner + ? await withSpinner(`Loading contacts${flags.query ? ` matching "${flags.query}"` : ''}…`, load, { + done: value => `${value.length} contact${value.length === 1 ? '' : 's'}`, + }) + : await load() + if (flags.ids) { + for (const item of items) { + const id = item.userID ?? item.id + if (id) process.stdout.write(`${String(id)}\n`) + } + return + } + if (flags.json) { + await printData({ items }, 'json') + return + } + await printList(items, 'human', { + title: 'No contacts found', + subtitle: flags.query ? `Nothing matched "${flags.query}".` : 'This account has no contacts to list.', + suggestions: [ + { command: `beeper contacts ${args.account} <query>`, hint: 'narrow with a search' }, + { command: 'beeper accounts', hint: 'check the account is online' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/contacts/search.ts b/packages/cli/src/commands/contacts/search.ts new file mode 100644 index 0000000..40577be --- /dev/null +++ b/packages/cli/src/commands/contacts/search.ts @@ -0,0 +1,53 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' +import { printData, printList } from '../../lib/output.js' +import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class ContactsSearch extends BeeperCommand { + static override summary = apiCopy.contacts.search + static override args = { + query: Args.string({ description: 'Contact search query', required: true }), + } + static override flags = { + account: Flags.string({ multiple: true, description: `${cliCopy.args.accountSelector}. Omit to search every account.` }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(ContactsSearch) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const load = async (): Promise<Array<Record<string, unknown>>> => { + const collected: Array<Record<string, unknown>> = [] + for (const accountID of accountIDs) { + try { + const result = await client.accounts.contacts.search(accountID, { query: args.query }) + collected.push(...result.items.map((item: unknown) => ({ ...(item as Record<string, unknown>), accountID }))) + } catch { + // Some networks reject exact lookups for some identifiers; keep trying the rest. + } + } + return collected + } + const useSpinner = !flags.json + const results = useSpinner + ? await withSpinner(`Searching contacts for "${args.query}"…`, load, { + done: value => `${value.length} match${value.length === 1 ? '' : 'es'} across ${accountIDs.length} account${accountIDs.length === 1 ? '' : 's'}`, + }) + : await load() + if (flags.json) { + await printData({ items: results }, 'json') + return + } + await printList(results, 'human', { + title: 'No contacts matched', + subtitle: `Nothing across your ${accountIDs.length} account${accountIDs.length === 1 ? '' : 's'} matched "${args.query}".`, + suggestions: [ + { command: 'beeper accounts', hint: 'verify which accounts are connected' }, + { command: `beeper contact <accountID> ${args.query}`, hint: 'exact lookup on one account' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/create-chat.ts b/packages/cli/src/commands/create-chat.ts new file mode 100644 index 0000000..67e0041 --- /dev/null +++ b/packages/cli/src/commands/create-chat.ts @@ -0,0 +1,32 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveAccountID } from '../lib/resolve.js' + +export default class CreateChat extends BeeperCommand { + static override summary = apiCopy.chats.create + static override flags = { + account: Flags.string({ description: cliCopy.args.accountSelector, required: true }), + message: Flags.string({ description: 'Optional first message' }), + participant: Flags.string({ multiple: true, required: true, description: 'Participant user ID' }), + title: Flags.string({ description: 'Group title' }), + type: Flags.string({ default: 'single', options: ['single', 'group'], description: 'Chat type' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(CreateChat) + ensureWritable(flags) + const client = await createClient(flags) + const accountID = await resolveAccountID(client, flags.account) + const result = await client.chats.create({ + accountID, + messageText: flags.message, + participantIDs: flags.participant, + title: flags.title, + type: flags.type as 'single' | 'group', + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/current-user.ts b/packages/cli/src/commands/current-user.ts new file mode 100644 index 0000000..7a81222 --- /dev/null +++ b/packages/cli/src/commands/current-user.ts @@ -0,0 +1,13 @@ +import { BeeperCommand } from '../lib/command.js' +import { appRequest } from '../lib/app-api.js' +import { printData } from '../lib/output.js' + +export default class CurrentUser extends BeeperCommand { + static override summary = 'Show the authenticated Desktop API user' + + async run(): Promise<void> { + const { flags } = await this.parse(CurrentUser) + const user = await appRequest<unknown>('GET', '/oauth/userinfo', { baseURL: flags['base-url'] }) + await printData(user, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/delete-message.ts b/packages/cli/src/commands/delete-message.ts new file mode 100644 index 0000000..6b46a46 --- /dev/null +++ b/packages/cli/src/commands/delete-message.ts @@ -0,0 +1,34 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printSuccess } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class DeleteMessage extends BeeperCommand { + static override summary = apiCopy.messages.delete + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + } + static override flags = { + 'for-everyone': Flags.boolean({ default: false, description: sdkParamCopy.forEveryone }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(DeleteMessage) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.messages.delete(args.message, { + chatID, + forEveryone: flags['for-everyone'] || undefined, + }) + await printSuccess({ + message: flags['for-everyone'] ? 'Deleted for everyone' : 'Deleted', + detail: args.message, + data: { messageID: args.message, chatID, forEveryone: flags['for-everyone'] }, + }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/description.ts b/packages/cli/src/commands/description.ts new file mode 100644 index 0000000..986e2f6 --- /dev/null +++ b/packages/cli/src/commands/description.ts @@ -0,0 +1,29 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Description extends BeeperCommand { + static override summary = 'Set or clear a group chat description' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + description: Args.string({ description: 'New description. Omit with --clear to remove it.', required: false }), + } + + static override flags = { + clear: Flags.boolean({ default: false, description: 'Clear the current description' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Description) + ensureWritable(flags) + if (flags.clear && args.description) throw new Error('Use either DESCRIPTION or --clear, not both') + if (!flags.clear && !args.description) throw new Error('Provide DESCRIPTION or pass --clear') + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { description: flags.clear ? null : args.description }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..2ef03c7 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,58 @@ +import { BeeperCommand } from '../lib/command.js' +import { createClient, requireToken } from '../lib/client.js' +import { readConfig } from '../lib/config.js' +import { printData } from '../lib/output.js' +import { createInkSpinner as createSpinner } from '../lib/ink/spinner.js' + +type Check = { ok: boolean; name: string; detail?: string } + +export default class Doctor extends BeeperCommand { + static override summary = 'Verify Desktop API reachability and authentication' + + async run(): Promise<void> { + const { flags } = await this.parse(Doctor) + const config = await readConfig() + const baseURL = flags['base-url'] ?? config.baseURL + const showSpinners = !flags.json && process.stderr.isTTY + + const checks: Check[] = [] + const runCheck = async <T>(name: string, label: string, action: () => Promise<T>, success?: (value: T) => string): Promise<void> => { + const spinner = showSpinners ? createSpinner(label) : undefined + try { + const value = await action() + const detail = success?.(value) + checks.push({ ok: true, name, detail }) + if (spinner) await spinner.succeed(detail ? `${label.replace(/…$/, '')} — ${detail}` : label.replace(/…$/, '')) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + checks.push({ ok: false, name, detail }) + if (spinner) await spinner.fail(`${label.replace(/…$/, '')} — ${detail}`) + } + } + + await runCheck('server', 'Checking Beeper Desktop server…', async () => { + const response = await fetch(new URL('/v1/info', baseURL), { signal: AbortSignal.timeout(5000) }) + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`) + return `${response.status} ${response.statusText}` + }, value => value) + + await runCheck('token', 'Checking auth token…', async () => { + await requireToken() + return process.env.BEEPER_ACCESS_TOKEN ? 'env' : 'config' + }, value => `loaded from ${value}`) + + await runCheck('authenticated-request', 'Calling authenticated endpoint…', async () => { + const client = await createClient({ ...flags, baseURL }) + await client.accounts.list() + return 'ok' + }) + + const result = { ok: checks.every(check => check.ok), checks } + if (flags.json) { + await printData(result, 'json') + } else { + await printData(result, 'human') + } + if (!result.ok) this.exit(1) + } +} diff --git a/packages/cli/src/commands/draft.ts b/packages/cli/src/commands/draft.ts new file mode 100644 index 0000000..4610d74 --- /dev/null +++ b/packages/cli/src/commands/draft.ts @@ -0,0 +1,51 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Draft extends BeeperCommand { + static override summary = 'Set a chat draft' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + text: Args.string({ description: 'Draft text', required: true }), + } + static override flags = { + file: Flags.string({ description: 'Draft attachment file' }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Draft) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const upload = flags.file + ? await client.assets.upload({ + file: createReadStream(flags.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + : undefined + const result = await client.chats.update(chatID, { + draft: { + text: args.text, + attachments: upload?.uploadID + ? { + [upload.uploadID]: { + uploadID: upload.uploadID, + duration: upload.duration, + fileName: upload.fileName, + mimeType: upload.mimeType, + size: upload.width && upload.height ? { height: upload.height, width: upload.width } : undefined, + }, + } + : undefined, + }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/edit.ts b/packages/cli/src/commands/edit.ts new file mode 100644 index 0000000..2b56094 --- /dev/null +++ b/packages/cli/src/commands/edit.ts @@ -0,0 +1,30 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Edit extends BeeperCommand { + static override summary = apiCopy.messages.update + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Edit) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.messages.update(args.message, { + chatID, + text: args.text, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts new file mode 100644 index 0000000..c7de80b --- /dev/null +++ b/packages/cli/src/commands/export.ts @@ -0,0 +1,50 @@ +import { Flags } from '@oclif/core' +import { BeeperCommand } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { exportBeeperData } from '../lib/export/index.js' +import { resolveAccountIDs, resolveChatID } from '../lib/resolve.js' + +export default class Export extends BeeperCommand { + static override summary = 'Export accounts, chats, messages, Markdown transcripts, and attachments.' + static override description = [ + 'Creates a resumable Beeper Desktop export using the official Desktop API SDK.', + 'The export directory contains accounts.json, chats.json, manifest.json, and one directory per chat with chat.json, messages.json, messages.markdown, messages.html, downloaded attachments, and checkpoint state for interrupted runs.', + ].join('\n') + + static override flags = { + account: Flags.string({ multiple: true, description: 'Limit to an account selector. Repeat to include more accounts.' }), + chat: Flags.string({ multiple: true, description: 'Limit to a chat selector. Repeat to include more chats.' }), + force: Flags.boolean({ default: false, description: 'Re-export chats even if checkpoint state says they are complete.' }), + 'limit-chats': Flags.integer({ description: 'Maximum chats to export. Intended for testing large exports.' }), + 'limit-messages': Flags.integer({ description: 'Maximum messages per chat. Intended for testing large exports.' }), + 'max-participants': Flags.integer({ default: 500, description: 'Maximum participants to include in each chat.json.' }), + 'no-attachments': Flags.boolean({ default: false, description: 'Skip downloading message attachments.' }), + out: Flags.directory({ char: 'o', default: 'beeper-export', description: 'Export directory.' }), + pick: Flags.integer({ description: 'Pick the Nth chat when a --chat selector is ambiguous.' }), + quiet: Flags.boolean({ default: false, description: 'Suppress progress output.' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(Export) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const chatIDs = flags.chat?.length + ? await Promise.all(flags.chat.map(chat => resolveChatID(client, chat, { accountIDs, pick: flags.pick }))) + : undefined + + const manifest = await exportBeeperData(client, { + accountIDs, + chatIDs, + downloadAttachments: !flags['no-attachments'], + events: flags.events, + force: flags.force, + limitChats: flags['limit-chats'], + limitMessages: flags['limit-messages'], + maxParticipants: flags['max-participants'], + outDir: flags.out, + quiet: flags.quiet, + }) + + this.log(`Exported ${manifest.chatCount} chats, ${manifest.messageCount} messages, ${manifest.attachmentCount} attachments to ${flags.out}`) + } +} diff --git a/packages/cli/src/commands/focus.ts b/packages/cli/src/commands/focus.ts new file mode 100644 index 0000000..6196d1d --- /dev/null +++ b/packages/cli/src/commands/focus.ts @@ -0,0 +1,32 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Focus extends BeeperCommand { + static override summary = 'Focus Beeper Desktop, optionally opening a chat or message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: false }), + message: Args.string({ description: 'Message ID', required: false }), + } + static override flags = { + attachment: Flags.string({ description: 'Draft attachment path' }), + draft: Flags.string({ description: 'Draft text' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Focus) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = args.chat ? await resolveChatID(client, args.chat, { pick: flags.pick }) : undefined + const result = await client.focus({ + chatID, + draftAttachmentPath: flags.attachment, + draftText: flags.draft, + messageID: args.message, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/inbox.ts b/packages/cli/src/commands/inbox.ts new file mode 100644 index 0000000..e2b4185 --- /dev/null +++ b/packages/cli/src/commands/inbox.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Inbox extends BeeperCommand { + static override summary = 'Move a chat to the primary inbox' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Inbox) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { isArchived: false, isLowPriority: false }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/llm.ts b/packages/cli/src/commands/llm.ts new file mode 100644 index 0000000..ccaf101 --- /dev/null +++ b/packages/cli/src/commands/llm.ts @@ -0,0 +1,18 @@ +import { BeeperCommand } from '../lib/command.js' +import { commandManifest } from '../lib/manifest.js' +import { printCommands } from '../lib/output.js' + +export default class LLM extends BeeperCommand { + static override summary = 'Print compact CLI help for agents' + + async run(): Promise<void> { + const { flags } = await this.parse(LLM) + await printCommands(commandManifest, flags.json ? 'json' : 'human', { + title: 'Beeper CLI', + intro: [ + 'Auth: beeper login', + 'Most commands accept --json. List commands accept --limit.', + ], + }) + } +} diff --git a/packages/cli/src/commands/login.tsx b/packages/cli/src/commands/login.tsx new file mode 100644 index 0000000..e951843 --- /dev/null +++ b/packages/cli/src/commands/login.tsx @@ -0,0 +1,173 @@ +import { Flags } from '@oclif/core' +import React from 'react' +import { Box, Text, render as inkRender } from 'ink' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { loginWithPKCE } from '../lib/oauth.js' +import { readConfig, updateConfig } from '../lib/config.js' +import { findLocalDesktop, getDesktopAppStatus } from '../lib/desktop-auth.js' +import { + type AppLoginOutput, + type AppLoginSuccess, + appRequest, + isRegistrationRequired, + promptText, + promptYesNo, +} from '../lib/app-api.js' +import { AuthSignedIn } from '../lib/ink/components.js' +import { theme, glyphs } from '../lib/ink/theme.js' +import { printData } from '../lib/output.js' + +async function showSignedIn(props: React.ComponentProps<typeof AuthSignedIn>): Promise<void> { + const instance = inkRender(<AuthSignedIn {...props} />, { exitOnCtrlC: false, patchConsole: false }) + setTimeout(() => instance.unmount(), 0) + await instance.waitUntilExit().catch(() => undefined) +} + +async function showStep(label: string, detail?: string): Promise<void> { + const node = ( + <Box> + <Text color={theme.primary}>{glyphs.arrow}</Text> + <Text> </Text> + <Text color={theme.text}>{label}</Text> + {detail ? <Text color={theme.muted}> {detail}</Text> : null} + </Box> + ) + const instance = inkRender(node, { exitOnCtrlC: false, patchConsole: false }) + setTimeout(() => instance.unmount(), 0) + await instance.waitUntilExit().catch(() => undefined) +} + +export default class Login extends BeeperCommand { + static override summary = 'Authenticate with local Beeper Desktop' + static override flags = { + 'server-url': Flags.string({ + description: 'Beeper Desktop API server URL', + }), + email: Flags.string({ description: 'Email address to send a sign-in code to' }), + code: Flags.string({ description: 'Email sign-in code' }), + username: Flags.string({ description: 'Username to create if registration is required' }), + 'accept-terms': Flags.boolean({ default: false, description: 'Accept the Terms of Use and acknowledge the Privacy Policy when creating an account' }), + 'app-login': Flags.boolean({ default: false, description: 'Sign in the local Beeper Desktop app itself instead of requesting a Desktop API token from an already-signed-in app' }), + 'no-save': Flags.boolean({ default: false, description: 'Do not store the returned Desktop API token' }), + oauth: Flags.boolean({ default: false, description: 'Use the OAuth2 PKCE Desktop API authorization flow' }), + 'client-name': Flags.string({ default: 'Beeper CLI', description: 'OAuth client name shown in Beeper Desktop' }), + 'no-open': Flags.boolean({ default: false, description: 'Print the authorization URL instead of opening a browser' }), + scope: Flags.string({ default: 'read write', description: 'Space-separated OAuth scopes' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(Login) + ensureWritable(flags) + const config = await readConfig() + const baseURLFlag = flags['server-url'] ?? flags['base-url'] + const desktop = await findLocalDesktop({ + baseURL: baseURLFlag ?? config.baseURL, + scan: !baseURLFlag, + }) + const baseURL = desktop.baseURL + + const useAppLogin = flags['app-login'] + || Boolean(flags.email || flags.code || flags.username || flags['accept-terms']) + + if (!useAppLogin && !flags.oauth && await this.shouldUseAppLogin(baseURL)) { + throw new Error('Beeper Desktop is not signed in. Open Beeper Desktop and sign in, then rerun this command, or pass --app-login to sign in the app itself.') + } + + if (!useAppLogin || flags.oauth) { + const token = await loginWithPKCE({ + baseURL, + clientName: flags['client-name'], + openBrowser: !flags['no-open'], + save: !flags['no-save'], + scope: flags.scope, + }) + if (flags.json) { + await printData(token, 'json') + return + } + const detail = token.expires_in ? `token expires in ${token.expires_in}s` : undefined + await showSignedIn({ as: token.clientID, detail, saved: !flags['no-save'] }) + return + } + + if (!flags.json) await showStep('Starting email sign-in', baseURL) + const start = await appRequest<{ request: string; type: string[] }>('POST', '/v1/app/login/start', { + baseURL, + token: false, + }) + const email = flags.email ?? await promptText('Email: ') + if (!flags.json) await showStep('Sending one-time code', email) + await appRequest<Record<string, never>>('POST', '/v1/app/login/email', { + baseURL, + token: false, + body: { request: start.request, email }, + }) + const code = flags.code ?? await promptText('Code: ') + if (!flags.json) await showStep('Verifying code') + let result = await appRequest<AppLoginOutput>('POST', '/v1/app/login/response', { + baseURL, + token: false, + body: { request: start.request, response: code }, + }) + + if (isRegistrationRequired(result)) result = await this.register(baseURL, result, flags) + await this.finishLogin(baseURL, result, { json: flags.json, save: !flags['no-save'] }) + } + + private async shouldUseAppLogin(baseURL: string): Promise<boolean> { + const status = await getDesktopAppStatus(baseURL) + return status?.state === 'needs-login' + } + + private async register( + baseURL: string, + required: Extract<AppLoginOutput, { registrationRequired: true }>, + flags: { username?: string; 'accept-terms': boolean; json?: boolean }, + ): Promise<AppLoginSuccess> { + if (!flags.json && required.copy?.title) await showStep(required.copy.title) + if (!flags.json && required.usernameSuggestions?.length) { + await showStep('Suggestions', required.usernameSuggestions.join(', ')) + } + const username = flags.username ?? await promptText(`${required.copy?.usernamePlaceholder ?? 'Username'}: `) + const accepted = flags['accept-terms'] || await promptYesNo(required.copy?.terms ?? 'Accept Terms of Use and acknowledge Privacy Policy?') + if (!accepted) throw new Error('Account creation requires --accept-terms or an interactive yes response.') + return appRequest<AppLoginSuccess>('POST', '/v1/app/login/register', { + baseURL, + token: false, + body: { + request: required.request, + leadToken: required.leadToken, + username, + acceptTerms: true, + }, + }) + } + + private async finishLogin( + baseURL: string, + result: AppLoginSuccess, + options: { json: boolean; save: boolean }, + ): Promise<void> { + if (options.save) { + await updateConfig(config => ({ + ...config, + baseURL, + auth: { + accessToken: result.desktopAPI.accessToken, + scope: result.desktopAPI.scope, + tokenType: result.desktopAPI.tokenType, + }, + })) + } + + if (options.json) { + await printData(result, 'json') + return + } + await showSignedIn({ + as: result.matrix.userID, + detail: `app state: ${result.appState.state}`, + saved: options.save, + }) + } +} diff --git a/packages/cli/src/commands/logout.ts b/packages/cli/src/commands/logout.ts new file mode 100644 index 0000000..404b948 --- /dev/null +++ b/packages/cli/src/commands/logout.ts @@ -0,0 +1,30 @@ +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { readConfig, updateConfig } from '../lib/config.js' +import { printSuccess } from '../lib/output.js' + +export default class Logout extends BeeperCommand { + static override summary = 'Remove the locally stored Beeper Desktop token' + + async run(): Promise<void> { + const { flags } = await this.parse(Logout) + ensureWritable(flags) + const config = await readConfig() + const token = config.auth?.accessToken + let revoked = false + if (token) { + const response = await fetch(new URL('/oauth/revoke', config.baseURL), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, token_type_hint: 'access_token' }), + signal: AbortSignal.timeout(5000), + }).catch(() => undefined) + revoked = Boolean(response?.ok) + } + await updateConfig(current => ({ ...current, auth: undefined })) + await printSuccess({ + message: 'Logged out', + detail: revoked ? 'token revoked on server' : token ? 'local token cleared (server revoke failed silently)' : 'no token was stored', + data: { revoked, hadToken: Boolean(token) }, + }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/low-priority.ts b/packages/cli/src/commands/low-priority.ts new file mode 100644 index 0000000..c6e6804 --- /dev/null +++ b/packages/cli/src/commands/low-priority.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class LowPriority extends BeeperCommand { + static override summary = 'Move a chat to Low Priority' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(LowPriority) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { isLowPriority: true }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/message-expiry.ts b/packages/cli/src/commands/message-expiry.ts new file mode 100644 index 0000000..23b270b --- /dev/null +++ b/packages/cli/src/commands/message-expiry.ts @@ -0,0 +1,28 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class MessageExpiry extends BeeperCommand { + static override summary = 'Set or clear disappearing-message expiry' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + seconds: Args.string({ description: 'Expiry in seconds, or "off"', required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(MessageExpiry) + ensureWritable(flags) + const expiry = args.seconds.toLowerCase() === 'off' ? null : Number(args.seconds) + if (expiry !== null && (!Number.isInteger(expiry) || expiry < 0)) throw new Error('SECONDS must be a positive integer or "off"') + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { messageExpirySeconds: expiry }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/message.ts b/packages/cli/src/commands/message.ts new file mode 100644 index 0000000..3d1b1e6 --- /dev/null +++ b/packages/cli/src/commands/message.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Message extends BeeperCommand { + static override summary = apiCopy.messages.retrieve + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Message) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.messages.retrieve(args.message, { chatID }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/messages/index.ts b/packages/cli/src/commands/messages/index.ts new file mode 100644 index 0000000..4d71ea1 --- /dev/null +++ b/packages/cli/src/commands/messages/index.ts @@ -0,0 +1,48 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' +import { collectPage, printIDs, printList } from '../../lib/output.js' +import { resolveChatID } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class MessagesIndex extends BeeperCommand { + static override summary = apiCopy.messages.list + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + before: Flags.string({ description: 'Fetch messages before cursor' }), + after: Flags.string({ description: 'Fetch messages after cursor' }), + ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), + limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(MessagesIndex) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + if (flags.before && flags.after) this.error('Use only one of --before or --after') + const cursor = flags.before ?? flags.after + const direction = flags.before ? 'before' : flags.after ? 'after' : undefined + const useSpinner = !flags.json && !flags.ids + const items = useSpinner + ? await withSpinner('Loading messages…', () => collectPage(client.messages.list(chatID, { cursor, direction }), flags.limit), { + done: value => `${value.length} message${value.length === 1 ? '' : 's'}`, + }) + : await collectPage(client.messages.list(chatID, { cursor, direction }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + await printList(items, flags.json ? 'json' : 'human', { + title: 'No messages yet', + subtitle: 'This chat is empty. Send the first message.', + suggestions: [ + { command: `beeper send ${chatID} "<text>"`, hint: 'start the conversation' }, + { command: `beeper watch ${chatID}`, hint: 'subscribe to events' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/messages/search.ts b/packages/cli/src/commands/messages/search.ts new file mode 100644 index 0000000..796c02e --- /dev/null +++ b/packages/cli/src/commands/messages/search.ts @@ -0,0 +1,67 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' +import { collectPage, printIDs, printList } from '../../lib/output.js' +import { resolveAccountIDs, resolveChatID } from '../../lib/resolve.js' +import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js' + +export default class MessagesSearch extends BeeperCommand { + static override summary = apiCopy.messages.search + static override args = { + query: Args.string({ description: sdkParamCopy.searchQuery, required: false }), + } + static override flags = { + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + chat: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.chatSelector}` }), + 'chat-type': Flags.string({ options: ['group', 'single'], description: 'Limit to group chats or direct messages' }), + 'date-after': Flags.string({ description: 'Only messages after this ISO timestamp' }), + 'date-before': Flags.string({ description: 'Only messages before this ISO timestamp' }), + 'exclude-low-priority': Flags.boolean({ allowNo: true, description: 'Exclude low-priority chats. Use --no-exclude-low-priority to include all.' }), + ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), + 'include-muted': Flags.boolean({ allowNo: true, default: true, description: 'Include muted chats. Use --no-include-muted for a tighter search.' }), + limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), + media: Flags.string({ multiple: true, options: ['any', 'video', 'image', 'link', 'file'], description: 'Filter by media type. Repeat for more types.' }), + sender: Flags.string({ description: 'me, others, or a user ID' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(MessagesSearch) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const chatIDs = flags.chat?.length + ? await Promise.all(flags.chat.map(chat => resolveChatID(client, chat, { accountIDs }))) + : undefined + const params = { + accountIDs, + chatIDs, + chatType: flags['chat-type'] as 'group' | 'single' | undefined, + dateAfter: flags['date-after'], + dateBefore: flags['date-before'], + excludeLowPriority: flags['exclude-low-priority'], + includeMuted: flags['include-muted'], + mediaTypes: flags.media as Array<'any' | 'video' | 'image' | 'link' | 'file'> | undefined, + query: args.query, + sender: flags.sender as 'me' | 'others' | (string & {}) | undefined, + } + const useSpinner = !flags.json && !flags.ids + const label = args.query ? `Searching messages for "${args.query}"…` : 'Searching messages…' + const items = useSpinner + ? await withSpinner(label, () => collectPage(client.messages.search(params), flags.limit), { + done: value => `${value.length} match${value.length === 1 ? '' : 'es'}`, + }) + : await collectPage(client.messages.search(params), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + await printList(items, flags.json ? 'json' : 'human', { + title: 'No messages matched', + subtitle: args.query ? `Nothing found for "${args.query}".` : 'Try a different filter combination.', + suggestions: [ + { command: 'beeper messages <chatID>', hint: 'list messages from a chat' }, + { command: 'beeper search "<term>"', hint: 'search chats + messages' }, + ], + }) + } +} diff --git a/packages/cli/src/commands/mute.ts b/packages/cli/src/commands/mute.ts new file mode 100644 index 0000000..364aab9 --- /dev/null +++ b/packages/cli/src/commands/mute.ts @@ -0,0 +1,24 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Mute extends BeeperCommand { + static override summary = 'Mute a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Mute) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { isMuted: true }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/notify-anyway.ts b/packages/cli/src/commands/notify-anyway.ts new file mode 100644 index 0000000..819031c --- /dev/null +++ b/packages/cli/src/commands/notify-anyway.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class NotifyAnyway extends BeeperCommand { + static override summary = apiCopy.chats.notifyAnyway + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(NotifyAnyway) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.notifyAnyway(chatID) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/pin.ts b/packages/cli/src/commands/pin.ts new file mode 100644 index 0000000..cb64914 --- /dev/null +++ b/packages/cli/src/commands/pin.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Pin extends BeeperCommand { + static override summary = 'Pin a chat' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Pin) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { isPinned: true }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/react.ts b/packages/cli/src/commands/react.ts new file mode 100644 index 0000000..8f663c2 --- /dev/null +++ b/packages/cli/src/commands/react.ts @@ -0,0 +1,32 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class React extends BeeperCommand { + static override summary = apiCopy.reactions.add + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + reaction: Args.string({ description: sdkParamCopy.reactionKey, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + transaction: Flags.string({ description: 'Optional transaction ID' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(React) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.messages.reactions.add(args.message, { + chatID, + reactionKey: args.reaction, + transactionID: flags.transaction, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/read.ts b/packages/cli/src/commands/read.ts new file mode 100644 index 0000000..dbce4f4 --- /dev/null +++ b/packages/cli/src/commands/read.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Read extends BeeperCommand { + static override summary = apiCopy.chats.markRead + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + message: Flags.string({ description: sdkParamCopy.messageID }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Read) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.markRead(chatID, { messageID: flags.message }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/remind.ts b/packages/cli/src/commands/remind.ts new file mode 100644 index 0000000..c8fbd17 --- /dev/null +++ b/packages/cli/src/commands/remind.ts @@ -0,0 +1,36 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printSuccess } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Remind extends BeeperCommand { + static override summary = apiCopy.reminders.create + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + when: Args.string({ description: sdkParamCopy.remindAt, required: true }), + } + static override flags = { + 'dismiss-on-message': Flags.boolean({ default: false, description: 'Cancel if someone messages in the chat' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Remind) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.reminders.create(chatID, { + reminder: { + dismissOnIncomingMessage: flags['dismiss-on-message'] || undefined, + remindAt: args.when, + }, + }) + await printSuccess({ + message: 'Reminder set', + detail: args.when, + data: { chatID, remindAt: args.when, dismissOnIncomingMessage: flags['dismiss-on-message'] }, + }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/rpc.ts b/packages/cli/src/commands/rpc.ts new file mode 100644 index 0000000..2bd419b --- /dev/null +++ b/packages/cli/src/commands/rpc.ts @@ -0,0 +1,54 @@ +import { BeeperCommand } from '../lib/command.js' +import { createInterface } from 'node:readline/promises' +import { stdin as input } from 'node:process' +import { splitCommandLine } from '../lib/argv.js' +import { runCli } from '../lib/runner.js' + +type RPCRequest = { + args?: string[] + argv?: string[] + command?: string + id?: string | number | null +} + +export default class RPC extends BeeperCommand { + static override summary = 'Run newline-delimited JSON command RPC' + static override description = 'Reads JSON lines like {"id":1,"command":"send CHAT hello"} or {"id":1,"args":["status","--json"]}.' + + async run(): Promise<void> { + const rl = createInterface({ input }) + + for await (const line of rl) { + if (!line.trim()) continue + let requestID: string | number | null = null + + try { + const request = JSON.parse(line) as RPCRequest + requestID = request.id ?? null + const args = normalizeArgs(request) + if (args[0] === 'rpc' || args[0] === 'shell') throw new Error(`Unsupported nested command: ${args[0]}`) + const result = await runCli(args) + process.stdout.write(`${JSON.stringify({ + id: requestID, + ok: result.code === 0, + code: result.code, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr, + })}\n`) + } catch (error) { + process.stdout.write(`${JSON.stringify({ + id: requestID, + ok: false, + error: error instanceof Error ? error.message : String(error), + })}\n`) + } + } + } +} + +function normalizeArgs(request: RPCRequest): string[] { + const args = request.args ?? request.argv ?? (request.command ? splitCommandLine(request.command) : undefined) + if (!args || args.length === 0) throw new Error('Expected args, argv, or command') + return args +} diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts new file mode 100644 index 0000000..8fcd6d6 --- /dev/null +++ b/packages/cli/src/commands/search.ts @@ -0,0 +1,28 @@ +import { Args } from '@oclif/core' +import { BeeperCommand } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { withInkSpinner as withSpinner } from '../lib/ink/spinner.js' + +export default class Search extends BeeperCommand { + static override summary = 'Search chats and messages' + static override args = { + query: Args.string({ description: 'Literal search query', required: true }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Search) + const client = await createClient(flags) + const result = flags.json + ? await client.search({ query: args.query }) + : await withSpinner(`Searching for "${args.query}"…`, () => client.search({ query: args.query }), { + done: value => { + const r = value as { chats?: unknown[]; messages?: unknown[] } + const chats = r.chats?.length ?? 0 + const messages = r.messages?.length ?? 0 + return `${chats} chat${chats === 1 ? '' : 's'}, ${messages} message${messages === 1 ? '' : 's'}` + }, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/send/file.ts b/packages/cli/src/commands/send/file.ts new file mode 100644 index 0000000..7df5250 --- /dev/null +++ b/packages/cli/src/commands/send/file.ts @@ -0,0 +1,44 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { cliCopy, sdkParamCopy } from '../../lib/copy.js' +import { printData } from '../../lib/output.js' +import { resolveChatID } from '../../lib/resolve.js' +import { sendMessage } from '../../lib/send-message.js' + +export default class SendFile extends BeeperCommand { + static override summary = 'Send a file to a chat' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + file: Args.string({ description: sdkParamCopy.attachmentFile, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: false }), + } + static override flags = { + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + 'reply-to': Flags.string({ description: sdkParamCopy.replyToMessageID }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(SendFile) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await sendMessage(client, { + chatID, + file: args.file, + fileName: flags['file-name'], + mimeType: flags['mime-type'], + replyTo: flags['reply-to'], + text: args.text || '', + wait: flags.wait, + waitIntervalMs: flags['wait-interval'], + waitTimeoutMs: flags['wait-timeout'], + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/send/text.ts b/packages/cli/src/commands/send/text.ts new file mode 100644 index 0000000..af9fbec --- /dev/null +++ b/packages/cli/src/commands/send/text.ts @@ -0,0 +1,44 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../../lib/command.js' +import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' +import { printData } from '../../lib/output.js' +import { resolveChatID } from '../../lib/resolve.js' +import { sendMessage } from '../../lib/send-message.js' + +export default class SendText extends BeeperCommand { + static override summary = apiCopy.messages.send + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: true }), + } + static override flags = { + file: Flags.string({ description: sdkParamCopy.attachmentFile }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + 'reply-to': Flags.string({ description: sdkParamCopy.replyToMessageID }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(SendText) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await sendMessage(client, { + chatID, + file: flags.file, + fileName: flags['file-name'], + mimeType: flags['mime-type'], + replyTo: flags['reply-to'], + text: args.text, + wait: flags.wait, + waitIntervalMs: flags['wait-interval'], + waitTimeoutMs: flags['wait-timeout'], + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/shell.ts b/packages/cli/src/commands/shell.ts new file mode 100644 index 0000000..b5057e9 --- /dev/null +++ b/packages/cli/src/commands/shell.ts @@ -0,0 +1,40 @@ +import { BeeperCommand } from '../lib/command.js' +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { splitCommandLine } from '../lib/argv.js' +import { runCli } from '../lib/runner.js' + +export default class Shell extends BeeperCommand { + static override summary = 'Run an interactive Beeper CLI shell' + + async run(): Promise<void> { + const rl = createInterface({ input, output, prompt: 'beeper> ' }) + let closed = false + rl.on('close', () => { + closed = true + }) + const interactive = Boolean(input.isTTY && output.isTTY) + if (interactive) rl.prompt() + + for await (const line of rl) { + const trimmed = line.trim() + if (!trimmed) { + if (interactive && !closed) rl.prompt() + continue + } + if (trimmed === 'exit' || trimmed === 'quit') break + + try { + const args = splitCommandLine(trimmed) + if (args[0] === 'shell') throw new Error('Nested shell is not supported') + await runCli(args, { inherit: true }) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), { exit: false }) + } + + if (interactive && !closed) rl.prompt() + } + + if (!closed) rl.close() + } +} diff --git a/packages/cli/src/commands/start-chat.ts b/packages/cli/src/commands/start-chat.ts new file mode 100644 index 0000000..6141b97 --- /dev/null +++ b/packages/cli/src/commands/start-chat.ts @@ -0,0 +1,58 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { listAccountIDs, resolveAccountIDs, userQueryFromInput } from '../lib/resolve.js' + +export default class StartChat extends BeeperCommand { + static override summary = apiCopy.chats.start + static override args = { + query: Args.string({ description: 'Phone, email, username, user ID, or name', required: false }), + } + static override flags = { + account: Flags.string({ multiple: true, description: `${cliCopy.args.accountSelector}. Omit to try every account.` }), + 'allow-invite': Flags.boolean({ default: false, description: 'Allow invite-based DM creation when required' }), + email: Flags.string({ description: 'Email address' }), + id: Flags.string({ description: 'Known user ID' }), + message: Flags.string({ description: 'Optional first message' }), + name: Flags.string({ description: 'Display name hint' }), + phone: Flags.string({ description: 'Phone number' }), + username: Flags.string({ description: 'Username' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(StartChat) + ensureWritable(flags) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const user: Record<string, string> = args.query ? userQueryFromInput(args.query) : {} + if (flags.email) user.email = flags.email + if (flags.name) user.fullName = flags.name + if (flags.id) user.id = flags.id + if (flags.phone) user.phoneNumber = flags.phone + if (flags.username) user.username = flags.username + if (Object.keys(user).length === 0) { + throw new Error('Provide a query or at least one of: --email, --id, --phone, --username, --name') + } + const result = await tryStartChat(client, accountIDs, { + allowInvite: flags['allow-invite'] || undefined, + messageText: flags.message, + user, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} + +async function tryStartChat(client: any, accountIDs: string[], body: Record<string, unknown>) { + const failures: string[] = [] + for (const accountID of accountIDs) { + try { + return await client.chats.start({ ...body, accountID }) + } catch (error) { + failures.push(`${accountID}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + throw new Error(`No account could start this chat:\n${failures.map(failure => ` - ${failure}`).join('\n')}`) +} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 0000000..88e307b --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,25 @@ +import { BeeperCommand } from '../lib/command.js' +import { readConfig } from '../lib/config.js' +import { printData } from '../lib/output.js' +import { withInkSpinner as withSpinner } from '../lib/ink/spinner.js' + +export default class Status extends BeeperCommand { + static override summary = 'Check Beeper Desktop API status' + + async run(): Promise<void> { + const { flags } = await this.parse(Status) + const config = await readConfig() + const baseURL = flags['base-url'] ?? config.baseURL + const fetchInfo = async (): Promise<unknown> => { + const response = await fetch(new URL('/v1/info', baseURL), { signal: AbortSignal.timeout(5000) }) + if (!response.ok) throw new Error(`Beeper Desktop API returned ${response.status} ${response.statusText}`) + return response.json() + } + const info = flags.json + ? await fetchInfo() + : await withSpinner(`Pinging Beeper Desktop at ${baseURL}…`, fetchInfo, { + done: value => `Beeper Desktop v${(value as { version?: string }).version ?? '?'} reachable`, + }) + await printData(info, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/title.ts b/packages/cli/src/commands/title.ts new file mode 100644 index 0000000..fa0e706 --- /dev/null +++ b/packages/cli/src/commands/title.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Title extends BeeperCommand { + static override summary = 'Set a custom chat title' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + title: Args.string({ description: 'New chat title', required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Title) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { title: args.title }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unarchive.ts b/packages/cli/src/commands/unarchive.ts new file mode 100644 index 0000000..4b637a7 --- /dev/null +++ b/packages/cli/src/commands/unarchive.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printSuccess } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unarchive extends BeeperCommand { + static override summary = apiCopy.chats.archive + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unarchive) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.archive(chatID, { archived: false }) + await printSuccess({ message: 'Unarchived', detail: chatID, data: { chatID } }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unmute.ts b/packages/cli/src/commands/unmute.ts new file mode 100644 index 0000000..40b7f9e --- /dev/null +++ b/packages/cli/src/commands/unmute.ts @@ -0,0 +1,24 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unmute extends BeeperCommand { + static override summary = 'Unmute a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unmute) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { isMuted: false }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unpin.ts b/packages/cli/src/commands/unpin.ts new file mode 100644 index 0000000..57d8597 --- /dev/null +++ b/packages/cli/src/commands/unpin.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { cliCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unpin extends BeeperCommand { + static override summary = 'Unpin a chat' + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unpin) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await printData(await client.chats.update(chatID, { isPinned: false }), flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unreact.ts b/packages/cli/src/commands/unreact.ts new file mode 100644 index 0000000..37f4340 --- /dev/null +++ b/packages/cli/src/commands/unreact.ts @@ -0,0 +1,30 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unreact extends BeeperCommand { + static override summary = apiCopy.reactions.delete + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + reaction: Args.string({ description: sdkParamCopy.reactionKey, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unreact) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.messages.reactions.delete(args.reaction, { + chatID, + messageID: args.message, + }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unread.ts b/packages/cli/src/commands/unread.ts new file mode 100644 index 0000000..a13d162 --- /dev/null +++ b/packages/cli/src/commands/unread.ts @@ -0,0 +1,26 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unread extends BeeperCommand { + static override summary = apiCopy.chats.markUnread + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + message: Flags.string({ description: sdkParamCopy.messageID }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unread) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.markUnread(chatID, { messageID: flags.message }) + await printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/unremind.ts b/packages/cli/src/commands/unremind.ts new file mode 100644 index 0000000..300548d --- /dev/null +++ b/packages/cli/src/commands/unremind.ts @@ -0,0 +1,25 @@ +import { Args, Flags } from '@oclif/core' +import { BeeperCommand, ensureWritable } from '../lib/command.js' +import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' +import { printSuccess } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unremind extends BeeperCommand { + static override summary = apiCopy.reminders.delete + static override args = { + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + } + static override flags = { + pick: Flags.integer({ description: cliCopy.flags.pick }), + } + + async run(): Promise<void> { + const { args, flags } = await this.parse(Unremind) + ensureWritable(flags) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.reminders.delete(chatID) + await printSuccess({ message: 'Reminder cleared', detail: chatID, data: { chatID } }, flags.json ? 'json' : 'human') + } +} diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts new file mode 100644 index 0000000..9ec8054 --- /dev/null +++ b/packages/cli/src/commands/watch.ts @@ -0,0 +1,106 @@ +import { Flags } from '@oclif/core' +import WebSocket from 'ws' +import { BeeperCommand, writeEvent } from '../lib/command.js' +import { requireToken } from '../lib/client.js' +import { getBaseURL } from '../lib/config.js' +import { startStream } from '../lib/output.js' + +export default class Watch extends BeeperCommand { + static override summary = 'Stream Desktop API WebSocket events' + static override flags = { + chat: Flags.string({ char: 'c', multiple: true, description: 'Chat ID to subscribe to. Defaults to all chats.' }), + json: Flags.boolean({ default: false, description: 'Print raw JSON, one event per line' }), + } + + async run(): Promise<void> { + const { flags } = await this.parse(Watch) + const token = await requireToken() + const baseURL = await getBaseURL(flags['base-url']) + const info = await fetch(new URL('/v1/info', baseURL)) + if (!info.ok) throw new Error(`Failed to fetch /v1/info: HTTP ${info.status}`) + const metadata = await info.json() as { endpoints?: { ws_events?: string } } + const endpoint = metadata.endpoints?.ws_events || '/v1/ws' + const url = new URL(endpoint, baseURL) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + + const subscribed = flags.chat?.length ? flags.chat : ['*'] + const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } }) + + if (flags.json) { + await this.runJSON(ws, subscribed, flags.events) + return + } + await this.runHuman(ws, subscribed, baseURL, flags.events) + } + + private async runJSON(ws: WebSocket, subscribed: string[], events: boolean): Promise<void> { + ws.addEventListener('open', () => { + if (events) writeEvent('watch.open', { subscribed }) + ws.send(JSON.stringify({ type: 'subscriptions.set', chatIDs: subscribed })) + }) + ws.addEventListener('message', event => { + const data = typeof event.data === 'string' ? event.data : event.data.toString() + if (events) writeEvent('watch.message') + process.stdout.write(`${data}\n`) + }) + ws.addEventListener('error', () => { + if (events) writeEvent('watch.error', { message: 'WebSocket connection failed' }) + this.error('WebSocket connection failed', { exit: 1 }) + }) + ws.addEventListener('close', event => { + if (events) writeEvent('watch.close', { code: event.code, reason: event.reason }) + if (event.code !== 1000) this.error(`WebSocket closed: ${event.code} ${event.reason}`, { exit: 1 }) + }) + await new Promise<void>(resolve => { + process.once('SIGINT', () => { ws.close(1000); resolve() }) + ws.addEventListener('close', () => resolve()) + }) + } + + private async runHuman(ws: WebSocket, subscribed: string[], baseURL: string, events: boolean): Promise<void> { + const stream = await startStream({ baseURL, subscribed }) + let closed = false + + const finish = async (): Promise<void> => { + if (closed) return + closed = true + try { ws.close(1000) } catch { /* ignore */ } + await stream.close() + } + + ws.addEventListener('open', () => { + if (events) writeEvent('watch.open', { subscribed }) + stream.setConnected(true) + ws.send(JSON.stringify({ type: 'subscriptions.set', chatIDs: subscribed })) + }) + ws.addEventListener('message', event => { + const data = typeof event.data === 'string' ? event.data : event.data.toString() + if (events) writeEvent('watch.message') + try { + const parsed = JSON.parse(data) as Record<string, unknown> + stream.push({ + type: typeof parsed.type === 'string' ? parsed.type : 'event', + chatID: typeof parsed.chatID === 'string' ? parsed.chatID : undefined, + messageID: typeof parsed.messageID === 'string' ? parsed.messageID : undefined, + ts: typeof parsed.timestamp === 'string' ? parsed.timestamp : new Date().toISOString(), + }) + } catch { + stream.push({ type: 'raw', ts: new Date().toISOString() }) + } + }) + ws.addEventListener('error', () => { + if (events) writeEvent('watch.error', { message: 'WebSocket connection failed' }) + stream.setConnected(false) + stream.setStatus('connection error') + }) + ws.addEventListener('close', event => { + if (events) writeEvent('watch.close', { code: event.code, reason: event.reason }) + stream.setConnected(false) + if (event.code !== 1000) stream.setStatus(`closed ${event.code}${event.reason ? ` ${event.reason}` : ''}`) + void finish() + }) + process.once('SIGINT', () => { void finish() }) + + await stream.done + } +} diff --git a/packages/cli/src/lib/account-login.ts b/packages/cli/src/lib/account-login.ts new file mode 100644 index 0000000..c85ba09 --- /dev/null +++ b/packages/cli/src/lib/account-login.ts @@ -0,0 +1,139 @@ +import { createInterface } from 'node:readline/promises' +import { execFileSync } from 'node:child_process' +import { stdin as input, stderr as output } from 'node:process' +import type { + AuthStartLoginResponse, + AuthSubmitCookiesResponse, + AuthSubmitUserInputResponse, + AuthWaitForStepResponse, +} from '@beeper/desktop-api/resources/matrix/bridges/auth.js' +import type BeeperDesktop from '@beeper/desktop-api' + +export type AccountLoginStep = + | AuthStartLoginResponse + | AuthSubmitCookiesResponse + | AuthSubmitUserInputResponse + | AuthWaitForStepResponse + +export type AccountLoginOptions = { + cookies?: Record<string, string> + fields?: Record<string, string> + nonInteractive?: boolean +} + +type CommonStep = { + instructions?: string + login_id?: string + step_id?: string + type: string +} + +export function printAccountLoginStep(step: AccountLoginStep): void { + const common = step as CommonStep + output.write(`step: ${common.type}\n`) + if (common.instructions) output.write(`${common.instructions}\n`) + if (common.login_id) output.write(`login_id: ${common.login_id}\n`) + if (common.step_id) output.write(`step_id: ${common.step_id}\n`) + + if ('display_and_wait' in step) { + const display = step.display_and_wait + output.write(`display: ${display.type}\n`) + if (display.data) output.write(`${display.data}\n`) + if (display.image_url) output.write(`image: ${display.image_url}\n`) + } else if ('user_input' in step) { + for (const field of step.user_input.fields) { + const details = [field.type, field.description, field.options?.length ? `options: ${field.options.join(', ')}` : undefined] + .filter(Boolean) + .join(' | ') + output.write(`field ${field.id}: ${field.name}${details ? ` (${details})` : ''}\n`) + } + } else if ('cookies' in step) { + output.write(`url: ${step.cookies.url}\n`) + if (step.cookies.user_agent) output.write(`user_agent: ${step.cookies.user_agent}\n`) + if (step.cookies.wait_for_url_pattern) output.write(`wait_for_url_pattern: ${step.cookies.wait_for_url_pattern}\n`) + for (const field of step.cookies.fields) output.write(`cookie field ${field.name}: ${field.type}\n`) + if (step.cookies.extract_js) output.write(`extract_js:\n${step.cookies.extract_js}\n`) + } else if ('complete' in step) { + output.write(`complete: ${step.complete.user_login_id ?? 'yes'}\n`) + } +} + +export async function runGuidedAccountLogin(client: BeeperDesktop, bridgeID: string, initialStep: AccountLoginStep, options: AccountLoginOptions = {}): Promise<AccountLoginStep> { + let step = initialStep + for (;;) { + printAccountLoginStep(step) + if ('complete' in step) return step + + const loginProcessID = (step as CommonStep).login_id + const stepID = (step as CommonStep).step_id + if (!loginProcessID || !stepID) throw new Error('Account login step did not include login_id and step_id.') + + if ('display_and_wait' in step) { + await promptText('Press Enter after completing this step.') + step = await client.matrix.bridges.auth.waitForStep(stepID, { bridgeID, loginProcessID }) + continue + } + + if ('user_input' in step) { + const body: Record<string, string> = {} + for (const field of step.user_input.fields) { + if (options.fields?.[field.id] !== undefined) { + body[field.id] = options.fields[field.id]! + continue + } + + if (options.nonInteractive) { + if (field.default_value !== undefined) { + body[field.id] = field.default_value + continue + } + + throw new Error(`Missing required field ${field.id}. Pass --field ${field.id}=... or run without --non-interactive.`) + } + + const fallback = field.default_value ? ` [${field.default_value}]` : '' + const value = await promptText(`${field.name}${fallback}: `) + body[field.id] = value || field.default_value || '' + } + step = await client.matrix.bridges.auth.submitUserInput(stepID, { bridgeID, loginProcessID, body }) + continue + } + + if ('cookies' in step) { + const body: Record<string, string> = {} + for (const field of step.cookies.fields) { + if (options.cookies?.[field.name] !== undefined) { + body[field.name] = options.cookies[field.name]! + continue + } + + if (options.nonInteractive) throw new Error(`Missing required cookie ${field.name}. Pass --cookie ${field.name}=... or run without --non-interactive.`) + body[field.name] = await promptSecret(`${field.name}: `) + } + step = await client.matrix.bridges.auth.submitCookies(stepID, { bridgeID, loginProcessID, body }) + continue + } + + throw new Error(`Unsupported account login step: ${(step as CommonStep).type}`) + } +} + +async function promptText(label: string): Promise<string> { + const rl = createInterface({ input, output }) + try { + return (await rl.question(label)).trim() + } finally { + rl.close() + } +} + +async function promptSecret(label: string): Promise<string> { + if (!input.isTTY) return promptText(label) + try { + execFileSync('stty', ['-echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) + return await promptText(label) + } finally { + execFileSync('stty', ['echo'], { stdio: ['inherit', 'ignore', 'ignore'] }) + output.write('\n') + } +} diff --git a/packages/cli/src/lib/app-api.ts b/packages/cli/src/lib/app-api.ts new file mode 100644 index 0000000..b5093db --- /dev/null +++ b/packages/cli/src/lib/app-api.ts @@ -0,0 +1,62 @@ +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { getAccessToken, readConfig } from './config.js' +import type { AppStatusResponse } from '@beeper/desktop-api/resources/app/app.js' +import type { + LoginRegisterResponse, + LoginResponseResponse, +} from '@beeper/desktop-api/resources/app/login.js' +import type { ResetBeginResponse } from '@beeper/desktop-api/resources/app/e2ee/recovery-code/reset.js' + +export type AppStateSnapshot = AppStatusResponse +export type AppLoginSuccess = LoginRegisterResponse +export type AppRegistrationRequired = Extract<LoginResponseResponse, { registrationRequired: true }> +export type AppLoginOutput = LoginResponseResponse + +export type AppMutationResponse = { + appState: AppStateSnapshot +} + +export type AppRecoveryCodeResetBeginResponse = ResetBeginResponse + +export async function appRequest<T>( + method: 'GET' | 'POST', + path: string, + options: { baseURL?: string; body?: Record<string, unknown>; token?: string | false } = {}, +): Promise<T> { + const config = await readConfig() + const baseURL = options.baseURL ?? config.baseURL + const token = options.token === false ? undefined : options.token ?? await getAccessToken() + const headers: Record<string, string> = {} + if (token) headers.authorization = `Bearer ${token}` + if (options.body) headers['content-type'] = 'application/json' + + const response = await fetch(new URL(path, baseURL), { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + if (!response.ok) throw new Error(`${method} ${path} failed: ${response.status} ${await response.text()}`) + if (response.status === 204) return undefined as T + const text = await response.text() + return (text ? JSON.parse(text) : {}) as T +} + +export async function promptText(label: string): Promise<string> { + const rl = createInterface({ input, output }) + try { + const value = await rl.question(label) + return value.trim() + } finally { + rl.close() + } +} + +export async function promptYesNo(label: string): Promise<boolean> { + const value = (await promptText(`${label} [y/N] `)).toLowerCase() + return value === 'y' || value === 'yes' +} + +export function isRegistrationRequired(output: AppLoginOutput): output is AppRegistrationRequired { + return 'registrationRequired' in output && output.registrationRequired === true +} diff --git a/packages/cli/src/lib/argv.ts b/packages/cli/src/lib/argv.ts new file mode 100644 index 0000000..31515cf --- /dev/null +++ b/packages/cli/src/lib/argv.ts @@ -0,0 +1,47 @@ +export function splitCommandLine(input: string): string[] { + const tokens: string[] = [] + let current = '' + let tokenStarted = false + let quote: '"' | "'" | undefined + let escaped = false + + for (const char of input) { + if (escaped) { + current += char + tokenStarted = true + escaped = false + continue + } + + if (char === '\\' && quote !== "'") { + escaped = true + continue + } + + if ((char === '"' || char === "'") && (!quote || quote === char)) { + if (!quote) tokenStarted = true + quote = quote ? undefined : char + continue + } + + if (!quote && /\s/.test(char)) { + if (tokenStarted) { + tokens.push(current) + current = '' + tokenStarted = false + } + continue + } + + current += char + tokenStarted = true + } + + if (escaped) { + current += '\\' + tokenStarted = true + } + if (quote) throw new Error(`Unclosed ${quote} quote`) + if (tokenStarted) tokens.push(current) + return tokens +} diff --git a/packages/cli/src/lib/client.ts b/packages/cli/src/lib/client.ts new file mode 100644 index 0000000..033cd88 --- /dev/null +++ b/packages/cli/src/lib/client.ts @@ -0,0 +1,23 @@ +import BeeperDesktop from '@beeper/desktop-api' +import { readConfig } from './config.js' +import { ensureDesktopToken } from './desktop-auth.js' + +export async function createClient(flags: { baseURL?: string; 'base-url'?: string; debug?: boolean } = {}) { + const explicitBaseURL = flags.baseURL || flags['base-url'] + const config = await readConfig() + const accessToken = process.env.BEEPER_ACCESS_TOKEN + || config.auth?.accessToken + || await ensureDesktopToken({ baseURL: explicitBaseURL, scan: !explicitBaseURL }) + return new BeeperDesktop({ + accessToken, + baseURL: explicitBaseURL || config.baseURL, + logLevel: flags.debug ? 'debug' : 'warn', + }) +} + +export async function requireToken(options: { baseURL?: string; scan?: boolean } = {}): Promise<string> { + const config = await readConfig() + const token = process.env.BEEPER_ACCESS_TOKEN || config.auth?.accessToken + if (token) return token + return ensureDesktopToken({ baseURL: options.baseURL, scan: options.scan }) +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts new file mode 100644 index 0000000..2e9b18c --- /dev/null +++ b/packages/cli/src/lib/command.ts @@ -0,0 +1,38 @@ +import { Command, Flags } from '@oclif/core' + +export abstract class BeeperCommand extends Command { + static override baseFlags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false, description: 'Print SDK debug logging' }), + events: Flags.boolean({ default: false, description: 'Emit NDJSON lifecycle events on stderr' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'read-only': Flags.boolean({ default: false, description: 'Reject commands that modify Beeper or local CLI state' }), + } + + protected override async catch(error: Error & { exitCode?: number }): Promise<void> { + process.exitCode = process.exitCode ?? error.exitCode ?? 1 + const message = error.message || String(error) + + if (this.argv.includes('--events')) { + writeEvent('error', { message }) + return + } + + if (this.argv.includes('--json')) { + process.stderr.write(`${JSON.stringify({ ok: false, error: message })}\n`) + return + } + + return super.catch(error) + } +} + +export function ensureWritable(flags: { 'read-only'?: boolean }): void { + const env = process.env.BEEPER_CLI_READONLY ?? process.env.BEEPER_READONLY + const readOnly = flags['read-only'] || ['1', 'true', 'yes', 'on'].includes(String(env ?? '').toLowerCase()) + if (readOnly) throw new Error('read-only mode: command would modify Beeper or local CLI state') +} + +export function writeEvent(event: string, data: Record<string, unknown> = {}): void { + process.stderr.write(`${JSON.stringify({ event, data, ts: new Date().toISOString() })}\n`) +} diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts new file mode 100644 index 0000000..24ab8bf --- /dev/null +++ b/packages/cli/src/lib/config.ts @@ -0,0 +1,60 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' + +export type StoredAuth = { + accessToken: string + clientID?: string + expiresAt?: string + scope?: string + tokenType: 'Bearer' +} + +export type Config = { + auth?: StoredAuth + baseURL: string +} + +const defaultBaseURL = 'http://127.0.0.1:23373' + +export const configPath = () => + join(process.env.BEEPER_CLI_CONFIG_DIR ?? join(homedir(), '.config', 'beeper'), 'config.json') + +export async function readConfig(): Promise<Config> { + const baseURL = process.env.BEEPER_DESKTOP_BASE_URL || process.env.BEEPER_BASE_URL + try { + const raw = await readFile(configPath(), 'utf8') + const parsed = JSON.parse(raw) as Partial<Config> + return { + baseURL: baseURL || parsed.baseURL || defaultBaseURL, + auth: parsed.auth, + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { baseURL: baseURL || defaultBaseURL } + throw error + } +} + +export async function writeConfig(config: Config): Promise<void> { + const file = configPath() + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }) +} + +export async function updateConfig(update: (config: Config) => Config | Promise<Config>): Promise<Config> { + const next = await update(await readConfig()) + await writeConfig(next) + return next +} + +export async function resetConfig(): Promise<void> { + await rm(configPath(), { force: true }) +} + +export async function getAccessToken(): Promise<string | undefined> { + return process.env.BEEPER_ACCESS_TOKEN || (await readConfig()).auth?.accessToken +} + +export async function getBaseURL(override?: string): Promise<string> { + return override || (await readConfig()).baseURL +} diff --git a/packages/cli/src/lib/copy.ts b/packages/cli/src/lib/copy.ts new file mode 100644 index 0000000..8a7789d --- /dev/null +++ b/packages/cli/src/lib/copy.ts @@ -0,0 +1,66 @@ +export const apiCopy = { + accounts: { + list: 'List Chat Accounts connected to this Beeper Desktop instance, including bridge metadata and network identity.', + }, + assets: { + download: 'Download a Matrix file using its mxc:// or localmxc:// URL to the device running Beeper Desktop and return the local file URL.', + upload: 'Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending a message or materializing a draft attachment.', + }, + chats: { + archive: 'Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox', + create: 'Create a direct or group chat from participant IDs. Returns the created chat.', + list: 'List all chats sorted by last activity (most recent first). Combines all accounts into a single paginated list.', + markRead: 'Mark a chat as read, optionally through a specific message ID.', + markUnread: 'Mark a chat as unread, optionally from a specific message ID.', + notifyAnyway: 'Force a delivery notification when supported by the underlying network. Currently intended for iMessage on macOS; unsupported networks return an error.', + retrieve: 'Retrieve chat details including metadata, participants, and latest message', + search: 'Search chats by title, network, or participant names.', + start: 'Resolve a user/contact and open a direct chat. Reuses and returns an existing direct chat when one is found. Available in Beeper Desktop v4.2.808+.', + }, + contacts: { + list: 'List merged contacts for a specific account with cursor-based pagination.', + search: 'Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup.', + }, + messages: { + delete: 'Delete a message by final message ID. Pending message IDs are not accepted because messages cannot be deleted while sending.', + list: 'List all messages in a chat with cursor-based pagination. Sorted by timestamp.', + retrieve: 'Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. Chat ID may be a Beeper chat ID or local chat ID.', + search: 'Search messages across chats.', + send: 'Send a text message to a specific chat. Supports replying to existing messages. Returns a pending message ID.', + update: 'Edit the text content of an existing message. Messages with attachments cannot be edited.', + }, + reactions: { + add: 'Add a reaction to an existing message.', + delete: 'Remove the reaction added by the authenticated user from an existing message.', + }, + reminders: { + create: 'Set a reminder for a chat at a specific time', + delete: 'Clear an existing reminder from a chat', + }, +} as const + +export const sdkParamCopy = { + attachmentFile: 'The file to upload (max 500 MB).', + chatID: 'Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.', + fileName: 'Original filename. Defaults to the uploaded file name if omitted', + forEveryone: 'True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported.', + messageID: 'Message ID.', + mimeType: 'MIME type. Auto-detected from magic bytes if omitted', + reactionKey: 'Reaction key to add (emoji, shortcode, or custom emoji key)', + remindAt: 'Timestamp when the reminder should trigger.', + replyToMessageID: 'Provide a message ID to send this as a reply to an existing message', + searchQuery: 'User-typed search text. Literal word matching (non-semantic).', + text: 'Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.', +} as const + +export const cliCopy = { + args: { + accountSelector: 'Account ID, network, bridge, or account user', + chatSelector: `${sdkParamCopy.chatID} Also accepts exact chat titles or search text.`, + }, + flags: { + baseURL: 'Beeper Desktop API base URL', + json: 'Print JSON', + pick: 'Pick the Nth chat when the input is ambiguous', + }, +} as const diff --git a/packages/cli/src/lib/desktop-auth.ts b/packages/cli/src/lib/desktop-auth.ts new file mode 100644 index 0000000..ec7808b --- /dev/null +++ b/packages/cli/src/lib/desktop-auth.ts @@ -0,0 +1,96 @@ +import { readConfig } from './config.js' +import { loginWithPKCE } from './oauth.js' + +export type DesktopAppStatus = { + state?: string +} + +type DesktopProbe = { + baseURL: string + status?: DesktopAppStatus +} + +const defaultPort = 23_373 +const scanPorts = Array.from({ length: 20 }, (_, index) => defaultPort + index) + +export async function findLocalDesktop(options: { baseURL?: string; scan?: boolean; timeoutMs?: number } = {}): Promise<DesktopProbe> { + const config = await readConfig() + const preferred = options.baseURL ?? config.baseURL + const candidates = candidateBaseURLs(preferred, options.scan ?? true) + const timeoutMs = options.timeoutMs ?? 500 + + const preferredProbe = await probeDesktop(preferred, timeoutMs) + if (preferredProbe) return preferredProbe + + const rest = candidates.filter(url => url !== preferred) + if (rest.length) { + try { + return await Promise.any(rest.map(async url => { + const probe = await probeDesktop(url, timeoutMs) + if (!probe) throw new Error('not found') + return probe + })) + } catch { /* fall through */ } + } + + throw new Error(`Could not find a running Beeper Desktop API on ${candidates.join(', ')}.`) +} + +export async function ensureDesktopToken(options: { + baseURL?: string + clientName?: string + openBrowser?: boolean + scan?: boolean + scope?: string +} = {}): Promise<string> { + const desktop = await findLocalDesktop({ baseURL: options.baseURL, scan: options.scan }) + if (desktop.status?.state === 'needs-login') { + throw new Error('Beeper Desktop is not signed in. Open Beeper Desktop and sign in, then rerun this command.') + } + + const token = await loginWithPKCE({ + baseURL: desktop.baseURL, + clientName: options.clientName ?? 'Beeper CLI', + openBrowser: options.openBrowser ?? true, + save: true, + scope: options.scope ?? 'read write', + }) + return token.access_token +} + +export async function getDesktopAppStatus(baseURL: string): Promise<DesktopAppStatus | undefined> { + const response = await fetchWithTimeout(new URL('/v1/app/status', baseURL), {}, 2_000) + if (response.status === 401 || response.status === 403 || response.status === 404) return undefined + if (!response.ok) throw new Error(`GET /v1/app/status failed: ${response.status} ${await response.text()}`) + return response.json() as Promise<DesktopAppStatus> +} + +function candidateBaseURLs(preferred: string, scan: boolean): string[] { + const urls = new Set<string>([preferred]) + if (!scan) return [...urls] + for (const port of scanPorts) { + urls.add(`http://127.0.0.1:${port}`) + urls.add(`http://localhost:${port}`) + } + return [...urls] +} + +async function probeDesktop(baseURL: string, timeoutMs: number): Promise<DesktopProbe | undefined> { + try { + const info = await fetchWithTimeout(new URL('/v1/info', baseURL), {}, timeoutMs) + if (!info.ok) return undefined + return { baseURL, status: await getDesktopAppStatus(baseURL) } + } catch { + return undefined + } +} + +async function fetchWithTimeout(url: URL, init: RequestInit = {}, timeoutMs: number): Promise<Response> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/cli/src/lib/export/index.ts b/packages/cli/src/lib/export/index.ts new file mode 100644 index 0000000..5422f73 --- /dev/null +++ b/packages/cli/src/lib/export/index.ts @@ -0,0 +1,557 @@ +import { createWriteStream } from 'node:fs' +import type { FileHandle } from 'node:fs/promises' +import { copyFile, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Chat } from '@beeper/desktop-api/resources/chats/chats' +import type { Attachment, Message } from '@beeper/desktop-api/resources/shared' + +type AnyRecord = Record<string, any> + +export type ExportOptions = { + accountIDs?: string[] + chatIDs?: string[] + downloadAttachments: boolean + events?: boolean + force: boolean + limitChats?: number + limitMessages?: number + maxParticipants?: number + outDir: string + quiet: boolean +} + +type ExportState = { + completedChatIDs: string[] + createdAt: string + exportVersion: 1 + chats: Record<string, ChatState> +} + +type ChatState = { + attachmentCount: number + complete: boolean + cursor: string | null + error?: string + messageCount: number + startedAt: string + updatedAt: string +} + +type ExportManifest = { + accounts: unknown[] + attachmentCount: number + chatCount: number + completedAt: string + createdAt: string + messageCount: number + version: 1 +} + +type AttachmentExport = { + attachment: Attachment + index: number + kind: 'attachment' | 'poster' + messageID: string + path: string + sourceURL: string +} + +export async function exportBeeperData(client: any, options: ExportOptions): Promise<ExportManifest> { + await mkdir(options.outDir, { recursive: true }) + await mkdir(join(options.outDir, 'chats'), { recursive: true }) + + const statePath = join(options.outDir, '.beeper-export-state.json') + const state = options.force ? createState() : await readState(statePath) + const startedAt = state.createdAt + + progress(options, `Export directory: ${options.outDir}`) + const accounts = accountItems(await client.accounts.list()) + await writeJSONAtomic(join(options.outDir, 'accounts.json'), accounts) + progress(options, `Accounts: ${accounts.length}`) + + const chats = await collectChats(client, options) + await writeJSONAtomic(join(options.outDir, 'chats.json'), chats) + progress(options, `Chats queued: ${chats.length}`) + + let totalMessages = 0 + let totalAttachments = 0 + + for (const [index, listedChat] of chats.entries()) { + const chatID = String(listedChat.id) + const chatDir = join(options.outDir, 'chats', safeSegment(chatID)) + const chatState = state.chats[chatID] + if (!options.force && chatState?.complete && state.completedChatIDs.includes(chatID)) { + totalMessages += chatState.messageCount + totalAttachments += chatState.attachmentCount + progress(options, `[${index + 1}/${chats.length}] ${chatTitle(listedChat)} already complete`) + continue + } + + progress(options, `[${index + 1}/${chats.length}] ${chatTitle(listedChat)} starting`) + if (options.force) await rm(chatDir, { recursive: true, force: true }) + await mkdir(chatDir, { recursive: true }) + await mkdir(join(chatDir, 'attachments'), { recursive: true }) + + const chat = await client.chats.retrieve(chatID, { + maxParticipantCount: options.maxParticipants, + }) + await writeJSONAtomic(join(chatDir, 'chat.json'), chat) + + state.chats[chatID] ??= { + attachmentCount: 0, + complete: false, + cursor: null, + messageCount: 0, + startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + state.chats[chatID]!.complete = false + delete state.chats[chatID]!.error + await writeJSONAtomic(statePath, state) + + try { + const result = await exportChatMessages(client, chat, chatDir, state, statePath, options) + state.chats[chatID] = { + ...state.chats[chatID]!, + attachmentCount: result.attachmentCount, + complete: true, + cursor: null, + messageCount: result.messages.length, + updatedAt: new Date().toISOString(), + } + state.completedChatIDs = Array.from(new Set([...state.completedChatIDs, chatID])) + await writeJSONAtomic(join(chatDir, 'messages.json'), result.messages) + await writeFileAtomic(join(chatDir, 'messages.markdown'), renderMarkdown(chat, result.messages, result.attachments)) + await writeFileAtomic(join(chatDir, 'messages.html'), renderHTML(chat, result.messages, result.attachments)) + await rm(join(chatDir, 'messages.partial.jsonl'), { force: true }) + await writeJSONAtomic(statePath, state) + totalMessages += result.messages.length + totalAttachments += result.attachmentCount + progress(options, `[${index + 1}/${chats.length}] ${chatTitle(chat)} complete: ${result.messages.length} messages, ${result.attachmentCount} attachments`) + } catch (error) { + state.chats[chatID] = { + ...state.chats[chatID]!, + complete: false, + error: error instanceof Error ? error.message : String(error), + updatedAt: new Date().toISOString(), + } + await writeJSONAtomic(statePath, state) + throw error + } + } + + const manifest: ExportManifest = { + accounts, + attachmentCount: totalAttachments, + chatCount: chats.length, + completedAt: new Date().toISOString(), + createdAt: startedAt, + messageCount: totalMessages, + version: 1, + } + await writeJSONAtomic(join(options.outDir, 'manifest.json'), manifest) + progress(options, `Done: ${manifest.chatCount} chats, ${manifest.messageCount} messages, ${manifest.attachmentCount} attachments`) + return manifest +} + +async function collectChats(client: any, options: ExportOptions): Promise<Chat[]> { + if (options.chatIDs?.length) { + return Promise.all(options.chatIDs.map(chatID => + client.chats.retrieve(chatID, { maxParticipantCount: 0 }) as Promise<Chat>, + )) + } + + const chats: Chat[] = [] + for await (const chat of client.chats.list({ accountIDs: options.accountIDs })) { + chats.push(chat) + if (options.limitChats && chats.length >= options.limitChats) break + } + return chats +} + +async function exportChatMessages( + client: any, + chat: Chat, + chatDir: string, + state: ExportState, + statePath: string, + options: ExportOptions, +): Promise<{ attachmentCount: number; attachments: AttachmentExport[]; messages: Message[] }> { + const chatID = chat.id + const partialPath = join(chatDir, 'messages.partial.jsonl') + const existing = await readJSONL<Message>(partialPath) + const seen = new Set(existing.map(message => message.id)) + const chatState = state.chats[chatID]! + let cursor = chatState.cursor + let messagesWritten = existing.length + let attachmentCount = chatState.attachmentCount + + await mkdir(dirname(partialPath), { recursive: true }) + const partialHandle = await open(partialPath, 'a') + let attachmentManifestHandle: FileHandle | undefined + const writeAttachmentEntry = async (entry: AttachmentExport): Promise<void> => { + if (!attachmentManifestHandle) { + const manifestPath = join(chatDir, 'attachments', 'attachments.jsonl') + await mkdir(dirname(manifestPath), { recursive: true }) + attachmentManifestHandle = await open(manifestPath, 'a') + } + await attachmentManifestHandle.appendFile(`${JSON.stringify(entry)}\n`) + } + + try { + while (true) { + const page = await client.messages.list(chatID, cursor ? { cursor } : undefined) + const items = page.items as Message[] + if (!items.length) break + + for (const message of items) { + if (seen.has(message.id)) continue + + if (options.downloadAttachments) { + const downloaded = await downloadMessageAttachments(client, chatDir, message, writeAttachmentEntry) + attachmentCount += downloaded + } + + await partialHandle.appendFile(`${JSON.stringify(message)}\n`) + seen.add(message.id) + existing.push(message) + messagesWritten += 1 + chatState.attachmentCount = attachmentCount + await writeJSONAtomic(statePath, state) + + if (options.limitMessages && messagesWritten >= options.limitMessages) break + } + + cursor = page.oldestCursor ?? null + chatState.cursor = cursor + chatState.messageCount = messagesWritten + chatState.attachmentCount = attachmentCount + chatState.updatedAt = new Date().toISOString() + await writeJSONAtomic(statePath, state) + progress(options, ` ${chatTitle(chat)}: ${messagesWritten} messages${options.downloadAttachments ? `, ${attachmentCount} attachments` : ''}`) + + if (options.limitMessages && messagesWritten >= options.limitMessages) break + if (!page.hasMore || !cursor) break + } + } finally { + await partialHandle.close() + if (attachmentManifestHandle) await attachmentManifestHandle.close() + } + + existing.sort((a, b) => String(a.sortKey || a.timestamp).localeCompare(String(b.sortKey || b.timestamp))) + const allAttachments = await readAttachmentsManifest(chatDir) + return { attachmentCount, attachments: allAttachments, messages: existing } +} + +async function downloadMessageAttachments( + client: any, + chatDir: string, + message: Message, + writeEntry: (entry: AttachmentExport) => Promise<void>, +): Promise<number> { + const attachments = message.attachments ?? [] + let count = 0 + for (const [index, attachment] of attachments.entries()) { + const sourceURL = attachment.id || attachment.srcURL + if (sourceURL) { + const path = await downloadURL(client, sourceURL, chatDir, message.id, index, attachment.fileName, attachment.mimeType) + if (path) { + await writeEntry({ attachment, index, kind: 'attachment', messageID: message.id, path, sourceURL }) + count += 1 + } + } + + if (attachment.posterImg) { + const path = await downloadURL(client, attachment.posterImg, chatDir, message.id, index, `poster-${attachment.fileName ?? index}`, undefined) + if (path) { + await writeEntry({ attachment, index, kind: 'poster', messageID: message.id, path, sourceURL: attachment.posterImg }) + count += 1 + } + } + } + + return count +} + +async function downloadURL( + client: any, + sourceURL: string, + chatDir: string, + messageID: string, + index: number, + fileName?: string, + mimeType?: string, +): Promise<string | undefined> { + const target = join('attachments', safeSegment(messageID), `${String(index + 1).padStart(2, '0')}-${safeFileName(fileName || fileNameFromURL(sourceURL, mimeType))}`) + const absoluteTarget = join(chatDir, target) + if (await exists(absoluteTarget)) return target + + await mkdir(join(chatDir, 'attachments', safeSegment(messageID)), { recursive: true }) + if (sourceURL.startsWith('file://')) { + await copyFile(fileURLToPath(sourceURL), absoluteTarget) + return target + } + if (sourceURL.startsWith('/')) { + await copyFile(sourceURL, absoluteTarget) + return target + } + + const response = sourceURL.startsWith('mxc://') || sourceURL.startsWith('localmxc://') + ? await client.assets.serve({ url: sourceURL }) + : await fetch(sourceURL) + if (!response.ok) throw new Error(`Failed to download ${sourceURL}: HTTP ${response.status}`) + await writeResponseBody(response, absoluteTarget) + return target +} + +async function writeResponseBody(response: Response, path: string): Promise<void> { + if (!response.body) { + await writeFile(path, Buffer.from(await response.arrayBuffer())) + return + } + + const stream = createWriteStream(path) + await new Promise<void>((resolve, reject) => { + stream.on('error', reject) + stream.on('finish', resolve) + const reader = response.body!.getReader() + const pump = (): void => { + reader.read().then(({ done, value }) => { + if (done) { + stream.end() + return + } + stream.write(Buffer.from(value), error => { + if (error) reject(error) + else pump() + }) + }, reject) + } + pump() + }) +} + +function renderMarkdown(chat: Chat, messages: Message[], attachments: AttachmentExport[]): string { + const byMessage = new Map<string, AttachmentExport[]>() + for (const attachment of attachments) { + const list = byMessage.get(attachment.messageID) ?? [] + list.push(attachment) + byMessage.set(attachment.messageID, list) + } + + const lines = [ + `# ${escapeMarkdown(chat.title || chat.id)}`, + '', + `- Chat ID: \`${chat.id}\``, + `- Account ID: \`${chat.accountID}\``, + `- Network: ${escapeMarkdown(chat.network ?? '')}`, + `- Type: ${chat.type}`, + `- Messages: ${messages.length}`, + '', + '## Messages', + '', + ] + + for (const message of messages) { + const sender = message.senderName || message.senderID || 'Unknown sender' + lines.push(`### ${escapeMarkdown(sender)} - ${message.timestamp}`) + if (message.text) lines.push('', message.text) + const messageAttachments = byMessage.get(message.id) ?? [] + for (const item of messageAttachments) { + lines.push('', `- [${escapeMarkdown(item.attachment.fileName || item.kind)}](${encodeURI(item.path)})`) + } + lines.push('') + } + + return `${lines.join('\n').trimEnd()}\n` +} + +function renderHTML(chat: Chat, messages: Message[], attachments: AttachmentExport[]): string { + const byMessage = new Map<string, AttachmentExport[]>() + for (const attachment of attachments) { + const list = byMessage.get(attachment.messageID) ?? [] + list.push(attachment) + byMessage.set(attachment.messageID, list) + } + + const rows = messages.map(message => { + const sender = message.senderName || message.senderID || 'Unknown sender' + const messageAttachments = byMessage.get(message.id) ?? [] + const attachmentsHTML = messageAttachments.length + ? `<ul class="attachments">${messageAttachments.map(item => `<li><a href="${escapeAttribute(item.path)}">${escapeHTML(item.attachment.fileName || item.kind)}</a></li>`).join('')}</ul>` + : '' + return [ + '<article class="message">', + ` <header><strong>${escapeHTML(sender)}</strong><time datetime="${escapeAttribute(message.timestamp)}">${escapeHTML(message.timestamp)}</time></header>`, + message.text ? ` <div class="body">${formatMessageHTML(message.text)}</div>` : '', + attachmentsHTML, + '</article>', + ].filter(Boolean).join('\n') + }).join('\n') + + return `<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>${escapeHTML(chat.title || chat.id)} + + + +
+

${escapeHTML(chat.title || chat.id)}

+
+ Chat ID: ${escapeHTML(chat.id)} + Account ID: ${escapeHTML(chat.accountID)} + Network: ${escapeHTML(chat.network ?? '')} + Type: ${escapeHTML(chat.type)} + Messages: ${messages.length} +
+
+${indent(rows, 6)} +
+
+ + +` +} + +async function readAttachmentsManifest(chatDir: string): Promise { + return readJSONL(join(chatDir, 'attachments', 'attachments.jsonl')) +} + +async function readJSONL(path: string): Promise { + try { + const content = await readFile(path, 'utf8') + return content.split('\n').filter(Boolean).map(line => JSON.parse(line) as T) + } catch (error: any) { + if (error?.code === 'ENOENT') return [] + throw error + } +} + +async function readState(path: string): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as ExportState + } catch (error: any) { + if (error?.code === 'ENOENT') return createState() + throw error + } +} + +function createState(): ExportState { + return { + chats: {}, + completedChatIDs: [], + createdAt: new Date().toISOString(), + exportVersion: 1, + } +} + +async function writeJSONAtomic(path: string, value: unknown): Promise { + await writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`) +} + +async function writeFileAtomic(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }) + const tmp = `${path}.tmp-${process.pid}` + await writeFile(tmp, content) + await rename(tmp, path) +} + +async function exists(path: string): Promise { + try { + await stat(path) + return true + } catch (error: any) { + if (error?.code === 'ENOENT') return false + throw error + } +} + +function accountItems(accounts: unknown): unknown[] { + if (Array.isArray(accounts)) return accounts + return (accounts as { items?: unknown[] }).items ?? [] +} + +function safeSegment(value: string): string { + const normalized = value.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '') + return normalized.slice(0, 120) || 'item' +} + +function safeFileName(value: string): string { + const normalized = basename(value).replace(/[/\\?%*:|"<>]+/g, '_').trim() + return normalized.slice(0, 160) || 'attachment' +} + +function fileNameFromURL(url: string, mimeType?: string): string { + try { + const parsed = new URL(url) + const name = basename(parsed.pathname) + if (name) return name + } catch { /* fall through */ } + return `attachment${extensionForMimeType(mimeType)}` +} + +const mimeExtensions: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'video/mp4': '.mp4', + 'audio/mpeg': '.mp3', +} + +function extensionForMimeType(mimeType?: string): string { + if (!mimeType) return '' + if (mimeExtensions[mimeType]) return mimeExtensions[mimeType]! + const subtype = mimeType.split('/')[1] + return subtype && !subtype.includes('+') ? `.${subtype}` : '' +} + +function chatTitle(chat: Pick): string { + return `${chat.title || chat.id}${chat.network ? ` (${chat.network})` : ''}` +} + +function escapeMarkdown(value: string): string { + return value.replace(/([\\`*_{}[\]()#+.!|-])/g, '\\$1') +} + +function escapeHTML(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function escapeAttribute(value: string): string { + return escapeHTML(encodeURI(value)) +} + +function formatMessageHTML(value: string): string { + return escapeHTML(value).replaceAll('\n', '
') +} + +function indent(value: string, spaces: number): string { + const padding = ' '.repeat(spaces) + return value.split('\n').map(line => line ? `${padding}${line}` : line).join('\n') +} + +function progress(options: ExportOptions, message: string): void { + if (options.events) process.stderr.write(`${JSON.stringify({ event: 'export.progress', data: { message }, ts: new Date().toISOString() })}\n`) + if (!options.quiet) process.stderr.write(`${message}\n`) +} diff --git a/packages/cli/src/lib/ink/components.tsx b/packages/cli/src/lib/ink/components.tsx new file mode 100644 index 0000000..2e00a66 --- /dev/null +++ b/packages/cli/src/lib/ink/components.tsx @@ -0,0 +1,789 @@ +import React from 'react' +import { Box, Text } from 'ink' +import { bridgeColor, glyphs, senderColor, theme } from './theme.js' + +// OSC 8 hyperlink — modern terminals (iTerm, Ghostty, WezTerm, VS Code, etc.) +// render this as clickable; everything else ignores the escapes and shows the +// label text once. +const supportsHyperlinks = process.stdout.isTTY && process.env.TERM !== 'dumb' +const OSC8_START = ']8;;' +const OSC8_END = ']8;;' +const BEL = '' +const Hyperlink: React.FC<{ url: string; children?: React.ReactNode }> = ({ url, children }) => { + if (!supportsHyperlinks) return <>{children ?? url} + return {OSC8_START}{url}{BEL}{children ?? url}{OSC8_END} +} + +import { + attachmentLabel, + chatPreview, + compact, + formatBytes, + formatDuration, + formatTime, + isArchived, + isLowPriority, + isMuted, + isPinned, + messageText, + participantsSummary, + type RecordValue, + shortID, + stringValue, +} from './format.js' + +// ─── primitives ──────────────────────────────────────────────────────────────── + +export const Rail: React.FC<{ color: string }> = ({ color }) => ( + {glyphs.rail} +) + +export const Hairline: React.FC<{ width?: number }> = ({ width = 60 }) => ( + {glyphs.hairline.repeat(width)} +) + +export const Meta: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +export const Dim: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +export const KV: React.FC<{ label: string; value: React.ReactNode; tone?: 'normal' | 'muted' | 'dim'; width?: number }> = + ({ label, value, tone = 'normal', width = 12 }) => { + const valueColor = tone === 'dim' ? theme.subtle : tone === 'muted' ? theme.muted : theme.text + return ( + + {label.padEnd(width)} + {value} + + ) + } + +export const Pill: React.FC<{ color: string; children: React.ReactNode; muted?: boolean }> = ({ color, children, muted }) => ( + + {children} + +) + +export type Suggestion = { command: string; hint?: string } + +export const Suggestions: React.FC<{ suggestions?: Suggestion[]; label?: string }> = ({ suggestions, label = 'Try' }) => { + if (!suggestions?.length) return null + return ( + + {label} + {suggestions.map(s => ( + + {glyphs.arrow} + {s.command} + {s.hint && — {s.hint}} + + ))} + + ) +} + +// ─── empty / success / failure ──────────────────────────────────────────────── + +export const EmptyState: React.FC<{ title: string; subtitle?: string; suggestions?: Suggestion[] }> = ({ title, subtitle, suggestions }) => ( + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + +) + +export const SuccessLine: React.FC<{ message: string; detail?: string; entity?: React.ReactNode }> = ({ message, detail, entity }) => ( + + + {glyphs.check} + + {message} + {detail && {detail}} + + {entity && ( + + {entity} + + )} + +) + +export const FailureLine: React.FC<{ message: string; detail?: string }> = ({ message, detail }) => ( + + {glyphs.cross} + + {message} + {detail && {detail}} + +) + +export const SectionHeader: React.FC<{ label: string; count?: number }> = ({ label, count }) => ( + + {label.toUpperCase()} + {count != null && {count}} + +) + +// ─── domain rows ─────────────────────────────────────────────────────────────── + +type RowProps = { item: T } + +function chatRailColor(chat: RecordValue, unread: number, mentions: number): string { + if (mentions > 0) return theme.mention + if (unread > 0) return theme.primary + if (isPinned(chat)) return theme.warn + const tint = bridgeColor(stringValue(chat.network)) + return tint ?? theme.subtle +} + +export const ChatRow: React.FC> = ({ item: chat }) => { + const unread = Number(chat.unreadCount ?? 0) + const mentions = Number(chat.unreadMentionsCount ?? 0) + const muted = isMuted(chat) + const pinned = isPinned(chat) + const archived = isArchived(chat) + const lowPriority = isLowPriority(chat) + const preview = chatPreview(chat) + const lastActivity = formatTime(chat.lastActivity) + const network = stringValue(chat.network) + const tint = bridgeColor(network) + const railColor = chatRailColor(chat, unread, mentions) + + const titleNode = ( + 0} color={muted ? theme.muted : theme.text}>{String(chat.title)} + ) + + return ( + + + + + {titleNode} + {network && {network.toLowerCase()}} + {pinned && {glyphs.pin}} + {muted && {glyphs.mute}} + {archived && {glyphs.archive}} + {lowPriority && {glyphs.lowPriority}} + + {mentions > 0 && ( + {glyphs.mention}{mentions} + )} + {unread > 0 && ( + + {' '}{unread}{mentions > 0 ? '' : ` unread`} + + )} + {lastActivity && ( + {lastActivity} + )} + + {preview && ( + + {preview.kind === 'draft' ? ( + + draft + {preview.text ? ` ${preview.text}` : ''} + + ) : ( + + {preview.sender ? {preview.sender} : null} + {preview.text} + + )} + + )} + + {String(chat.id)} + + + ) +} + +export const ChatDetail: React.FC> = ({ item: chat }) => { + const unread = Number(chat.unreadCount ?? 0) + const mentions = Number(chat.unreadMentionsCount ?? 0) + const participants = chat.participants && typeof chat.participants === 'object' ? chat.participants as RecordValue : undefined + const items = Array.isArray(participants?.items) ? participants!.items as RecordValue[] : [] + const network = stringValue(chat.network) + const tint = bridgeColor(network) + + return ( + + + + + {String(chat.title)} + {network && {network.toLowerCase()}} + {isPinned(chat) && {glyphs.pin} pinned} + {isMuted(chat) && {glyphs.mute} muted} + {isArchived(chat) && {glyphs.archive} archived} + {isLowPriority(chat) && {glyphs.lowPriority} low-priority} + + {chat.type ? : null} + {chat.lastActivity ? : null} + {unread > 0 && 0 ? ` (${mentions} @)` : ''}`} />} + {participants && ( + + )} + {items.length > 0 && ( + + PARTICIPANTS + {items.slice(0, 20).map((p, i) => ( + + {glyphs.bullet} + + {stringValue(p.fullName) ?? stringValue(p.username) ?? shortID(String(p.id))} + {p.isSelf ? you : null} + + ))} + {items.length > 20 && … {items.length - 20} more} + + )} + + id + {String(chat.id)} + + {chat.accountID ? ( + + acct + {String(chat.accountID)} + + ) : null} + + ) +} + +export const MessageRow: React.FC> = ({ item: message }) => { + const mine = Boolean(message.isSender) + const senderID = stringValue(message.senderID) + const sender = stringValue(message.senderName) ?? (senderID ? shortID(senderID) : 'unknown') + const text = messageText(message) + const timestamp = formatTime(message.timestamp) + const status = typeof message.sendStatus === 'object' && message.sendStatus ? message.sendStatus as RecordValue : undefined + const showFailure = status?.status && status.status !== 'SUCCESS' + const attachments = Array.isArray(message.attachments) ? message.attachments : [] + const reactions = Array.isArray(message.reactions) ? message.reactions : [] + const railColor = mine ? theme.mine : senderColor(senderID) + + return ( + + + + + {mine ? 'you' : sender} + {message.type != null && message.type !== 'TEXT' ? ( + {String(message.type).toLowerCase()} + ) : null} + {message.isUnread ? ( + {glyphs.unread} unread + ) : null} + {message.isDeleted ? ( + deleted + ) : null} + {message.editedTimestamp ? ( + {glyphs.edited} edited + ) : null} + {attachments.length > 0 && ( + {glyphs.attachment} {attachmentLabel(attachments)} + )} + {reactions.length > 0 && ( + {glyphs.reaction}{reactions.length} + )} + + {timestamp && {timestamp}} + + {text && ( + + {text} + + )} + {showFailure ? ( + + {glyphs.cross} {String(status?.status)} + {status?.message ? {String(status.message)} : null} + + ) : null} + + {String(message.id)} + {message.chatID ? in {String(message.chatID)} : null} + + + ) +} + +export const UserRow: React.FC> = ({ item: user }) => { + const title = stringValue(user.fullName) + ?? stringValue(user.username) + ?? stringValue(user.email) + ?? stringValue(user.phoneNumber) + ?? String(user.id) + const handles = compact([ + user.username ? `@${user.username}` : undefined, + user.email ? String(user.email) : undefined, + user.phoneNumber ? String(user.phoneNumber) : undefined, + ]) + const rail = user.isSelf ? theme.mine : senderColor(stringValue(user.id)) + + return ( + + + + + {title} + {user.isSelf ? you : null} + {user.cannotMessage ? cannot message : null} + + {handles.length > 0 ? ( + + {handles.join(' ')} + + ) : null} + + {String(user.id)} + {user.accountID ? on {String(user.accountID)} : null} + + + ) +} + +export const AccountRow: React.FC> = ({ item: account }) => { + const id = account.accountID ?? account.id + const title = stringValue(account.displayName) + ?? stringValue(account.name) + ?? stringValue(account.network) + ?? String(id) + const network = stringValue(account.network) + const tint = bridgeColor(network) + const state = stringValue(account.state) + const stateLow = state?.toLowerCase() ?? '' + const connected = stateLow.includes('online') || stateLow.includes('connect') + const errored = stateLow.includes('error') || stateLow.includes('fail') + const stateTone = connected ? theme.mine : errored ? theme.danger : theme.warnAlt + const handles = compact([ + account.username ? `@${account.username}` : undefined, + stringValue(account.userID), + ]) + + return ( + + + + + {title} + {network && {network.toLowerCase()}} + {state && {connected ? glyphs.dot : errored ? glyphs.cross : glyphs.ring} {stateLow}} + + {handles.length > 0 && ( + + {handles.join(' ')} + + )} + + {String(id)} + {account.bridge ? bridge {String(account.bridge)} : null} + + + ) +} + +export const AssetRow: React.FC> = ({ item: asset }) => { + const title = stringValue(asset.fileName) + ?? stringValue(asset.uploadID) + ?? stringValue(asset.srcURL) + ?? 'asset' + const mime = stringValue(asset.mimeType) + const meta = compact([ + typeof asset.fileSize === 'number' ? formatBytes(asset.fileSize) : undefined, + asset.width && asset.height ? `${String(asset.width)}×${String(asset.height)}` : undefined, + typeof asset.duration === 'number' ? formatDuration(asset.duration) : undefined, + ]) + + return ( + + + + + {title} + {mime && {mime}} + {meta.length > 0 && {meta.join(' ')}} + + {asset.srcURL ? ( + + {String(asset.srcURL)} + + ) : null} + {asset.uploadID ? ( + + upload {String(asset.uploadID)} + + ) : null} + {asset.error ? ( + + {glyphs.cross} {String(asset.error)} + + ) : null} + + ) +} + +// ─── system / cards ──────────────────────────────────────────────────────────── + +export const InfoCard: React.FC<{ info: RecordValue }> = ({ info }) => { + const version = stringValue(info.version) + const platform = stringValue(info.platform) + const user = info.user && typeof info.user === 'object' ? info.user as RecordValue : undefined + const userName = user ? (stringValue(user.fullName) ?? stringValue(user.username) ?? stringValue(user.id)) : undefined + const endpoints = info.endpoints && typeof info.endpoints === 'object' ? info.endpoints as RecordValue : undefined + + return ( + + + + + Beeper Desktop + {version && v{version}} + {platform && {platform}} + + {userName && } + {endpoints && Object.entries(endpoints).map(([key, value]) => + typeof value === 'string' ? ( + + {key.padEnd(12)} + + {value} + + + ) : null, + )} + + ) +} + +export const DoctorCard: React.FC<{ checks: Array<{ ok: boolean; name: string; detail?: string }>; ok: boolean }> = ({ checks, ok }) => { + const longest = Math.max(0, ...checks.map(c => c.name.length)) + return ( + + + + + Doctor + + {ok ? ( + {glyphs.check} healthy + ) : ( + {glyphs.cross} attention needed + )} + + + {checks.map(check => ( + + {check.ok ? glyphs.check : glyphs.cross} + + {check.name.padEnd(longest + 2)} + {check.detail && {check.detail}} + + ))} + + {!ok && ( + + )} + + ) +} + +export const AuthStatusCard: React.FC<{ auth: RecordValue }> = ({ auth }) => { + const ok = Boolean(auth.authenticated) + const expires = auth.expiresAt ? formatTime(auth.expiresAt) ?? String(auth.expiresAt) : undefined + return ( + + + + + Authentication + + {ok ? ( + {glyphs.check} signed in + ) : ( + {glyphs.ring} signed out + )} + + {String(auth.baseURL)} + } /> + + {auth.clientID ? : null} + {auth.scope ? : null} + {expires ? : null} + {!ok && ( + + )} + + ) +} + +export const UserInfoCard: React.FC<{ user: RecordValue }> = ({ user }) => { + const name = stringValue(user.name) + ?? stringValue(user.preferred_username) + ?? stringValue(user.email) + ?? String(user.sub) + return ( + + + + + {name} + you + + {user.email ? : null} + {user.preferred_username ? : null} + {user.sub ? : null} + + ) +} + +// ─── auth flow / login wizard ────────────────────────────────────────────────── + +export const AuthCodeCard: React.FC<{ url: string; code?: string; hint?: string }> = ({ url, code, hint }) => ( + + + + + Sign in to Beeper + + {hint && ( + + {hint} + + )} + + {url} + + {code && ( + + code + {code} + + )} + +) + +export const AuthSignedIn: React.FC<{ as: string; detail?: string; saved?: boolean }> = ({ as, detail, saved }) => ( + + + {glyphs.check} + + Signed in + as + {as} + + {detail && {detail}} + {saved === false && token not saved (--no-save)} + +) + +// ─── config / commands manifest ──────────────────────────────────────────────── + +function maskToken(value: string): string { + if (value.length <= 12) return '••••' + return `${value.slice(0, 6)}…${value.slice(-4)}` +} + +function renderConfigValue(key: string, value: unknown): React.ReactNode { + if (value == null) return + if (typeof value !== 'object') { + if (/token|secret|key/i.test(key) && typeof value === 'string') { + return {maskToken(value)} + } + return {String(value)} + } + const record = value as RecordValue + const inner = Object.entries(record).filter(([, v]) => v != null) + if (!inner.length) return {'{}'} + const width = Math.max(...inner.map(([k]) => k.length)) + 2 + return ( + + {inner.map(([k, v]) => ( + + {k.padEnd(width)} + {/^(accesstoken|token|secret|key)$/i.test(k) && typeof v === 'string' + ? {maskToken(v)} + : {typeof v === 'object' ? JSON.stringify(v) : String(v)}} + + ))} + + ) +} + +export const ConfigView: React.FC<{ data: RecordValue }> = ({ data }) => { + const entries = Object.entries(data).filter(([, v]) => v != null) + if (!entries.length) { + return ', hint: 'override the API endpoint' }, + ]} /> + } + const width = Math.max(...entries.map(([k]) => k.length)) + 2 + return ( + + + + + Config + + {entries.map(([key, value]) => ( + + + {key.padEnd(width)} + {typeof value !== 'object' || value == null ? renderConfigValue(key, value) : null} + + {typeof value === 'object' && value != null && ( + + {renderConfigValue(key, value)} + + )} + + ))} + + ) +} + +type ManifestItem = { command: string; description: string; group?: string } + +export const CommandsView: React.FC<{ items: ManifestItem[]; title?: string; intro?: string[] }> = ({ items, title = 'Commands', intro }) => { + const groups = new Map() + for (const item of items) { + const g = item.group ?? 'Common' + if (!groups.has(g)) groups.set(g, []) + groups.get(g)!.push(item) + } + // Hard cap so descriptions stay aligned. Anything longer drops its description onto the next indented line. + const NAME_WIDTH = 32 + return ( + + + + + {title} + + {intro?.map((line, i) => ( + + {line} + + ))} + {[...groups.entries()].map(([group, list]) => ( + + {group.toUpperCase()} + {list.map(item => { + const fits = item.command.length <= NAME_WIDTH + return fits ? ( + + {item.command.padEnd(NAME_WIDTH + 2)} + {item.description} + + ) : ( + + {item.command} + {item.description} + + ) + })} + + ))} + + ) +} + +// ─── stream feed (used by `watch`) ───────────────────────────────────────────── + +export type StreamEvent = { type: string; chatID?: string; messageID?: string; ts?: string } + +const eventTone: Record = { + 'message.new': theme.primary, + 'message.send': theme.mine, + 'message.edit': theme.warn, + 'message.delete': theme.danger, + 'chat.update': theme.cyan, + 'chat.read': theme.subtle, + 'chat.typing': theme.magenta, + 'reaction.add': theme.magenta, + 'reaction.remove': theme.subtle, +} + +export const StreamEventLine: React.FC<{ event: StreamEvent; index: number }> = ({ event, index }) => { + const color = eventTone[event.type] ?? theme.primary + const time = event.ts ? formatTime(event.ts) : undefined + return ( + + {String(index).padStart(4)} + {glyphs.dot} + + {event.type} + {event.chatID && in {event.chatID}} + {event.messageID && msg {event.messageID}} + {time && {time}} + + ) +} + +export const StreamHeader: React.FC<{ subscribed: string[]; baseURL: string; connected: boolean }> = ({ subscribed, baseURL, connected }) => { + const label = subscribed.length === 1 && subscribed[0] === '*' ? 'all chats' : `${subscribed.length} chat${subscribed.length === 1 ? '' : 's'}` + return ( + + + + + Watching events + {label} + {!connected && connecting…} + + + {baseURL} · press ⌃C to stop + + + ) +} + +// ─── generic fallback ───────────────────────────────────────────────────────── + +export const GenericRow: React.FC> = ({ item }) => { + const title = item.title ?? item.displayName ?? item.name ?? item.id ?? item.messageID + const scalarEntries = Object.entries(item).filter(([key, value]) => { + if (value == null) return false + if (key === 'title' || key === 'displayName' || key === 'name') return false + if (typeof value === 'object') return false + return true + }) + const width = scalarEntries.length ? Math.max(...scalarEntries.map(([k]) => k.length)) + 2 : 0 + return ( + + {title != null && ( + + + + {String(title)} + + )} + {scalarEntries.map(([key, value]) => ( + + {key.padEnd(width)} + {String(value)} + + ))} + + ) +} diff --git a/packages/cli/src/lib/ink/format.ts b/packages/cli/src/lib/ink/format.ts new file mode 100644 index 0000000..64102c8 --- /dev/null +++ b/packages/cli/src/lib/ink/format.ts @@ -0,0 +1,121 @@ +export type RecordValue = Record + +export function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined +} + +export function compact(values: unknown[]): string[] { + return values.filter((value): value is string => typeof value === 'string' && value.length > 0) +} + +export function formatTime(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return String(value) + const now = Date.now() + const diffMs = now - date.valueOf() + const abs = Math.abs(diffMs) + const suffix = diffMs >= 0 ? 'ago' : 'from now' + if (abs < 60_000) return 'just now' + if (abs < 3_600_000) return `${Math.round(abs / 60_000)}m ${suffix}` + if (abs < 86_400_000) return `${Math.round(abs / 3_600_000)}h ${suffix}` + if (abs < 604_800_000) return `${Math.round(abs / 86_400_000)}d ${suffix}` + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric', + }) +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +export function formatDuration(ms: number): string { + const seconds = Math.round(ms / 1000) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const remainder = seconds % 60 + return `${minutes}m ${remainder}s` +} + +export function shortID(value: string): string { + const local = value.split(':')[0] + return local?.replace(/^@/, '') || value +} + +export function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max - 1)}…` +} + +export function cleanText(value: unknown): string | undefined { + const raw = stringValue(value) + if (!raw) return undefined + const text = raw + .replace(/<[^>]*>/g, ' ') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/[*_`~>#]/g, '') + .replace(/\s+/g, ' ') + .trim() + return text ? truncate(text, 240) : undefined +} + +export function attachmentLabel(attachments: unknown[]): string { + const labels = attachments.map(item => { + if (!item || typeof item !== 'object') return 'attachment' + const attachment = item as RecordValue + return stringValue(attachment.fileName) ?? stringValue(attachment.type) ?? 'attachment' + }) + return labels.length === 1 ? labels[0]! : `${labels.length} attachments` +} + +export function messageText(message: RecordValue): string | undefined { + if (message.isDeleted) return 'deleted message' + const text = cleanText(message.text) + if (text) return text + if (Array.isArray(message.attachments) && message.attachments.length > 0) return attachmentLabel(message.attachments) + if (Array.isArray(message.links) && message.links.length > 0) return `${message.links.length} link${message.links.length === 1 ? '' : 's'}` + return undefined +} + +export function chatPreview(chat: RecordValue): { kind: 'draft' | 'message'; sender?: string; text: string } | undefined { + if (chat.draft && typeof chat.draft === 'object') { + const draft = chat.draft as RecordValue + const text = cleanText(draft.text) ?? '' + return { kind: 'draft', text } + } + const lastMessage = (chat.latestMessage ?? chat.lastMessage) as RecordValue | undefined + if (lastMessage && typeof lastMessage === 'object') { + const sender = stringValue(lastMessage.senderName) ?? (lastMessage.isSender ? 'you' : undefined) + const text = messageText(lastMessage) ?? '' + if (!text && !sender) return undefined + return { kind: 'message', sender, text } + } + return undefined +} + +export function participantsSummary(participants: RecordValue): string | undefined { + const total = participants.total + const items = Array.isArray(participants.items) ? participants.items : [] + if (typeof total === 'number') return `${total} participant${total === 1 ? '' : 's'}` + if (items.length) return `${items.length} participant${items.length === 1 ? '' : 's'}` + return undefined +} + +export function isPinned(chat: RecordValue): boolean { + return Boolean(chat.isPinned ?? chat.pinned) +} + +export function isMuted(chat: RecordValue): boolean { + return Boolean(chat.isMuted ?? chat.muted) +} + +export function isArchived(chat: RecordValue): boolean { + return Boolean(chat.isArchived ?? chat.archived) +} + +export function isLowPriority(chat: RecordValue): boolean { + return Boolean(chat.isLowPriority ?? chat.lowPriority) +} diff --git a/packages/cli/src/lib/ink/render.tsx b/packages/cli/src/lib/ink/render.tsx new file mode 100644 index 0000000..5acd229 --- /dev/null +++ b/packages/cli/src/lib/ink/render.tsx @@ -0,0 +1,308 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { Box, render as inkRender, Static, Text, useApp, useInput } from 'ink' +import Spinner from 'ink-spinner' +import { + AccountRow, + AssetRow, + AuthStatusCard, + ChatDetail, + ChatRow, + CommandsView, + ConfigView, + DoctorCard, + EmptyState, + FailureLine, + GenericRow, + InfoCard, + MessageRow, + SectionHeader, + type StreamEvent, + StreamEventLine, + StreamHeader, + type Suggestion, + SuccessLine, + UserInfoCard, + UserRow, +} from './components.js' +import type { RecordValue } from './format.js' +import { glyphs, theme } from './theme.js' + +const App: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { exit } = useApp() + useEffect(() => { + setTimeout(() => exit(), 0) + }, [exit]) + return <>{children} +} + +async function renderOnce(node: React.ReactNode): Promise { + const instance = inkRender({node}, { exitOnCtrlC: false, patchConsole: false }) + await instance.waitUntilExit().catch(() => undefined) +} + +type Kind = + | 'chat' | 'chatDetail' | 'message' | 'user' | 'account' | 'asset' + | 'info' | 'doctor' | 'auth' | 'oauth' | 'search' + | 'commandManifest' | 'config' + | 'generic' + +function detectKind(record: RecordValue): Kind { + if (typeof record.id === 'string' && typeof record.accountID === 'string' && typeof record.title === 'string' && typeof record.unreadCount === 'number') { + if (record.participants && typeof record.participants === 'object') return 'chatDetail' + return 'chat' + } + if (typeof record.id === 'string' && typeof record.chatID === 'string' && typeof record.senderID === 'string' && typeof record.timestamp === 'string') return 'message' + if (typeof record.id === 'string' && (typeof record.fullName === 'string' || typeof record.username === 'string' || typeof record.email === 'string' || typeof record.phoneNumber === 'string')) return 'user' + if (typeof (record.accountID ?? record.id) === 'string' && (typeof record.network === 'string' || typeof record.bridge === 'string' || typeof record.displayName === 'string')) return 'account' + if (typeof record.uploadID === 'string' || typeof record.srcURL === 'string') return 'asset' + if (typeof record.version === 'string' && typeof record.endpoints === 'object') return 'info' + if (typeof record.ok === 'boolean' && Array.isArray(record.checks)) return 'doctor' + if (typeof record.authenticated === 'boolean' && typeof record.baseURL === 'string') return 'auth' + if (typeof record.sub === 'string' && (typeof record.email === 'string' || typeof record.name === 'string' || typeof record.preferred_username === 'string')) return 'oauth' + if (Array.isArray(record.chats) && Array.isArray(record.messages)) return 'search' + return 'generic' +} + +function isManifestList(items: unknown[]): items is Array<{ command: string; description: string; group?: string }> { + return items.every(item => + item != null + && typeof item === 'object' + && typeof (item as Record).command === 'string' + && typeof (item as Record).description === 'string', + ) +} + +function rowFor(kind: Kind, item: RecordValue, key: number): React.ReactNode { + switch (kind) { + case 'chat': return + case 'chatDetail': return + case 'message': return + case 'user': return + case 'account': return + case 'asset': return + default: return + } +} + +export async function renderList(items: RecordValue[], empty?: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { + if (!items.length) { + if (empty) await renderOnce() + return + } + if (isManifestList(items)) { + await renderOnce() + return + } + const kind = detectKind(items[0]!) + await renderOnce( + + {items.map((item, index) => rowFor(kind === 'chatDetail' ? 'chat' : kind, item, index))} + , + ) +} + +export async function renderValue(value: unknown): Promise { + if (Array.isArray(value)) { + await renderList(value as RecordValue[]) + return + } + if (!value || typeof value !== 'object') { + if (value === undefined) return + process.stdout.write(`${String(value)}\n`) + return + } + const record = value as RecordValue + const kind = detectKind(record) + switch (kind) { + case 'info': + await renderOnce() + return + case 'doctor': { + const checks = (record.checks as Array<{ ok: boolean; name: string; detail?: string }>) ?? [] + await renderOnce() + return + } + case 'auth': + await renderOnce() + return + case 'oauth': + await renderOnce() + return + case 'search': { + const chats = Array.isArray(record.chats) ? record.chats as RecordValue[] : [] + const messages = Array.isArray(record.messages) ? record.messages as RecordValue[] : [] + if (!chats.length && !messages.length) { + await renderOnce( + "', hint: 'narrow with filters' }, + ]} + />, + ) + return + } + await renderOnce( + + {chats.length > 0 && } + {chats.map((item, index) => )} + {messages.length > 0 && } + {messages.map((item, index) => )} + , + ) + return + } + case 'chat': + case 'chatDetail': + await renderOnce() + return + case 'message': + await renderOnce() + return + case 'user': + await renderOnce() + return + case 'account': + await renderOnce() + return + case 'asset': + await renderOnce() + return + default: + await renderOnce() + } +} + +export async function renderEmptyState(opts: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { + await renderOnce() +} + +export async function renderSuccess(opts: { message: string; detail?: string; entity?: unknown }): Promise { + let entityNode: React.ReactNode = null + if (opts.entity && typeof opts.entity === 'object') { + const record = opts.entity as RecordValue + const kind = detectKind(record) + entityNode = rowFor(kind === 'chatDetail' ? 'chat' : kind, record, 0) + } + await renderOnce() +} + +export async function renderFailure(opts: { message: string; detail?: string }): Promise { + await renderOnce() +} + +export async function renderConfig(data: RecordValue): Promise { + await renderOnce() +} + +export async function renderCommands(items: Array<{ command: string; description: string; group?: string }>, opts?: { title?: string; intro?: string[] }): Promise { + await renderOnce() +} + +// ─── streaming render (used by `watch`) ─────────────────────────────────────── + +export type StreamController = { + push(event: StreamEvent): void + setConnected(connected: boolean): void + setStatus(status: string | undefined): void + close(): Promise + done: Promise +} + +type StreamState = { + events: StreamEvent[] + connected: boolean + status: string | undefined +} + +type StreamProps = { + initialState: StreamState + baseURL: string + subscribed: string[] + bind: (api: { update: (next: StreamState) => void; exit: () => void; onInterrupt: (fn: () => void) => void }) => void +} + +const StreamView: React.FC = ({ initialState, baseURL, subscribed, bind }) => { + const [state, setState] = useState(initialState) + const { exit } = useApp() + const interruptRef = useRef<(() => void) | undefined>(undefined) + + useEffect(() => { + bind({ + update: setState, + exit: () => exit(), + onInterrupt: fn => { interruptRef.current = fn }, + }) + }, [bind, exit]) + + useInput((input, key) => { + if (key.ctrl && input === 'c') { + interruptRef.current?.() + } + }) + + return ( + + + ({ event, index: index + 1 }))}> + {({ event, index }) => } + + {!state.connected && state.status && ( + + + {state.status} + + )} + + ) +} + +export function renderStream(opts: { baseURL: string; subscribed: string[] }): StreamController { + const initial: StreamState = { events: [], connected: false, status: 'connecting' } + let current = initial + let api: { update: (next: StreamState) => void; exit: () => void; onInterrupt: (fn: () => void) => void } | undefined + const interruptHandlers: Array<() => void> = [] + + const setState = (next: StreamState): void => { + current = next + api?.update(next) + } + + const instance = inkRender( + { + api = hooks + hooks.onInterrupt(() => { + for (const fn of interruptHandlers) fn() + }) + }} + />, + { exitOnCtrlC: false, patchConsole: false }, + ) + + return { + push(event) { + setState({ ...current, events: [...current.events, event] }) + }, + setConnected(connected) { + setState({ ...current, connected, status: connected ? undefined : current.status }) + }, + setStatus(status) { + setState({ ...current, status }) + }, + async close() { + api?.exit() + await instance.waitUntilExit().catch(() => undefined) + }, + get done() { + return instance.waitUntilExit().then(() => undefined) + }, + } +} + +export type { Suggestion, StreamEvent } diff --git a/packages/cli/src/lib/ink/spinner.tsx b/packages/cli/src/lib/ink/spinner.tsx new file mode 100644 index 0000000..0956e26 --- /dev/null +++ b/packages/cli/src/lib/ink/spinner.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react' +import { Box, render, Text, useApp } from 'ink' +import Spinner from 'ink-spinner' +import { glyphs, theme } from './theme.js' + +type State = + | { kind: 'spinning'; label: string } + | { kind: 'succeed'; label: string } + | { kind: 'fail'; label: string } + +const externalListeners = new Map void>() + +type SpinnerLineProps = { + id: symbol + initial: State +} + +const SpinnerLine: React.FC = ({ id, initial }) => { + const [state, setState] = useState(initial) + const { exit } = useApp() + + useEffect(() => { + externalListeners.set(id, value => { + setState(value) + if (value.kind !== 'spinning') { + setTimeout(() => exit(), 0) + } + }) + return () => { externalListeners.delete(id) } + }, [id, exit]) + + if (state.kind === 'spinning') { + return ( + + + {state.label} + + ) + } + if (state.kind === 'succeed') { + return ( + + {glyphs.check} + {state.label} + + ) + } + return ( + + {glyphs.cross} + {state.label} + + ) +} + +export type SpinnerHandle = { + update(label: string): void + succeed(label?: string): Promise + fail(label?: string): Promise + stop(): Promise +} + +export function createInkSpinner(initialLabel: string, stream: NodeJS.WriteStream = process.stderr): SpinnerHandle { + const id = Symbol('spinner') + let currentLabel = initialLabel + let finished = false + + const instance = render( + , + { stdout: stream as unknown as NodeJS.WriteStream, exitOnCtrlC: false, patchConsole: false }, + ) + + const finish = (state: State): Promise => { + if (finished) return Promise.resolve() + finished = true + const listener = externalListeners.get(id) + if (listener) listener(state) + else instance.unmount() + return instance.waitUntilExit().then(() => undefined).catch(() => undefined) + } + + return { + update(label) { + if (finished) return + currentLabel = label + const listener = externalListeners.get(id) + listener?.({ kind: 'spinning', label }) + }, + succeed(label) { + return finish({ kind: 'succeed', label: label ?? currentLabel }) + }, + fail(label) { + return finish({ kind: 'fail', label: label ?? currentLabel }) + }, + stop() { + if (finished) return Promise.resolve() + finished = true + instance.unmount() + return instance.waitUntilExit().then(() => undefined).catch(() => undefined) + }, + } +} + +export async function withInkSpinner( + label: string, + fn: () => Promise, + options?: { done?: (value: T) => string | undefined; stream?: NodeJS.WriteStream }, +): Promise { + const stream = options?.stream ?? process.stderr + const spinner = createInkSpinner(label, stream) + try { + const value = await fn() + const doneLabel = options?.done?.(value) + if (doneLabel) await spinner.succeed(doneLabel) + else await spinner.stop() + return value + } catch (error) { + await spinner.fail(label) + throw error + } +} diff --git a/packages/cli/src/lib/ink/theme.ts b/packages/cli/src/lib/ink/theme.ts new file mode 100644 index 0000000..32986f4 --- /dev/null +++ b/packages/cli/src/lib/ink/theme.ts @@ -0,0 +1,107 @@ +// Beeper Desktop dark-theme palette → Ink hex strings. +// Mirrors src/renderer/scss/tokens/colors{,_dark}.scss in the desktop app. +export const theme = { + // Brand / accent + primary: '#2561fb', + primaryDim: '#1b43aa', + primaryGlow: '#5a86ff', + link: '#5cadff', + + // Text + text: '#ededed', // --color-text-neutrals (dark) + muted: '#adadad', // --color-text-neutrals-weak + subtle: '#7e7e7e', // --color-text-neutrals-subtle + hairline: '#343434', // --color-border-neutrals + + // Surface (only used when we explicitly fill — Ink defaults to the user's term bg) + surface: '#000000', + surfaceAlt: '#1c1c1c', + surfaceHover: '#232323', + + // Semantic + mine: '#4cc38a', // --color-text-success (dark) + online: '#1ec843', + warn: '#f6ce46', // --color-pin + warnAlt: '#f1a10d', + danger: '#ff6369', // --color-text-error (dark) + magenta: '#912ce1', // --color-mute (dark) + cyan: '#00c2d7', + + // Highlights + mention: '#5a86ff', + draft: '#f6ce46', +} as const + +// Per-bridge "iconBackground" tints from beeper/desktop bundled-platforms/bridges/*/info.ts. +// Used to tint the rail/badge on chat & account rows so a glance reveals the network. +const bridgeTint: Record = { + imessage: '#19BA3B', + imessagecloud: '#19BA3B', + imessagego: '#19BA3B', + androidsms: '#19BA3B', + whatsapp: '#48C95F', + telegram: '#2EA4DB', + signal: '#3542FF', + discord: '#5865F2', + linkedin: '#086CE1', + twitter: '#202124', + x: '#202124', + bluesky: '#549B57', + beeper: '#0D4FFB', + beeperai: '#0D4FFB', + ai: '#0D4FFB', + instagram: '#d833ca', + facebook: '#0d4ffb', + messenger: '#0d4ffb', + googlechat: '#1ab5a2', + googlevoice: '#0eb3ef', + googlemessages: '#19ba3b', + slack: '#9745ea', + matrix: '#0eb3ef', +} + +export function bridgeColor(network: string | undefined | null): string | undefined { + if (!network) return undefined + const key = String(network).toLowerCase().replace(/[^a-z0-9]/g, '') + return bridgeTint[key] +} + +// Group-chat sender name palette (8-color rotation) from the desktop dark theme. +const groupSenderPalette = [ + '#63c174', '#f1a10d', '#f76190', '#bf7af0', + '#00c2d7', '#f65cb6', '#849dff', '#0ac5b3', +] as const + +export function senderColor(id: string | undefined | null): string { + if (!id) return theme.text + let hash = 0 + for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0 + const palette = groupSenderPalette + return palette[Math.abs(hash) % palette.length]! +} + +// Glyphs — every visual cue we use sits in this map so a single audit covers them. +export const glyphs = { + rail: '▎', // narrow left-edge bar; replaces avatars + arrow: '›', + arrowR: '→', + arrowL: '←', + check: '✓', + cross: '✗', + dot: '●', + ring: '○', + pin: '★', + mute: '◐', + archive: '◇', + reaction: '♥', + attachment: '📎', + reply: '↳', + edited: '✎', + bullet: '·', + lowPriority: '◌', + mention: '@', + draft: '✎', + unread: '●', + spinner: '◇', + hairline: '─', +} as const diff --git a/packages/cli/src/lib/manifest.ts b/packages/cli/src/lib/manifest.ts new file mode 100644 index 0000000..5169764 --- /dev/null +++ b/packages/cli/src/lib/manifest.ts @@ -0,0 +1,75 @@ +import { apiCopy } from './copy.js' + +export const commandManifest = [ + { command: 'accounts', description: apiCopy.accounts.list }, + { command: 'accounts add', description: 'Add a Beeper account' }, + { command: 'api get', description: 'Make an authenticated raw GET request' }, + { command: 'api post', description: 'Make an authenticated raw POST request' }, + { command: 'app e2ee recovery-code mark-backed-up', description: 'Mark the recovery key as saved' }, + { command: 'app e2ee recovery-code reset begin', description: 'Create a new recovery key' }, + { command: 'app e2ee recovery-code reset confirm', description: 'Confirm a newly created recovery key' }, + { command: 'app e2ee recovery-code verify', description: 'Unlock encrypted messages with a recovery key' }, + { command: 'app e2ee verification accept', description: 'Accept a device verification request' }, + { command: 'app e2ee verification cancel', description: 'Cancel device verification' }, + { command: 'app e2ee verification qr confirm-scanned', description: 'Confirm another device scanned this QR code' }, + { command: 'app e2ee verification qr scan', description: 'Submit a scanned verification QR payload' }, + { command: 'app e2ee verification sas confirm', description: 'Confirm matching emoji verification' }, + { command: 'app e2ee verification sas start', description: 'Start emoji verification' }, + { command: 'app e2ee verification start', description: 'Start device verification' }, + { command: 'app status', description: 'Show Beeper app login and encrypted messaging state' }, + { command: 'archive', description: apiCopy.chats.archive }, + { command: 'assets download', description: apiCopy.assets.download }, + { command: 'assets upload', description: apiCopy.assets.upload }, + { command: 'auth status', description: 'Show local auth state' }, + { command: 'avatar', description: 'Set or clear a group chat avatar' }, + { command: 'chat', description: apiCopy.chats.retrieve }, + { command: 'chats', description: apiCopy.chats.list }, + { command: 'chats search', description: apiCopy.chats.search }, + { command: 'clear-draft', description: 'Clear a chat draft' }, + { command: 'commands', description: 'Print the command manifest' }, + { command: 'config get', description: 'Print CLI configuration' }, + { command: 'config path', description: 'Print the config file path' }, + { command: 'config reset', description: 'Reset CLI configuration' }, + { command: 'config set', description: 'Set CLI configuration' }, + { command: 'contacts list', description: apiCopy.contacts.list }, + { command: 'contacts search', description: apiCopy.contacts.search }, + { command: 'create-chat', description: apiCopy.chats.create }, + { command: 'current-user', description: 'Show the OAuth userinfo response' }, + { command: 'delete-message', description: apiCopy.messages.delete }, + { command: 'description', description: 'Set or clear a group chat description' }, + { command: 'doctor', description: 'Check Desktop API readiness' }, + { command: 'draft', description: 'Set a chat draft' }, + { command: 'edit', description: apiCopy.messages.update }, + { command: 'export', description: 'Export accounts, chats, messages, Markdown transcripts, and attachments' }, + { command: 'focus', description: 'Focus Beeper Desktop or one chat' }, + { command: 'inbox', description: 'Move a chat to the primary inbox' }, + { command: 'llm', description: 'Print compact CLI help for agents' }, + { command: 'login', description: 'Authenticate with Beeper Desktop' }, + { command: 'logout', description: 'Remove local credentials' }, + { command: 'low-priority', description: 'Move a chat to Low Priority' }, + { command: 'message', description: apiCopy.messages.retrieve }, + { command: 'message-expiry', description: 'Set or clear disappearing-message expiry' }, + { command: 'messages', description: apiCopy.messages.list }, + { command: 'messages search', description: apiCopy.messages.search }, + { command: 'mute', description: 'Mute a chat' }, + { command: 'notify-anyway', description: apiCopy.chats.notifyAnyway }, + { command: 'pin', description: 'Pin a chat' }, + { command: 'react', description: apiCopy.reactions.add }, + { command: 'read', description: apiCopy.chats.markRead }, + { command: 'remind', description: apiCopy.reminders.create }, + { command: 'rpc', description: 'Run JSONL command RPC over stdin/stdout' }, + { command: 'search', description: 'Search chats and messages' }, + { command: 'send file', description: 'Send a file to a chat' }, + { command: 'send text', description: apiCopy.messages.send }, + { command: 'shell', description: 'Run an interactive Beeper CLI shell' }, + { command: 'start-chat', description: apiCopy.chats.start }, + { command: 'status', description: 'Show Desktop API server info' }, + { command: 'title', description: 'Set a custom chat title' }, + { command: 'unarchive', description: apiCopy.chats.archive }, + { command: 'unmute', description: 'Unmute a chat' }, + { command: 'unpin', description: 'Unpin a chat' }, + { command: 'unreact', description: apiCopy.reactions.delete }, + { command: 'unread', description: apiCopy.chats.markUnread }, + { command: 'unremind', description: apiCopy.reminders.delete }, + { command: 'watch', description: 'Stream Desktop API WebSocket events' }, +] diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts new file mode 100644 index 0000000..7f5f50b --- /dev/null +++ b/packages/cli/src/lib/oauth.ts @@ -0,0 +1,168 @@ +import { createServer } from 'node:http' +import { AddressInfo } from 'node:net' +import { spawn } from 'node:child_process' +import { createPKCEPair, createState } from './pkce.js' +import { updateConfig } from './config.js' + +export type OAuthLoginOptions = { + baseURL: string + clientName: string + openBrowser: boolean + save?: boolean + scope: string + timeoutMs?: number +} + +type RegisterResponse = { + authorization_endpoint?: string + client_id: string + token_endpoint?: string +} + +type TokenResponse = { + access_token: string + expires_in?: number + scope?: string + token_type: 'Bearer' +} + +export async function loginWithPKCE(options: OAuthLoginOptions): Promise { + const callback = await createCallbackServer(options.timeoutMs ?? 120_000) + try { + const redirectURI = `http://127.0.0.1:${callback.port}/callback` + const registered = await registerClient(options.baseURL, options.clientName, redirectURI, options.scope) + const pkce = createPKCEPair() + const state = createState() + const authorizeURL = new URL(registered.authorization_endpoint ?? '/oauth/authorize', options.baseURL) + authorizeURL.searchParams.set('client_id', registered.client_id) + authorizeURL.searchParams.set('redirect_uri', redirectURI) + authorizeURL.searchParams.set('response_type', 'code') + authorizeURL.searchParams.set('scope', options.scope) + authorizeURL.searchParams.set('state', state) + authorizeURL.searchParams.set('code_challenge', pkce.codeChallenge) + authorizeURL.searchParams.set('code_challenge_method', 'S256') + + if (options.openBrowser) openExternal(authorizeURL.toString()) + else process.stderr.write(`Open this URL to authenticate:\n${authorizeURL.toString()}\n`) + + const result = await callback.waitForCode + if (result.state !== state) throw new Error('OAuth state mismatch.') + if (result.error) throw new Error(`OAuth authorization failed: ${result.error}`) + if (!result.code) throw new Error('OAuth callback did not include an authorization code.') + + const token = await exchangeToken( + registered.token_endpoint ?? new URL('/oauth/token', options.baseURL).toString(), + registered.client_id, + result.code, + pkce.codeVerifier, + redirectURI, + ) + + if (options.save !== false) { + await updateConfig(config => ({ + ...config, + baseURL: options.baseURL, + auth: { + accessToken: token.access_token, + clientID: registered.client_id, + expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined, + scope: token.scope, + tokenType: token.token_type, + }, + })) + } + + return { ...token, clientID: registered.client_id } + } finally { + await callback.close() + } +} + +async function registerClient(baseURL: string, clientName: string, redirectURI: string, scope: string): Promise { + const response = await fetch(new URL('/oauth/register', baseURL), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: clientName, + grant_types: ['authorization_code'], + response_types: ['code'], + redirect_uris: [redirectURI], + scope, + token_endpoint_auth_method: 'none', + }), + }) + if (!response.ok) throw new Error(`OAuth client registration failed: ${response.status} ${await response.text()}`) + return response.json() as Promise +} + +async function exchangeToken(tokenEndpoint: string, clientID: string, code: string, codeVerifier: string, redirectURI: string): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientID, + code, + code_verifier: codeVerifier, + redirect_uri: redirectURI, + }) + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!response.ok) throw new Error(`OAuth token exchange failed: ${response.status} ${await response.text()}`) + return response.json() as Promise +} + +function openExternal(url: string): void { + const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open' + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url] + const child = spawn(command, args, { detached: true, stdio: 'ignore' }) + child.unref() +} + +function createCallbackServer(timeoutMs: number): Promise<{ + close: () => Promise + port: number + waitForCode: Promise<{ code?: string; error?: string; state?: string }> +}> { + return new Promise((resolve, reject) => { + let settle!: (value: { code?: string; error?: string; state?: string }) => void + let fail!: (error: Error) => void + const waitForCode = new Promise<{ code?: string; error?: string; state?: string }>((res, rej) => { + settle = res + fail = rej + }) + + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== '/callback') { + res.writeHead(404).end('Not found') + return + } + const code = url.searchParams.get('code') ?? undefined + const error = url.searchParams.get('error') ?? undefined + const state = url.searchParams.get('state') ?? undefined + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('Beeper CLI

You can close this tab and return to the terminal.

') + settle({ code, error, state }) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + } + }) + + const timeout = setTimeout(() => fail(new Error('Timed out waiting for OAuth callback.')), timeoutMs) + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo + resolve({ + close: () => + new Promise(closeResolve => { + clearTimeout(timeout) + server.close(() => closeResolve()) + }), + port: address.port, + waitForCode, + }) + }) + }) +} diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts new file mode 100644 index 0000000..fdae892 --- /dev/null +++ b/packages/cli/src/lib/output.ts @@ -0,0 +1,121 @@ +import type { StreamController, Suggestion } from './ink/render.js' + +export type OutputFormat = 'human' | 'json' | 'jsonl' +type RecordValue = Record + +const writeJSON = (value: unknown, format: 'json' | 'jsonl'): void => { + process.stdout.write(`${JSON.stringify(value, null, format === 'json' ? 2 : 0)}\n`) +} + +const loadInk = () => import('./ink/render.js') + +export async function printData(value: unknown, format: OutputFormat): Promise { + if (format === 'json') { + writeJSON(value, 'json') + return + } + if (format === 'jsonl') { + if (Array.isArray(value)) { + for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`) + return + } + process.stdout.write(`${JSON.stringify(value)}\n`) + return + } + const { renderValue } = await loadInk() + await renderValue(value) +} + +export async function printList( + value: unknown[], + format: OutputFormat, + empty: { title: string; subtitle?: string; suggestions?: Suggestion[] }, +): Promise { + if (format === 'json') { + writeJSON(value, 'json') + return + } + if (format === 'jsonl') { + for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`) + return + } + const { renderList } = await loadInk() + await renderList(value as RecordValue[], empty) +} + +export async function collectPage(iterable: AsyncIterable, limit?: number): Promise { + if (limit !== undefined && limit <= 0) return [] + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + if (limit !== undefined && items.length >= limit) break + } + return items +} + +export function printIDs(values: unknown[]): void { + for (const value of values) { + if (!value || typeof value !== 'object') continue + const record = value as Record + const id = record.id ?? record.chatID ?? record.messageID + if (id) process.stdout.write(`${String(id)}\n`) + } +} + +export async function emptyState(opts: { title: string; subtitle?: string; suggestions?: Suggestion[] }): Promise { + const { renderEmptyState } = await loadInk() + await renderEmptyState(opts) +} + +export async function printSuccess( + opts: { message: string; detail?: string; entity?: unknown; data?: Record }, + format: OutputFormat, +): Promise { + if (format === 'json' || format === 'jsonl') { + writeJSON({ ok: true, message: opts.message, ...(opts.data ?? {}) }, format) + return + } + const { renderSuccess } = await loadInk() + await renderSuccess(opts) +} + +export async function printFailure( + opts: { message: string; detail?: string; data?: Record }, + format: OutputFormat, +): Promise { + if (format === 'json' || format === 'jsonl') { + writeJSON({ ok: false, message: opts.message, ...(opts.data ?? {}) }, format) + return + } + const { renderFailure } = await loadInk() + await renderFailure(opts) +} + +export async function printConfig(data: Record, format: OutputFormat): Promise { + if (format === 'json' || format === 'jsonl') { + writeJSON(data, format) + return + } + const { renderConfig } = await loadInk() + await renderConfig(data) +} + +export async function printCommands( + items: Array<{ command: string; description: string; group?: string }>, + format: OutputFormat, + opts?: { title?: string; intro?: string[] }, +): Promise { + if (format === 'json' || format === 'jsonl') { + writeJSON(items, format) + return + } + const { renderCommands } = await loadInk() + await renderCommands(items, opts) +} + +export async function startStream(opts: { baseURL: string; subscribed: string[] }): Promise { + const { renderStream } = await loadInk() + return renderStream(opts) +} + +export type { Suggestion } from './ink/render.js' diff --git a/packages/cli/src/lib/pkce.ts b/packages/cli/src/lib/pkce.ts new file mode 100644 index 0000000..34cac24 --- /dev/null +++ b/packages/cli/src/lib/pkce.ts @@ -0,0 +1,16 @@ +import { createHash, randomBytes } from 'node:crypto' + +export type PKCEPair = { + codeChallenge: string + codeVerifier: string +} + +export function createPKCEPair(): PKCEPair { + const codeVerifier = randomBytes(64).toString('base64url') + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') + return { codeChallenge, codeVerifier } +} + +export function createState(): string { + return randomBytes(24).toString('base64url') +} diff --git a/packages/cli/src/lib/resolve.ts b/packages/cli/src/lib/resolve.ts new file mode 100644 index 0000000..56f3bf3 --- /dev/null +++ b/packages/cli/src/lib/resolve.ts @@ -0,0 +1,151 @@ +type AnyRecord = Record + +export type AccountResolutionOptions = { + allowMultiplePerInput?: boolean +} + +export type ChatResolutionOptions = { + accountIDs?: string[] + pick?: number +} + +export async function resolveAccountIDs( + client: any, + inputs?: string[], + options: AccountResolutionOptions = {}, +): Promise { + if (!inputs?.length) return undefined + + const accounts = accountItems(await client.accounts.list()) + const resolved: string[] = [] + for (const input of inputs) { + const matches = matchAccounts(accounts, input) + if (matches.length === 0) throw new Error(`No account matches "${input}"`) + if (matches.length > 1 && !options.allowMultiplePerInput) { + throw new Error(formatAmbiguous(`account "${input}"`, matches.map(formatAccount))) + } + resolved.push(...matches.map(account => String(account.accountID))) + } + + return Array.from(new Set(resolved)) +} + +export async function resolveAccountID(client: any, input: string): Promise { + const [accountID] = await resolveAccountIDs(client, [input]) ?? [] + if (!accountID) throw new Error(`No account matches "${input}"`) + return accountID +} + +export async function listAccountIDs(client: any): Promise { + const accounts = accountItems(await client.accounts.list()) + return accounts.map(account => String(account.accountID)).filter(Boolean) +} + +export async function resolveChatID(client: any, input: string, options: ChatResolutionOptions = {}): Promise { + const exact = await retrieveChat(client, input) + if (exact) return exact.id + + const candidates = await collect(client.chats.search({ + accountIDs: options.accountIDs, + query: input, + scope: 'titles', + }), 10) + + const normalizedInput = normalize(input) + const exactMatches = candidates.filter(chat => + normalize(chat.id) === normalizedInput || + normalize(chat.localChatID) === normalizedInput || + normalize(chat.title) === normalizedInput + ) + const matches = exactMatches.length ? exactMatches : candidates + if (matches.length === 0) return input + if (matches.length === 1) return String(matches[0]!.id) + + if (options.pick) { + const selected = matches[options.pick - 1] + if (!selected) throw new Error(`--pick ${options.pick} is outside the ${matches.length} matching chats`) + return String(selected.id) + } + + throw new Error(formatAmbiguous(`chat "${input}"`, matches.map(formatChat))) +} + +function accountItems(accounts: unknown): AnyRecord[] { + if (Array.isArray(accounts)) return accounts as AnyRecord[] + return ((accounts as { items?: AnyRecord[] }).items ?? []) +} + +function matchAccounts(accounts: AnyRecord[], input: string): AnyRecord[] { + const normalizedInput = normalize(input) + const exact = accounts.filter(account => + normalize(account.accountID) === normalizedInput || + normalize(account.network) === normalizedInput || + normalize(account.bridge?.type) === normalizedInput || + normalize(account.bridge?.id) === normalizedInput || + normalize(account.user?.id) === normalizedInput || + normalize(account.user?.username) === normalizedInput || + normalize(account.user?.displayName) === normalizedInput || + normalize(account.user?.name) === normalizedInput || + normalize(account.user?.email) === normalizedInput + ) + if (exact.length) return exact + + return accounts.filter(account => + includesNormalized(account.accountID, normalizedInput) || + includesNormalized(account.network, normalizedInput) || + includesNormalized(account.bridge?.type, normalizedInput) || + includesNormalized(account.bridge?.id, normalizedInput) || + includesNormalized(account.user?.displayName, normalizedInput) || + includesNormalized(account.user?.name, normalizedInput) + ) +} + +async function retrieveChat(client: any, input: string): Promise { + try { + return await client.chats.retrieve(input, { maxParticipantCount: 0 }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (/not\s*found|404/i.test(message)) return undefined + throw error + } +} + +async function collect(iterable: AsyncIterable, limit: number): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + if (items.length >= limit) break + } + return items +} + +function normalize(value: unknown): string { + return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '') +} + +function includesNormalized(value: unknown, normalizedInput: string): boolean { + return normalize(value).includes(normalizedInput) +} + +function formatAmbiguous(label: string, choices: string[]): string { + return `Ambiguous ${label}. Use an exact ID or --pick N:\n${choices.map((choice, index) => ` ${index + 1}. ${choice}`).join('\n')}` +} + +function formatAccount(account: AnyRecord): string { + const network = account.network ? ` ${account.network}` : '' + const bridge = account.bridge?.type ? ` ${account.bridge.type}` : '' + const user = account.user?.displayName || account.user?.name || account.user?.username || account.user?.id || '' + return `${account.accountID}${network}${bridge}${user ? ` ${user}` : ''}` +} + +function formatChat(chat: AnyRecord): string { + const network = chat.network ? ` ${chat.network}` : '' + return `${chat.id}${network} ${chat.title ?? ''}`.trim() +} + +export function userQueryFromInput(input: string): AnyRecord { + const trimmed = input.trim() + if (trimmed.includes('@')) return { email: trimmed, username: trimmed } + if (/^\+?[\d\s().-]{5,}$/.test(trimmed)) return { phoneNumber: trimmed } + return { fullName: trimmed, username: trimmed, id: trimmed } +} diff --git a/packages/cli/src/lib/runner.ts b/packages/cli/src/lib/runner.ts new file mode 100644 index 0000000..f6d2341 --- /dev/null +++ b/packages/cli/src/lib/runner.ts @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process' + +export type RunResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export async function runCli(args: string[], options: { inherit?: boolean } = {}): Promise { + const child = spawn(process.execPath, [process.argv[1]!, ...args], { + env: process.env, + stdio: options.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }) + + if (options.inherit) { + return new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, stdout: '', stderr: '' })) + }) + } + + let stdout = '' + let stderr = '' + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', chunk => { + stdout += chunk + }) + child.stderr?.on('data', chunk => { + stderr += chunk + }) + + return new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr })) + }) +} diff --git a/packages/cli/src/lib/send-message.ts b/packages/cli/src/lib/send-message.ts new file mode 100644 index 0000000..f4e04a8 --- /dev/null +++ b/packages/cli/src/lib/send-message.ts @@ -0,0 +1,44 @@ +import { createReadStream } from 'node:fs' +import { waitForMessage } from './wait.js' + +export async function sendMessage(client: any, options: { + chatID: string + file?: string + fileName?: string + mimeType?: string + replyTo?: string + text: string + wait?: boolean + waitIntervalMs?: number + waitTimeoutMs?: number +}): Promise { + const uploaded = options.file + ? await client.assets.upload({ + file: createReadStream(options.file), + fileName: options.fileName, + mimeType: options.mimeType, + }) + : undefined + + if (options.file && !uploaded?.uploadID) throw new Error('Upload did not return an uploadID') + + const pending = await client.messages.send(options.chatID, { + attachment: uploaded?.uploadID + ? { + uploadID: uploaded.uploadID, + duration: uploaded.duration, + fileName: uploaded.fileName, + mimeType: uploaded.mimeType, + size: uploaded.width && uploaded.height ? { height: uploaded.height, width: uploaded.width } : undefined, + } + : undefined, + replyToMessageID: options.replyTo, + text: options.text, + }) + + if (!options.wait) return pending + return waitForMessage(client, options.chatID, pending.pendingMessageID, { + intervalMs: options.waitIntervalMs, + timeoutMs: options.waitTimeoutMs, + }) +} diff --git a/packages/cli/src/lib/wait.ts b/packages/cli/src/lib/wait.ts new file mode 100644 index 0000000..12d0ad5 --- /dev/null +++ b/packages/cli/src/lib/wait.ts @@ -0,0 +1,24 @@ +import { setTimeout as sleep } from 'node:timers/promises' + +export type WaitOptions = { + intervalMs?: number + timeoutMs?: number +} + +export async function waitForMessage(client: any, chatID: string, pendingMessageID: string, options: WaitOptions = {}) { + const started = Date.now() + const timeoutMs = options.timeoutMs ?? 30_000 + const intervalMs = options.intervalMs ?? 750 + let lastError: unknown + + while (Date.now() - started < timeoutMs) { + try { + return await client.messages.retrieve(pendingMessageID, { chatID }) + } catch (error) { + lastError = error + await sleep(intervalMs) + } + } + + throw new Error(`Timed out waiting for ${pendingMessageID}${lastError instanceof Error ? `: ${lastError.message}` : ''}`) +} diff --git a/packages/cli/src/plugin-sdk.ts b/packages/cli/src/plugin-sdk.ts new file mode 100644 index 0000000..36335c7 --- /dev/null +++ b/packages/cli/src/plugin-sdk.ts @@ -0,0 +1,38 @@ +import type BeeperDesktop from '@beeper/desktop-api' + +export { Args, Command, Flags, ux } from '@oclif/core' +export { BeeperCommand, ensureWritable, writeEvent } from './lib/command.js' +export { + configPath, + getAccessToken, + getBaseURL, + readConfig, + resetConfig, + updateConfig, + writeConfig, + type Config, + type StoredAuth, +} from './lib/config.js' +export { createClient as createBeeperClient, requireToken } from './lib/client.js' +export { + collectPage, + emptyState, + printConfig, + printData, + printFailure, + printIDs, + printList, + printSuccess, + startStream, + type OutputFormat, + type Suggestion, +} from './lib/output.js' + +export type BeeperClient = InstanceType + +export type BeeperPluginContext = { + baseURL?: string + debug?: boolean + json?: boolean + readOnly?: boolean +} diff --git a/packages/cli/test/cli-smoke.mjs b/packages/cli/test/cli-smoke.mjs new file mode 100644 index 0000000..7b62fc3 --- /dev/null +++ b/packages/cli/test/cli-smoke.mjs @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { join, relative } from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { commandManifest } from '../dist/lib/manifest.js' +import { exportBeeperData } from '../dist/lib/export/index.js' +import { ensureDesktopToken, findLocalDesktop } from '../dist/lib/desktop-auth.js' +import { resolveAccountID, resolveAccountIDs, resolveChatID } from '../dist/lib/resolve.js' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const run = (...args) => spawnSync(process.execPath, ['./bin/run.js', ...args], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, +}) + +const ok = (...args) => { + const result = run(...args) + assert.equal(result.status, 0, `${args.join(' ')} failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`) + return result.stdout +} + +const commandFiles = listCommandFiles(join(root, 'src/commands')) +const commandNames = commandFiles.map(file => fileToCommand(file)).sort() +const manifestNames = commandManifest.map(item => item.command).sort() + +assert.deepEqual(manifestNames, commandNames, 'command manifest must match src/commands') +assert.equal(new Set(manifestNames).size, manifestNames.length, 'command manifest must not contain duplicates') + +const help = ok('--help') +assert.match(help, /\bchat\b/, 'help should expose canonical chat command') +assert.match(help, /\bchats\b/, 'help should expose canonical chats command') +assert.doesNotMatch(help, /\bthread\b|\bthreads\b|\btail\b|\bwhoami\b/, 'help must not expose compatibility aliases') +assert.doesNotMatch(help, /\bfailed-sends\b|\bscheduled\b|\blocal\s+stats\b/, 'help must not expose stale local DB commands') + +for (const command of [ + ['chat', '--help'], + ['chats', '--help'], + ['send', 'text', '--help'], + ['send', 'file', '--help'], + ['watch', '--help'], + ['current-user', '--help'], + ['export', '--help'], + ['contacts', 'list', '--help'], + ['pin', '--help'], + ['unpin', '--help'], + ['low-priority', '--help'], + ['inbox', '--help'], + ['title', '--help'], + ['description', '--help'], + ['avatar', '--help'], + ['message-expiry', '--help'], + ['login', '--help'], + ['logout', '--help'], + ['config', 'get', '--help'], + ['config', 'set', '--help'], + ['config', 'reset', '--help'], + ['llm'], +]) { + ok(...command) +} + +assert.match(ok('send', 'text', '--help'), /--pick/, 'send text should expose --pick for ambiguous chat names') +assert.match(ok('send', 'text', '--help'), /--wait/, 'send text should expose --wait') +assert.match(ok('messages', '--help'), /--pick/, 'messages should expose --pick for ambiguous chat names') +assert.match(ok('chats', '--help'), /--account=\.\.\./, 'chats should accept account selectors') +assert.match(ok('export', '--help'), /--out/, 'export should expose output directory selection') +assert.match(ok('export', '--help'), /--no-attachments/, 'export should expose attachment control') +assert.match(ok('login', '--help'), /--server-url/, 'login should expose --server-url') + +const commandsJSON = JSON.parse(ok('commands', '--json')) +assert.equal(commandsJSON.length, commandManifest.length, 'commands --json should expose the full manifest') +assert(commandsJSON.some(item => item.command === 'chat'), 'commands --json should include canonical chat command') +assert(commandsJSON.some(item => item.command === 'send text'), 'commands --json should include send text') +assert(commandsJSON.some(item => item.command === 'send file'), 'commands --json should include send file') +assert(!commandsJSON.some(item => ['thread', 'threads', 'tail', 'whoami'].includes(item.command)), 'commands --json must not include compatibility aliases') +assert(!commandsJSON.some(item => item.command.includes('serve')), 'commands --json must not include serve') +assert(!commandsJSON.some(item => item.command.includes('base64')), 'commands --json must not include base64 asset variants') + +const configDir = '/tmp/beeper-cli-test-config' +const configEnv = { ...process.env, BEEPER_CLI_CONFIG_DIR: configDir } +let config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'set', 'baseURL', 'http://127.0.0.1:23373'], { + cwd: root, + encoding: 'utf8', + env: configEnv, +}) +assert.equal(config.status, 0, config.stderr) +config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'get', 'baseURL'], { + cwd: root, + encoding: 'utf8', + env: { ...configEnv, BEEPER_DESKTOP_BASE_URL: 'http://127.0.0.1:24444' }, +}) +assert.equal(config.status, 0, config.stderr) +assert.match(config.stdout, /127\.0\.0\.1:24444/) +config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'reset'], { + cwd: root, + encoding: 'utf8', + env: configEnv, +}) +assert.equal(config.status, 0, config.stderr) + +const rpc = spawnSync('printf', ['%s\n', '{"id":1,"command":"auth status --json"}'], { + encoding: 'utf8', + cwd: root, +}) +assert.equal(rpc.status, 0, rpc.stderr) +const rpcResult = spawnSync(process.execPath, ['./bin/run.js', 'rpc'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, + input: rpc.stdout, +}) +assert.equal(rpcResult.status, 0, rpcResult.stderr) +const rpcLine = JSON.parse(rpcResult.stdout) +assert.equal(rpcLine.id, 1) +assert.equal(rpcLine.ok, true) +assert.match(rpcLine.stdout, /"authenticated": false/) + +const shell = spawnSync(process.execPath, ['./bin/run.js', 'shell'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, + input: 'auth status --json\nquit\n', +}) +assert.equal(shell.status, 0, shell.stderr) +assert.match(shell.stdout, /"authenticated": false/) + +const accountsOutput = await withMockAPI(async baseURL => { + const result = await runAsync(['./bin/run.js', 'accounts', '--base-url', baseURL], { + cwd: root, + env: { + ...process.env, + BEEPER_ACCESS_TOKEN: 'bdapi_test', + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-accounts-test', + }, + }) + assert.equal(result.status, 0, result.stderr) + return result.stdout +}) +assert.match(accountsOutput, /iMessage/) +assert.doesNotMatch(accountsOutput, /No accounts connected/) + +const fakeClient = { + accounts: { + list: async () => [ + { accountID: 'imessage-main', bridge: { id: 'local-imessage', type: 'imessage' }, network: 'iMessage', user: { displayName: 'Main' } }, + { accountID: 'telegram-main', bridge: { id: 'telegramgo', type: 'telegram' }, network: 'Telegram', user: { displayName: 'Main' } }, + ], + }, + chats: { + retrieve: async id => { + if (id === '!exact:beeper.com' || id === 'local-family') return { id: '!family:beeper.com', localChatID: 'local-family', title: 'Family', network: 'iMessage' } + throw new Error('not found') + }, + search: async function* ({ query }) { + const rows = [ + { id: '!family:beeper.com', localChatID: 'local-family', title: 'Family', network: 'iMessage' }, + { id: '!family-work:beeper.com', localChatID: 'local-family-work', title: 'Family Work', network: 'Telegram' }, + ].filter(chat => chat.title.toLowerCase().includes(String(query).toLowerCase())) + for (const row of rows) yield row + }, + }, +} + +assert.equal(await resolveAccountID(fakeClient, 'imessage'), 'imessage-main') +assert.deepEqual(await resolveAccountIDs(fakeClient, ['main'], { allowMultiplePerInput: true }), ['imessage-main', 'telegram-main']) +await assert.rejects(() => resolveAccountID(fakeClient, 'main'), /Ambiguous account/) +assert.equal(await resolveChatID(fakeClient, 'local-family'), '!family:beeper.com') +assert.equal(await resolveChatID(fakeClient, 'Family Work'), '!family-work:beeper.com') +assert.equal(await resolveChatID(fakeClient, 'fam', { pick: 2 }), '!family-work:beeper.com') +await assert.rejects(() => resolveChatID(fakeClient, 'fam'), /Ambiguous chat/) + +const loggedOutDesktop = await withMockDesktop({ state: 'needs-login' }, async baseURL => { + const desktop = await findLocalDesktop({ baseURL }) + assert.equal(desktop.baseURL, baseURL) + assert.equal(desktop.status?.state, 'needs-login') + await assert.rejects( + () => ensureDesktopToken({ baseURL, openBrowser: false }), + /Beeper Desktop is not signed in/, + ) + return true +}) +assert.equal(loggedOutDesktop, true) + +const exportRoot = mkdtempSync(join(tmpdir(), 'beeper-export-test-')) +const attachmentSource = join(exportRoot, 'source.txt') +writeFileSync(attachmentSource, 'hello attachment') +let messageListCalls = 0 +const exportClient = { + accounts: { + list: async () => [ + { accountID: 'imessage-main', bridge: { id: 'local-imessage', type: 'imessage' }, network: 'iMessage', user: { id: 'me', fullName: 'Me' } }, + ], + }, + chats: { + list: async function* () { + yield { id: '!family:beeper.com', accountID: 'imessage-main', network: 'iMessage', title: 'Family', type: 'group', participants: { hasMore: false, items: [], total: 0 }, unreadCount: 0 } + }, + retrieve: async () => ({ id: '!family:beeper.com', accountID: 'imessage-main', network: 'iMessage', title: 'Family', type: 'group', participants: { hasMore: false, items: [], total: 0 }, unreadCount: 0 }), + }, + messages: { + list: async (_chatID, query) => { + messageListCalls += 1 + if (query?.cursor === 'older') { + return { items: [messageOne], hasMore: false, oldestCursor: null } + } + return { items: [messageTwo], hasMore: true, oldestCursor: 'older' } + }, + }, +} +const messageOne = { + id: 'm1', + accountID: 'imessage-main', + chatID: '!family:beeper.com', + senderID: '@alice:example', + senderName: 'Alice', + sortKey: '1', + timestamp: '2026-05-13T10:00:00Z', + text: 'first', + attachments: [{ type: 'unknown', id: `file://${attachmentSource}`, fileName: 'source.txt', mimeType: 'text/plain' }], +} +const messageTwo = { + id: 'm2', + accountID: 'imessage-main', + chatID: '!family:beeper.com', + senderID: '@me:example', + senderName: 'Me', + sortKey: '2', + timestamp: '2026-05-13T10:01:00Z', + text: 'second', +} + +const manifest = await exportBeeperData(exportClient, { + downloadAttachments: true, + force: false, + outDir: exportRoot, + quiet: true, +}) +assert.equal(manifest.chatCount, 1) +assert.equal(manifest.messageCount, 2) +assert.equal(manifest.attachmentCount, 1) +const exportedChatDir = join(exportRoot, 'chats', 'family_beeper.com') +assert.deepEqual(JSON.parse(readFileSync(join(exportRoot, 'accounts.json'), 'utf8'))[0].accountID, 'imessage-main') +assert.equal(JSON.parse(readFileSync(join(exportedChatDir, 'messages.json'), 'utf8')).length, 2) +assert.match(readFileSync(join(exportedChatDir, 'messages.markdown'), 'utf8'), /Alice[\s\S]*first[\s\S]*source\.txt/) +assert.match(readFileSync(join(exportedChatDir, 'messages.html'), 'utf8'), /Family<\/title>[\s\S]*Alice[\s\S]*first[\s\S]*source\.txt/) +assert.equal(readFileSync(join(exportedChatDir, 'attachments', 'm1', '01-source.txt'), 'utf8'), 'hello attachment') +assert(!existsSync(join(exportedChatDir, 'messages.partial.jsonl')), 'completed exports should remove partial checkpoint streams') +const callsAfterFirstExport = messageListCalls +await exportBeeperData(exportClient, { + downloadAttachments: true, + force: false, + outDir: exportRoot, + quiet: true, +}) +assert.equal(messageListCalls, callsAfterFirstExport, 'completed chats should be skipped on resumable rerun') +rmSync(exportRoot, { recursive: true, force: true }) + +console.log(`cli-smoke: ${commandManifest.length} commands verified`) + +function listCommandFiles(dir) { + const files = [] + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) { + files.push(...listCommandFiles(path)) + } else if (path.endsWith('.ts') || path.endsWith('.tsx')) { + files.push(path) + } + } + return files +} + +function fileToCommand(file) { + const rel = relative(join(root, 'src/commands'), file).replace(/\.tsx?$/, '') + return rel.endsWith('/index') + ? rel.slice(0, -'/index'.length).replaceAll('/', ' ') + : rel.replaceAll('/', ' ') +} + +function withMockDesktop(appStatus, callback) { + const server = createServer((req, res) => { + if (req.url === '/v1/info') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + return + } + if (req.url === '/v1/app/status') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(appStatus)) + return + } + res.writeHead(404).end('Not found') + }) + + return new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + try { + const result = await callback(`http://127.0.0.1:${address.port}`) + server.close(() => resolve(result)) + } catch (error) { + server.close(() => reject(error)) + } + }) + }) +} + +function withMockAPI(callback) { + const server = createServer((req, res) => { + if (req.url === '/v1/accounts') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify([ + { accountID: 'imessage-main', bridge: { id: 'local-imessage', type: 'imessage' }, network: 'iMessage', user: { displayName: 'Main' } }, + ])) + return + } + res.writeHead(404).end('Not found') + }) + + return new Promise((resolve, reject) => { + server.listen(0, '127.0.0.1', async () => { + const address = server.address() + try { + const result = await callback(`http://127.0.0.1:${address.port}`) + server.close(() => resolve(result)) + } catch (error) { + server.close(() => reject(error)) + } + }) + }) +} + +function runAsync(args, options) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, args, { + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', chunk => { stdout += chunk }) + child.stderr.on('data', chunk => { stderr += chunk }) + child.on('error', reject) + child.on('close', status => resolve({ status, stdout, stderr })) + }) +} diff --git a/packages/cli/test/e2e-staging.mjs b/packages/cli/test/e2e-staging.mjs new file mode 100644 index 0000000..2820f41 --- /dev/null +++ b/packages/cli/test/e2e-staging.mjs @@ -0,0 +1,1004 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..') +const cliBin = path.join(repoRoot, 'bin/run.js') +const defaultDesktopRoot = '/Users/batuhan/.codex/worktrees/43bc/beeper/desktop' +const desktopRoot = process.env.BEEPER_DESKTOP_ROOT || defaultDesktopRoot +const accountCount = Number(process.env.BEEPER_E2E_ACCOUNT_COUNT || 4) +const otp = process.env.BEEPER_E2E_OTP || '959729' +const runID = process.env.BEEPER_E2E_RUN_ID || String(Date.now()) +const startDesktop = process.env.BEEPER_E2E_START_DESKTOP !== '0' +const keepDesktop = process.env.BEEPER_E2E_KEEP_DESKTOP === '1' +const workDir = process.env.BEEPER_E2E_WORKDIR || path.join(tmpdir(), `beeper-cli-e2e-${runID}`) +const reportPath = process.env.BEEPER_E2E_REPORT || path.join(workDir, 'report.json') +const portStart = Number(process.env.BEEPER_E2E_PORT_START || 23373) +const portEnd = Number(process.env.BEEPER_E2E_PORT_END || 23423) +const emailBase = Number(process.env.BEEPER_E2E_EMAIL_BASE || (900000 + Math.floor(Math.random() * 50000))) +const bridgeAccount = process.env.BEEPER_E2E_BRIDGE_ACCOUNT + +const children = [] +const report = { + runID, + startedAt: new Date().toISOString(), + desktopRoot, + workDir, + commands: [], + endpoints: [], + instances: [], + artifacts: {}, +} + +const coveredCommands = new Set() +let cleanedUp = false + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +async function main() { + await mkdir(workDir, { recursive: true }) + const manifest = await loadCommandManifest() + report.manifestCommandCount = manifest.length + report.manifestCommands = manifest.map(item => item.command) + + const profiles = Array.from({ length: accountCount }, (_, index) => ({ + index, + profile: process.env[`BEEPER_E2E_PROFILE_${index + 1}`] || `cli-e2e-${runID}-${index + 1}`, + email: process.env[`BEEPER_E2E_EMAIL_${index + 1}`] || `qatest+${emailBase + index}@beeper.com`, + })) + + const beforePorts = await scanDesktopServers() + const usedPorts = new Set(beforePorts.map(server => portFromBaseURL(server.baseURL))) + for (const profile of profiles) { + profile.desiredPort = nextAvailablePort(usedPorts) + profile.expectedBaseURL = `http://127.0.0.1:${profile.desiredPort}` + } + if (startDesktop) { + const [firstProfile, ...remainingProfiles] = profiles + if (firstProfile) { + await startDesktopProfile(firstProfile, firstProfile.index) + await waitForDesktopProfile(firstProfile) + } + for (const profile of remainingProfiles) await startDesktopProfile(profile, profile.index) + } + + for (const profile of profiles) { + const baseURL = await waitForDesktopProfile(profile) + const configDir = path.join(workDir, 'cli-config', profile.profile) + await mkdir(configDir, { recursive: true }) + const instance = { ...profile, baseURL, configDir } + report.instances.push(instance) + await loginInstance(instance) + await waitForAppUsable(instance) + } + + const primary = report.instances[0] + assert(primary, 'expected at least one instance') + + await runLocalCommands(primary) + const fixture = await createFixtureFile() + const context = await buildMessagingContext(primary) + await runAuthenticatedReadCommands(primary, context) + await runMessagingCommands(primary, context, fixture) + await runAccountLoginCoverage(primary) + await runWatchAndRpcCommands(primary, context) + await runE2EECommands(primary) + await runRawEndpointCommands(primary, context, fixture) + await runCrossInstanceMessaging(context) + await runCleanupCommands(primary) + + const missing = manifest.map(item => item.command).filter(command => !coveredCommands.has(command)) + report.missingCommands = missing + if (report.missingOpenAPIOperations?.length) { + throw new Error(`E2E did not cover ${report.missingOpenAPIOperations.length} OpenAPI operation(s): ${report.missingOpenAPIOperations.join(', ')}`) + } + report.finishedAt = new Date().toISOString() + await writeReport() + + if (missing.length) { + throw new Error(`E2E did not exercise ${missing.length} command(s): ${missing.join(', ')}`) + } +} + +async function loadCommandManifest() { + const result = await runCli(['commands', '--json'], { allowFailure: false, record: false }) + const parsed = JSON.parse(result.stdout) + assert(Array.isArray(parsed), 'commands --json did not return an array') + coveredCommands.add('commands') + report.commands.push({ command: 'commands', ok: true, phase: 'manifest' }) + return parsed +} + +async function startDesktopProfile(profile, index) { + const logPath = path.join(workDir, `${profile.profile}.desktop.log`) + const log = createWriteStream(logPath, { flags: 'a' }) + const launch = index === 0 ? devServerLaunch(profile) : electronLaunch(profile) + const child = spawn(launch.command, launch.args, { + cwd: desktopRoot, + env: { + ...process.env, + BEEPER_PROFILE: profile.profile, + BEEPER_SMOKE_TEST: 'true', + PAS_PORT: String(profile.desiredPort), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.pipe(log) + child.stderr.pipe(log) + children.push({ child, profile: profile.profile, log, logPath }) + report.artifacts[`${profile.profile}.desktopLog`] = logPath + await sleep(1500) + if (child.exitCode !== null) throw new Error(`Desktop profile ${profile.profile} exited early. See ${logPath}`) +} + +function devServerLaunch(profile) { + return { command: 'yarn', args: ['dev:staging', `--pas-port=${profile.desiredPort}`] } +} + +function electronLaunch(profile) { + const args = ['.', '--server-env=staging', `--pas-port=${profile.desiredPort}`] + if (process.env.BEEPER_E2E_ELECTRON_BIN) { + return { command: process.env.BEEPER_E2E_ELECTRON_BIN, args } + } + if (process.platform === 'darwin') { + return { + command: path.join(desktopRoot, 'node_modules/electron/dist/Electron.app/Contents/MacOS/Electron'), + args, + } + } + if (process.platform === 'win32') { + return { + command: path.join(desktopRoot, 'node_modules/electron/dist/electron.exe'), + args, + } + } + return { + command: path.join(desktopRoot, 'node_modules/electron/dist/electron'), + args, + } +} + +async function waitForDesktopProfile(profile) { + const deadline = Date.now() + 180_000 + while (Date.now() < deadline) { + try { + const response = await fetchWithTimeout(new URL('/v1/info', profile.expectedBaseURL), {}, 1000) + if (response.ok) { + const appStatus = await fetchWithTimeout(new URL('/v1/app/status', profile.expectedBaseURL), {}, 1000) + if (appStatus.status !== 404) return profile.expectedBaseURL + } + } catch { + // Keep waiting for this exact profile port. + } + await sleep(1000) + } + throw new Error(`Timed out waiting for Desktop profile ${profile.profile} to expose /v1/info on ${profile.expectedBaseURL}`) +} + +function portFromBaseURL(baseURL) { + return Number(new URL(baseURL).port) +} + +function nextAvailablePort(usedPorts) { + for (let port = portStart; port <= portEnd; port++) { + if (!usedPorts.has(port)) { + usedPorts.add(port) + return port + } + } + throw new Error(`No free candidate PAS ports in ${portStart}..${portEnd}`) +} + +async function scanDesktopServers() { + const servers = [] + await Promise.all(Array.from({ length: portEnd - portStart + 1 }, async (_, offset) => { + const port = portStart + offset + const baseURL = `http://127.0.0.1:${port}` + try { + const response = await fetchWithTimeout(new URL('/v1/info', baseURL), {}, 500) + if (!response.ok) return + const info = await response.json() + if (!info?.server?.mcp_enabled) return + let supportsAppStatus = false + try { + const appStatus = await fetchWithTimeout(new URL('/v1/app/status', baseURL), {}, 500) + supportsAppStatus = appStatus.status !== 404 + } catch { + supportsAppStatus = false + } + servers.push({ baseURL, info, supportsAppStatus }) + } catch { + // Port is not a Beeper Desktop API server. + } + })) + return servers.sort((a, b) => a.baseURL.localeCompare(b.baseURL)) +} + +async function fetchWithTimeout(url, init = {}, timeoutMs = 10_000) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + return await fetch(url, { ...init, signal: controller.signal }) + } finally { + clearTimeout(timeout) + } +} + +async function loginInstance(instance) { + const status = await getUnauthedAppStatus(instance.baseURL) + if (status?.state === 'needs-login') { + const username = instance.email.match(/\+(\d+)@/)?.[1] ? `qatest${RegExp.$1}` : `qatest${runID}${instance.index}` + const result = await runCli([ + 'login', + '--app-login', + '--server-url', instance.baseURL, + '--email', instance.email, + '--code', otp, + '--username', username, + '--accept-terms', + '--json', + ], { instance }) + const body = parseJSON(result.stdout, 'login --app-login') + instance.userID = body.matrix?.userID + instance.accessToken = body.desktopAPI?.accessToken + assert(instance.accessToken, `login did not return a Desktop API token for ${instance.profile}`) + coveredCommands.add('login') + return + } + + const configPath = path.join(instance.configDir, 'config.json') + try { + const config = JSON.parse(await readFile(configPath, 'utf8')) + if (config.auth?.accessToken) { + instance.accessToken = config.auth.accessToken + return + } + } catch { + // No reusable CLI token. + } + + throw new Error([ + `${instance.profile} is already signed in but this E2E run has no reusable Desktop API token.`, + 'Use a fresh BEEPER_PROFILE, preserve the E2E workdir, or approve OAuth manually with BEEPER_E2E_START_DESKTOP=0.', + ].join(' ')) +} + +async function getUnauthedAppStatus(baseURL) { + const response = await fetchWithTimeout(new URL('/v1/app/status', baseURL), {}, 10_000) + if (response.status === 401 || response.status === 403) return { state: 'signed-in-auth-required' } + if (!response.ok) throw new Error(`GET /v1/app/status failed for ${baseURL}: ${response.status} ${await response.text()}`) + return response.json() +} + +async function waitForAppUsable(instance) { + let lastObservation = 'no app status response' + for (let attempt = 0; attempt < 300; attempt++) { + try { + const result = await runCli(['app', 'status', '--json'], { instance, allowFailure: true }) + lastObservation = `app status exited ${result.code}: ${result.stderr || result.stdout}`.slice(0, 1000) + if (result.code === 0) { + const status = parseJSON(result.stdout, 'app status') + instance.appState = status.state + if (status.matrix?.userID) instance.userID = status.matrix.userID + lastObservation = `state=${status.state} user=${instance.userID ?? 'unknown'}` + if (status.state !== 'initializing' && status.state !== 'needs-first-sync') return + } + } catch { + lastObservation = 'app status did not return parseable JSON' + } + await sleep(1000) + } + throw new Error(`Desktop API never became usable for ${instance.profile}; last observation: ${lastObservation}`) +} + +async function runLocalCommands(instance) { + await runCli(['auth', 'status', '--json'], { instance }) + await runCli(['config', 'path'], { instance }) + await runCli(['config', 'get', '--json'], { instance }) + await runCli(['config', 'get', 'baseURL', '--json'], { instance }) + await runCli(['config', 'set', 'baseURL', instance.baseURL], { instance }) + await runCli(['llm'], { instance }) + coveredCommands.add('auth status') + coveredCommands.add('config path') + coveredCommands.add('config get') + coveredCommands.add('config set') + coveredCommands.add('llm') +} + +async function buildMessagingContext(instance) { + const accounts = parseJSON((await runCli(['accounts', '--json'], { instance })).stdout, 'accounts') + coveredCommands.add('accounts') + const account = firstItem(accounts) + assert(account, 'accounts returned no usable accounts') + const accountID = String(account.accountID ?? account.id) + const bridgeID = String(account.bridgeID ?? account.bridge?.id ?? account.protocolID ?? account.network ?? accountID) + + const chats = parseJSON((await runCli(['chats', '--json', '--limit', '20'], { instance })).stdout, 'chats') + coveredCommands.add('chats') + coveredCommands.add('chats') + + let chat = firstWritableChat(firstArray(chats)) + if (!chat && report.instances[1]?.userID) { + const start = await retryJSONCommand(() => runCli([ + 'start-chat', + '--id', report.instances[1].userID, + '--message', `cli e2e hello ${runID}`, + '--allow-invite', + '--json', + ], { instance }), 'start-chat', 12, 5000) + coveredCommands.add('start-chat') + chat = start.chat ?? start + } + if (!chat && instance.userID) { + const start = await retryJSONCommand(() => runCli([ + 'start-chat', + '--id', instance.userID, + '--message', `cli e2e self hello ${runID}`, + '--allow-invite', + '--json', + ], { instance }), 'start-chat self', 6, 5000) + coveredCommands.add('start-chat') + chat = start.chat ?? start + } + if (!chat) throw new Error('Could not find or create a chat for message command coverage') + + const chatID = String(chat.id ?? chat.chatID) + const messages = parseJSON((await runCli(['messages', chatID, '--json', '--limit', '20'], { instance })).stdout, 'messages') + coveredCommands.add('messages') + const message = firstArray(messages)[0] + return { + accountID, + bridgeID, + chatID, + chat, + messageID: message?.id, + matrixEventID: message?.eventID ?? message?.eventId ?? message?.matrixEventID ?? message?.id, + } +} + +async function retryJSONCommand(fn, label, attempts, intervalMs) { + let lastError + for (let attempt = 1; attempt <= attempts; attempt++) { + try { + return parseJSON((await fn()).stdout, label) + } catch (error) { + lastError = error + if (attempt < attempts) await sleep(intervalMs) + } + } + throw lastError +} + +async function runAuthenticatedReadCommands(instance, context) { + await runCli(['status', '--json'], { instance }) + await runCli(['doctor', '--json'], { instance }) + await runCli(['app', 'status', '--json'], { instance }) + await runCli(['current-user', '--json'], { instance }) + await runCli(['current-user', '--json'], { instance }) + await runCli(['api', 'get', '/v1/info', '--json'], { instance }) + await runCli(['api', 'post', '/v1/search', '--body', JSON.stringify({ query: 'cli' }), '--json'], { instance, allowFailure: true }) + await runCli(['chats', 'search', 'cli', '--json', '--limit', '5'], { instance, allowFailure: true }) + await runCli(['search', 'cli', '--json'], { instance, allowFailure: true }) + await runCli(['chat', context.chatID, '--json'], { instance }) + await runCli(['chat', context.chatID, '--json'], { instance }) + await runCli(['messages', 'search', 'cli', '--json', '--limit', '5'], { instance, allowFailure: true }) + await runCli(['contacts', 'list', context.accountID, '--json'], { instance, allowFailure: true }) + await runCli(['contacts', 'search', 'qatest', '--account', context.accountID, '--json'], { instance, allowFailure: true }) + + for (const command of [ + 'status', 'doctor', 'app status', 'current-user', 'current-user', 'api get', 'api post', + 'chats search', 'search', 'chat', 'chat', 'messages search', 'contacts list', 'contacts search', + ]) coveredCommands.add(command) +} + +async function runMessagingCommands(instance, context, fixture) { + const send = parseJSON((await runCli(['send', 'text', context.chatID, `cli e2e send ${runID}`, '--json'], { instance })).stdout, 'send text') + coveredCommands.add('send text') + context.pendingMessageID = send.pendingMessageID + if (!context.messageID) context.messageID = send.messageID ?? send.id ?? send.pendingMessageID + + await runCli(['send', 'file', context.chatID, fixture, `file ${runID}`, '--json'], { instance, allowFailure: true }) + coveredCommands.add('send file') + await runCli(['draft', context.chatID, `draft ${runID}`, '--json'], { instance }) + await runCli(['clear-draft', context.chatID, '--json'], { instance }) + await runCli(['read', context.chatID, '--json'], { instance }) + await runCli(['unread', context.chatID, '--json'], { instance }) + await runCli(['read', context.chatID, '--json'], { instance }) + await runCli(['unread', context.chatID, '--json'], { instance }) + await runCli(['mute', context.chatID, '--json'], { instance }) + await runCli(['unmute', context.chatID, '--json'], { instance }) + await runCli(['pin', context.chatID, '--json'], { instance }) + await runCli(['unpin', context.chatID, '--json'], { instance }) + await runCli(['archive', context.chatID], { instance }) + await runCli(['unarchive', context.chatID], { instance }) + await runCli(['low-priority', context.chatID, '--json'], { instance }) + await runCli(['inbox', context.chatID, '--json'], { instance }) + await runCli(['message-expiry', context.chatID, 'off', '--json'], { instance, allowFailure: true }) + await runCli(['title', context.chatID, `CLI E2E ${runID}`, '--json'], { instance, allowFailure: true }) + await runCli(['description', context.chatID, `CLI E2E description ${runID}`, '--json'], { instance, allowFailure: true }) + await runCli(['avatar', context.chatID, '--clear', '--json'], { instance, allowFailure: true }) + await runCli(['notify-anyway', context.chatID, '--json'], { instance, allowFailure: true }) + await runCli(['focus', '--base-url', instance.baseURL], { instance, allowFailure: true }) + await runCli(['focus', context.chatID, '--base-url', instance.baseURL], { instance, allowFailure: true }) + + const reminderWhen = new Date(Date.now() + 60 * 60 * 1000).toISOString() + await runCli(['remind', context.chatID, reminderWhen], { instance, allowFailure: true }) + await runCli(['unremind', context.chatID], { instance, allowFailure: true }) + + if (context.messageID) { + await runCli(['message', context.chatID, context.messageID, '--json'], { instance, allowFailure: true }) + await runCli(['send', 'text', context.chatID, `reply ${runID}`, '--reply-to', context.messageID, '--json'], { instance, allowFailure: true }) + await runCli(['send', 'file', context.chatID, fixture, `reply file ${runID}`, '--reply-to', context.messageID, '--json'], { instance, allowFailure: true }) + await runCli(['react', context.chatID, context.messageID, '👍', '--json'], { instance, allowFailure: true }) + await runCli(['unreact', context.chatID, context.messageID, '👍', '--json'], { instance, allowFailure: true }) + await runCli(['edit', context.chatID, context.messageID, `edited ${runID}`, '--json'], { instance, allowFailure: true }) + await runCli(['delete-message', context.chatID, context.messageID], { instance, allowFailure: true }) + } + + await runCli(['assets', 'upload', fixture, '--json'], { instance, allowFailure: true }) + await runCli(['assets', 'download', `file://${fixture}`, '--json'], { instance, allowFailure: true }) + await runCli(['export', '--limit-chats', '1', '--limit-messages', '3', '--no-attachments', '--quiet', '--out', path.join(workDir, 'export')], { instance }) + + for (const command of [ + 'send text', 'send file', 'draft', 'clear-draft', 'read', 'unread', + 'mute', 'unmute', 'pin', 'unpin', 'archive', 'unarchive', 'low-priority', 'inbox', + 'message-expiry', 'title', 'description', 'avatar', 'notify-anyway', 'focus', 'focus', 'remind', + 'unremind', 'message', 'send text', 'send file', 'react', 'unreact', 'edit', + 'delete-message', 'assets upload', 'assets download', 'export', + ]) coveredCommands.add(command) + + await runCli(['create-chat', '--account', context.accountID, '--participant', instance.userID ?? 'missing-user-id', '--json'], { instance, allowFailure: true }) + coveredCommands.add('create-chat') + + if (!report.instances[1]?.userID) { + await runCli(['start-chat', '--id', instance.userID ?? 'missing-user-id', '--json'], { instance, allowFailure: true }) + coveredCommands.add('start-chat') + } +} + +async function runRawEndpointCommands(instance, context, fixture) { + const spec = await fetchJSON(new URL('/v1/spec', instance.baseURL), authHeaders(instance)) + report.openapiPathCount = Object.keys(spec.paths ?? {}).length + report.openapiPaths = Object.entries(spec.paths ?? {}).flatMap(([routePath, methods]) => + Object.keys(methods).map(method => `${method.toUpperCase()} ${routePath}`)) + report.endpointCoverage = [] + + const operationKeys = new Set(report.openapiPaths) + const coveredOperations = new Set() + const recordCoverage = (method, specPath, evidence) => { + const operation = `${method.toUpperCase()} ${specPath}` + coveredOperations.add(operation) + report.endpointCoverage.push({ operation, ...evidence }) + } + + for (const [method, specPath, command] of commandEndpointCoverage()) { + recordCoverage(method, specPath, { via: 'cli', command }) + } + + const uploadedAsset = await uploadBase64AssetForCoverage(instance, recordCoverage) + const matrixRoomForLeave = await createMatrixRoomForCoverage(instance, recordCoverage) + + const rawChecks = [ + ['GET', '/v1/info', '/v1/info'], + ['GET', '/oauth/userinfo', '/oauth/userinfo'], + ['POST', '/oauth/introspect', '/oauth/introspect', new URLSearchParams({ token: instance.accessToken, token_type_hint: 'access_token' })], + ['POST', '/oauth/revoke', '/oauth/revoke', new URLSearchParams({ token: `invalid-${runID}`, token_type_hint: 'access_token' })], + ['POST', '/oauth/register', '/oauth/register', { + client_name: `Beeper CLI E2E ${runID}`, + grant_types: ['authorization_code'], + response_types: ['code'], + redirect_uris: ['http://127.0.0.1:9/callback'], + scope: 'read write', + token_endpoint_auth_method: 'none', + }], + ['GET', '/v1/search', '/v1/search?query=cli'], + ['GET', '/v1/accounts', '/v1/accounts'], + ['GET', '/v1/bridges', '/v1/bridges'], + ['GET', '/v1/messages/search', '/v1/messages/search?query=cli&limit=5'], + ['GET', '/v1/chats/search', '/v1/chats/search?query=cli&limit=5'], + ['GET', '/v1/accounts/{accountID}/contacts', `/v1/accounts/${encodeURIComponent(context.accountID)}/contacts?query=qatest`], + ['GET', '/v1/accounts/{accountID}/contacts/list', `/v1/accounts/${encodeURIComponent(context.accountID)}/contacts/list?limit=5`], + ['GET', '/v1/chats', '/v1/chats?limit=5'], + ['GET', '/v1/chats/{chatID}', `/v1/chats/${encodeURIComponent(context.chatID)}`], + ['GET', '/v1/chats/{chatID}/messages', `/v1/chats/${encodeURIComponent(context.chatID)}/messages?limit=5`], + ['GET', '/v1/chats/{chatID}/messages/{messageID}', `/v1/chats/${encodeURIComponent(context.chatID)}/messages/${encodeURIComponent(context.messageID ?? context.pendingMessageID ?? 'missing-message-id')}`], + ['PUT', '/_matrix/client/v3/user/{userId}/account_data/{type}', `/_matrix/client/v3/user/${encodeURIComponent(instance.userID ?? 'missing-user')}/account_data/${encodeURIComponent(`com.beeper.cli_e2e.${runID}`)}`, { runID, scope: 'user' }], + ['GET', '/_matrix/client/v3/user/{userId}/account_data/{type}', `/_matrix/client/v3/user/${encodeURIComponent(instance.userID ?? 'missing-user')}/account_data/${encodeURIComponent(`com.beeper.cli_e2e.${runID}`)}`], + ['PUT', '/_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type}', `/_matrix/client/v3/user/${encodeURIComponent(instance.userID ?? 'missing-user')}/rooms/${encodeURIComponent(context.chatID)}/account_data/${encodeURIComponent(`com.beeper.cli_e2e.${runID}`)}`, { runID, scope: 'room' }], + ['GET', '/_matrix/client/v3/user/{userId}/rooms/{roomId}/account_data/{type}', `/_matrix/client/v3/user/${encodeURIComponent(instance.userID ?? 'missing-user')}/rooms/${encodeURIComponent(context.chatID)}/account_data/${encodeURIComponent(`com.beeper.cli_e2e.${runID}`)}`], + ['GET', '/v1/assets/serve', `/v1/assets/serve?url=${encodeURIComponent(uploadedAsset?.srcURL ?? uploadedAsset?.url ?? `mxc://invalid/${runID}`)}`], + ['GET', '/_matrix/client/v3/rooms/{roomId}/state', `/_matrix/client/v3/rooms/${encodeURIComponent(context.chatID)}/state`], + ['GET', '/_matrix/client/v3/rooms/{roomId}/state/{eventType}/{stateKey}', `/_matrix/client/v3/rooms/${encodeURIComponent(context.chatID)}/state/${encodeURIComponent('m.room.create')}/`], + ['GET', '/_matrix/client/v3/rooms/{roomId}/event/{eventId}', `/_matrix/client/v3/rooms/${encodeURIComponent(context.chatID)}/event/${encodeURIComponent(context.matrixEventID ?? context.messageID ?? 'missing-event-id')}`], + ['GET', '/_matrix/client/v3/profile/{userId}', `/_matrix/client/v3/profile/${encodeURIComponent(instance.userID ?? 'missing-user')}`], + ['POST', '/_matrix/client/v3/join/{roomIdOrAlias}', `/_matrix/client/v3/join/${encodeURIComponent(context.chatID)}`, {}], + ['POST', '/_matrix/client/v3/rooms/{roomId}/leave', `/_matrix/client/v3/rooms/${encodeURIComponent(matrixRoomForLeave ?? '!invalid-cli-e2e-room:beeper-staging.com')}/leave`, {}], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/current-user', bridgeProvisionEndpoint(context, '/v3/current-user')], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/login/flows', bridgeProvisionEndpoint(context, '/v3/login/flows')], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/logins', bridgeProvisionEndpoint(context, '/v3/logins')], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/login/start/{flowID}', bridgeProvisionEndpoint(context, '/v3/login/start/invalid-flow'), {}], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/login/step/{loginProcessID}/{stepID}/user_input', bridgeProvisionEndpoint(context, '/v3/login/step/invalid-login/invalid-step/user_input'), {}], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/login/step/{loginProcessID}/{stepID}/cookies', bridgeProvisionEndpoint(context, '/v3/login/step/invalid-login/invalid-step/cookies'), { cookies: [] }], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/login/step/{loginProcessID}/{stepID}/display_and_wait', bridgeProvisionEndpoint(context, '/v3/login/step/invalid-login/invalid-step/display_and_wait'), {}], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/logout/{loginID}', bridgeProvisionEndpoint(context, '/v3/logout/invalid-login'), {}], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/contacts', bridgeProvisionEndpoint(context, '/v3/contacts')], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/search_users', bridgeProvisionEndpoint(context, '/v3/search_users'), { query: 'qatest' }], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/resolve_identifier/{identifier}', bridgeProvisionEndpoint(context, `/v3/resolve_identifier/${encodeURIComponent(instance.userID ?? 'qatest')}`)], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/create_dm/{identifier}', bridgeProvisionEndpoint(context, `/v3/create_dm/${encodeURIComponent(instance.userID ?? 'qatest')}`), {}], + ['POST', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/create_group/{groupType}', bridgeProvisionEndpoint(context, '/v3/create_group/default'), { name: { name: `CLI E2E ${runID}` } }], + ['GET', '/_matrix/client/unstable/com.beeper.bridge/{bridgeID}/_matrix/provision/v3/capabilities', bridgeProvisionEndpoint(context, '/v3/capabilities')], + ] + + const oauthClient = await registerOAuthClientForCoverage(instance, rawChecks, recordCoverage) + if (oauthClient?.client_id) { + rawChecks.push( + ['GET', '/oauth/authorize', `/oauth/authorize?client_id=${encodeURIComponent(oauthClient.client_id)}&redirect_uri=${encodeURIComponent('http://127.0.0.1:9/callback')}&response_type=code&scope=${encodeURIComponent('read write')}&state=${encodeURIComponent(`e2e-${runID}`)}&code_challenge=${encodeURIComponent('invalidchallenge')}&code_challenge_method=S256`], + ['POST', '/oauth/authorize/callback', '/oauth/authorize/callback', { + clientInfo: { clientID: oauthClient.client_id, name: `Beeper CLI E2E ${runID}` }, + redirectUri: 'http://127.0.0.1:9/callback', + scope: 'read write', + scopes: ['read', 'write'], + state: `e2e-${runID}`, + codeChallenge: 'invalidchallenge', + codeChallengeMethod: 'S256', + }], + ['POST', '/oauth/token', '/oauth/token', new URLSearchParams({ grant_type: 'authorization_code', code: 'invalid-code', code_verifier: 'invalid-verifier', client_id: oauthClient.client_id })], + ) + } else { + recordCoverage('GET', '/oauth/authorize', { via: 'skipped', reason: 'oauth client registration failed before authorize coverage' }) + recordCoverage('POST', '/oauth/authorize/callback', { via: 'skipped', reason: 'oauth client registration failed before callback coverage' }) + recordCoverage('POST', '/oauth/token', { via: 'skipped', reason: 'oauth client registration failed before token coverage' }) + } + + for (const [method, specPath, endpoint, body] of rawChecks) { + try { + const response = await rawRequest(instance, method, endpoint, body) + report.endpoints.push(await endpointReportEntry(method, endpoint, response)) + recordCoverage(method, specPath, { via: 'raw', endpoint, status: response.status }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + report.endpoints.push({ method, endpoint, error: message }) + recordCoverage(method, specPath, { via: 'raw', endpoint, error: message }) + } + } + + for (const operation of operationKeys) { + if (!coveredOperations.has(operation)) { + report.endpointCoverage.push({ operation, via: 'missing' }) + } + } + report.missingOpenAPIOperations = [...operationKeys].filter(operation => !coveredOperations.has(operation)).sort() + await writeReport() +} + +async function runAccountLoginCoverage(instance) { + await runCli(['accounts', 'add', '--json'], { instance, allowFailure: true }) + coveredCommands.add('accounts add') + + if (!bridgeAccount) { + report.bridgeAccountLogin = { + status: 'requires-user-input', + message: 'Set BEEPER_E2E_BRIDGE_ACCOUNT plus BEEPER_E2E_BRIDGE_FIELD/BEEPER_E2E_BRIDGE_COOKIE/BEEPER_E2E_BRIDGE_FLOW as needed to run a real bridge login flow.', + } + if (process.env.BEEPER_E2E_REQUIRE_BRIDGE_ACCOUNT === '1') { + throw new Error(report.bridgeAccountLogin.message) + } + return + } + + const args = ['accounts', 'add', bridgeAccount, '--json'] + for (const value of splitEnvList(process.env.BEEPER_E2E_BRIDGE_FIELD)) args.push('--field', value) + for (const value of splitEnvList(process.env.BEEPER_E2E_BRIDGE_COOKIE)) args.push('--cookie', value) + if (process.env.BEEPER_E2E_BRIDGE_FLOW) args.push('--flow', process.env.BEEPER_E2E_BRIDGE_FLOW) + if (process.env.BEEPER_E2E_BRIDGE_LOGIN_ID) args.push('--login-id', process.env.BEEPER_E2E_BRIDGE_LOGIN_ID) + args.push('--non-interactive') + await runCli(args, { instance, allowFailure: true }) + report.bridgeAccountLogin = { + status: 'attempted', + bridgeAccount, + } +} + +function commandEndpointCoverage() { + return [ + ['POST', '/v1/focus', 'focus'], + ['POST', '/v1/chats', 'create-chat'], + ['POST', '/v1/chats/start', 'start-chat'], + ['PATCH', '/v1/chats/{chatID}', 'draft, clear-draft, mute, title, description, avatar, etc.'], + ['POST', '/v1/chats/{chatID}/archive', 'archive, unarchive'], + ['POST', '/v1/chats/{chatID}/reminders', 'remind'], + ['DELETE', '/v1/chats/{chatID}/reminders', 'unremind'], + ['POST', '/v1/chats/{chatID}/read', 'read'], + ['POST', '/v1/chats/{chatID}/unread', 'unread'], + ['POST', '/v1/chats/{chatID}/notify-anyway', 'notify-anyway'], + ['POST', '/v1/chats/{chatID}/messages', 'send text, send file'], + ['PUT', '/v1/chats/{chatID}/messages/{messageID}', 'edit'], + ['DELETE', '/v1/chats/{chatID}/messages/{messageID}', 'delete-message'], + ['POST', '/v1/chats/{chatID}/messages/{messageID}/reactions', 'react'], + ['DELETE', '/v1/chats/{chatID}/messages/{messageID}/reactions/{reactionKey}', 'unreact'], + ['POST', '/v1/assets/download', 'assets download'], + ['POST', '/v1/assets/upload', 'assets upload, send file'], + ['POST', '/v1/app/login/start', 'login --app-login'], + ['POST', '/v1/app/login/email', 'login --app-login'], + ['POST', '/v1/app/login/response', 'login --app-login'], + ['POST', '/v1/app/login/register', 'login --app-login for new qatest account'], + ['GET', '/v1/app/status', 'app status, login smart probe'], + ['POST', '/v1/app/e2ee/recovery-code/verify', 'app e2ee recovery-code verify'], + ['POST', '/v1/app/e2ee/recovery-code/reset', 'app e2ee recovery-code reset begin'], + ['POST', '/v1/app/e2ee/recovery-code/reset/confirm', 'app e2ee recovery-code reset confirm'], + ['POST', '/v1/app/e2ee/recovery-code/mark-backed-up', 'app e2ee recovery-code mark-backed-up'], + ['POST', '/v1/app/e2ee/verification', 'app e2ee verification start'], + ['POST', '/v1/app/e2ee/verification/qr/scan', 'app e2ee verification qr scan'], + ['POST', '/v1/app/e2ee/verification/{verificationID}/accept', 'app e2ee verification accept'], + ['POST', '/v1/app/e2ee/verification/{verificationID}/cancel', 'app e2ee verification cancel'], + ['POST', '/v1/app/e2ee/verification/{verificationID}/qr/confirm-scanned', 'app e2ee verification qr confirm-scanned'], + ['POST', '/v1/app/e2ee/verification/{verificationID}/sas/start', 'app e2ee verification sas start'], + ['POST', '/v1/app/e2ee/verification/{verificationID}/sas/confirm', 'app e2ee verification sas confirm'], + ] +} + +function bridgeProvisionEndpoint(context, suffix) { + const bridgeID = process.env.BEEPER_E2E_PROVISION_BRIDGE || bridgeAccount || (context.bridgeID === 'matrix' ? 'discordgo' : context.bridgeID) + return `/_matrix/client/unstable/com.beeper.bridge/${encodeURIComponent(bridgeID)}/_matrix/provision${suffix}` +} + +async function uploadBase64AssetForCoverage(instance, recordCoverage) { + const endpoint = '/v1/assets/upload/base64' + const body = { + content: Buffer.from(`Beeper CLI E2E base64 fixture ${runID}\n`).toString('base64'), + fileName: 'fixture.txt', + mimeType: 'text/plain', + } + try { + const response = await rawRequest(instance, 'POST', endpoint, body) + report.endpoints.push(await endpointReportEntry('POST', endpoint, response)) + recordCoverage('POST', endpoint, { via: 'raw', endpoint, status: response.status }) + if (!response.ok) return undefined + return response.json() + } catch (error) { + recordCoverage('POST', endpoint, { + via: 'raw', + endpoint, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +async function createMatrixRoomForCoverage(instance, recordCoverage) { + const endpoint = '/_matrix/client/v3/createRoom' + try { + const response = await rawRequest(instance, 'POST', endpoint, { + name: `CLI E2E ${runID}`, + preset: 'private_chat', + }) + report.endpoints.push(await endpointReportEntry('POST', endpoint, response)) + recordCoverage('POST', endpoint, { via: 'raw', endpoint, status: response.status }) + if (!response.ok) return undefined + const body = await response.json() + return body.room_id ?? body.roomId + } catch (error) { + recordCoverage('POST', endpoint, { + via: 'raw', + endpoint, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +async function registerOAuthClientForCoverage(instance, rawChecks, recordCoverage) { + const registerCheck = rawChecks.find(([method, specPath]) => method === 'POST' && specPath === '/oauth/register') + if (!registerCheck) return undefined + const [, specPath, endpoint, body] = registerCheck + try { + const response = await rawRequest(instance, 'POST', endpoint, body) + report.endpoints.push(await endpointReportEntry('POST', endpoint, response)) + recordCoverage('POST', specPath, { via: 'raw', endpoint, status: response.status }) + if (!response.ok) return undefined + return response.json() + } catch (error) { + recordCoverage('POST', specPath, { + via: 'raw', + endpoint, + error: error instanceof Error ? error.message : String(error), + }) + return undefined + } +} + +async function runWatchAndRpcCommands(instance, context) { + await runCli(['rpc'], { + instance, + input: JSON.stringify({ id: 1, args: ['status', '--json'] }) + '\n', + timeoutMs: 20_000, + }) + coveredCommands.add('rpc') + + await runCli(['shell'], { + instance, + input: 'status --json\nquit\n', + timeoutMs: 20_000, + }) + coveredCommands.add('shell') + + await runCli(['watch', '--json', '--chat', context.chatID], { + instance, + timeoutMs: 5000, + allowTimeout: true, + }) + coveredCommands.add('watch') + coveredCommands.add('watch') +} + +async function runCleanupCommands(instance) { + await runCli(['logout'], { instance, allowFailure: true }) + await runCli(['config', 'reset'], { instance, allowFailure: true }) + coveredCommands.add('logout') + coveredCommands.add('config reset') +} + +async function runE2EECommands(instance) { + const status = parseJSON((await runCli(['app', 'status', '--json'], { instance })).stdout, 'app status') + if (status.state === 'needs-login') throw new Error('cannot cover app e2ee commands before login') + + await runCli(['app', 'e2ee', 'recovery-code', 'reset', 'begin', '--json'], { instance, allowFailure: true }) + coveredCommands.add('app e2ee recovery-code reset begin') + + const verification = status.verification + const verificationID = verification?.verificationID || 'missing-verification-id' + await runCli(['app', 'e2ee', 'verification', 'start', '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'accept', verificationID, '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'sas', 'start', verificationID, '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'sas', 'confirm', verificationID, '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'qr', 'scan', 'invalid-e2e-test-qr', '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'qr', 'confirm-scanned', verificationID, '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'verification', 'cancel', verificationID, '--reason', 'e2e cleanup', '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'recovery-code', 'verify', 'invalid-e2e-test-code', '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'recovery-code', 'mark-backed-up', '--json'], { instance, allowFailure: true }) + await runCli(['app', 'e2ee', 'recovery-code', 'reset', 'confirm', 'invalid-e2e-test-code', '--json'], { instance, allowFailure: true }) + + for (const command of [ + 'app e2ee verification start', + 'app e2ee verification accept', + 'app e2ee verification sas start', + 'app e2ee verification sas confirm', + 'app e2ee verification qr scan', + 'app e2ee verification qr confirm-scanned', + 'app e2ee verification cancel', + 'app e2ee recovery-code verify', + 'app e2ee recovery-code mark-backed-up', + 'app e2ee recovery-code reset confirm', + ]) coveredCommands.add(command) +} + +async function runCrossInstanceMessaging(context) { + if (report.instances.length < 2) return + for (let index = 1; index < report.instances.length; index++) { + const sender = report.instances[index] + const target = report.instances[0] + if (!target.userID) continue + await runCli([ + 'start-chat', + '--id', target.userID, + '--message', `cli e2e cross-instance ${runID} from ${sender.profile}`, + '--allow-invite', + '--json', + ], { instance: sender, allowFailure: true }) + } +} + +async function createFixtureFile() { + const fixture = path.join(workDir, 'fixture.txt') + await writeFile(fixture, `Beeper CLI E2E fixture ${runID}\n`) + report.artifacts.fixture = fixture + return fixture +} + +async function runCli(args, options = {}) { + const commandName = commandFromArgs(args) + process.stderr.write(`[e2e] ${args.join(' ')}\n`) + const env = { + ...process.env, + ...(options.instance ? { + BEEPER_CLI_CONFIG_DIR: options.instance.configDir, + BEEPER_DESKTOP_BASE_URL: options.instance.baseURL, + } : {}), + } + const result = await runProcess(process.execPath, [cliBin, ...args], { + cwd: repoRoot, + env, + input: options.input, + timeoutMs: options.timeoutMs ?? 60_000, + allowTimeout: options.allowTimeout, + }) + const entry = { + command: args.join(' '), + commandName, + code: result.code, + timedOut: result.timedOut, + stdoutBytes: result.stdout.length, + stderr: result.stderr.slice(-2000), + } + if (options.record !== false) report.commands.push(entry) + if (options.record !== false) await writeReport() + if (result.code === 0 || options.allowFailure || result.timedOut && options.allowTimeout) { + coveredCommands.add(commandName) + return result + } + throw new Error(`Command failed: ${args.join(' ')}\n${result.stderr}\n${result.stdout}`) +} + +function runProcess(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + let settled = false + let timedOut = false + const timeout = setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + setTimeout(() => child.kill('SIGKILL'), 2000).unref() + }, options.timeoutMs ?? 60_000) + + child.stdout.on('data', chunk => { stdout += chunk.toString() }) + child.stderr.on('data', chunk => { stderr += chunk.toString() }) + child.on('error', error => { + if (settled) return + settled = true + clearTimeout(timeout) + reject(error) + }) + child.on('close', (code, signal) => { + if (settled) return + settled = true + clearTimeout(timeout) + if (timedOut && !options.allowTimeout) { + reject(new Error(`${command} ${args.join(' ')} timed out\n${stderr}\n${stdout}`)) + return + } + resolve({ code: code ?? 0, signal, stdout, stderr, timedOut }) + }) + + if (options.input) child.stdin.end(options.input) + else child.stdin.end() + }) +} + +function commandFromArgs(args) { + const aliases = { + login: 'login', + logout: 'logout', + watch: 'watch', + chat: 'chat', + chats: 'chats', + 'current-user': 'current-user', + 'read': 'read', + 'unread': 'unread', + } + const joined = [] + for (const arg of args) { + if (arg.startsWith('-')) break + joined.push(arg) + } + const key = joined.join(' ') + if (aliases[key]) return aliases[key] + return key +} + +function parseJSON(stdout, label) { + try { + return JSON.parse(stdout) + } catch (error) { + throw new Error(`${label} did not return JSON: ${error instanceof Error ? error.message : String(error)}\n${stdout}`) + } +} + +function firstItem(value) { + const items = firstArray(value) + return items[0] +} + +function firstArray(value) { + if (Array.isArray(value)) return value + if (Array.isArray(value?.items)) return value.items + if (Array.isArray(value?.data)) return value.data + return [] +} + +function firstWritableChat(chats) { + return chats.find(chat => chat && !chat.isReadOnly && (chat.id || chat.chatID)) ?? chats.find(chat => chat?.id || chat?.chatID) +} + +function authHeaders(instance) { + return { headers: { Authorization: `Bearer ${instance.accessToken}` } } +} + +async function fetchJSON(url, init) { + const response = await fetchWithTimeout(url, init) + if (!response.ok) throw new Error(`${url} failed: ${response.status} ${await response.text()}`) + return response.json() +} + +async function rawRequest(instance, method, endpoint, body) { + const headers = { Authorization: `Bearer ${instance.accessToken}` } + let requestBody + if (body instanceof URLSearchParams) { + headers['content-type'] = 'application/x-www-form-urlencoded' + requestBody = body + } else if (body) { + headers['content-type'] = 'application/json' + requestBody = JSON.stringify(body) + } + return fetchWithTimeout(new URL(endpoint, instance.baseURL), { method, headers, body: requestBody }) +} + +async function endpointReportEntry(method, endpoint, response) { + let bodySample + if (response.status >= 400) { + try { + bodySample = (await response.clone().text()).slice(0, 1000) + } catch { + bodySample = '<failed to read response body>' + } + } + return { method, endpoint, status: response.status, bodySample } +} + +function splitEnvList(value) { + return value ? value.split(',').map(item => item.trim()).filter(Boolean) : [] +} + +async function writeReport() { + await mkdir(path.dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n') +} + +async function cleanup() { + if (cleanedUp) return + cleanedUp = true + await writeReport().catch(() => {}) + if (keepDesktop) return + for (const { child } of children.reverse()) { + if (child.exitCode === null) child.kill('SIGTERM') + } + await sleep(1000) + for (const { child } of children.reverse()) { + if (child.exitCode === null) child.kill('SIGKILL') + child.stdout?.destroy() + child.stderr?.destroy() + child.unref() + } + for (const { log } of children) { + log.destroy() + } +} + +process.on('SIGINT', () => { + cleanup().finally(() => process.exit(130)) +}) +process.on('SIGTERM', () => { + cleanup().finally(() => process.exit(143)) +}) + +main() + .catch(async error => { + report.error = error instanceof Error ? { message: error.message, stack: error.stack } : { message: String(error) } + await cleanup() + console.error(error instanceof Error ? error.stack || error.message : String(error)) + process.exit(1) + }) + .finally(async () => { + await cleanup() + }) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..64a740e --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "isolatedModules": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go deleted file mode 100644 index 6ee1774..0000000 --- a/pkg/cmd/account.go +++ /dev/null @@ -1,56 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var accountsList = cli.Command{ - Name: "list", - Usage: "Lists chat accounts across networks (WhatsApp, Telegram, Twitter/X, etc.)\nactively connected to this Beeper Desktop instance", - Suggest: true, - Flags: []cli.Flag{}, - Action: handleAccountsList, - HideHelpCommand: true, -} - -func handleAccountsList(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Accounts.List(ctx, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "accounts list", obj, format, transform) -} diff --git a/pkg/cmd/account_test.go b/pkg/cmd/account_test.go deleted file mode 100644 index cc8844f..0000000 --- a/pkg/cmd/account_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestAccountsList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "accounts", "list", - ) - }) -} diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go deleted file mode 100644 index 76cc862..0000000 --- a/pkg/cmd/accountcontact.go +++ /dev/null @@ -1,174 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var accountsContactsList = cli.Command{ - Name: "list", - Usage: "List merged contacts for a specific account with cursor-based pagination.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "cursor", - Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.", - QueryPath: "cursor", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", - QueryPath: "direction", - }, - &requestflag.Flag[int64]{ - Name: "limit", - Usage: "Maximum contacts to return per page.", - Default: 50, - QueryPath: "limit", - }, - &requestflag.Flag[string]{ - Name: "query", - Usage: "Optional search query for blended contact lookup.", - QueryPath: "query", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleAccountsContactsList, - HideHelpCommand: true, -} - -var accountsContactsSearch = cli.Command{ - Name: "search", - Usage: "Search contacts on a specific account using merged account contacts, network\nsearch, and exact identifier lookup.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "query", - Usage: "Text to search users by. Network-specific behavior.", - Required: true, - QueryPath: "query", - }, - }, - Action: handleAccountsContactsSearch, - HideHelpCommand: true, -} - -func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("account-id") && len(unusedArgs) > 0 { - cmd.Set("account-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AccountContactListParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Accounts.Contacts.List( - ctx, - cmd.Value("account-id").(string), - params, - options..., - ) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "accounts:contacts list", obj, format, transform) - } else { - iter := client.Accounts.Contacts.ListAutoPaging( - ctx, - cmd.Value("account-id").(string), - params, - options..., - ) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform, maxItems) - } -} - -func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("account-id") && len(unusedArgs) > 0 { - cmd.Set("account-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AccountContactSearchParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Accounts.Contacts.Search( - ctx, - cmd.Value("account-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "accounts:contacts search", obj, format, transform) -} diff --git a/pkg/cmd/accountcontact_test.go b/pkg/cmd/accountcontact_test.go deleted file mode 100644 index 36d7908..0000000 --- a/pkg/cmd/accountcontact_test.go +++ /dev/null @@ -1,37 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestAccountsContactsList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "accounts:contacts", "list", - "--max-items", "10", - "--account-id", "accountID", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - "--limit", "1", - "--query", "x", - ) - }) -} - -func TestAccountsContactsSearch(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "accounts:contacts", "search", - "--account-id", "accountID", - "--query", "x", - ) - }) -} diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go deleted file mode 100644 index d7fc779..0000000 --- a/pkg/cmd/asset.go +++ /dev/null @@ -1,226 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var assetsDownload = cli.Command{ - Name: "download", - Usage: "Download a Matrix asset using its mxc:// or localmxc:// URL to the device\nrunning Beeper Desktop and return the local file URL.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "url", - Usage: "Matrix content URL (mxc:// or localmxc://) for the asset to download.", - Required: true, - BodyPath: "url", - }, - }, - Action: handleAssetsDownload, - HideHelpCommand: true, -} - -var assetsServe = cli.Command{ - Name: "serve", - Usage: "Stream a file given an mxc://, localmxc://, or file:// URL. Downloads first if\nnot cached. Supports Range requests for seeking in large files.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "url", - Usage: "Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.", - Required: true, - QueryPath: "url", - }, - }, - Action: handleAssetsServe, - HideHelpCommand: true, -} - -var assetsUpload = cli.Command{ - Name: "upload", - Usage: "Upload a file to a temporary location using multipart/form-data. Returns an\nuploadID that can be referenced when sending messages with attachments.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to upload (max 500 MB).", - Required: true, - BodyPath: "file", - }, - &requestflag.Flag[string]{ - Name: "file-name", - Usage: "Original filename. Defaults to the uploaded file name if omitted", - BodyPath: "fileName", - }, - &requestflag.Flag[string]{ - Name: "mime-type", - Usage: "MIME type. Auto-detected from magic bytes if omitted", - BodyPath: "mimeType", - }, - }, - Action: handleAssetsUpload, - HideHelpCommand: true, -} - -var assetsUploadBase64 = cli.Command{ - Name: "upload-base64", - Usage: "Upload a file using a JSON body with base64-encoded content. Returns an uploadID\nthat can be referenced when sending messages with attachments. Alternative to\nthe multipart upload endpoint.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "content", - Usage: "Base64-encoded file content (max ~500MB decoded)", - Required: true, - BodyPath: "content", - }, - &requestflag.Flag[string]{ - Name: "file-name", - Usage: "Original filename. Generated if omitted", - BodyPath: "fileName", - }, - &requestflag.Flag[string]{ - Name: "mime-type", - Usage: "MIME type. Auto-detected from magic bytes if omitted", - BodyPath: "mimeType", - }, - }, - Action: handleAssetsUploadBase64, - HideHelpCommand: true, -} - -func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AssetDownloadParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Assets.Download(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets download", obj, format, transform) -} - -func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AssetServeParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Assets.Serve(ctx, params, options...) -} - -func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AssetUploadParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - MultipartFormEncoded, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Assets.Upload(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets upload", obj, format, transform) -} - -func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.AssetUploadBase64Params{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Assets.UploadBase64(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets upload-base64", obj, format, transform) -} diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go deleted file mode 100644 index 2ce574c..0000000 --- a/pkg/cmd/asset_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestAssetsDownload(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "assets", "download", - "--url", "mxc://example.org/Q4x9CqGz1pB3Oa6XgJ", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("url: mxc://example.org/Q4x9CqGz1pB3Oa6XgJ") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "assets", "download", - ) - }) -} - -func TestAssetsServe(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "assets", "serve", - "--url", "x", - ) - }) -} - -func TestAssetsUpload(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "assets", "upload", - "--file", "Example data", - "--file-name", "fileName", - "--mime-type", "mimeType", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "file: Example data\n" + - "fileName: fileName\n" + - "mimeType: mimeType\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "assets", "upload", - ) - }) -} - -func TestAssetsUploadBase64(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "assets", "upload-base64", - "--content", "x", - "--file-name", "fileName", - "--mime-type", "mimeType", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "content: x\n" + - "fileName: fileName\n" + - "mimeType: mimeType\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "assets", "upload-base64", - ) - }) -} diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go deleted file mode 100644 index bbef6b7..0000000 --- a/pkg/cmd/beeperdesktopapi.go +++ /dev/null @@ -1,130 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var focus = cli.Command{ - Name: "focus", - Usage: "Focus Beeper Desktop and optionally navigate to a specific chat, message, or\npre-fill draft text and attachment.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Optional Beeper chat ID (or local chat ID) to focus after opening the app. If omitted, only opens/focuses the app.", - BodyPath: "chatID", - }, - &requestflag.Flag[string]{ - Name: "draft-attachment-path", - Usage: "Optional draft attachment path to populate in the message input field.", - BodyPath: "draftAttachmentPath", - }, - &requestflag.Flag[string]{ - Name: "draft-text", - Usage: "Optional draft text to populate in the message input field.", - BodyPath: "draftText", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "Optional message ID. Jumps to that message in the chat when opening.", - BodyPath: "messageID", - }, - }, - Action: handleFocus, - HideHelpCommand: true, -} - -var search = cli.Command{ - Name: "search", - Usage: "Returns matching chats, participant name matches in groups, and the first page\nof messages in one call. Paginate messages via search-messages. Paginate chats\nvia search-chats.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "query", - Usage: "User-typed search text. Literal word matching (non-semantic).", - Required: true, - QueryPath: "query", - }, - }, - Action: handleSearch, - HideHelpCommand: true, -} - -func handleFocus(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.FocusParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Focus(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "focus", obj, format, transform) -} - -func handleSearch(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.SearchParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Search(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "search", obj, format, transform) -} diff --git a/pkg/cmd/beeperdesktopapi_test.go b/pkg/cmd/beeperdesktopapi_test.go deleted file mode 100644 index f07d043..0000000 --- a/pkg/cmd/beeperdesktopapi_test.go +++ /dev/null @@ -1,48 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestFocus(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "focus", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--draft-attachment-path", "draftAttachmentPath", - "--draft-text", "draftText", - "--message-id", "messageID", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "chatID: '!NCdzlIaMjZUmvmvyHU:beeper.com'\n" + - "draftAttachmentPath: draftAttachmentPath\n" + - "draftText: draftText\n" + - "messageID: messageID\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "focus", - ) - }) -} - -func TestSearch(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "search", - "--query", "x", - ) - }) -} diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go deleted file mode 100644 index 41a766b..0000000 --- a/pkg/cmd/chat.go +++ /dev/null @@ -1,437 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var chatsCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account to create or start the chat on.", - Required: true, - BodyPath: "accountID", - }, - &requestflag.Flag[bool]{ - Name: "allow-invite", - Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.", - Default: true, - BodyPath: "allowInvite", - }, - &requestflag.Flag[string]{ - Name: "message-text", - Usage: "Optional first message content if the platform requires it to create the chat.", - BodyPath: "messageText", - }, - &requestflag.Flag[string]{ - Name: "mode", - Usage: "Operation mode. Defaults to 'create' when omitted.", - BodyPath: "mode", - }, - &requestflag.Flag[[]string]{ - Name: "participant-id", - Usage: "Required when mode='create'. User IDs to include in the new chat.", - BodyPath: "participantIDs", - }, - &requestflag.Flag[string]{ - Name: "title", - Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.", - BodyPath: "title", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.", - BodyPath: "type", - }, - &requestflag.Flag[map[string]any]{ - Name: "user", - Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.", - BodyPath: "user", - }, - }, - Action: handleChatsCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "user": { - &requestflag.InnerFlag[string]{ - Name: "user.id", - Usage: "Known user ID when available.", - InnerField: "id", - }, - &requestflag.InnerFlag[string]{ - Name: "user.email", - Usage: "Email candidate.", - InnerField: "email", - }, - &requestflag.InnerFlag[string]{ - Name: "user.full-name", - Usage: "Display name hint used for ranking only.", - InnerField: "fullName", - }, - &requestflag.InnerFlag[string]{ - Name: "user.phone-number", - Usage: "Phone number candidate (E.164 preferred).", - InnerField: "phoneNumber", - }, - &requestflag.InnerFlag[string]{ - Name: "user.username", - Usage: "Username/handle candidate.", - InnerField: "username", - }, - }, -}) - -var chatsRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Retrieve chat details including metadata, participants, and latest message", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[any]{ - Name: "max-participant-count", - Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to all (-1).", - Default: -1, - QueryPath: "maxParticipantCount", - }, - }, - Action: handleChatsRetrieve, - HideHelpCommand: true, -} - -var chatsList = cli.Command{ - Name: "list", - Usage: "List all chats sorted by last activity (most recent first). Combines all\naccounts into a single paginated list.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[[]string]{ - Name: "account-id", - Usage: "Limit to specific account IDs. If omitted, fetches from all accounts.", - QueryPath: "accountIDs", - }, - &requestflag.Flag[string]{ - Name: "cursor", - Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.", - QueryPath: "cursor", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", - QueryPath: "direction", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleChatsList, - HideHelpCommand: true, -} - -var chatsArchive = cli.Command{ - Name: "archive", - Usage: "Archive or unarchive a chat. Set archived=true to move to archive,\narchived=false to move back to inbox", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[bool]{ - Name: "archived", - Usage: "True to archive, false to unarchive", - Default: true, - BodyPath: "archived", - }, - }, - Action: handleChatsArchive, - HideHelpCommand: true, -} - -var chatsSearch = cli.Command{ - Name: "search", - Usage: "Search chats by title/network or participants using Beeper Desktop's renderer\nalgorithm.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[[]string]{ - Name: "account-id", - Usage: "Provide an array of account IDs to filter chats from specific messaging accounts only", - QueryPath: "accountIDs", - }, - &requestflag.Flag[string]{ - Name: "cursor", - Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.", - QueryPath: "cursor", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", - QueryPath: "direction", - }, - &requestflag.Flag[string]{ - Name: "inbox", - Usage: `Filter by inbox type: "primary" (non-archived, non-low-priority), "low-priority", or "archive". If not specified, shows all chats.`, - QueryPath: "inbox", - }, - &requestflag.Flag[any]{ - Name: "include-muted", - Usage: "Include chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.", - Default: true, - QueryPath: "includeMuted", - }, - &requestflag.Flag[any]{ - Name: "last-activity-after", - Usage: "Provide an ISO datetime string to only retrieve chats with last activity after this time", - QueryPath: "lastActivityAfter", - }, - &requestflag.Flag[any]{ - Name: "last-activity-before", - Usage: "Provide an ISO datetime string to only retrieve chats with last activity before this time", - QueryPath: "lastActivityBefore", - }, - &requestflag.Flag[int64]{ - Name: "limit", - Usage: "Set the maximum number of chats to retrieve. Valid range: 1-200, default is 50", - Default: 50, - QueryPath: "limit", - }, - &requestflag.Flag[string]{ - Name: "query", - Usage: `Literal token search (non-semantic). Use single words users type (e.g., "dinner"). When multiple words provided, ALL must match. Case-insensitive.`, - QueryPath: "query", - }, - &requestflag.Flag[string]{ - Name: "scope", - Usage: "Search scope: 'titles' matches title + network; 'participants' matches participant names.", - Default: "titles", - QueryPath: "scope", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: `Specify the type of chats to retrieve: use "single" for direct messages, "group" for group chats, or "any" to get all types`, - Default: "any", - QueryPath: "type", - }, - &requestflag.Flag[any]{ - Name: "unread-only", - Usage: "Set to true to only retrieve chats that have unread messages", - QueryPath: "unreadOnly", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleChatsSearch, - HideHelpCommand: true, -} - -func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatNewParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats create", obj, format, transform) -} - -func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatGetParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.Get( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats retrieve", obj, format, transform) -} - -func handleChatsList(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatListParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.List(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats list", obj, format, transform) - } else { - iter := client.Chats.ListAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(os.Stdout, "chats list", iter, format, transform, maxItems) - } -} - -func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatArchiveParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - return client.Chats.Archive( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) -} - -func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatSearchParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.Search(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats search", obj, format, transform) - } else { - iter := client.Chats.SearchAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(os.Stdout, "chats search", iter, format, transform, maxItems) - } -} diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go deleted file mode 100644 index ff041f7..0000000 --- a/pkg/cmd/chat_test.go +++ /dev/null @@ -1,150 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" - "github.com/beeper/desktop-api-cli/internal/requestflag" -) - -func TestChatsCreate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "create", - "--account-id", "accountID", - "--allow-invite=true", - "--message-text", "messageText", - "--mode", "create", - "--participant-id", "string", - "--title", "title", - "--type", "single", - "--user.id", "id", - "--user.email", "email", - "--user.full-name", "fullName", - "--user.phone-number", "phoneNumber", - "--user.username", "username", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "accountID: accountID\n" + - "allowInvite: true\n" + - "messageText: messageText\n" + - "mode: create\n" + - "participantIDs:\n" + - " - string\n" + - "title: title\n" + - "type: single\n" + - "user:\n" + - " id: id\n" + - " email: email\n" + - " fullName: fullName\n" + - " phoneNumber: phoneNumber\n" + - " username: username\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "chats", "create", - ) - }) -} - -func TestChatsRetrieve(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "retrieve", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--max-participant-count", "50", - ) - }) -} - -func TestChatsList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "list", - "--max-items", "10", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - ) - }) -} - -func TestChatsArchive(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "archive", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--archived=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("archived: true") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "chats", "archive", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - }) -} - -func TestChatsSearch(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats", "search", - "--max-items", "10", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - "--inbox", "primary", - "--include-muted=true", - "--last-activity-after", "'2019-12-27T18:11:19.117Z'", - "--last-activity-before", "'2019-12-27T18:11:19.117Z'", - "--limit", "1", - "--query", "x", - "--scope", "titles", - "--type", "single", - "--unread-only=true", - ) - }) -} diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go deleted file mode 100644 index f72f189..0000000 --- a/pkg/cmd/chatmessagereaction.go +++ /dev/null @@ -1,159 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var chatsMessagesReactionsDelete = cli.Command{ - Name: "delete", - Usage: "Remove the authenticated user's reaction from an existing message.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "message-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "reaction-key", - Usage: "Reaction key to remove", - Required: true, - QueryPath: "reactionKey", - }, - }, - Action: handleChatsMessagesReactionsDelete, - HideHelpCommand: true, -} - -var chatsMessagesReactionsAdd = cli.Command{ - Name: "add", - Usage: "Add a reaction to an existing message.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "message-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "reaction-key", - Usage: "Reaction key to add (emoji, shortcode, or custom emoji key)", - Required: true, - BodyPath: "reactionKey", - }, - &requestflag.Flag[string]{ - Name: "transaction-id", - Usage: "Optional transaction ID for deduplication and local echo tracking", - BodyPath: "transactionID", - }, - }, - Action: handleChatsMessagesReactionsAdd, - HideHelpCommand: true, -} - -func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatMessageReactionDeleteParams{ - ChatID: cmd.Value("chat-id").(string), - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.Messages.Reactions.Delete( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions delete", obj, format, transform) -} - -func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatMessageReactionAddParams{ - ChatID: cmd.Value("chat-id").(string), - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Chats.Messages.Reactions.Add( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions add", obj, format, transform) -} diff --git a/pkg/cmd/chatmessagereaction_test.go b/pkg/cmd/chatmessagereaction_test.go deleted file mode 100644 index 74168cd..0000000 --- a/pkg/cmd/chatmessagereaction_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestChatsMessagesReactionsDelete(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats:messages:reactions", "delete", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--reaction-key", "x", - ) - }) -} - -func TestChatsMessagesReactionsAdd(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats:messages:reactions", "add", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--reaction-key", "x", - "--transaction-id", "transactionID", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "reactionKey: x\n" + - "transactionID: transactionID\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "chats:messages:reactions", "add", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - ) - }) -} diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go deleted file mode 100644 index 5f288e1..0000000 --- a/pkg/cmd/chatreminder.go +++ /dev/null @@ -1,119 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/urfave/cli/v3" -) - -var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "Set a reminder for a chat at a specific time", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[map[string]any]{ - Name: "reminder", - Usage: "Reminder configuration", - Required: true, - BodyPath: "reminder", - }, - }, - Action: handleChatsRemindersCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "reminder": { - &requestflag.InnerFlag[float64]{ - Name: "reminder.remind-at-ms", - Usage: "Unix timestamp in milliseconds when reminder should trigger", - InnerField: "remindAtMs", - }, - &requestflag.InnerFlag[bool]{ - Name: "reminder.dismiss-on-incoming-message", - Usage: "Cancel reminder if someone messages in the chat", - InnerField: "dismissOnIncomingMessage", - }, - }, -}) - -var chatsRemindersDelete = cli.Command{ - Name: "delete", - Usage: "Clear an existing reminder from a chat", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - }, - Action: handleChatsRemindersDelete, - HideHelpCommand: true, -} - -func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.ChatReminderNewParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - return client.Chats.Reminders.New( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) -} - -func handleChatsRemindersDelete(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Chats.Reminders.Delete(ctx, cmd.Value("chat-id").(string), options...) -} diff --git a/pkg/cmd/chatreminder_test.go b/pkg/cmd/chatreminder_test.go deleted file mode 100644 index 84f08fb..0000000 --- a/pkg/cmd/chatreminder_test.go +++ /dev/null @@ -1,62 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" - "github.com/beeper/desktop-api-cli/internal/requestflag" -) - -func TestChatsRemindersCreate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats:reminders", "create", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(chatsRemindersCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats:reminders", "create", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder.remind-at-ms", "0", - "--reminder.dismiss-on-incoming-message=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "reminder:\n" + - " remindAtMs: 0\n" + - " dismissOnIncomingMessage: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "chats:reminders", "create", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - }) -} - -func TestChatsRemindersDelete(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "chats:reminders", "delete", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - }) -} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go deleted file mode 100644 index 3351e7f..0000000 --- a/pkg/cmd/cmd.go +++ /dev/null @@ -1,239 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "bytes" - "compress/gzip" - "context" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/beeper/desktop-api-cli/internal/autocomplete" - "github.com/beeper/desktop-api-cli/internal/requestflag" - docs "github.com/urfave/cli-docs/v3" - "github.com/urfave/cli/v3" -) - -var ( - Command *cli.Command - CommandErrorBuffer bytes.Buffer -) - -func init() { - Command = &cli.Command{ - Name: "beeper-desktop-cli", - Usage: "CLI for the beeperdesktop API", - Suggest: true, - Version: Version, - ErrWriter: &CommandErrorBuffer, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "debug", - Usage: "Enable debug logging", - }, - &cli.StringFlag{ - Name: "base-url", - DefaultText: "url", - Usage: "Override the base URL for API requests", - }, - &cli.StringFlag{ - Name: "format", - Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "format-error", - Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "transform", - Usage: "The GJSON transformation for data output.", - }, - &cli.StringFlag{ - Name: "transform-error", - Usage: "The GJSON transformation for errors.", - }, - &requestflag.Flag[string]{ - Name: "access-token", - Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations.", - Sources: cli.EnvVars("BEEPER_ACCESS_TOKEN"), - }, - }, - Commands: []*cli.Command{ - &focus, - &search, - { - Name: "accounts", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &accountsList, - }, - }, - { - Name: "accounts:contacts", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &accountsContactsList, - &accountsContactsSearch, - }, - }, - { - Name: "chats", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &chatsCreate, - &chatsRetrieve, - &chatsList, - &chatsArchive, - &chatsSearch, - }, - }, - { - Name: "chats:reminders", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &chatsRemindersCreate, - &chatsRemindersDelete, - }, - }, - { - Name: "chats:messages:reactions", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &chatsMessagesReactionsDelete, - &chatsMessagesReactionsAdd, - }, - }, - { - Name: "messages", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &messagesUpdate, - &messagesList, - &messagesSearch, - &messagesSend, - }, - }, - { - Name: "assets", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &assetsDownload, - &assetsServe, - &assetsUpload, - &assetsUploadBase64, - }, - }, - { - Name: "info", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &infoRetrieve, - }, - }, - { - Name: "@manpages", - Usage: "Generate documentation for 'man'", - UsageText: "beeper-desktop-cli @manpages [-o beeper-desktop-cli.1] [--gzip]", - Hidden: true, - Action: generateManpages, - HideHelpCommand: true, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "output", - Aliases: []string{"o"}, - Usage: "write manpages to the given folder", - Value: "man", - }, - &cli.BoolFlag{ - Name: "gzip", - Aliases: []string{"z"}, - Usage: "output gzipped manpage files to .gz", - Value: true, - }, - &cli.BoolFlag{ - Name: "text", - Aliases: []string{"z"}, - Usage: "output uncompressed text files", - Value: false, - }, - }, - }, - { - Name: "__complete", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.ExecuteShellCompletion, - }, - { - Name: "@completion", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.OutputCompletionScript, - }, - }, - HideHelpCommand: true, - } -} - -func generateManpages(ctx context.Context, c *cli.Command) error { - manpage, err := docs.ToManWithSection(Command, 1) - if err != nil { - return err - } - dir := c.String("output") - err = os.MkdirAll(filepath.Join(dir, "man1"), 0755) - if err != nil { - // handle error - } - if c.Bool("text") { - file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-cli.1")) - if err != nil { - return err - } - defer file.Close() - if _, err := file.WriteString(manpage); err != nil { - return err - } - } - if c.Bool("gzip") { - file, err := os.Create(filepath.Join(dir, "man1", "beeper-desktop-cli.1.gz")) - if err != nil { - return err - } - defer file.Close() - gzWriter := gzip.NewWriter(file) - defer gzWriter.Close() - _, err = gzWriter.Write([]byte(manpage)) - if err != nil { - return err - } - } - fmt.Printf("Wrote manpages to %s\n", dir) - return nil -} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go deleted file mode 100644 index 9b24177..0000000 --- a/pkg/cmd/cmdutil.go +++ /dev/null @@ -1,465 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "mime" - "net/http" - "net/http/httputil" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strings" - "syscall" - - "github.com/beeper/desktop-api-cli/internal/jsonview" - "github.com/beeper/desktop-api-go/option" - - "github.com/charmbracelet/x/term" - "github.com/itchyny/json2yaml" - "github.com/muesli/reflow/wrap" - "github.com/tidwall/gjson" - "github.com/tidwall/pretty" - "github.com/urfave/cli/v3" -) - -var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} - -func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { - opts := []option.RequestOption{ - option.WithHeader("User-Agent", fmt.Sprintf("BeeperDesktop/CLI %s", Version)), - option.WithHeader("X-Stainless-Lang", "cli"), - option.WithHeader("X-Stainless-Package-Version", Version), - option.WithHeader("X-Stainless-Runtime", "cli"), - option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), - } - if cmd.IsSet("access-token") { - opts = append(opts, option.WithAccessToken(cmd.String("access-token"))) - } - - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { - opts = append(opts, option.WithBaseURL(baseURL)) - } - - return opts -} - -var debugMiddlewareOption = option.WithMiddleware( - func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { - logger := log.Default() - - if reqBytes, err := httputil.DumpRequest(r, true); err == nil { - logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(r) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - }, -) - -// isInputPiped tries to check for input being piped into the CLI which tells us that we should try to read -// from stdin. This can be a bit tricky in some cases like when an stdin is connected to a pipe but nothing is -// being piped in (this may happen in some environments like Cursor's integration terminal or CI), which is -// why this function is a little more elaborate than it'd be otherwise. -func isInputPiped() bool { - stat, err := os.Stdin.Stat() - if err != nil { - return false - } - - mode := stat.Mode() - - // Regular file (redirect like < file.txt) — only if non-empty. - // - // Notably, on Unix the case like `< /dev/null` is handled below because `/dev/null` is not a regular - // file. On Windows, NUL appears as a regular file with size 0, so it's also handled correctly. - if mode.IsRegular() && stat.Size() > 0 { - return true - } - - // For pipes/sockets (e.g. `echo foo | stainlesscli`), use an OS-specific check to determine whether - // data is actually available. Some environments like Cursor's integrated terminal connect stdin as a - // pipe even when nothing is being piped. - if mode&(os.ModeNamedPipe|os.ModeSocket) != 0 { - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return isPipedDataAvailableOSSpecific() - } - - return false -} - -func isTerminal(w io.Writer) bool { - switch v := w.(type) { - case *os.File: - return term.IsTerminal(v.Fd()) - default: - return false - } -} - -func streamOutput(label string, generateOutput func(w *os.File) error) error { - // For non-tty output (probably a pipe), write directly to stdout - if !isTerminal(os.Stdout) { - return streamToStdout(generateOutput) - } - - // When streaming output on Unix-like systems, there's a special trick involving creating two socket pairs - // that we prefer because it supports small buffer sizes which results in less pagination per buffer. The - // constructs needed to run it don't exist on Windows builds, so we have this function broken up into - // OS-specific files with conditional build comments. Under Windows (and in case our fancy constructs fail - // on Unix), we fall back to using pipes (`streamToPagerWithPipe`), which are OS agnostic. - // - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return streamOutputOSSpecific(label, generateOutput) -} - -func streamToPagerWithPipe(label string, generateOutput func(w *os.File) error) error { - r, w, err := os.Pipe() - if err != nil { - return err - } - defer r.Close() - defer w.Close() - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - if _, err := exec.LookPath(pagerProgram); err != nil { - return err - } - - cmd := exec.Command(pagerProgram) - cmd.Stdin = r - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), - "LESS=-X -r -P "+label, - "MORE=-r -P "+label, - ) - - if err := cmd.Start(); err != nil { - return err - } - - if err := r.Close(); err != nil { - return err - } - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - if err := generateOutput(w); err != nil && !strings.Contains(err.Error(), "broken pipe") { - return err - } - - w.Close() - return cmd.Wait() -} - -func streamToStdout(generateOutput func(w *os.File) error) error { - signal.Ignore(syscall.SIGPIPE) - err := generateOutput(os.Stdout) - if err != nil && strings.Contains(err.Error(), "broken pipe") { - return nil - } - return err -} - -func writeBinaryResponse(response *http.Response, outfile string) (string, error) { - defer response.Body.Close() - body, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - switch outfile { - case "-", "/dev/stdout": - _, err := os.Stdout.Write(body) - return "", err - case "": - // If output file is unspecified, then print to stdout for plain text or - // if stdout is not a terminal: - if !isTerminal(os.Stdout) || isUTF8TextFile(body) { - _, err := os.Stdout.Write(body) - return "", err - } - - // If response has a suggested filename in the content-disposition - // header, then use that (with an optional suffix to ensure uniqueness): - file, err := createDownloadFile(response, body) - if err != nil { - return "", err - } - defer file.Close() - if _, err := file.Write(body); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", file.Name()), nil - default: - if err := os.WriteFile(outfile, body, 0644); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", outfile), nil - } -} - -// Return a writable file handle to a new file, which attempts to choose a good filename -// based on the Content-Disposition header or sniffing the MIME filetype of the response. -func createDownloadFile(response *http.Response, data []byte) (*os.File, error) { - filename := "file" - // If the header provided an output filename, use that - disp := response.Header.Get("Content-Disposition") - _, params, err := mime.ParseMediaType(disp) - if err == nil { - if dispFilename, ok := params["filename"]; ok { - // Only use the last path component to prevent directory traversal - filename = filepath.Base(dispFilename) - // Try to create the file with exclusive flag to avoid race conditions - file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if err == nil { - return file, nil - } - } - } - - // If file already exists, create a unique filename using CreateTemp - ext := filepath.Ext(filename) - if ext == "" { - ext = guessExtension(data) - } - base := strings.TrimSuffix(filename, ext) - return os.CreateTemp(".", base+"-*"+ext) -} - -func guessExtension(data []byte) string { - ct := http.DetectContentType(data) - - // Prefer common extensions over obscure ones - switch ct { - case "application/gzip": - return ".gz" - case "application/pdf": - return ".pdf" - case "application/zip": - return ".zip" - case "audio/mpeg": - return ".mp3" - case "image/bmp": - return ".bmp" - case "image/gif": - return ".gif" - case "image/jpeg": - return ".jpg" - case "image/png": - return ".png" - case "image/webp": - return ".webp" - case "video/mp4": - return ".mp4" - } - - exts, err := mime.ExtensionsByType(ct) - if err == nil && len(exts) > 0 { - return exts[0] - } else if isUTF8TextFile(data) { - return ".txt" - } else { - return ".bin" - } -} - -func shouldUseColors(w io.Writer) bool { - force, ok := os.LookupEnv("FORCE_COLOR") - if ok { - if force == "1" { - return true - } - if force == "0" { - return false - } - } - return isTerminal(w) -} - -func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { - if format != "raw" && transform != "" { - transformed := res.Get(transform) - if transformed.Exists() { - res = transformed - } - } - switch strings.ToLower(format) { - case "auto": - return formatJSON(expectedOutput, title, res, "json", "") - case "pretty": - return []byte(jsonview.RenderJSON(title, res) + "\n"), nil - case "json": - prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(expectedOutput) { - return pretty.Color(prettyJSON, pretty.TerminalStyle), nil - } else { - return prettyJSON, nil - } - case "jsonl": - // @ugly is gjson syntax for "no whitespace", so it fits on one line - oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(expectedOutput) { - bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') - return bytes, nil - } else { - return []byte(oneLineJSON + "\n"), nil - } - case "raw": - return []byte(res.Raw + "\n"), nil - case "yaml": - input := strings.NewReader(res.Raw) - var yaml strings.Builder - if err := json2yaml.Convert(&yaml, input); err != nil { - return nil, err - } - _, err := expectedOutput.Write([]byte(yaml.String())) - return nil, err - default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) - } -} - -// Display JSON to the user in various different formats -func ShowJSON(out *os.File, title string, res gjson.Result, format string, transform string) error { - if format != "raw" && transform != "" { - transformed := res.Get(transform) - if transformed.Exists() { - res = transformed - } - } - - switch strings.ToLower(format) { - case "auto": - return ShowJSON(out, title, res, "json", "") - case "explore": - return jsonview.ExploreJSON(title, res) - default: - bytes, err := formatJSON(out, title, res, format, transform) - if err != nil { - return err - } - - _, err = out.Write(bytes) - return err - } -} - -// Get the number of lines that would be output by writing the data to the terminal -func countTerminalLines(data []byte, terminalWidth int) int { - return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) -} - -type HasRawJSON interface { - RawJSON() string -} - -// For an iterator over different value types, display its values to the user in -// different formats. -func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterator[T], format string, transform string, itemsToDisplay int64) error { - if format == "explore" { - return jsonview.ExploreJSONStream(title, iter) - } - - terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - terminalWidth = 100 - terminalHeight = 40 - } - - // Decide whether or not to use a pager based on whether it's a short output or a long output - usePager := false - output := []byte{} - numberOfNewlines := 0 - for iter.Next() { - if itemsToDisplay == 0 { - break - } - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - json, err := formatJSON(stdout, title, obj, format, transform) - if err != nil { - return err - } - - output = append(output, json...) - itemsToDisplay -= 1 - numberOfNewlines += countTerminalLines(json, terminalWidth) - - // If the output won't fit in the terminal window, stream it to a pager - if numberOfNewlines >= terminalHeight-3 { - usePager = true - break - } - } - - if !usePager { - _, err := stdout.Write(output) - if err != nil { - return err - } - - return iter.Err() - } - - return streamOutput(title, func(pager *os.File) error { - // Write the output we used during the initial terminal size computation - _, err := pager.Write(output) - if err != nil { - return err - } - - for iter.Next() { - if itemsToDisplay == 0 { - break - } - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - if err := ShowJSON(pager, title, obj, format, transform); err != nil { - return err - } - itemsToDisplay -= 1 - } - return iter.Err() - }) -} diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go deleted file mode 100644 index 0a46fd1..0000000 --- a/pkg/cmd/cmdutil_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package cmd - -import ( - "bytes" - "io" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestStreamOutput(t *testing.T) { - t.Setenv("PAGER", "cat") - err := streamOutput("stream test", func(w *os.File) error { - _, writeErr := w.WriteString("Hello world\n") - return writeErr - }) - if err != nil { - t.Errorf("streamOutput failed: %v", err) - } -} - -func TestWriteBinaryResponse(t *testing.T) { - t.Run("write to explicit file", func(t *testing.T) { - tmpDir := t.TempDir() - outfile := tmpDir + "/output.txt" - body := []byte("test content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - - msg, err := writeBinaryResponse(resp, outfile) - - require.NoError(t, err) - assert.Contains(t, msg, outfile) - - content, err := os.ReadFile(outfile) - require.NoError(t, err) - assert.Equal(t, body, content) - }) - - t.Run("write to stdout", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - body := []byte("stdout content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - msg, err := writeBinaryResponse(resp, "-") - - w.Close() - os.Stdout = oldStdout - - require.NoError(t, err) - assert.Empty(t, msg) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - assert.Equal(t, body, buf.Bytes()) - }) -} - -func TestCreateDownloadFile(t *testing.T) { - t.Run("creates file with filename from header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "test.txt", filepath.Base(file.Name())) - - // Create a second file with the same name to ensure it doesn't clobber the first - resp2 := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file2, err := createDownloadFile(resp2, []byte("second content")) - require.NoError(t, err) - defer file2.Close() - assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name") - assert.Contains(t, filepath.Base(file2.Name()), "test") - }) - - t.Run("creates temp file when no header", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) - - resp := &http.Response{Header: http.Header{}} - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Contains(t, filepath.Base(file.Name()), "file-") - }) - - t.Run("prevents directory traversal", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "passwd", filepath.Base(file.Name())) - }) -} diff --git a/pkg/cmd/cmdutil_unix.go b/pkg/cmd/cmdutil_unix.go deleted file mode 100644 index edefcd7..0000000 --- a/pkg/cmd/cmdutil_unix.go +++ /dev/null @@ -1,127 +0,0 @@ -//go:build !windows - -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -func isPipedDataAvailableOSSpecific() bool { - // Try to determine if there's non-empty data being piped into the command by polling for data for a short - // amount of time. This is necessary because some environments (e.g. Cursor's integrated terminal) connect - // stdin as a pipe even when nothing is being piped, which would cause the command to block indefinitely - // waiting for input that will never come. The 10 ms timeout is arbitrary -- designed to be long enough to - // allow data to be detected, but short enough that it shouldn't cause a noticeable delay in command runs. - fds := []unix.PollFd{{Fd: int32(os.Stdin.Fd()), Events: unix.POLLIN}} - n, _ := unix.Poll(fds, 10 /* ms */) - return n > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // Try to use socket pair for better buffer control - pagerInput, pid, err := openSocketPairPager(label) - if err != nil || pagerInput == nil { - // Fall back to pipe if socket setup fails - return streamToPagerWithPipe(label, generateOutput) - } - defer pagerInput.Close() - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - // If the pager exits before reading all input, then generateOutput() will - // produce a broken pipe error, which is fine and we don't want to propagate it. - if err := generateOutput(pagerInput); err != nil && - !strings.Contains(err.Error(), "broken pipe") { - return err - } - - // Close the file NOW before we wait for the child process to terminate. - // This way, the child will receive the end-of-file signal and know that - // there is no more input. Otherwise the child process may block - // indefinitely waiting for another line (this can happen when streaming - // less than a screenful of data to a pager). - pagerInput.Close() - - // Wait for child process to exit - var wstatus syscall.WaitStatus - _, err = syscall.Wait4(pid, &wstatus, 0, nil) - if wstatus.ExitStatus() != 0 { - return fmt.Errorf("Pager exited with non-zero exit status: %d", wstatus.ExitStatus()) - } - return err -} - -func openSocketPairPager(label string) (*os.File, int, error) { - fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) - if err != nil { - return nil, 0, err - } - - // The child file descriptor will be sent to the child process through - // ProcAttr and ForkExec(), while the parent process will always close the - // child file descriptor. - // The parent file descriptor will be wrapped in an os.File wrapper and - // returned from this function, or closed if something goes wrong. - parentFd, childFd := fds[0], fds[1] - defer unix.Close(childFd) - - // Use small buffer sizes so we don't ask the server for more paginated - // values than we actually need. - if err := unix.SetsockoptInt(parentFd, unix.SOL_SOCKET, unix.SO_SNDBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - if err := unix.SetsockoptInt(childFd, unix.SOL_SOCKET, unix.SO_RCVBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - // Set CLOEXEC on the parent file descriptor so it doesn't leak to child - syscall.CloseOnExec(parentFd) - - parentConn := os.NewFile(uintptr(parentFd), "parent-socket") - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - pagerPath, err := exec.LookPath(pagerProgram) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - env := os.Environ() - env = append(env, "LESS=-r -P "+label) - env = append(env, "MORE=-r -P "+label) - - procAttr := &syscall.ProcAttr{ - Dir: "", - Env: env, - Files: []uintptr{ - uintptr(childFd), // stdin (fd 0) - uintptr(syscall.Stdout), // stdout (fd 1) - uintptr(syscall.Stderr), // stderr (fd 2) - }, - } - - pid, err := syscall.ForkExec(pagerPath, []string{pagerProgram}, procAttr) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - return parentConn, pid, nil -} diff --git a/pkg/cmd/cmdutil_windows.go b/pkg/cmd/cmdutil_windows.go deleted file mode 100644 index 49b025e..0000000 --- a/pkg/cmd/cmdutil_windows.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build windows - -package cmd - -import ( - "os" - "syscall" - "unsafe" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe") -) - -func isPipedDataAvailableOSSpecific() bool { - // On Windows, unix.Poll is not available. Use PeekNamedPipe to check if data is available - // on the pipe without consuming it. - var available uint32 - r, _, _ := procPeekNamedPipe.Call( - os.Stdin.Fd(), - 0, - 0, - 0, - uintptr(unsafe.Pointer(&available)), - 0, - ) - return r != 0 && available > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't - // available on Windows, so we fall back to using pipes. - return streamToPagerWithPipe(label, generateOutput) -} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go deleted file mode 100644 index 4a39cd0..0000000 --- a/pkg/cmd/flagoptions.go +++ /dev/null @@ -1,375 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "maps" - "mime/multipart" - "net/http" - "os" - "reflect" - "strings" - "unicode/utf8" - - "github.com/beeper/desktop-api-cli/internal/apiform" - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/debugmiddleware" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go/option" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -type BodyContentType int - -const ( - EmptyBody BodyContentType = iota - MultipartFormEncoded - ApplicationJSON - ApplicationOctetStream -) - -type FileEmbedStyle int - -const ( - EmbedText FileEmbedStyle = iota - EmbedIOReader -) - -func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { - if obj == nil { - return obj, nil - } - v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle) - if err != nil { - return nil, err - } - return result.Interface(), nil -} - -// Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { - // Unwrap interface values to get the concrete type - if v.Kind() == reflect.Interface { - if v.IsNil() { - return v, nil - } - v = v.Elem() - } - - switch v.Kind() { - case reflect.Map: - if v.Len() == 0 { - return v, nil - } - // Always create map[string]any to handle potential type changes when embedding files - result := reflect.MakeMap(reflect.TypeOf(map[string]any{})) - - iter := v.MapRange() - for iter.Next() { - key := iter.Key() - val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle) - if err != nil { - return reflect.Value{}, err - } - result.SetMapIndex(key, newVal) - } - return result, nil - - case reflect.Slice, reflect.Array: - if v.Len() == 0 { - return v, nil - } - // Use `[]any` to allow for types to change when embedding files - result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) - for i := 0; i < v.Len(); i++ { - newVal, err := embedFilesValue(v.Index(i), embedStyle) - if err != nil { - return reflect.Value{}, err - } - result.Index(i).Set(newVal) - } - return result, nil - - case reflect.String: - s := v.String() - if literal, ok := strings.CutPrefix(s, "\\@"); ok { - // Allow for escaped @ signs if you don't want them to be treated as files - return reflect.ValueOf("@" + literal), nil - } - - if embedStyle == EmbedText { - if filename, ok := strings.CutPrefix(s, "@data://"); ok { - // The "@data://" prefix is for files you explicitly want to upload - // as base64-encoded (even if the file itself is plain text) - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@file://"); ok { - // The "@file://" prefix is for files that you explicitly want to - // upload as a string literal with backslash escapes (not base64 - // encoded) - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@"); ok { - content, err := os.ReadFile(filename) - if err != nil { - // If the string is "@username", it's probably supposed to be a - // string literal and not a file reference. However, if the - // string looks like "@file.txt" or "@/tmp/file", then it's - // probably supposed to be a file. - probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/") - if probablyFile { - // Give a useful error message if the user tried to upload a - // file, but the file couldn't be read (e.g. mistyped - // filename or permission error) - return v, err - } - // Fall back to the raw value if the user provided something - // like "@username" that's not intended to be a file. - return v, nil - } - // If the file looks like a plain text UTF8 file format, then use the contents directly. - if isUTF8TextFile(content) { - return reflect.ValueOf(string(content)), nil - } - // Otherwise it's a binary file, so encode it with base64 - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - } else { - if filename, ok := strings.CutPrefix(s, "@"); ok { - // Behavior is the same for @file, @data://file, and @file://file, except that - // @username will be treated as a literal string if no "username" file exists - expectsFile := true - if withoutPrefix, ok := strings.CutPrefix(filename, "data://"); ok { - filename = withoutPrefix - } else if withoutPrefix, ok := strings.CutPrefix(filename, "file://"); ok { - filename = withoutPrefix - } else { - expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") - } - - file, err := os.Open(filename) - if err != nil { - if !expectsFile { - // For strings that start with "@" and don't look like a filename, return the string - return v, nil - } - return v, err - } - return reflect.ValueOf(file), nil - } - } - return v, nil - - default: - return v, nil - } -} - -// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed -// to plain text (e.g. .txt or .md). -func isUTF8TextFile(content []byte) bool { - // Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and - // these are the sniffable content types that are plain text: - textTypes := []string{ - "text/", - "application/json", - "application/xml", - "application/javascript", - "application/x-javascript", - "application/ecmascript", - "application/x-ecmascript", - } - - contentType := http.DetectContentType(content) - for _, prefix := range textTypes { - if strings.HasPrefix(contentType, prefix) { - return utf8.Valid(content) - } - } - return false -} - -func flagOptions( - cmd *cli.Command, - nestedFormat apiquery.NestedQueryFormat, - arrayFormat apiquery.ArrayQueryFormat, - bodyType BodyContentType, - - // This parameter is true if stdin is already in use to pass a binary parameter by using the special value - // "-". In this case, we won't attempt to read it as a JSON/YAML blob for options setting. - ignoreStdin bool, -) ([]option.RequestOption, error) { - var options []option.RequestOption - if cmd.Bool("debug") { - options = append(options, option.WithMiddleware(debugmiddleware.NewRequestLogger().Middleware())) - } - - requestContents := requestflag.ExtractRequestContents(cmd) - - if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { - pipeData, err := io.ReadAll(os.Stdin) - if err != nil { - return nil, err - } - - if len(pipeData) > 0 { - var bodyData any - if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { - return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) - } - if bodyMap, ok := bodyData.(map[string]any); ok { - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap - } else { - bodyData = requestContents.Body - } - } else if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) - } else { - requestContents.Body = bodyData - } - } - } - - if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { - var buf bytes.Buffer - cli.HelpPrinter(&buf, cli.SubcommandHelpTemplate, cmd) - usage := buf.String() - if len(missingFlags) == 1 { - return nil, fmt.Errorf("%sRequired flag %q not set", usage, missingFlags[0].Names()[0]) - } else { - names := []string{} - for _, flag := range missingFlags { - names = append(names, flag.Names()[0]) - } - return nil, fmt.Errorf("%sRequired flags %q not set", usage, strings.Join(names, ", ")) - } - } - - // Embed files passed as "@file.jpg" in the request body, headers, and query: - embedStyle := EmbedText - if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { - embedStyle = EmbedIOReader - } - - if embedded, err := embedFiles(requestContents.Body, embedStyle); err != nil { - return nil, err - } else { - requestContents.Body = embedded - } - - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { - return nil, err - } else { - requestContents.Headers = headersWithFiles.(map[string]any) - } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { - return nil, err - } else { - requestContents.Queries = queriesWithFiles.(map[string]any) - } - - querySettings := apiquery.QuerySettings{ - NestedFormat: nestedFormat, - ArrayFormat: arrayFormat, - } - - // Add query parameters: - if values, err := apiquery.MarshalWithSettings(requestContents.Queries, querySettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithQueryDel(k)) - } else { - options = append(options, option.WithQuery(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithQueryAdd(k, v)) - } - } - } - } - - // Add header parameters - headerSettings := apiquery.QuerySettings{ - NestedFormat: apiquery.NestedQueryFormatDots, - ArrayFormat: apiquery.ArrayQueryFormatRepeat, - } - if values, err := apiquery.MarshalWithSettings(requestContents.Headers, headerSettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithHeaderDel(k)) - } else { - options = append(options, option.WithHeader(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithHeaderAdd(k, v)) - } - } - } - } - - switch bodyType { - case EmptyBody: - break - case MultipartFormEncoded: - buf := new(bytes.Buffer) - writer := multipart.NewWriter(buf) - - // For multipart/form-encoded, we need a map structure - bodyMap, ok := requestContents.Body.(map[string]any) - if !ok { - return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", requestContents.Body) - } - encodingFormat := apiform.FormatRepeat - if err := apiform.MarshalWithSettings(bodyMap, writer, encodingFormat); err != nil { - return nil, err - } - if err := writer.Close(); err != nil { - return nil, err - } - options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) - - case ApplicationJSON: - bodyBytes, err := json.Marshal(requestContents.Body) - if err != nil { - return nil, err - } - options = append(options, option.WithRequestBody("application/json", bodyBytes)) - - case ApplicationOctetStream: - // If there is a body root parameter, that will handle setting the request body, we don't need to do it here. - for _, flag := range cmd.Flags { - if toSend, ok := flag.(requestflag.InRequest); ok && toSend.IsBodyRoot() { - return options, nil - } - } - if bodyBytes, ok := requestContents.Body.([]byte); ok { - options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) - } else if bodyStr, ok := requestContents.Body.(string); ok { - options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) - } else { - return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", requestContents.Body) - } - - default: - panic("Invalid body content type!") - } - - return options, nil -} diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go deleted file mode 100644 index e5dad4b..0000000 --- a/pkg/cmd/flagoptions_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package cmd - -import ( - "encoding/base64" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIsUTF8TextFile(t *testing.T) { - tests := []struct { - content []byte - expected bool - }{ - {[]byte("Hello, world!"), true}, - {[]byte(`{"key": "value"}`), true}, - {[]byte(`<?xml version="1.0"?><root/>`), true}, - {[]byte(`function test() {}`), true}, - {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, false}, // JPEG header - {[]byte{0x00, 0x01, 0xFF, 0xFE}, false}, // binary - {[]byte("Hello \xFF\xFE"), false}, // invalid UTF-8 - {[]byte("Hello ☺️"), true}, // emoji - {[]byte{}, true}, // empty - } - - for _, tt := range tests { - assert.Equal(t, tt.expected, isUTF8TextFile(tt.content)) - } -} - -func TestEmbedFiles(t *testing.T) { - // Create temporary directory for test files - tmpDir := t.TempDir() - - // Create test files - configContent := "host=localhost\nport=8080" - templateContent := "<html><body>Hello</body></html>" - dataContent := `{"key": "value"}` - - writeTestFile(t, tmpDir, "config.txt", configContent) - writeTestFile(t, tmpDir, "template.html", templateContent) - writeTestFile(t, tmpDir, "data.json", dataContent) - jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46} - writeTestFile(t, tmpDir, "image.jpg", string(jpegHeader)) - - tests := []struct { - name string - input any - want any - wantErr bool - }{ - { - name: "map[string]any with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "template": "@file://" + filepath.Join(tmpDir, "template.html"), - "count": 42, - }, - want: map[string]any{ - "config": configContent, - "template": templateContent, - "count": 42, - }, - wantErr: false, - }, - { - name: "map[string]string with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "name": "test", - }, - want: map[string]any{ - "config": configContent, - "name": "test", - }, - wantErr: false, - }, - { - name: "[]any with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - 42, - true, - "@file://" + filepath.Join(tmpDir, "data.json"), - }, - want: []any{ - configContent, - 42, - true, - dataContent, - }, - wantErr: false, - }, - { - name: "[]string with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - "normal string", - }, - want: []any{ - configContent, - "normal string", - }, - wantErr: false, - }, - { - name: "nested structures", - input: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - map[string]any{ - "data": "@" + filepath.Join(tmpDir, "data.json"), - }, - }, - }, - }, - want: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - configContent, - map[string]any{ - "data": dataContent, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "base64 encoding", - input: map[string]any{ - "encoded": "@data://" + filepath.Join(tmpDir, "config.txt"), - "image": "@" + filepath.Join(tmpDir, "image.jpg"), - }, - want: map[string]any{ - "encoded": base64.StdEncoding.EncodeToString([]byte(configContent)), - "image": base64.StdEncoding.EncodeToString(jpegHeader), - }, - wantErr: false, - }, - { - name: "non-existent file with @ prefix", - input: map[string]any{ - "missing": "@file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "non-file-like thing with @ prefix", - input: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - want: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - wantErr: false, - }, - { - name: "non-existent file with @file:// prefix (error)", - input: map[string]any{ - "missing": "@file:///nonexistent/file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "escaping", - input: map[string]any{ - "simple": "\\@file.txt", - "file": "\\@file://file.txt", - "data": "\\@data://file.txt", - "keep_escape": "user\\@example.com", - }, - want: map[string]any{ - "simple": "@file.txt", - "file": "@file://file.txt", - "data": "@data://file.txt", - "keep_escape": "user\\@example.com", - }, - wantErr: false, - }, - { - name: "primitive types", - input: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - want: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - wantErr: false, - }, - { - name: "[]int values unchanged", - input: []int{1, 2, 3, 4, 5}, - want: []any{1, 2, 3, 4, 5}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) - if tt.wantErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - assert.Equal(t, tt.want, got) - } - }) - - t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) - if tt.wantErr { - assert.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -func writeTestFile(t *testing.T, dir, filename, content string) { - t.Helper() - path := filepath.Join(dir, filename) - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err, "failed to write test file %s", path) -} diff --git a/pkg/cmd/info.go b/pkg/cmd/info.go deleted file mode 100644 index 2b46d49..0000000 --- a/pkg/cmd/info.go +++ /dev/null @@ -1,56 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var infoRetrieve = cli.Command{ - Name: "retrieve", - Usage: "Returns app, platform, server, and endpoint discovery metadata for this Beeper\nDesktop instance.", - Suggest: true, - Flags: []cli.Flag{}, - Action: handleInfoRetrieve, - HideHelpCommand: true, -} - -func handleInfoRetrieve(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Info.Get(ctx, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "info retrieve", obj, format, transform) -} diff --git a/pkg/cmd/info_test.go b/pkg/cmd/info_test.go deleted file mode 100644 index 507d04e..0000000 --- a/pkg/cmd/info_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" -) - -func TestInfoRetrieve(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "info", "retrieve", - ) - }) -} diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go deleted file mode 100644 index b65cef7..0000000 --- a/pkg/cmd/message.go +++ /dev/null @@ -1,398 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - "os" - - "github.com/beeper/desktop-api-cli/internal/apiquery" - "github.com/beeper/desktop-api-cli/internal/requestflag" - "github.com/beeper/desktop-api-go" - "github.com/beeper/desktop-api-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var messagesUpdate = cli.Command{ - Name: "update", - Usage: "Edit the text content of an existing message. Messages with attachments cannot\nbe edited.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "message-id", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "text", - Usage: "New text content for the message", - Required: true, - BodyPath: "text", - }, - }, - Action: handleMessagesUpdate, - HideHelpCommand: true, -} - -var messagesList = cli.Command{ - Name: "list", - Usage: "List all messages in a chat with cursor-based pagination. Sorted by timestamp.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[string]{ - Name: "cursor", - Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.", - QueryPath: "cursor", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", - QueryPath: "direction", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMessagesList, - HideHelpCommand: true, -} - -var messagesSearch = cli.Command{ - Name: "search", - Usage: "Search messages across chats using Beeper's message index", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[[]string]{ - Name: "account-id", - Usage: "Limit search to specific account IDs.", - QueryPath: "accountIDs", - }, - &requestflag.Flag[[]string]{ - Name: "chat-id", - Usage: "Limit search to specific chat IDs.", - QueryPath: "chatIDs", - }, - &requestflag.Flag[string]{ - Name: "chat-type", - Usage: "Filter by chat type: 'group' for group chats, 'single' for 1:1 chats.", - QueryPath: "chatType", - }, - &requestflag.Flag[string]{ - Name: "cursor", - Usage: "Opaque pagination cursor; do not inspect. Use together with 'direction'.", - QueryPath: "cursor", - }, - &requestflag.Flag[any]{ - Name: "date-after", - Usage: "Only include messages with timestamp strictly after this ISO 8601 datetime (e.g., '2024-07-01T00:00:00Z' or '2024-07-01T00:00:00+02:00').", - QueryPath: "dateAfter", - }, - &requestflag.Flag[any]{ - Name: "date-before", - Usage: "Only include messages with timestamp strictly before this ISO 8601 datetime (e.g., '2024-07-31T23:59:59Z' or '2024-07-31T23:59:59+02:00').", - QueryPath: "dateBefore", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Pagination direction used with 'cursor': 'before' fetches older results, 'after' fetches newer results. Defaults to 'before' when only 'cursor' is provided.", - QueryPath: "direction", - }, - &requestflag.Flag[any]{ - Name: "exclude-low-priority", - Usage: "Exclude messages marked Low Priority by the user. Default: true. Set to false to include all.", - Default: true, - QueryPath: "excludeLowPriority", - }, - &requestflag.Flag[any]{ - Name: "include-muted", - Usage: "Include messages in chats marked as Muted by the user, which are usually less important. Default: true. Set to false if the user wants a more refined search.", - Default: true, - QueryPath: "includeMuted", - }, - &requestflag.Flag[int64]{ - Name: "limit", - Usage: "Maximum number of messages to return.", - Default: 20, - QueryPath: "limit", - }, - &requestflag.Flag[[]string]{ - Name: "media-type", - Usage: "Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering.", - QueryPath: "mediaTypes", - }, - &requestflag.Flag[string]{ - Name: "query", - Usage: `Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner plans", use "sick" not "health issues". If omitted, returns results filtered only by other parameters.`, - QueryPath: "query", - }, - &requestflag.Flag[string]{ - Name: "sender", - Usage: "Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id).", - QueryPath: "sender", - }, - &requestflag.Flag[int64]{ - Name: "max-items", - Usage: "The maximum number of items to return (use -1 for unlimited).", - }, - }, - Action: handleMessagesSearch, - HideHelpCommand: true, -} - -var messagesSend = requestflag.WithInnerFlags(cli.Command{ - Name: "send", - Usage: "Send a text message to a specific chat. Supports replying to existing messages.\nReturns a pending message ID.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, - }, - &requestflag.Flag[map[string]any]{ - Name: "attachment", - Usage: "Single attachment to send with the message", - BodyPath: "attachment", - }, - &requestflag.Flag[string]{ - Name: "reply-to-message-id", - Usage: "Provide a message ID to send this as a reply to an existing message", - BodyPath: "replyToMessageID", - }, - &requestflag.Flag[string]{ - Name: "text", - Usage: "Text content of the message you want to send. You may use markdown.", - BodyPath: "text", - }, - }, - Action: handleMessagesSend, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[string]{ - Name: "attachment.upload-id", - Usage: "Upload ID from uploadAsset endpoint. Required to reference uploaded files.", - InnerField: "uploadID", - }, - &requestflag.InnerFlag[float64]{ - Name: "attachment.duration", - Usage: "Duration in seconds (optional override of cached value)", - InnerField: "duration", - }, - &requestflag.InnerFlag[string]{ - Name: "attachment.file-name", - Usage: "Filename (optional override of cached value)", - InnerField: "fileName", - }, - &requestflag.InnerFlag[string]{ - Name: "attachment.mime-type", - Usage: "MIME type (optional override of cached value)", - InnerField: "mimeType", - }, - &requestflag.InnerFlag[map[string]any]{ - Name: "attachment.size", - Usage: "Dimensions (optional override of cached value)", - InnerField: "size", - }, - &requestflag.InnerFlag[string]{ - Name: "attachment.type", - Usage: "Special attachment type (gif, voiceNote, sticker). If omitted, auto-detected from mimeType", - InnerField: "type", - }, - }, -}) - -func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.MessageUpdateParams{ - ChatID: cmd.Value("chat-id").(string), - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Messages.Update( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "messages update", obj, format, transform) -} - -func handleMessagesList(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.MessageListParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Messages.List( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages list", obj, format, transform) - } else { - iter := client.Messages.ListAutoPaging( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(os.Stdout, "messages list", iter, format, transform, maxItems) - } -} - -func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.MessageSearchParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - EmptyBody, - false, - ) - if err != nil { - return err - } - - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - if format == "raw" { - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Messages.Search(ctx, params, options...) - if err != nil { - return err - } - obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages search", obj, format, transform) - } else { - iter := client.Messages.SearchAutoPaging(ctx, params, options...) - maxItems := int64(-1) - if cmd.IsSet("max-items") { - maxItems = cmd.Value("max-items").(int64) - } - return ShowJSONIterator(os.Stdout, "messages search", iter, format, transform, maxItems) - } -} - -func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { - client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("chat-id") && len(unusedArgs) > 0 { - cmd.Set("chat-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - params := beeperdesktopapi.MessageSendParams{} - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatRepeat, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Messages.Send( - ctx, - cmd.Value("chat-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "messages send", obj, format, transform) -} diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go deleted file mode 100644 index 11fe9a2..0000000 --- a/pkg/cmd/message_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/beeper/desktop-api-cli/internal/mocktest" - "github.com/beeper/desktop-api-cli/internal/requestflag" -) - -func TestMessagesUpdate(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "messages", "update", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - "--text", "x", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("text: x") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "messages", "update", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", - ) - }) -} - -func TestMessagesList(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "messages", "list", - "--max-items", "10", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--direction", "before", - ) - }) -} - -func TestMessagesSearch(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "messages", "search", - "--max-items", "10", - "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--chat-id", "1231073", - "--chat-type", "group", - "--cursor", "1725489123456|c29tZUltc2dQYWdl", - "--date-after", "'2025-08-01T00:00:00Z'", - "--date-before", "'2025-08-31T23:59:59Z'", - "--direction", "before", - "--exclude-low-priority=true", - "--include-muted=true", - "--limit", "20", - "--media-type", "any", - "--query", "dinner", - "--sender", "sender", - ) - }) -} - -func TestMessagesSend(t *testing.T) { - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "messages", "send", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: gif}", - "--reply-to-message-id", "replyToMessageID", - "--text", "text", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(messagesSend) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--access-token", "string", - "messages", "send", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--attachment.upload-id", "uploadID", - "--attachment.duration", "0", - "--attachment.file-name", "fileName", - "--attachment.mime-type", "mimeType", - "--attachment.size", "{height: 0, width: 0}", - "--attachment.type", "gif", - "--reply-to-message-id", "replyToMessageID", - "--text", "text", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachment:\n" + - " uploadID: uploadID\n" + - " duration: 0\n" + - " fileName: fileName\n" + - " mimeType: mimeType\n" + - " size:\n" + - " height: 0\n" + - " width: 0\n" + - " type: gif\n" + - "replyToMessageID: replyToMessageID\n" + - "text: text\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--access-token", "string", - "messages", "send", - "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - ) - }) -} diff --git a/pkg/cmd/suggest.go b/pkg/cmd/suggest.go deleted file mode 100644 index b4b637c..0000000 --- a/pkg/cmd/suggest.go +++ /dev/null @@ -1,126 +0,0 @@ -package cmd - -import ( - "fmt" - "math" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -// This entire file is mostly taken from urfave/cli/v3's source, with the exception of suggestCommand which is -// modified for a nicer error message. - -// jaroDistance is the measure of similarity between two strings. It returns a -// value between 0 and 1, where 1 indicates identical strings and 0 indicates -// completely different strings. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go. -func jaroDistance(a, b string) float64 { - if len(a) == 0 && len(b) == 0 { - return 1 - } - if len(a) == 0 || len(b) == 0 { - return 0 - } - - lenA := float64(len(a)) - lenB := float64(len(b)) - hashA := make([]bool, len(a)) - hashB := make([]bool, len(b)) - maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1)) - - var matches float64 - for i := 0; i < len(a); i++ { - start := int(math.Max(0, float64(i-maxDistance))) - end := int(math.Min(lenB-1, float64(i+maxDistance))) - - for j := start; j <= end; j++ { - if hashB[j] { - continue - } - if a[i] == b[j] { - hashA[i] = true - hashB[j] = true - matches++ - break - } - } - } - if matches == 0 { - return 0 - } - - var transpositions float64 - var j int - for i := 0; i < len(a); i++ { - if !hashA[i] { - continue - } - for !hashB[j] { - j++ - } - if a[i] != b[j] { - transpositions++ - } - j++ - } - - transpositions /= 2 - return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0 -} - -// jaroWinkler is more accurate when strings have a common prefix up to a -// defined maximum length. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go. -func jaroWinkler(a, b string) float64 { - const ( - boostThreshold = 0.7 - prefixSize = 4 - ) - jaroDist := jaroDistance(a, b) - if jaroDist <= boostThreshold { - return jaroDist - } - - prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b))))) - - var prefixMatch float64 - for i := 0; i < prefix; i++ { - if a[i] == b[i] { - prefixMatch++ - } else { - break - } - } - return jaroDist + 0.1*prefixMatch*(1.0-jaroDist) -} - -// suggestCommand takes a list of commands and a provided string to suggest a -// command name -func suggestCommand(commands []*cli.Command, provided string) string { - distance := 0.0 - var lineage []*cli.Command - for _, command := range commands { - for _, name := range command.Names() { - newDistance := jaroWinkler(name, provided) - if newDistance > distance { - distance = newDistance - lineage = command.Lineage() - } - } - } - - var parts []string - for _, command := range lineage { - parts = append(parts, command.Name) - } - slices.Reverse(parts) - return fmt.Sprintf("Did you mean '%s'?", strings.Join(parts, " ")) -} - -func init() { - cli.SuggestCommand = suggestCommand -} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go deleted file mode 100644 index 5266c84..0000000 --- a/pkg/cmd/version.go +++ /dev/null @@ -1,5 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -const Version = "0.3.0" // x-release-please-version diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..0466b2c --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3543 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@changesets/changelog-github': + specifier: ^0.6.0 + version: 0.6.0 + '@changesets/cli': + specifier: ^2.31.0 + version: 2.31.0(@types/node@20.19.41) + '@types/node': + specifier: ^20.0.0 + version: 20.19.41 + tsdown: + specifier: ^0.21.10 + version: 0.21.10(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.6(@types/node@20.19.41)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0)) + + packages/cli: + dependencies: + '@beeper/desktop-api': + specifier: ^5.0.0 + version: 5.0.0 + '@oclif/core': + specifier: ^4.11.2 + version: 4.11.3 + '@oclif/plugin-autocomplete': + specifier: ^3.2.49 + version: 3.2.49 + '@oclif/plugin-help': + specifier: ^6.2.48 + version: 6.2.48 + '@oclif/plugin-not-found': + specifier: ^3.2.85 + version: 3.2.85(@types/node@20.19.41) + '@oclif/plugin-plugins': + specifier: ^5.4.67 + version: 5.4.67 + figures: + specifier: ^6.1.0 + version: 6.1.0 + ink: + specifier: ^7.0.3 + version: 7.0.3(@types/react@19.2.14)(react@19.2.6) + ink-spinner: + specifier: ^5.0.0 + version: 5.0.0(ink@7.0.3(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + react: + specifier: ^19.2.6 + version: 19.2.6 + ws: + specifier: ^8.20.1 + version: 8.20.1 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.41 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + tsx: + specifier: ^4.21.0 + version: 4.22.0 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + +packages: + + '@alcalzone/ansi-tokenize@0.3.0': + resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} + engines: {node: '>=18'} + + '@babel/generator@8.0.0-rc.3': + resolution: {integrity: sha512-em37/13/nR320G4jab/nIIHZgc2Wz2y/D39lxnTyxB4/D/omPQncl/lSdlnJY1OhQcRGugTSIF2l/69o31C9dA==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-string-parser@8.0.0-rc.5': + resolution: {integrity: sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@8.0.0-rc.3': + resolution: {integrity: sha512-8AWCJ2VJJyDFlGBep5GpaaQ9AAaE/FjAcrqI7jyssYhtL7WGV0DOKpJsQqM037xDbpRLHXsY8TwU7zDma7coOw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/parser@8.0.0-rc.3': + resolution: {integrity: sha512-B20dvP3MfNc/XS5KKCHy/oyWl5IA6Cn9YjXRdDlCjNmUFrjvLXMNUfQq/QUy9fnG2gYkKKcrto2YaF9B32ToOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.0-rc.3': + resolution: {integrity: sha512-mOm5ZrYmphGfqVWoH5YYMTITb3cDXsFgmvFlvkvWDMsR9X8RFnt7a0Wb6yNIdoFsiMO9WjYLq+U/FMtqIYAF8Q==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@beeper/desktop-api@5.0.0': + resolution: {integrity: sha512-f9GhxQw6E0VMiVJGcdkUPNc6qCypaQlgMgxG6yU2lLy9gzx8xEObYnJbuhmT+HBg2SaQzNB33fC5ZT4OL3GnQw==} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/changelog-github@0.6.0': + resolution: {integrity: sha512-wA2/y4hR/A1K411cCT75rz0d46Iezxp1WYRFoFJDIUpkQ6oDBAIUiU7BZkDCmYgz0NBl94X1lgcZO+mHoiHnFg==} + + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-github-info@0.8.0': + resolution: {integrity: sha512-cRnC+xdF0JIik7coko3iUP9qbnfi1iJQ3sAa6dE+Tx3+ET8bjFEm63PA4WEohgjYcmsOikPHWzPsMWWiZmntOQ==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.0': + resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.0': + resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.0': + resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.0': + resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.0': + resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.0': + resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.0': + resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.0': + resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.0': + resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.0': + resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.0': + resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.0': + resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.0': + resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.0': + resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.0': + resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.0': + resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.0': + resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.0': + resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.0': + resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.0': + resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.0': + resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.0': + resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.0': + resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.0': + resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.0': + resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.0': + resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oclif/core@4.11.3': + resolution: {integrity: sha512-gQCSYAtUhJilGKaSaZhqejH9X1dDu+jWQjLmtGOgN/XcKaAEPPSeT2mu1UvlvtPox1/NNRdlBcUa8KRKo2HnJQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-autocomplete@3.2.49': + resolution: {integrity: sha512-+rrAZ468bW/B9uVrn6sEnFYepy3M1N/BWht8mHzhFIFCIduPSoE+8MweROxZLOGBZrXGWt0iavuPQmy0eaXRfQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-help@6.2.48': + resolution: {integrity: sha512-nvGLBtUZUWrHfoAEDRsRZUHKVwptyZ6F+MErdVRLQBo3dja0GCZH8DE33dA7mBux2KOmbxGqop15gyud9HZYhQ==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-not-found@3.2.85': + resolution: {integrity: sha512-Si18rRKWknlvQ5anmFbQz9oKBae5/l/Npreuf05xdoNWfOV1J97Z7cpzqBlHbldmxCIiDRgmDKuCBBi4XN6ACA==} + engines: {node: '>=18.0.0'} + + '@oclif/plugin-plugins@5.4.67': + resolution: {integrity: sha512-hNSNSo3kGxWsU7aRICN82bmpgPkQmjr+SAMrFnlH3v9UchoIG9bBoj5DSSCsoDAShIU118h8xRBOhRyEzq4+Qg==} + engines: {node: '>=18.0.0'} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.0.1': + resolution: {integrity: sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.0.1': + resolution: {integrity: sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.1': + resolution: {integrity: sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.0.1': + resolution: {integrity: sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + resolution: {integrity: sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + resolution: {integrity: sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.1': + resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.1': + resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.1': + resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.0.1': + resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.0.1': + resolution: {integrity: sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + resolution: {integrity: sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.1': + resolution: {integrity: sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@20.19.41': + resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} + + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} + + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} + + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} + + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} + + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@3.17.0: + resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} + engines: {node: '>=14'} + + ansis@4.3.0: + resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} + engines: {node: '>=14'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@3.0.0-beta.1: + resolution: {integrity: sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw==} + engines: {node: '>=20.19.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + birpc@4.0.0: + resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + clean-stack@3.0.1: + resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} + engines: {node: '>=10'} + + cli-boxes@4.0.1: + resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} + engines: {node: '>=18.20 <19 || >=20.10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@6.0.0: + resolution: {integrity: sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==} + engines: {node: '>=22'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dataloader@1.4.0: + resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dotenv@8.6.0: + resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} + engines: {node: '>=10'} + + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-toolkit@1.46.1: + resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + + esbuild@0.28.0: + resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-levenshtein@3.0.0: + resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-without-cache@0.3.3: + resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} + engines: {node: '>=20.19.0'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + ink-spinner@5.0.0: + resolution: {integrity: sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==} + engines: {node: '>=14.16'} + peerDependencies: + ink: '>=4.0.0' + react: '>=18.0.0' + + ink@7.0.3: + resolution: {integrity: sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==} + engines: {node: '>=22'} + peerDependencies: + '@types/react': '>=19.2.0' + react: '>=19.2.0' + react-devtools-core: '>=6.1.2' + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ci@2.0.0: + resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} + engines: {node: '>=20'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + npm-package-arg@11.0.3: + resolution: {integrity: sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==} + engines: {node: ^16.14.0 || >=18.0.0} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm@11.14.1: + resolution: {integrity: sha512-aopNZ0eEl6LbxoFcrXLmTEPzNBNxfiQnVgR9RmJBqzm+5h5pFoOmRljpRJbsXxocBeSl7GLcx3MoDf2UlEOjZw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + bundledDependencies: + - '@isaacs/string-locale-compare' + - '@npmcli/arborist' + - '@npmcli/config' + - '@npmcli/fs' + - '@npmcli/map-workspaces' + - '@npmcli/metavuln-calculator' + - '@npmcli/package-json' + - '@npmcli/promise-spawn' + - '@npmcli/redact' + - '@npmcli/run-script' + - '@sigstore/tuf' + - abbrev + - archy + - cacache + - chalk + - ci-info + - fastest-levenshtein + - fs-minipass + - glob + - graceful-fs + - hosted-git-info + - ini + - init-package-json + - is-cidr + - json-parse-even-better-errors + - libnpmaccess + - libnpmdiff + - libnpmexec + - libnpmfund + - libnpmorg + - libnpmpack + - libnpmpublish + - libnpmsearch + - libnpmteam + - libnpmversion + - make-fetch-happen + - minimatch + - minipass + - minipass-pipeline + - ms + - node-gyp + - nopt + - npm-audit-report + - npm-install-checks + - npm-package-arg + - npm-pick-manifest + - npm-profile + - npm-registry-fetch + - npm-user-validate + - p-map + - pacote + - parse-conflict-json + - proc-log + - qrcode-terminal + - read + - semver + - spdx-expression-parse + - ssri + - supports-color + - tar + - text-table + - tiny-relative-date + - treeverse + - validate-npm-package-name + - which + + object-treeify@4.0.1: + resolution: {integrity: sha512-Y6tg5rHfsefSkfKujv2SwHulInROy/rCL5F4w0QOWxut8AnxYxf0YmNhTh95Zfyxpsudo66uqkux0ACFnyMSgQ==} + engines: {node: '>= 16'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + proc-log@4.2.0: + resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-plugin-dts@0.23.2: + resolution: {integrity: sha512-PbSqLawLgZBGcOGT3yqWBGn4cX+wh2nt5FuBGdcMHyOhoukmjbhYAl8NT9sE4U38Cm9tqLOIQeOrvzeayM0DLQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20260325.1' + rolldown: ^1.0.0-rc.12 + typescript: ^5.0.0 || ^6.0.0 + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.0.1: + resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@9.0.0: + resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} + engines: {node: '>=22'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + engines: {node: '>=20'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + terminal-size@4.0.1: + resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + tsdown@0.21.10: + resolution: {integrity: sha512-3wk73yBhZe/wX7REqSdivNQ84TDs1mJ+IlnzrrEREP70xlJ/AEIzqaI04l/TzMKVIdkTdC3CPaADn2Lk/0SkdA==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.21.10 + '@tsdown/exe': 0.21.10 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.0: + resolution: {integrity: sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unrun@0.2.39: + resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vite@8.0.13: + resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + + widest-line@6.0.0: + resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} + engines: {node: '>=20'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yarn@1.22.22: + resolution: {integrity: sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==} + engines: {node: '>=4.0.0'} + hasBin: true + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + +snapshots: + + '@alcalzone/ansi-tokenize@0.3.0': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + '@babel/generator@8.0.0-rc.3': + dependencies: + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@8.0.0-rc.5': {} + + '@babel/helper-validator-identifier@8.0.0-rc.3': {} + + '@babel/parser@8.0.0-rc.3': + dependencies: + '@babel/types': 8.0.0-rc.3 + + '@babel/runtime@7.29.2': {} + + '@babel/types@8.0.0-rc.3': + dependencies: + '@babel/helper-string-parser': 8.0.0-rc.5 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + + '@beeper/desktop-api@5.0.0': {} + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.0 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.0 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/changelog-github@0.6.0': + dependencies: + '@changesets/get-github-info': 0.8.0 + '@changesets/types': 6.1.0 + dotenv: 8.6.0 + transitivePeerDependencies: + - encoding + + '@changesets/cli@2.31.0(@types/node@20.19.41)': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@20.19.41) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.0 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.0 + + '@changesets/get-github-info@0.8.0': + dependencies: + dataloader: 1.4.0 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.0': + optional: true + + '@esbuild/android-arm64@0.28.0': + optional: true + + '@esbuild/android-arm@0.28.0': + optional: true + + '@esbuild/android-x64@0.28.0': + optional: true + + '@esbuild/darwin-arm64@0.28.0': + optional: true + + '@esbuild/darwin-x64@0.28.0': + optional: true + + '@esbuild/freebsd-arm64@0.28.0': + optional: true + + '@esbuild/freebsd-x64@0.28.0': + optional: true + + '@esbuild/linux-arm64@0.28.0': + optional: true + + '@esbuild/linux-arm@0.28.0': + optional: true + + '@esbuild/linux-ia32@0.28.0': + optional: true + + '@esbuild/linux-loong64@0.28.0': + optional: true + + '@esbuild/linux-mips64el@0.28.0': + optional: true + + '@esbuild/linux-ppc64@0.28.0': + optional: true + + '@esbuild/linux-riscv64@0.28.0': + optional: true + + '@esbuild/linux-s390x@0.28.0': + optional: true + + '@esbuild/linux-x64@0.28.0': + optional: true + + '@esbuild/netbsd-arm64@0.28.0': + optional: true + + '@esbuild/netbsd-x64@0.28.0': + optional: true + + '@esbuild/openbsd-arm64@0.28.0': + optional: true + + '@esbuild/openbsd-x64@0.28.0': + optional: true + + '@esbuild/openharmony-arm64@0.28.0': + optional: true + + '@esbuild/sunos-x64@0.28.0': + optional: true + + '@esbuild/win32-arm64@0.28.0': + optional: true + + '@esbuild/win32-ia32@0.28.0': + optional: true + + '@esbuild/win32-x64@0.28.0': + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@20.19.41)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.41) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/confirm@5.1.21(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/core@10.3.2(@types/node@20.19.41)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.41) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/editor@4.2.23(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/external-editor': 1.0.3(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/expand@4.0.23(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/external-editor@1.0.3(@types/node@20.19.41)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/number@3.0.23(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/password@4.0.23(@types/node@20.19.41)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/prompts@7.10.1(@types/node@20.19.41)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.19.41) + '@inquirer/confirm': 5.1.21(@types/node@20.19.41) + '@inquirer/editor': 4.2.23(@types/node@20.19.41) + '@inquirer/expand': 4.0.23(@types/node@20.19.41) + '@inquirer/input': 4.3.1(@types/node@20.19.41) + '@inquirer/number': 3.0.23(@types/node@20.19.41) + '@inquirer/password': 4.0.23(@types/node@20.19.41) + '@inquirer/rawlist': 4.1.11(@types/node@20.19.41) + '@inquirer/search': 3.2.2(@types/node@20.19.41) + '@inquirer/select': 4.4.2(@types/node@20.19.41) + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/rawlist@4.1.11(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/type': 3.0.10(@types/node@20.19.41) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/search@3.2.2(@types/node@20.19.41)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.41) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/select@4.4.2(@types/node@20.19.41)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.19.41) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.19.41) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.19.41 + + '@inquirer/type@3.0.10(@types/node@20.19.41)': + optionalDependencies: + '@types/node': 20.19.41 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.2 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oclif/core@4.11.3': + dependencies: + ansi-escapes: 4.3.2 + ansis: 3.17.0 + clean-stack: 3.0.1 + cli-spinners: 2.9.2 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + get-package-type: 0.1.0 + indent-string: 4.0.0 + is-wsl: 2.2.0 + lilconfig: 3.1.3 + minimatch: 10.2.5 + semver: 7.8.0 + string-width: 4.2.3 + supports-color: 8.1.1 + tinyglobby: 0.2.16 + widest-line: 3.1.0 + wordwrap: 1.0.0 + wrap-ansi: 7.0.0 + + '@oclif/plugin-autocomplete@3.2.49': + dependencies: + '@oclif/core': 4.11.3 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + ejs: 3.1.10 + transitivePeerDependencies: + - supports-color + + '@oclif/plugin-help@6.2.48': + dependencies: + '@oclif/core': 4.11.3 + + '@oclif/plugin-not-found@3.2.85(@types/node@20.19.41)': + dependencies: + '@inquirer/prompts': 7.10.1(@types/node@20.19.41) + '@oclif/core': 4.11.3 + ansis: 3.17.0 + fast-levenshtein: 3.0.0 + transitivePeerDependencies: + - '@types/node' + + '@oclif/plugin-plugins@5.4.67': + dependencies: + '@oclif/core': 4.11.3 + ansis: 3.17.0 + debug: 4.4.3(supports-color@8.1.1) + npm: 11.14.1 + npm-package-arg: 11.0.3 + npm-run-path: 5.3.0 + object-treeify: 4.0.1 + semver: 7.8.0 + validate-npm-package-name: 5.0.1 + which: 4.0.0 + yarn: 1.22.22 + transitivePeerDependencies: + - supports-color + + '@oxc-project/types@0.127.0': {} + + '@oxc-project/types@0.130.0': {} + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-android-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.1': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.1': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.1': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.1': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.1': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.1': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.1': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.1': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.1': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.1': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/jsesc@2.5.1': {} + + '@types/node@12.20.55': {} + + '@types/node@20.19.41': + dependencies: + undici-types: 6.21.0 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.41 + + '@vitest/expect@4.1.6': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0))': + dependencies: + '@vitest/spy': 4.1.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0) + + '@vitest/pretty-format@4.1.6': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.6': + dependencies: + '@vitest/utils': 4.1.6 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.6': {} + + '@vitest/utils@4.1.6': + dependencies: + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@3.17.0: {} + + ansis@4.3.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + ast-kit@3.0.0-beta.1: + dependencies: + '@babel/parser': 8.0.0-rc.3 + estree-walker: 3.0.3 + pathe: 2.0.3 + + async@3.2.6: {} + + auto-bind@5.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + birpc@4.0.0: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@7.0.0: {} + + chai@6.2.2: {} + + chalk@5.6.2: {} + + chardet@2.1.1: {} + + clean-stack@3.0.1: + dependencies: + escape-string-regexp: 4.0.0 + + cli-boxes@4.0.1: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + + cli-spinners@2.9.2: {} + + cli-truncate@6.0.0: + dependencies: + slice-ansi: 9.0.0 + string-width: 8.2.1 + + cli-width@4.1.0: {} + + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + convert-source-map@2.0.0: {} + + convert-to-spaces@2.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + dataloader@1.4.0: {} + + debug@4.4.3(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + defu@6.1.7: {} + + detect-indent@6.1.0: {} + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dotenv@8.6.0: {} + + dts-resolver@2.1.3: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + emoji-regex@8.0.0: {} + + empathic@2.0.1: {} + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + environment@1.1.0: {} + + es-module-lexer@2.1.0: {} + + es-toolkit@1.46.1: {} + + esbuild@0.28.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.0 + '@esbuild/android-arm': 0.28.0 + '@esbuild/android-arm64': 0.28.0 + '@esbuild/android-x64': 0.28.0 + '@esbuild/darwin-arm64': 0.28.0 + '@esbuild/darwin-x64': 0.28.0 + '@esbuild/freebsd-arm64': 0.28.0 + '@esbuild/freebsd-x64': 0.28.0 + '@esbuild/linux-arm': 0.28.0 + '@esbuild/linux-arm64': 0.28.0 + '@esbuild/linux-ia32': 0.28.0 + '@esbuild/linux-loong64': 0.28.0 + '@esbuild/linux-mips64el': 0.28.0 + '@esbuild/linux-ppc64': 0.28.0 + '@esbuild/linux-riscv64': 0.28.0 + '@esbuild/linux-s390x': 0.28.0 + '@esbuild/linux-x64': 0.28.0 + '@esbuild/netbsd-arm64': 0.28.0 + '@esbuild/netbsd-x64': 0.28.0 + '@esbuild/openbsd-arm64': 0.28.0 + '@esbuild/openbsd-x64': 0.28.0 + '@esbuild/openharmony-arm64': 0.28.0 + '@esbuild/sunos-x64': 0.28.0 + '@esbuild/win32-arm64': 0.28.0 + '@esbuild/win32-ia32': 0.28.0 + '@esbuild/win32-x64': 0.28.0 + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + extendable-error@0.1.7: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-levenshtein@3.0.0: + dependencies: + fastest-levenshtein: 1.0.16 + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + get-east-asian-width@1.6.0: {} + + get-package-type@0.1.0: {} + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hookable@6.1.1: {} + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + + human-id@4.1.3: {} + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-without-cache@0.3.3: {} + + indent-string@4.0.0: {} + + indent-string@5.0.0: {} + + ink-spinner@5.0.0(ink@7.0.3(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + cli-spinners: 2.9.2 + ink: 7.0.3(@types/react@19.2.14)(react@19.2.6) + react: 19.2.6 + + ink@7.0.3(@types/react@19.2.14)(react@19.2.6): + dependencies: + '@alcalzone/ansi-tokenize': 0.3.0 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 4.0.1 + cli-cursor: 4.0.0 + cli-truncate: 6.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.46.1 + indent-string: 5.0.0 + is-in-ci: 2.0.0 + patch-console: 2.0.0 + react: 19.2.6 + react-reconciler: 0.33.0(react@19.2.6) + scheduler: 0.27.0 + signal-exit: 3.0.7 + slice-ansi: 9.0.0 + stack-utils: 2.0.6 + string-width: 8.2.1 + terminal-size: 4.0.1 + type-fest: 5.6.0 + widest-line: 6.0.0 + wrap-ansi: 10.0.0 + ws: 8.20.1 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ci@2.0.0: {} + + is-number@7.0.0: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-unicode-supported@2.1.0: {} + + is-windows@1.0.2: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.startcase@4.4.0: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + mri@1.2.0: {} + + ms@2.1.3: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.12: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + npm-package-arg@11.0.3: + dependencies: + hosted-git-info: 7.0.2 + proc-log: 4.2.0 + semver: 7.8.0 + validate-npm-package-name: 5.0.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm@11.14.1: {} + + object-treeify@4.0.1: {} + + obug@2.1.1: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + patch-console@2.0.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@4.0.1: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@2.8.8: {} + + proc-log@4.2.0: {} + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + react-reconciler@0.33.0(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react@19.2.6: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rolldown-plugin-dts@0.23.2(rolldown@1.0.0-rc.17)(typescript@5.9.3): + dependencies: + '@babel/generator': 8.0.0-rc.3 + '@babel/helper-validator-identifier': 8.0.0-rc.3 + '@babel/parser': 8.0.0-rc.3 + '@babel/types': 8.0.0-rc.3 + ast-kit: 3.0.0-beta.1 + birpc: 4.0.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.0 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.17 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + rolldown@1.0.1: + dependencies: + '@oxc-project/types': 0.130.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.1 + '@rolldown/binding-darwin-arm64': 1.0.1 + '@rolldown/binding-darwin-x64': 1.0.1 + '@rolldown/binding-freebsd-x64': 1.0.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.1 + '@rolldown/binding-linux-arm64-gnu': 1.0.1 + '@rolldown/binding-linux-arm64-musl': 1.0.1 + '@rolldown/binding-linux-ppc64-gnu': 1.0.1 + '@rolldown/binding-linux-s390x-gnu': 1.0.1 + '@rolldown/binding-linux-x64-gnu': 1.0.1 + '@rolldown/binding-linux-x64-musl': 1.0.1 + '@rolldown/binding-openharmony-arm64': 1.0.1 + '@rolldown/binding-wasm32-wasi': 1.0.1 + '@rolldown/binding-win32-arm64-msvc': 1.0.1 + '@rolldown/binding-win32-x64-msvc': 1.0.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@7.8.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + source-map-js@1.2.1: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@8.2.1: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + tagged-tag@1.0.0: {} + + term-size@2.2.1: {} + + terminal-size@4.0.1: {} + + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tr46@0.0.3: {} + + tree-kill@1.2.2: {} + + tsdown@0.21.10(typescript@5.9.3): + dependencies: + ansis: 4.3.0 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.3.3 + obug: 2.1.1 + picomatch: 4.0.4 + rolldown: 1.0.0-rc.17 + rolldown-plugin-dts: 0.23.2(rolldown@1.0.0-rc.17)(typescript@5.9.3) + semver: 7.8.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.39 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tslib@2.8.1: + optional: true + + tsx@4.22.0: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@0.21.3: {} + + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + + typescript@5.9.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@6.21.0: {} + + universalify@0.1.2: {} + + unrun@0.2.39: + dependencies: + rolldown: 1.0.0-rc.17 + + validate-npm-package-name@5.0.1: {} + + vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.1 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 20.19.41 + esbuild: 0.28.0 + fsevents: 2.3.3 + tsx: 4.22.0 + + vitest@4.1.6(@types/node@20.19.41)(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0)): + dependencies: + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.13(@types/node@20.19.41)(esbuild@0.28.0)(tsx@4.22.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.41 + transitivePeerDependencies: + - msw + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@3.1.0: + dependencies: + string-width: 4.2.3 + + widest-line@6.0.0: + dependencies: + string-width: 8.2.1 + + wordwrap@1.0.0: {} + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.1 + strip-ansi: 7.2.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.20.1: {} + + yarn@1.22.22: {} + + yoctocolors-cjs@2.1.3: {} + + yoga-layout@3.2.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1043482 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "packages/cli" diff --git a/scripts/build b/scripts/build deleted file mode 100755 index 7eb0308..0000000 --- a/scripts/build +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" - -echo "==> Building beeper-desktop-cli" -go build ./cmd/beeper-desktop-cli diff --git a/scripts/link b/scripts/link deleted file mode 100755 index 2da9715..0000000 --- a/scripts/link +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" - -REPLACEMENT="${1:-"../beeperdesktop-go"}" -echo "==> Replacing Go SDK with $REPLACEMENT" -go mod edit -replace github.com/beeper/desktop-api-go="$REPLACEMENT" -go mod tidy -e diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index bfcb7b6..0000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" - -echo "==> Running Go build" -go build ./... diff --git a/scripts/run b/scripts/run deleted file mode 100755 index 5cfec5f..0000000 --- a/scripts/run +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go" - -go run ./cmd/beeper-desktop-cli "$@" diff --git a/scripts/unlink b/scripts/unlink deleted file mode 100755 index dfb62ae..0000000 --- a/scripts/unlink +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Unlinking with local directory" -go mod edit -dropreplace github.com/beeper/desktop-api-go diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index 9848696..0000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -set -exuo pipefail - -BINARY_NAME="beeper-desktop-cli" -DIST_DIR="dist" -FILENAME="dist.zip" - -files=() -while IFS= read -r -d '' file; do - files+=("$file") -done < <(find "$DIST_DIR" -type f \( \ - -path "*amd64*/$BINARY_NAME" -o \ - -path "*arm64*/$BINARY_NAME" -o \ - -path "*amd64*/${BINARY_NAME}.exe" -o \ - -path "*arm64*/${BINARY_NAME}.exe" \ - \) -print0) - -if [[ ${#files[@]} -eq 0 ]]; then - echo -e "\033[31mNo binaries found for packaging.\033[0m" - exit 1 -fi - -rm -f "${DIST_DIR}/${FILENAME}" - -while IFS= read -r -d '' dir; do - printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \ - "$BINARY_NAME" >"${dir}/README.txt" - files+=("${dir}/README.txt") -done < <(find "$DIST_DIR" -type d -path '*macos*' -print0) - -relative_files=() -for file in "${files[@]}"; do - relative_files+=("${file#"${DIST_DIR}"/}") -done - -(cd "$DIST_DIR" && zip -r "$FILENAME" "${relative_files[@]}") - -RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - -SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') - -if [[ "$SIGNED_URL" == "null" ]]; then - echo -e "\033[31mFailed to get signed URL.\033[0m" - exit 1 -fi - -UPLOAD_RESPONSE=$(curl -v -X PUT \ - -H "Content-Type: application/zip" \ - --data-binary "@${DIST_DIR}/${FILENAME}" "$SIGNED_URL" 2>&1) - -if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then - echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/beeper-desktop-api-cli/$SHA'. On macOS, run 'xattr -d com.apple.quarantine {executable name}'.\033[0m" -else - echo -e "\033[31mFailed to upload artifact.\033[0m" - exit 1 -fi diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..c59a715 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "useDefineForClassFields": true + } +}