diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml index 480f40f..4210aa6 100644 --- a/.github/actions/setup-go/action.yml +++ b/.github/actions/setup-go/action.yml @@ -7,7 +7,7 @@ inputs: runs: using: composite steps: - - uses: stainless-api/retrieve-github-access-token@v1 + - uses: stainless-api/retrieve-github-access-token@1f03f929b746c5b03dcdafa2bebbb18ca5672e1a # v1.0.0 if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' id: get_token with: @@ -20,7 +20,7 @@ runs: 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 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: ./go.mod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2742613..54838a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,17 +15,17 @@ on: - 'stl-preview-base/**' env: - GOPRIVATE: github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go + GOPRIVATE: github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5 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 + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: @@ -34,7 +34,7 @@ jobs: - 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 + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap @@ -49,9 +49,9 @@ jobs: 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 + if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: @@ -60,13 +60,13 @@ jobs: - 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 + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap - name: Run goreleaser - uses: goreleaser/goreleaser-action@v6.1.0 + uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 with: version: latest args: release --snapshot --clean --skip=publish @@ -78,7 +78,7 @@ jobs: github.repository == 'stainless-sdks/beeper-desktop-api-cli' && !startsWith(github.ref, 'refs/heads/stl/') id: github-oidc - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: script: core.setOutput('github_token', await core.getIDToken()); @@ -98,7 +98,7 @@ jobs: 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: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/setup-go with: @@ -107,7 +107,7 @@ jobs: - 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 + ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go/v5@${{ github.ref_name }}' || true - name: Bootstrap run: ./scripts/bootstrap diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4e556fb..058282a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,20 +10,21 @@ on: push: tags: - "v*" + workflow_dispatch: {} jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 with: go-version-file: "go.mod" - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.1.0 + uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 with: version: latest args: release --clean diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index 8f8d88b..bd60cc6 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -12,7 +12,7 @@ jobs: if: github.repository == 'beeper/desktop-api-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Check release environment run: | diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6b7b74c..8e76abb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.0" + ".": "5.0.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2b39be6..62239e7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ -configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-5a8ac7b545c48dc892e5c680303e305254921554dabee848e40a808659dbcf1e.yml -openapi_spec_hash: 0103975601aac1445d3a4ef418c5d17a -config_hash: ca148af6be59ec54295b2c5f852a38d1 +configured_endpoints: 72 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper/beeper-desktop-api-8ba2755730c4180ec88f92a300948445d7917898abfc912ca3fa6adc766a7520.yml +openapi_spec_hash: 30b435d7585d8b6951610e7147369779 +config_hash: a53888715ed00d433e5a5dafab9f7b9f diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fa2ac..7447e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## 5.0.0 (2026-05-16) + +Full Changelog: [v0.3.0...v5.0.0](https://github.com/beeper/desktop-api-cli/compare/v0.3.0...v5.0.0) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([eacfea0](https://github.com/beeper/desktop-api-cli/commit/eacfea0f881332d854999d73092a4786d0b3ae33)) +* **api:** add network, bridge fields to accounts ([ccd116d](https://github.com/beeper/desktop-api-cli/commit/ccd116df91e25275b2f63f53bedf9939aae8a64a)) +* **api:** api update ([3c79717](https://github.com/beeper/desktop-api-cli/commit/3c797176b7ced4499d6bf3ff71f7d3a94967e026)) +* **api:** api update ([3920764](https://github.com/beeper/desktop-api-cli/commit/3920764851961421776d64cf694dee1f999afa3e)) +* **api:** api update ([bf4b43c](https://github.com/beeper/desktop-api-cli/commit/bf4b43c832c3b56adda72018ffd91a7dc0660966)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([8d976d4](https://github.com/beeper/desktop-api-cli/commit/8d976d441c86ddfaa67c8fad50a156f9374e0d16)) +* binary-only parameters become CLI flags that take filenames only ([ed64986](https://github.com/beeper/desktop-api-cli/commit/ed64986c234a83e6c875a1202a7c217df9efa206)) +* **cli:** add `--raw-output`/`-r` option to print raw (non-JSON) strings ([963cb22](https://github.com/beeper/desktop-api-cli/commit/963cb2275ff0b6900c09dbbd0cc45c37e745d561)) +* **cli:** alias parameters in data with `x-stainless-cli-data-alias` ([301f6f2](https://github.com/beeper/desktop-api-cli/commit/301f6f2894cae408e91bb5834d50d6c16b78c28f)) +* **cli:** send filename and content type when reading input from files ([b1ab49b](https://github.com/beeper/desktop-api-cli/commit/b1ab49bb30c6d4132fb9827cff19f73d83e7af5c)) +* set CLI flag constant values automatically where `x-stainless-const` is set ([c332ed8](https://github.com/beeper/desktop-api-cli/commit/c332ed822bc6e94cb521620032bdcf941d4b7593)) +* support passing path and query params over stdin ([5fe5a8d](https://github.com/beeper/desktop-api-cli/commit/5fe5a8d8aa02222e08a8407728d8063d08d9fdb9)) + + +### Bug Fixes + +* **cli:** correctly load zsh autocompletion ([2b1da99](https://github.com/beeper/desktop-api-cli/commit/2b1da99d8aa0d518a3a1e90126467b4eabff4a03)) +* fall back to main branch if linking fails in CI ([6960f67](https://github.com/beeper/desktop-api-cli/commit/6960f674e30ae4b66c8c727accf4b140df4aa257)) +* fix for failing to drop invalid module replace in link script ([5480c0e](https://github.com/beeper/desktop-api-cli/commit/5480c0e637e57e45ef1780847a2ec1638ef8550d)) +* fix for off-by-one error in pagination logic ([16566c5](https://github.com/beeper/desktop-api-cli/commit/16566c5d80a77de14f33e1895ec72cfa89da99c7)) +* fix quoting typo ([81a96d9](https://github.com/beeper/desktop-api-cli/commit/81a96d9880aee422f73677cdbe67134af37f6814)) +* flags for nullable body scalar fields are strictly typed ([865b851](https://github.com/beeper/desktop-api-cli/commit/865b85180334a84053ebbc19c718f17c29c9d9d7)) +* handle empty data set using `--format explore` ([6df0024](https://github.com/beeper/desktop-api-cli/commit/6df00248f562bbbd98e7cbccf8ddd82b35a40ae3)) +* use `RawJSON` when iterating items with `--format explore` in the CLI ([4af189d](https://github.com/beeper/desktop-api-cli/commit/4af189dc0e5add5c654204e0e90c91e199de479a)) + + +### Chores + +* add documentation for ./scripts/link ([b57a694](https://github.com/beeper/desktop-api-cli/commit/b57a69441818f20bf3d1bbf949a30b65c55aa728)) +* **ci:** skip lint on metadata-only changes ([d18d972](https://github.com/beeper/desktop-api-cli/commit/d18d972e88c1a03890fcbe3ba2f0dbffb06f5e48)) +* **ci:** support manually triggering release workflow ([5a018e8](https://github.com/beeper/desktop-api-cli/commit/5a018e83f5f9347b262b10176639fcc24ec5f370)) +* **cli:** additional test cases for `ShowJSONIterator` ([79f9f75](https://github.com/beeper/desktop-api-cli/commit/79f9f751f5fd415d9a34893e47b8409aa82dcb7c)) +* **cli:** fall back to JSON when using default "explore" with non-TTY ([de64b1c](https://github.com/beeper/desktop-api-cli/commit/de64b1c7b2620be74c563e4da9ae4ff6a486555e)) +* **cli:** let `--format raw` be used in conjunction with `--transform` ([c37cb3e](https://github.com/beeper/desktop-api-cli/commit/c37cb3e7e49185c7e7e398fb70a254ea8daf2456)) +* **cli:** switch long lists of positional args over to param structs ([d4e5630](https://github.com/beeper/desktop-api-cli/commit/d4e5630718dc3170dac5f9efe0acfd0be2f1a390)) +* **cli:** use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals ([384a7f4](https://github.com/beeper/desktop-api-cli/commit/384a7f434bd6d40b626b6e635f005853f66ff917)) +* **internal:** codegen related update ([ec112ec](https://github.com/beeper/desktop-api-cli/commit/ec112ec91ee336d835177ab87406e72ad585966e)) +* **internal:** more robust bootstrap script ([888af53](https://github.com/beeper/desktop-api-cli/commit/888af53697b79c93159f9fce5e60cca4ea1e5a04)) +* **internal:** update multipart form array serialization ([180bf35](https://github.com/beeper/desktop-api-cli/commit/180bf353b36de3cfee9e9497dca4558622654e18)) +* mark all CLI-related tests in Go with `t.Parallel()` ([59678e1](https://github.com/beeper/desktop-api-cli/commit/59678e17b692460f3fdc03ca82ba6270d13ed460)) +* modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary ([9e6fe8f](https://github.com/beeper/desktop-api-cli/commit/9e6fe8f0d1cba9d5115fba0b82f24a5938b3e7bc)) +* omit full usage information when missing required CLI parameters ([8b749cf](https://github.com/beeper/desktop-api-cli/commit/8b749cf09b595e66687d4030cee0791aff7e14c4)) +* redact api-key headers in debug logs ([3b786e2](https://github.com/beeper/desktop-api-cli/commit/3b786e24f0f0b206fc0817fa1fc80fa3b084346a)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([c0de07e](https://github.com/beeper/desktop-api-cli/commit/c0de07ef35b6d776781b0347a102c3855445f0cb)) +* **tests:** bump steady to v0.19.7 ([c7c519c](https://github.com/beeper/desktop-api-cli/commit/c7c519cf80adffc7cebbd3ec220227baf48672d7)) +* **tests:** bump steady to v0.20.1 ([533501d](https://github.com/beeper/desktop-api-cli/commit/533501d2960fc565d5891d5f610a7feef458d0b2)) +* **tests:** bump steady to v0.20.2 ([3a7b085](https://github.com/beeper/desktop-api-cli/commit/3a7b085b2130d21ee0a2b434dbaa3efada1e1bb3)) +* **tests:** bump steady to v0.22.1 ([2164645](https://github.com/beeper/desktop-api-cli/commit/216464542c88aca1c22ebf0e9af3ac0eb6854a38)) + ## 0.3.0 (2026-03-24) Full Changelog: [v0.2.0...v0.3.0](https://github.com/beeper/desktop-api-cli/compare/v0.2.0...v0.3.0) diff --git a/README.md b/README.md index 09e97fb..be90c67 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ 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 @@ -55,6 +53,10 @@ beeper-desktop-cli [resource] [flags...] ```sh beeper-desktop-cli chats search \ + --access-token 'My Access Token' \ + --account-id matrix \ + --account-id discordgo \ + --account-id local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc \ --include-muted \ --limit 3 \ --type single @@ -64,13 +66,13 @@ 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 | +| Environment variable | Description | Required | Default value | +| --------------------- | --------------------------------------------------------------------------------------------------------------- | -------- | ------------- | +| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated API operations. | no | `null` | ### 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) +- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated 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 @@ -119,3 +121,23 @@ base64-encoding). Note that absolute paths will begin with `@file://` or ```bash beeper-desktop-cli --arg @data://file.txt ``` + +## Linking different Go SDK versions + +You can link the CLI against a different version of the Beeper Desktop Go SDK +for development purposes using the `./scripts/link` script. + +To link to a specific version from a repository (version can be a branch, +git tag, or commit hash): + +```bash +./scripts/link github.com/org/repo@version +``` + +To link to a local copy of the SDK: + +```bash +./scripts/link ../path/to/beeperdesktopapi-go +``` + +If you run the link script without any arguments, it will default to `../beeperdesktopapi-go`. diff --git a/cmd/beeper-desktop-cli/main.go b/cmd/beeper-desktop-cli/main.go index e27225a..189bd39 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -11,7 +11,7 @@ import ( "slices" "github.com/beeper/desktop-api-cli/pkg/cmd" - "github.com/beeper/desktop-api-go" + "github.com/beeper/desktop-api-go/v5" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("BEEPER_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "BEEPER_BASE_URL"); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } + } + if err := app.Run(context.Background(), os.Args); err != nil { exitCode := 1 @@ -36,7 +43,12 @@ func main() { 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")) + show_err := cmd.ShowJSON(json, cmd.ShowJSONOpts{ + ExplicitFormat: app.IsSet("format-error"), + Format: format, + Title: "Error", + Transform: app.String("transform-error"), + }) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) diff --git a/go.mod b/go.mod index 4d42343..f23b9d7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/beeper/desktop-api-cli go 1.25 require ( - github.com/beeper/desktop-api-go v0.5.0 + github.com/beeper/desktop-api-go/v5 v5.0.0 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 diff --git a/go.sum b/go.sum index c6fed8f..57a38af 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE 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/beeper/desktop-api-go/v5 v5.0.0 h1:kKL/HNGnGS9K3fjM3+f7TTmiA5KeWO3ZusG42AXRAeo= +github.com/beeper/desktop-api-go/v5 v5.0.0/go.mod h1:WUEtzxs0a2AhNYPlLy2Ip3SEXV9w9h1o3M4zz/qXfi0= 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= diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go index 2cf5bdd..f68cfd1 100644 --- a/internal/apiform/form_test.go +++ b/internal/apiform/form_test.go @@ -85,8 +85,12 @@ var tests = map[string]struct { } func TestEncode(t *testing.T) { + t.Parallel() + for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + buf := bytes.NewBuffer(nil) writer := multipart.NewWriter(buf) writer.SetBoundary("xxx") diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go index 8bee784..3791ec9 100644 --- a/internal/apiquery/query_test.go +++ b/internal/apiquery/query_test.go @@ -6,6 +6,8 @@ import ( ) func TestEncode(t *testing.T) { + t.Parallel() + tests := map[string]struct { val any settings QuerySettings @@ -114,6 +116,8 @@ func TestEncode(t *testing.T) { for name, test := range tests { t.Run(name, func(t *testing.T) { + t.Parallel() + query := map[string]any{"query": test.val} values, err := MarshalWithSettings(query, test.settings) if err != nil { diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go index 3e8aa33..2338924 100644 --- a/internal/autocomplete/autocomplete_test.go +++ b/internal/autocomplete/autocomplete_test.go @@ -8,6 +8,8 @@ import ( ) func TestGetCompletions_EmptyArgs(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -26,6 +28,8 @@ func TestGetCompletions_EmptyArgs(t *testing.T) { } func TestGetCompletions_SubcommandPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -43,6 +47,8 @@ func TestGetCompletions_SubcommandPrefix(t *testing.T) { } func TestGetCompletions_HiddenCommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "visible", Usage: "Visible command"}, @@ -57,6 +63,8 @@ func TestGetCompletions_HiddenCommand(t *testing.T) { } func TestGetCompletions_NestedSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -79,6 +87,8 @@ func TestGetCompletions_NestedSubcommand(t *testing.T) { } func TestGetCompletions_FlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -102,6 +112,8 @@ func TestGetCompletions_FlagCompletion(t *testing.T) { } func TestGetCompletions_ShortFlagCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -123,6 +135,8 @@ func TestGetCompletions_ShortFlagCompletion(t *testing.T) { } func TestGetCompletions_FileFlagBehavior(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -142,6 +156,8 @@ func TestGetCompletions_FileFlagBehavior(t *testing.T) { } func TestGetCompletions_NonBoolFlagValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -161,6 +177,8 @@ func TestGetCompletions_NonBoolFlagValue(t *testing.T) { } func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -185,6 +203,8 @@ func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { } func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -202,6 +222,8 @@ func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -221,6 +243,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { } func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -240,6 +264,8 @@ func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -257,6 +283,8 @@ func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { } func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -271,6 +299,8 @@ func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { } func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "config:get", Usage: "Get config value"}, @@ -287,6 +317,8 @@ func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { } func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Usage: "Generate SDK"}, @@ -305,6 +337,8 @@ func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { } func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -329,6 +363,8 @@ func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { } func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { @@ -353,6 +389,8 @@ func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { } func TestGetCompletions_CommandAliases(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, @@ -372,6 +410,8 @@ func TestGetCompletions_CommandAliases(t *testing.T) { } func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { + t.Parallel() + root := &cli.Command{ Commands: []*cli.Command{ { diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh index 4d4bdcd..d937171 100644 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh @@ -1,5 +1,4 @@ -#!/bin/zsh -compdef ____APPNAME___zsh_autocomplete __APPNAME__ +#compdef __APPNAME__ ____APPNAME___zsh_autocomplete() { @@ -44,3 +43,14 @@ ____APPNAME___zsh_autocomplete() { ;; esac } + +# When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file +# is autoloaded as the function ___APPNAME__ and its body becomes that function's +# body. Detect that case via funcstack and dispatch to the completion function. +# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the +# function with compdef instead. +if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then + ____APPNAME___zsh_autocomplete "$@" +else + compdef ____APPNAME___zsh_autocomplete __APPNAME__ +fi diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go index f07b93b..647f1de 100644 --- a/internal/debugmiddleware/debug_middleware.go +++ b/internal/debugmiddleware/debug_middleware.go @@ -21,7 +21,12 @@ 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{} +var sensitiveHeaders = []string{ + "api-key", + "x-api-key", + "cookie", + "set-cookie", +} // RequestLogger is a middleware that logs HTTP requests and responses. type RequestLogger struct { diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go index 055541e..836bb2c 100644 --- a/internal/jsonview/explorer.go +++ b/internal/jsonview/explorer.go @@ -1,6 +1,7 @@ package jsonview import ( + "bytes" "encoding/json" "errors" "fmt" @@ -309,6 +310,10 @@ func ExploreJSON(title string, json gjson.Result) error { return err } +type hasRawJSON interface { + RawJSON() string +} + // ExploreJSONStream explores JSON data loaded incrementally via an iterator func ExploreJSONStream[T any](title string, it Iterator[T]) error { anyIt := genericToAnyIterator(it) @@ -327,12 +332,12 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } - // Convert items to JSON array - jsonBytes, err := json.Marshal(items) + arrayJSONBytes, err := marshalItemsToJSONArray(items) if err != nil { return err } - arrayJSON := gjson.ParseBytes(jsonBytes) + + arrayJSON := gjson.ParseBytes(arrayJSONBytes) view, err := newTableView("", arrayJSON, false) if err != nil { return err @@ -352,6 +357,29 @@ func ExploreJSONStream[T any](title string, it Iterator[T]) error { return err } +func marshalItemsToJSONArray(items []any) ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('[') + + for i, item := range items { + if i > 0 { + buf.WriteByte(',') + } + if hasRaw, ok := item.(hasRawJSON); ok { + buf.WriteString(hasRaw.RawJSON()) + } else { + jsonData, err := json.Marshal(item) + if err != nil { + return nil, err + } + buf.Write(jsonData) + } + } + + buf.WriteByte(']') + return buf.Bytes(), nil +} + func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } func (v *JSONViewer) Init() tea.Cmd { return nil } @@ -406,6 +434,10 @@ func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { return v, nil } + if len(tableView.rowData) < 1 { + return v, nil + } + cursor := tableView.table.Cursor() selected := tableView.rowData[cursor] if !v.canNavigateInto(selected) { diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go new file mode 100644 index 0000000..67ee730 --- /dev/null +++ b/internal/jsonview/explorer_test.go @@ -0,0 +1,66 @@ +package jsonview + +import ( + "testing" + + "github.com/charmbracelet/bubbles/help" + "github.com/tidwall/gjson" + + "github.com/stretchr/testify/require" +) + +func TestNavigateForward_EmptyRowData(t *testing.T) { + t.Parallel() + + // An empty JSON array produces a TableView with no rows. + emptyArray := gjson.Parse("[]") + view, err := newTableView("", emptyArray, false) + require.NoError(t, err) + + viewer := &JSONViewer{ + stack: []JSONView{view}, + root: "test", + help: help.New(), + } + + // Should return without panicking despite the empty data set. + model, cmd := viewer.navigateForward() + require.Equal(t, model, viewer, "expected same viewer model returned") + require.Nil(t, cmd) + + // Stack should remain unchanged (no new view pushed). + require.Equal(t, 1, len(viewer.stack), "expected stack length 1, got %d", len(viewer.stack)) +} + +// rawJSONItem implements HasRawJSON, returning pre-built JSON. +type rawJSONItem struct { + raw string +} + +func (r rawJSONItem) RawJSON() string { return r.raw } + +func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { + t.Parallel() + + items := []any{ + rawJSONItem{raw: `{"id":1,"name":"alice"}`}, + rawJSONItem{raw: `{"id":2,"name":"bob"}`}, + } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) +} + +func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + t.Parallel() + + items := []any{ + map[string]any{"id": 1, "name": "alice"}, + map[string]any{"id": 2, "name": "bob"}, + } + + got, err := marshalItemsToJSONArray(items) + require.NoError(t, err) + require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) +} diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go index 102624f..528915f 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -14,7 +14,8 @@ import ( 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, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag DefaultText string // default text of the flag for usage purposes @@ -22,14 +23,35 @@ type InnerFlag[ 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 + OuterFlag cli.Flag // The flag on which this inner flag will set values + InnerField string // The inner field which this flag will set + DataAliases []string // alternate names recognized in YAML values passed as the outer flag + + // OuterIsArrayOfObjects tells an untyped outer flag (Flag[any], used for nullable + // complex schemas) to seed its underlying value as []map[string]any rather than + // map[string]any before SetInnerField runs. The hint is ignored for typed outer + // flags whose zero value already carries a dispatchable reflect.Kind. + OuterIsArrayOfObjects bool +} + +// GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. +func (f *InnerFlag[T]) GetDataAliases() []string { + return f.DataAliases +} + +// GetInnerField returns the API field name that this inner flag sets on its outer flag's value. +// For example, the flag --parent.foo targeting a parameter whose OpenAPI property name is "foo" +// would return "foo". This is distinct from the flag's CLI name and from any DataAliases entries. +func (f *InnerFlag[T]) GetInnerField() string { + return f.InnerField } type HasOuterFlag interface { cli.Flag SetOuterFlag(cli.Flag) GetOuterFlag() cli.Flag + GetInnerField() string + GetDataAliases() []string } func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { @@ -61,6 +83,10 @@ func (f *InnerFlag[T]) Set(name string, rawVal string) error { } } + if seeder, ok := f.OuterFlag.(InnerFieldSeeder); ok { + seeder.SeedInnerCollection(f.OuterIsArrayOfObjects) + } + if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { settableInnerField.SetInnerField(f.InnerField, parsedValue) } else { @@ -121,6 +147,9 @@ func (f *InnerFlag[T]) TypeName() string { if ty == nil { return "" } + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go index 3f204c9..133e8b4 100644 --- a/internal/requestflag/innerflag_test.go +++ b/internal/requestflag/innerflag_test.go @@ -8,6 +8,8 @@ import ( ) func TestInnerFlagSet(t *testing.T) { + t.Parallel() + tests := []struct { name string flagType string @@ -27,6 +29,8 @@ func TestInnerFlagSet(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{ Name: "test-flag", } @@ -81,6 +85,8 @@ func TestInnerFlagSet(t *testing.T) { } func TestInnerFlagValidator(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "test-flag"} innerFlag := &InnerFlag[int64]{ @@ -105,6 +111,8 @@ func TestInnerFlagValidator(t *testing.T) { } func TestWithInnerFlags(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[string]{ Name: "outer.baz", @@ -126,6 +134,8 @@ func TestWithInnerFlags(t *testing.T) { } func TestInnerFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -143,6 +153,8 @@ func TestInnerFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) @@ -150,8 +162,12 @@ func TestInnerFlagTypeNames(t *testing.T) { } func TestInnerYamlHandling(t *testing.T) { + t.Parallel() + // Test with map value t.Run("Parse YAML to map", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -176,6 +192,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} innerFlag := &InnerFlag[map[string]any]{ Name: "outer.baz", @@ -190,6 +208,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting inner flags on a map multiple times t.Run("Set inner flags on map multiple times", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // Set first inner flag @@ -219,6 +239,8 @@ func TestInnerYamlHandling(t *testing.T) { // Test setting YAML and then an inner flag t.Run("Set YAML and then inner flag", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[map[string]any]{Name: "outer"} // First set the outer flag with YAML @@ -246,7 +268,11 @@ func TestInnerYamlHandling(t *testing.T) { } func TestInnerFlagWithSliceType(t *testing.T) { + t.Parallel() + t.Run("Setting inner flags on slice of maps", func(t *testing.T) { + t.Parallel() + outerFlag := &Flag[[]map[string]any]{Name: "outer"} // Set first inner flag (should create first item) @@ -284,6 +310,8 @@ func TestInnerFlagWithSliceType(t *testing.T) { }) t.Run("Appending to existing slice", func(t *testing.T) { + t.Parallel() + // Initialize with existing items outerFlag := &Flag[[]map[string]any]{Name: "outer"} err := outerFlag.Set(outerFlag.Name, `{name: initial}`) diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go index 21a8a69..77c4f1f 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "reflect" "strconv" @@ -12,13 +13,38 @@ import ( "github.com/urfave/cli/v3" ) +// formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string +// that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. +// Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so +// JSON-quoting must be avoided). Scalars use %v. Complex types (maps, slices) are +// JSON-encoded, which the yaml.Unmarshal default branch in parseCLIArg can parse. +func formatForFlagSet(val any) (string, error) { + switch v := val.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprintf("%v", val), nil + default: + b, err := json.Marshal(val) + if err != nil { + return "", fmt.Errorf("cannot format value %T for flag.Set: %w", val, err) + } + return string(b), nil + } +} + // 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.). +// +// Pointer-to-primitive type parameters (e.g. *string) are used for flags whose underlying +// schema is nullable. They give flags a tri-state: unset (excluded from the request), +// set to the literal "null" (nil pointer → JSON null), or set to a value (*v → JSON value). 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, + string | float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { Name string // name of the flag Category string // category of the flag, if any @@ -36,6 +62,22 @@ type Flag[ 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 + PathParam string // name of the URL path parameter this flag's value maps to + + // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value + // and always included in the request (IsSet returns true). The user can still see and override the flag, + // but isn't required to provide it. This is used for single-value enums and `x-stainless-const` + // parameters. + Const bool + + // FileInput, when true, indicates that the flag value is always treated as a file path. The file is read + // automatically without requiring the "@" prefix. This is used for parameters with `type: string, format: + // binary` in the OpenAPI spec. + FileInput bool + + // DataAliases is a list of alternate names for this parameter recognized when parsing piped YAML/JSON + // input. Values keyed by any alias are translated to the canonical API name before being sent. + DataAliases []string // unexported fields for internal use count int // number of times the flag has been set @@ -52,7 +94,10 @@ type InRequest interface { GetQueryPath() string GetHeaderPath() string GetBodyPath() string + GetPathParam() string IsBodyRoot() bool + IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -67,10 +112,22 @@ func (f Flag[T]) GetBodyPath() string { return f.BodyPath } +func (f Flag[T]) GetPathParam() string { + return f.PathParam +} + func (f Flag[T]) IsBodyRoot() bool { return f.BodyRoot } +func (f Flag[T]) IsFileInput() bool { + return f.FileInput +} + +func (f Flag[T]) GetDataAliases() []string { + return f.DataAliases +} + // The values that will be sent in different parts of a request. type RequestContents struct { Queries map[string]any @@ -78,7 +135,91 @@ type RequestContents struct { Body any } -// Extract query parameters, headers, and body values from command flags. +// ApplyStdinDataToFlags sets flag values from a parsed stdin data map for flags that have not already been +// set via the command line. This allows piped YAML/JSON data to satisfy path, query, and header parameters. +// Body parameters are excluded: they are already handled by the maps.Copy merge in flagOptions. +// For each unset flag, if the parsed data map contains a key matching the flag's QueryPath, HeaderPath, or +// PathParam (or any of its DataAliases), the flag is set to that value via flag.Set. +// +// Inner flags (those with an outer flag) are also handled: if the outer flag's body path key exists in the +// data map and contains a nested map with a key matching the inner flag's field (or aliases), the inner +// flag is set from that nested value. +func ApplyStdinDataToFlags(cmd *cli.Command, data map[string]any) error { + for _, flag := range cmd.Flags { + if flag.IsSet() { + continue + } + + // Handle inner flags: look for their value nested under the outer flag's body path. + if inner, ok := flag.(HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(InRequest) + if !outerOk || outer.GetBodyPath() == "" { + continue + } + nested, ok := data[outer.GetBodyPath()].(map[string]any) + if !ok { + continue + } + innerField := inner.GetInnerField() + val, found := nested[innerField] + if !found { + for _, alias := range inner.GetDataAliases() { + if alias != "" && alias != innerField { + if v, ok := nested[alias]; ok { + val, found = v, true + break + } + } + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + continue + } + + inReq, ok := flag.(InRequest) + if !ok { + continue + } + + // Try each request location in turn, checking the canonical path key and all aliases. + // Body params are excluded: they are already handled by the maps.Copy merge in flagOptions. + for _, path := range []string{inReq.GetQueryPath(), inReq.GetHeaderPath(), inReq.GetPathParam()} { + if path == "" { + continue + } + var val any + var found bool + for _, key := range append([]string{path}, inReq.GetDataAliases()...) { + if v, ok := data[key]; ok { + val, found = v, true + break + } + } + if !found { + continue + } + setVal, err := formatForFlagSet(val) + if err != nil { + return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) + } + if err := flag.Set(flag.Names()[0], setVal); err != nil { + return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) + } + break + } + } + return nil +} + func ExtractRequestContents(cmd *cli.Command) RequestContents { bodyMap := make(map[string]any) res := RequestContents{ @@ -229,7 +370,7 @@ func (f *Flag[T]) String() string { } func (f *Flag[T]) IsSet() bool { - return f.hasBeenSet + return f.hasBeenSet || f.Const } func (f *Flag[T]) Names() []string { @@ -255,9 +396,13 @@ func (f *Flag[T]) SetCategory(c string) { var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance func (f *Flag[T]) IsRequired() bool { + // Const flags are always auto-set, so never required from the user. + if f.Const { + return false + } // Intentionally don't use `f.Required`, because request flags may be passed // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot { + if f.BodyPath != "" || f.BodyRoot || f.PathParam != "" || f.QueryPath != "" || f.HeaderPath != "" { return false } return f.Required @@ -268,6 +413,10 @@ type RequiredFlagOrStdin interface { } func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { + // Const flags are always auto-set, so never required from the user. + if f.Const { + return false + } return f.Required } @@ -308,6 +457,11 @@ func (f *Flag[T]) TypeName() string { if ty == nil { return "" } + // Deref pointer-typed flags so --help surfaces the pointee kind (e.g. "string"), not + // Go's pointer syntax. + if ty.Kind() == reflect.Pointer { + ty = ty.Elem() + } // Get base type name with special handling for built-in types getTypeName := func(t reflect.Type) string { @@ -363,6 +517,8 @@ func (f *Flag[T]) IsMultiValueFlag() bool { } func (f *Flag[T]) IsBoolFlag() bool { + // Flag[*bool] is deliberately not treated as a bool flag — the pointer form needs an + // explicit value (`--foo true`, `--foo null`) to disambiguate the tri-state. _, isBool := any(f.Default).(bool) return isBool } @@ -386,7 +542,8 @@ func (f Flag[T]) IsLocal() bool { 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, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ] struct { value T } @@ -396,12 +553,27 @@ type cliValue[ 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, + float64 | int64 | bool | + *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, ](value string) (T, error) { var parsedValue any var err error var empty T + + if value == "null" { + switch any(empty).(type) { + // Pointer-to-primitive: explicit nil gives the tri-state its "null" state + // (unset / null / value). Without this, numeric flags would fail to parse + // "null" and string flags would accept the literal word as a raw value. + case *string, *int64, *float64, *bool, *DateValue, *DateTimeValue, *TimeValue: + return empty, nil + // Maps marshal nil as JSON null natively; short-circuit avoids a YAML round-trip. + case map[string]any: + return empty, nil + } + } + switch any(empty).(type) { case string: parsedValue = value @@ -432,6 +604,48 @@ func parseCLIArg[ parsedValue = t } + // Pointer-to-primitive flags reach here only when `value != "null"`; we parse the + // pointee type and return its address so JSON marshaling emits the underlying value. + case *string: + v := value + parsedValue = &v + case *int64: + var v int64 + v, err = strconv.ParseInt(value, 0, 64) + if err == nil { + parsedValue = &v + } + case *float64: + var v float64 + v, err = strconv.ParseFloat(value, 64) + if err == nil { + parsedValue = &v + } + case *bool: + var v bool + v, err = strconv.ParseBool(value) + if err == nil { + parsedValue = &v + } + 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 @@ -468,6 +682,13 @@ func parseCLIArg[ } +// Ptr returns a pointer to its argument. It is used to initialize `Default` on pointer-typed +// Flag values, since Go does not allow taking the address of a composite literal's element +// or of an untyped constant. +func Ptr[T any](v T) *T { + return &v +} + // 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`), @@ -561,6 +782,15 @@ func (c *cliValue[T]) String() string { // For basic types, use standard string representation return fmt.Sprintf("%v", v) + case *string, *int64, *float64, *bool, *DateTimeValue, *DateValue, *TimeValue: + // Pointer-to-primitive: nil renders as "null" (the CLI literal that produces it); + // non-nil derefs to the pointee's standard representation. + rv := reflect.ValueOf(v) + if rv.IsNil() { + return "null" + } + return fmt.Sprintf("%v", rv.Elem().Interface()) + default: // For complex types, convert to YAML yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) @@ -672,6 +902,15 @@ type SettableInnerField interface { SetInnerField(string, any) } +// InnerFieldSeeder lets an InnerFlag prepare its outer flag's underlying value +// before dispatching SetInnerField. This is only meaningful for Flag[any] — +// the codegen output for nullable complex schemas — whose untyped-nil zero +// value would otherwise have no reflect.Kind for the inner-field switch to +// dispatch on. +type InnerFieldSeeder interface { + SeedInnerCollection(isArrayOfObjects bool) +} + func (f *Flag[T]) SetInnerField(field string, val any) { if f.value == nil { f.value = &cliValue[T]{} @@ -685,6 +924,33 @@ func (f *Flag[T]) SetInnerField(field string, val any) { } } +// SeedInnerCollection initializes a Flag[any]'s underlying value as an empty +// map[string]any or []map[string]any so subsequent SetInnerField calls have a +// dispatchable reflect.Kind. For typed Flag[T] this is a no-op: the type +// assertion fails and the existing reflect.Kind on the typed-nil zero value +// already routes correctly. +func (f *Flag[T]) SeedInnerCollection(isArrayOfObjects bool) { + if f.value == nil { + f.value = &cliValue[T]{} + } + cv, ok := f.value.(*cliValue[T]) + if !ok { + return + } + if reflect.ValueOf(cv.value).Kind() != reflect.Invalid { + return + } + if isArrayOfObjects { + if seed, ok := any([]map[string]any{}).(T); ok { + cv.value = seed + } + return + } + if seed, ok := any(map[string]any{}).(T); ok { + cv.value = seed + } +} + func (c *cliValue[T]) SetInnerField(field string, val any) { flagVal := c.value flagValReflect := reflect.ValueOf(flagVal) diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 9751904..779bd57 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -1,6 +1,7 @@ package requestflag import ( + "encoding/json" "fmt" "testing" "time" @@ -11,6 +12,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +59,8 @@ func TestDateValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateValue err := d.Parse(tt.input) @@ -70,6 +75,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +126,8 @@ func TestDateTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var d DateTimeValue err := d.Parse(tt.input) @@ -136,6 +145,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +192,8 @@ func TestTimeValueParse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var tv TimeValue err := tv.Parse(tt.input) @@ -195,7 +208,11 @@ func TestTimeValueParse(t *testing.T) { } func TestRequestParams(t *testing.T) { + t.Parallel() + t.Run("map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -283,6 +300,8 @@ func TestRequestParams(t *testing.T) { }) t.Run("non-map body type", func(t *testing.T) { + t.Parallel() + // Create a mock command with flags cmd := &cli.Command{ Name: "test", @@ -304,6 +323,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +348,52 @@ func TestFlagSet(t *testing.T) { // Test initialization and setting t.Run("PreParse initialization", func(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + 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) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) }) t.Run("Set int flag with validator failing", func(t *testing.T) { + t.Parallel() + assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) }) t.Run("Set bool flag", func(t *testing.T) { + t.Parallel() + 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) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{}, @@ -381,6 +416,8 @@ func TestFlagSet(t *testing.T) { }) t.Run("Set slice flag with a nonempty default", func(t *testing.T) { + t.Parallel() + sliceFlag := &Flag[[]int64]{ Name: "slice-flag", Default: []int64{99, 100}, @@ -400,6 +437,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +478,8 @@ func TestParseTimeWithFormats(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := parseTimeWithFormats(tt.input, tt.formats) if tt.wantErr { @@ -452,8 +493,12 @@ func TestParseTimeWithFormats(t *testing.T) { } func TestYamlHandling(t *testing.T) { + t.Parallel() + // Test with any value t.Run("Parse YAML to any", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("name: test\nvalue: 42\n") assert.NoError(t, err) @@ -478,6 +523,8 @@ func TestYamlHandling(t *testing.T) { // Test with array t.Run("Parse YAML array", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} err := cv.Set("- item1\n- item2\n- item3\n") assert.NoError(t, err) @@ -495,6 +542,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[any]{ Name: "file-flag", Default: nil, @@ -507,6 +556,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse @file.txt list as YAML", func(t *testing.T) { + t.Parallel() + flag := &Flag[[]any]{ Name: "file-flag", Default: nil, @@ -520,6 +571,8 @@ func TestYamlHandling(t *testing.T) { }) t.Run("Parse identifiers as YAML", func(t *testing.T) { + t.Parallel() + tests := []string{ "hello", "e4e355fa-b03b-4c57-a73d-25c9733eec79", @@ -555,6 +608,8 @@ func TestYamlHandling(t *testing.T) { // Test with invalid YAML t.Run("Parse invalid YAML", func(t *testing.T) { + t.Parallel() + invalidYaml := `[not closed` cv := &cliValue[any]{} err := cv.Set(invalidYaml) @@ -562,7 +617,181 @@ func TestYamlHandling(t *testing.T) { }) } +// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null" +// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how +// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field +// via `--foo null`. Non-pointer primitive flags treat "null" as a raw value — these are +// non-nullable schemas where explicit null has no API semantics anyway. +func TestNullLiteralHandling(t *testing.T) { + t.Parallel() + + assertJSONBody := func(t *testing.T, value any, expected string) { + t.Helper() + body, err := json.Marshal(map[string]any{"foo": value}) + assert.NoError(t, err) + assert.JSONEq(t, expected, string(body)) + } + + t.Run("Flag[any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[string] null is the raw string \"null\"", func(t *testing.T) { + t.Parallel() + cv := &cliValue[string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":"null"}`) + }) + + t.Run("Flag[int64] null errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[int64]{} + assert.Error(t, cv.Set("null")) + }) + + t.Run("Flag[*string] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*string] value sends the string", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*string]{} + assert.NoError(t, cv.Set("1.1")) + assertJSONBody(t, cv.Get(), `{"foo":"1.1"}`) + }) + + t.Run("Flag[*int64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*int64] value sends the integer", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.NoError(t, cv.Set("42")) + assertJSONBody(t, cv.Get(), `{"foo":42}`) + }) + + t.Run("Flag[*int64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*int64]{} + assert.Error(t, cv.Set("not-an-int")) + }) + + t.Run("Flag[*bool] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*bool] value sends the boolean", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*bool]{} + assert.NoError(t, cv.Set("true")) + assertJSONBody(t, cv.Get(), `{"foo":true}`) + }) + + t.Run("Flag[*float64] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*float64] value sends the float", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.NoError(t, cv.Set("1.5")) + assertJSONBody(t, cv.Get(), `{"foo":1.5}`) + }) + + t.Run("Flag[*float64] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*float64]{} + assert.Error(t, cv.Set("not-a-float")) + }) + + t.Run("Flag[*DateValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateValue] value sends the date", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.NoError(t, cv.Set("2023-05-15")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15"}`) + }) + + t.Run("Flag[*DateValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateValue]{} + assert.Error(t, cv.Set("not-a-date")) + }) + + t.Run("Flag[*DateTimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*DateTimeValue] value sends the datetime", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.NoError(t, cv.Set("2023-05-15T14:30:45Z")) + assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15T14:30:45Z"}`) + }) + + t.Run("Flag[*DateTimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*DateTimeValue]{} + assert.Error(t, cv.Set("not-a-datetime")) + }) + + t.Run("Flag[*TimeValue] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) + + t.Run("Flag[*TimeValue] value sends the time", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.NoError(t, cv.Set("14:30:45")) + assertJSONBody(t, cv.Get(), `{"foo":"14:30:45"}`) + }) + + t.Run("Flag[*TimeValue] invalid value errors", func(t *testing.T) { + t.Parallel() + cv := &cliValue[*TimeValue]{} + assert.Error(t, cv.Set("not-a-time")) + }) + + // Nullable maps don't need pointer wrapping — a nil map already marshals as JSON null. + t.Run("Flag[map[string]any] null sends JSON null", func(t *testing.T) { + t.Parallel() + cv := &cliValue[map[string]any]{} + assert.NoError(t, cv.Set("null")) + assertJSONBody(t, cv.Get(), `{"foo":null}`) + }) +} + func TestFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,8 +812,416 @@ func TestFlagTypeNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + typeName := tt.flag.TypeName() assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) }) } } + +// TestInnerFlagDispatchOnUntypedFlag pins inner-flag behavior for `Flag[any]`, +// which is the codegen output for nullable complex schemas (`anyOf: [T, null]` +// or `{nullable: true}`). The untyped-nil zero value carries no reflect.Kind, +// so SetInnerField has nowhere to dispatch the assignment — without explicit +// help the inner-field value silently drops. +func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { + t.Parallel() + + t.Run("nullable array of objects appends element from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first"}]}`, string(body)) + }) + + t.Run("nullable object sets field from inner flag", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "metadata"} + assert.NoError(t, outer.PreParse()) + + keyFlag := &InnerFlag[string]{ + Name: "metadata.key", InnerField: "key", OuterFlag: outer, + } + assert.NoError(t, keyFlag.Set("metadata.key", "value")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":{"key":"value"}}`, string(body)) + }) + + t.Run("multiple inner flags merge into the trailing element", func(t *testing.T) { + t.Parallel() + outer := &Flag[any]{Name: "mcp-server"} + assert.NoError(t, outer.PreParse()) + + nameFlag := &InnerFlag[string]{ + Name: "mcp-server.name", InnerField: "name", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + urlFlag := &InnerFlag[string]{ + Name: "mcp-server.url", InnerField: "url", + OuterFlag: outer, OuterIsArrayOfObjects: true, + } + assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) + assert.NoError(t, urlFlag.Set("mcp-server.url", "https://example.com")) + + body, err := json.Marshal(map[string]any{"foo": outer.Get()}) + assert.NoError(t, err) + assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) + }) +} + +func TestApplyStdinDataToFlags(t *testing.T) { + t.Parallel() + + t.Run("sets query path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"account_id": "acct_123"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_123", flag.Get()) + }) + + t.Run("sets header path flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "idempotency-key", + HeaderPath: "Idempotency-Key", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"Idempotency-Key": "key-xyz"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "key-xyz", flag.Get()) + }) + + t.Run("does not set body path flag from piped data", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "message", + BodyPath: "message", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"message": "hello world"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("does not override flag already set via CLI", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + assert.NoError(t, flag.Set("account-id", "explicit_value")) + + data := map[string]any{"account_id": "piped_value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // The explicitly-set value should win. + assert.Equal(t, "explicit_value", flag.Get()) + }) + + t.Run("sets integer query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[int64]{ + Name: "page-size", + QueryPath: "page_size", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"page_size": int64(50)} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, int64(50), flag.Get()) + }) + + t.Run("sets boolean query flag from piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[bool]{ + Name: "include-deleted", + QueryPath: "include_deleted", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"include_deleted": true} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, true, flag.Get()) + }) + + t.Run("resolves query path flag via data alias", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId", "account"}, + } + assert.NoError(t, flag.PreParse()) + + // Use one of the aliases as the key in piped data. + data := map[string]any{"accountId": "acct_alias"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "acct_alias", flag.Get()) + }) + + t.Run("does not set body path flag via data alias", func(t *testing.T) { + t.Parallel() + + // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. + flag := &Flag[string]{ + Name: "user-name", + BodyPath: "user_name", + DataAliases: []string{"userName", "username"}, + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"userName": "alice"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no matching key in piped data", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"other_key": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("ignores flags with no path set", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "some-flag", + // No QueryPath, HeaderPath, or BodyPath + } + assert.NoError(t, flag.PreParse()) + + data := map[string]any{"some-flag": "value"} + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, flag.IsSet()) + }) + + t.Run("handles multiple flags from piped data", func(t *testing.T) { + t.Parallel() + + accountFlag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + limitFlag := &Flag[int64]{ + Name: "limit", + QueryPath: "limit", + } + assert.NoError(t, accountFlag.PreParse()) + assert.NoError(t, limitFlag.PreParse()) + + data := map[string]any{ + "account_id": "acct_abc", + "limit": int64(25), + } + cmd := &cli.Command{Flags: []cli.Flag{accountFlag, limitFlag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, accountFlag.IsSet()) + assert.Equal(t, "acct_abc", accountFlag.Get()) + assert.True(t, limitFlag.IsSet()) + assert.Equal(t, int64(25), limitFlag.Get()) + }) + + t.Run("sets inner flag from nested piped data under outer body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + data := map[string]any{ + "address": map[string]any{"city": "San Francisco"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "San Francisco", outerVal["city"]) + }) + + t.Run("sets inner flag via data alias in nested piped data", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + cityInner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + DataAliases: []string{"cityName"}, + OuterFlag: outer, + } + + // Use the alias in piped data. + data := map[string]any{ + "address": map[string]any{"cityName": "Portland"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + // InnerFlag.IsSet() is always false by design; verify the value was written + // into the outer flag's underlying map instead. + outerVal, ok := outer.Get().(map[string]any) + assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) + assert.Equal(t, "Portland", outerVal["city"]) + }) + + t.Run("does not set inner flag when outer flag has no body path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "options", + // No BodyPath set + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "options.key", + InnerField: "key", + OuterFlag: outer, + } + + data := map[string]any{ + "options": map[string]any{"key": "value"}, + } + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("does not set inner flag when piped data has no nested map for outer path", func(t *testing.T) { + t.Parallel() + + outer := &Flag[map[string]any]{ + Name: "address", + BodyPath: "address", + } + assert.NoError(t, outer.PreParse()) + + inner := &InnerFlag[string]{ + Name: "address.city", + InnerField: "city", + OuterFlag: outer, + } + + // The outer body path key is missing from the piped data. + data := map[string]any{"other": "value"} + cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.False(t, inner.IsSet()) + }) + + t.Run("canonical path key takes precedence over alias when both are present", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + DataAliases: []string{"accountId"}, + } + assert.NoError(t, flag.PreParse()) + + // Both canonical and alias present — canonical should win because it's checked first. + data := map[string]any{ + "account_id": "canonical_value", + "accountId": "alias_value", + } + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) + + assert.True(t, flag.IsSet()) + assert.Equal(t, "canonical_value", flag.Get()) + }) + + t.Run("empty data map does not set any flags", func(t *testing.T) { + t.Parallel() + + flag := &Flag[string]{ + Name: "account-id", + QueryPath: "account_id", + } + assert.NoError(t, flag.PreParse()) + + cmd := &cli.Command{Flags: []cli.Flag{flag}} + assert.NoError(t, ApplyStdinDataToFlags(cmd, map[string]any{})) + + assert.False(t, flag.IsSet()) + }) +} diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index 6ee1774..284208c 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -5,18 +5,17 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/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", + Usage: "List Chat Accounts connected to this Beeper Desktop instance, including bridge\nmetadata and network identity.", Suggest: true, Flags: []cli.Flag{}, Action: handleAccountsList, @@ -50,7 +49,17 @@ func handleAccountsList(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "accounts list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "accounts list", + Transform: transform, + }) } diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 76cc862..40e36b6 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -5,12 +5,11 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) @@ -21,9 +20,10 @@ var accountsContactsList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, + Name: "account-id", + Usage: "Account ID this resource belongs to.", + Required: true, + PathParam: "accountID", }, &requestflag.Flag[string]{ Name: "cursor", @@ -61,9 +61,10 @@ var accountsContactsSearch = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account ID this resource belongs to.", - Required: true, + Name: "account-id", + Usage: "Account ID this resource belongs to.", + Required: true, + PathParam: "accountID", }, &requestflag.Flag[string]{ Name: "query", @@ -87,8 +88,6 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AccountContactListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -100,7 +99,13 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + params := beeperdesktopapi.AccountContactListParams{} + + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -115,7 +120,13 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "accounts:contacts list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "accounts:contacts list", + Transform: transform, + }) } else { iter := client.Accounts.Contacts.ListAutoPaging( ctx, @@ -127,7 +138,13 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "accounts:contacts list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "accounts:contacts list", + Transform: transform, + }) } } @@ -142,8 +159,6 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AccountContactSearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -155,6 +170,8 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AccountContactSearchParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Accounts.Contacts.Search( @@ -168,7 +185,17 @@ func handleAccountsContactsSearch(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "accounts:contacts search", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "accounts:contacts search", + Transform: transform, + }) } diff --git a/pkg/cmd/app.go b/pkg/cmd/app.go new file mode 100644 index 0000000..aa20f30 --- /dev/null +++ b/pkg/cmd/app.go @@ -0,0 +1,65 @@ +// 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-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appStatus = cli.Command{ + Name: "status", + Usage: "Return the current Beeper Desktop sign-in and encrypted messaging setup state.\nThis endpoint is public before sign-in so apps can discover that login is\nneeded; after sign-in, pass a read token.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppStatus, + HideHelpCommand: true, +} + +func handleAppStatus(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.App.Status(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app status", + Transform: transform, + }) +} diff --git a/pkg/cmd/app_test.go b/pkg/cmd/app_test.go new file mode 100644 index 0000000..214d308 --- /dev/null +++ b/pkg/cmd/app_test.go @@ -0,0 +1,19 @@ +// 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 TestAppStatus(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app", "status", + ) + }) +} diff --git a/pkg/cmd/appe2eerecoverycode.go b/pkg/cmd/appe2eerecoverycode.go new file mode 100644 index 0000000..6279a23 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycode.go @@ -0,0 +1,120 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeRecoveryCodeMarkBackedUp = cli.Command{ + Name: "mark-backed-up", + Usage: "Record that the user saved their recovery key.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppE2eeRecoveryCodeMarkBackedUp, + HideHelpCommand: true, +} + +var appE2eeRecoveryCodeVerify = cli.Command{ + Name: "verify", + Usage: "Unlock encrypted messages with the user recovery key.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "Recovery key saved by the user.", + Required: true, + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeVerify, + HideHelpCommand: true, +} + +func handleAppE2eeRecoveryCodeMarkBackedUp(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.App.E2ee.RecoveryCode.MarkBackedUp(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code mark-backed-up", + Transform: transform, + }) +} + +func handleAppE2eeRecoveryCodeVerify(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeVerifyParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Verify(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code verify", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eerecoverycode_test.go b/pkg/cmd/appe2eerecoverycode_test.go new file mode 100644 index 0000000..c460860 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycode_test.go @@ -0,0 +1,40 @@ +// 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 TestAppE2eeRecoveryCodeMarkBackedUp(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code", "mark-backed-up", + ) + }) +} + +func TestAppE2eeRecoveryCodeVerify(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code", "verify", + "--recovery-code", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code", "verify", + ) + }) +} diff --git a/pkg/cmd/appe2eerecoverycodereset.go b/pkg/cmd/appe2eerecoverycodereset.go new file mode 100644 index 0000000..1226ae8 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycodereset.go @@ -0,0 +1,128 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeRecoveryCodeResetCreate = cli.Command{ + Name: "create", + Usage: "Create a new recovery key when the user cannot use the existing one.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "Existing recovery key, if the user has it.", + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeResetCreate, + HideHelpCommand: true, +} + +var appE2eeRecoveryCodeResetConfirm = cli.Command{ + Name: "confirm", + Usage: "Confirm that the new recovery key should be used for this account.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "recovery-code", + Usage: "New recovery key returned by the reset step.", + Required: true, + BodyPath: "recoveryCode", + }, + }, + Action: handleAppE2eeRecoveryCodeResetConfirm, + HideHelpCommand: true, +} + +func handleAppE2eeRecoveryCodeResetCreate(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeResetNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Reset.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code:reset create", + Transform: transform, + }) +} + +func handleAppE2eeRecoveryCodeResetConfirm(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeRecoveryCodeResetConfirmParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.RecoveryCode.Reset.Confirm(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:recovery-code:reset confirm", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eerecoverycodereset_test.go b/pkg/cmd/appe2eerecoverycodereset_test.go new file mode 100644 index 0000000..75f8543 --- /dev/null +++ b/pkg/cmd/appe2eerecoverycodereset_test.go @@ -0,0 +1,51 @@ +// 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 TestAppE2eeRecoveryCodeResetCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "create", + "--recovery-code", "recoveryCode", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: recoveryCode") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "create", + ) + }) +} + +func TestAppE2eeRecoveryCodeResetConfirm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "confirm", + "--recovery-code", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("recoveryCode: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:recovery-code:reset", "confirm", + ) + }) +} diff --git a/pkg/cmd/appe2eeverification.go b/pkg/cmd/appe2eeverification.go new file mode 100644 index 0000000..4caecba --- /dev/null +++ b/pkg/cmd/appe2eeverification.go @@ -0,0 +1,204 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationCreate = cli.Command{ + Name: "create", + Usage: "Start verifying this device from another signed-in device.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Usage: "User ID to verify. Defaults to the signed-in user.", + BodyPath: "userID", + }, + }, + Action: handleAppE2eeVerificationCreate, + HideHelpCommand: true, +} + +var appE2eeVerificationAccept = cli.Command{ + Name: "accept", + Usage: "Accept an incoming device verification request.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationAccept, + HideHelpCommand: true, +} + +var appE2eeVerificationCancel = cli.Command{ + Name: "cancel", + Usage: "Cancel an active device verification request.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + &requestflag.Flag[string]{ + Name: "code", + Usage: "Optional cancellation code.", + BodyPath: "code", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional user-facing cancellation reason.", + BodyPath: "reason", + }, + }, + Action: handleAppE2eeVerificationCancel, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationCreate(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification create", + Transform: transform, + }) +} + +func handleAppE2eeVerificationAccept(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Accept(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification accept", + Transform: transform, + }) +} + +func handleAppE2eeVerificationCancel(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationCancelParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Cancel( + ctx, + cmd.Value("verification-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification cancel", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverification_test.go b/pkg/cmd/appe2eeverification_test.go new file mode 100644 index 0000000..57130ce --- /dev/null +++ b/pkg/cmd/appe2eeverification_test.go @@ -0,0 +1,67 @@ +// 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 TestAppE2eeVerificationCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "create", + "--user-id", "userID", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("userID: userID") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification", "create", + ) + }) +} + +func TestAppE2eeVerificationAccept(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "accept", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationCancel(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification", "cancel", + "--verification-id", "x", + "--code", "code", + "--reason", "reason", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "code: code\n" + + "reason: reason\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification", "cancel", + "--verification-id", "x", + ) + }) +} diff --git a/pkg/cmd/appe2eeverificationqr.go b/pkg/cmd/appe2eeverificationqr.go new file mode 100644 index 0000000..331aa1b --- /dev/null +++ b/pkg/cmd/appe2eeverificationqr.go @@ -0,0 +1,130 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationQrConfirmScanned = cli.Command{ + Name: "confirm-scanned", + Usage: "Confirm that another device scanned this device QR code.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationQrConfirmScanned, + HideHelpCommand: true, +} + +var appE2eeVerificationQrScan = cli.Command{ + Name: "scan", + Usage: "Submit the QR code scanned from another signed-in device.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "data", + Usage: "QR code payload scanned from the other device.", + Required: true, + BodyPath: "data", + }, + }, + Action: handleAppE2eeVerificationQrScan, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationQrConfirmScanned(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Qr.ConfirmScanned(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:qr confirm-scanned", + Transform: transform, + }) +} + +func handleAppE2eeVerificationQrScan(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppE2eeVerificationQrScanParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Qr.Scan(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:qr scan", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverificationqr_test.go b/pkg/cmd/appe2eeverificationqr_test.go new file mode 100644 index 0000000..635420d --- /dev/null +++ b/pkg/cmd/appe2eeverificationqr_test.go @@ -0,0 +1,41 @@ +// 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 TestAppE2eeVerificationQrConfirmScanned(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:qr", "confirm-scanned", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationQrScan(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:qr", "scan", + "--data", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("data: x") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:e2ee:verification:qr", "scan", + ) + }) +} diff --git a/pkg/cmd/appe2eeverificationsa.go b/pkg/cmd/appe2eeverificationsa.go new file mode 100644 index 0000000..afbda96 --- /dev/null +++ b/pkg/cmd/appe2eeverificationsa.go @@ -0,0 +1,131 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appE2eeVerificationSasConfirm = cli.Command{ + Name: "confirm", + Usage: "Confirm that the emoji or number sequence matches on both devices.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationSasConfirm, + HideHelpCommand: true, +} + +var appE2eeVerificationSasStart = cli.Command{ + Name: "start", + Usage: "Start emoji comparison for device verification.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "verification-id", + Usage: "Verification ID.", + Required: true, + PathParam: "verificationID", + }, + }, + Action: handleAppE2eeVerificationSasStart, + HideHelpCommand: true, +} + +func handleAppE2eeVerificationSasConfirm(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Sas.Confirm(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:sas confirm", + Transform: transform, + }) +} + +func handleAppE2eeVerificationSasStart(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("verification-id") && len(unusedArgs) > 0 { + cmd.Set("verification-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.E2ee.Verification.Sas.Start(ctx, cmd.Value("verification-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:e2ee:verification:sas start", + Transform: transform, + }) +} diff --git a/pkg/cmd/appe2eeverificationsa_test.go b/pkg/cmd/appe2eeverificationsa_test.go new file mode 100644 index 0000000..526a653 --- /dev/null +++ b/pkg/cmd/appe2eeverificationsa_test.go @@ -0,0 +1,31 @@ +// 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 TestAppE2eeVerificationSasConfirm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:sas", "confirm", + "--verification-id", "x", + ) + }) +} + +func TestAppE2eeVerificationSasStart(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:e2ee:verification:sas", "start", + "--verification-id", "x", + ) + }) +} diff --git a/pkg/cmd/applogin.go b/pkg/cmd/applogin.go new file mode 100644 index 0000000..2c83b7f --- /dev/null +++ b/pkg/cmd/applogin.go @@ -0,0 +1,264 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var appLoginEmail = cli.Command{ + Name: "email", + Usage: "Send a sign-in code to the user email address.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "email", + Usage: "Email address to send the sign-in code to.", + Required: true, + BodyPath: "email", + }, + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + }, + Action: handleAppLoginEmail, + HideHelpCommand: true, +} + +var appLoginRegister = cli.Command{ + Name: "register", + Usage: "Create a Beeper account after the user chooses a username and accepts the Terms\nof Use.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[bool]{ + Name: "accept-terms", + Usage: "Confirms that the user accepted the Terms of Use and acknowledged the Privacy Policy.", + Required: true, + BodyPath: "acceptTerms", + }, + &requestflag.Flag[string]{ + Name: "lead-token", + Usage: "Registration token returned by Beeper.", + Required: true, + BodyPath: "leadToken", + }, + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + &requestflag.Flag[string]{ + Name: "username", + Usage: "Username selected by the user.", + Required: true, + BodyPath: "username", + }, + }, + Action: handleAppLoginRegister, + HideHelpCommand: true, +} + +var appLoginResponse = cli.Command{ + Name: "response", + Usage: "Finish sign-in with the code sent to the user email address. If the user needs a\nnew account, the response includes account creation copy and username\nsuggestions.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "request", + Usage: "Login request ID returned by the start step.", + Required: true, + BodyPath: "request", + }, + &requestflag.Flag[string]{ + Name: "response", + Usage: "Sign-in code from the user email.", + Required: true, + BodyPath: "response", + }, + }, + Action: handleAppLoginResponse, + HideHelpCommand: true, +} + +var appLoginStart = cli.Command{ + Name: "start", + Usage: "Start a first-party Beeper Desktop sign-in session.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleAppLoginStart, + HideHelpCommand: true, +} + +func handleAppLoginEmail(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginEmailParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Email(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login email", + Transform: transform, + }) +} + +func handleAppLoginRegister(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginRegisterParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Register(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login register", + Transform: transform, + }) +} + +func handleAppLoginResponse(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.AppLoginResponseParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.App.Login.Response(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login response", + Transform: transform, + }) +} + +func handleAppLoginStart(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.App.Login.Start(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "app:login start", + Transform: transform, + }) +} diff --git a/pkg/cmd/applogin_test.go b/pkg/cmd/applogin_test.go new file mode 100644 index 0000000..b4f866a --- /dev/null +++ b/pkg/cmd/applogin_test.go @@ -0,0 +1,95 @@ +// 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 TestAppLoginEmail(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "email", + "--email", "dev@stainless.com", + "--request", "request", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "email: dev@stainless.com\n" + + "request: request\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "email", + ) + }) +} + +func TestAppLoginRegister(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "register", + "--accept-terms=true", + "--lead-token", "leadToken", + "--request", "request", + "--username", "x", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "acceptTerms: true\n" + + "leadToken: leadToken\n" + + "request: request\n" + + "username: x\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "register", + ) + }) +} + +func TestAppLoginResponse(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "response", + "--request", "request", + "--response", "response", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "request: request\n" + + "response: response\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "app:login", "response", + ) + }) +} + +func TestAppLoginStart(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "app:login", "start", + ) + }) +} diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index d7fc779..2222a2c 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -9,20 +9,20 @@ import ( "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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/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.", + Usage: "Download a Matrix file using its mxc:// or localmxc:// URL to the device running\nBeeper 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.", + Usage: "Matrix content URL (mxc:// or localmxc://) for the file to download.", Required: true, BodyPath: "url", }, @@ -38,10 +38,15 @@ var assetsServe = cli.Command{ Flags: []cli.Flag{ &requestflag.Flag[string]{ Name: "url", - Usage: "Asset URL to serve. Accepts mxc://, localmxc://, or file:// URLs.", + Usage: "File URL to serve. Accepts mxc://, localmxc://, or file:// URLs.", Required: true, QueryPath: "url", }, + &requestflag.Flag[string]{ + Name: "output", + Aliases: []string{"o"}, + Usage: "The file where the response contents will be stored. Use the value '-' to force output to stdout.", + }, }, Action: handleAssetsServe, HideHelpCommand: true, @@ -49,14 +54,15 @@ var assetsServe = cli.Command{ 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.", + Usage: "Upload a file to a temporary location using multipart/form-data. Returns an\nuploadID that can be referenced when sending a message or materializing a draft\nattachment.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "file", - Usage: "The file to upload (max 500 MB).", - Required: true, - BodyPath: "file", + Name: "file", + Usage: "The file to upload (max 500 MB).", + Required: true, + BodyPath: "file", + FileInput: true, }, &requestflag.Flag[string]{ Name: "file-name", @@ -75,7 +81,7 @@ var assetsUpload = cli.Command{ 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.", + Usage: "Upload a file using a JSON body with base64-encoded content. Returns an uploadID\nthat can be referenced when sending a message or materializing a draft\nattachment. Alternative to the multipart upload endpoint.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -107,8 +113,6 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetDownloadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -120,6 +124,8 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetDownloadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.Download(ctx, params, options...) @@ -129,8 +135,15 @@ func handleAssetsDownload(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets download", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "assets download", + Transform: transform, + }) } func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { @@ -141,8 +154,6 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetServeParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -154,7 +165,17 @@ func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { return err } - return client.Assets.Serve(ctx, params, options...) + params := beeperdesktopapi.AssetServeParams{} + + response, err := client.Assets.Serve(ctx, params, options...) + if err != nil { + return err + } + message, err := writeBinaryResponse(response, os.Stdout, cmd.String("output")) + if message != "" { + fmt.Println(message) + } + return err } func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { @@ -165,8 +186,6 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetUploadParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -178,6 +197,8 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetUploadParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.Upload(ctx, params, options...) @@ -187,8 +208,15 @@ func handleAssetsUpload(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets upload", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "assets upload", + Transform: transform, + }) } func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { @@ -199,8 +227,6 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.AssetUploadBase64Params{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -212,6 +238,8 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.AssetUploadBase64Params{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Assets.UploadBase64(ctx, params, options...) @@ -221,6 +249,13 @@ func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "assets upload-base64", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "assets upload-base64", + Transform: transform, + }) } diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 2ce574c..4375d2e 100644 --- a/pkg/cmd/asset_test.go +++ b/pkg/cmd/asset_test.go @@ -3,6 +3,7 @@ package cmd import ( + "strings" "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" @@ -36,6 +37,7 @@ func TestAssetsServe(t *testing.T) { "--access-token", "string", "assets", "serve", "--url", "x", + "--output", "/dev/null", ) }) } @@ -46,18 +48,21 @@ func TestAssetsUpload(t *testing.T) { t, "--access-token", "string", "assets", "upload", - "--file", "Example data", + "--file", mocktest.TestFile(t, "Example data"), "--file-name", "fileName", "--mime-type", "mimeType", ) }) t.Run("piping data", func(t *testing.T) { + testFile := mocktest.TestFile(t, "Example data") // Test piping YAML data over stdin - pipeData := []byte("" + + pipeDataStr := "" + "file: Example data\n" + "fileName: fileName\n" + - "mimeType: mimeType\n") + "mimeType: mimeType\n" + pipeDataStr = strings.ReplaceAll(pipeDataStr, "Example data", testFile) + pipeData := []byte(pipeDataStr) mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", diff --git a/pkg/cmd/beeperdesktopapi.go b/pkg/cmd/beeperdesktopapi.go index bbef6b7..88d2583 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -5,19 +5,18 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/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.", + Usage: "Focus Beeper Desktop and optionally navigate to a specific chat, message, or\npre-fill plain text and an image path.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -27,12 +26,12 @@ var focus = cli.Command{ }, &requestflag.Flag[string]{ Name: "draft-attachment-path", - Usage: "Optional draft attachment path to populate in the message input field.", + Usage: "Optional image 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.", + Usage: "Optional plain text to populate in the message input field.", BodyPath: "draftText", }, &requestflag.Flag[string]{ @@ -69,8 +68,6 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.FocusParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -82,6 +79,8 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.FocusParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Focus(ctx, params, options...) @@ -91,8 +90,15 @@ func handleFocus(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "focus", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "focus", + Transform: transform, + }) } func handleSearch(ctx context.Context, cmd *cli.Command) error { @@ -103,8 +109,6 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.SearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -116,6 +120,8 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.SearchParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Search(ctx, params, options...) @@ -124,7 +130,17 @@ func handleSearch(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "search", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "search", + Transform: transform, + }) } diff --git a/pkg/cmd/bridge.go b/pkg/cmd/bridge.go new file mode 100644 index 0000000..a33054e --- /dev/null +++ b/pkg/cmd/bridge.go @@ -0,0 +1,65 @@ +// 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-go/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var bridgesList = cli.Command{ + Name: "list", + Usage: "List bridge-backed account types that can be shown in add-account flows, grouped\nwith connected accounts that use the same Account schema as GET /v1/accounts.", + Suggest: true, + Flags: []cli.Flag{}, + Action: handleBridgesList, + HideHelpCommand: true, +} + +func handleBridgesList(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.Bridges.List(ctx, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "bridges list", + Transform: transform, + }) +} diff --git a/pkg/cmd/bridge_test.go b/pkg/cmd/bridge_test.go new file mode 100644 index 0000000..25bdeb8 --- /dev/null +++ b/pkg/cmd/bridge_test.go @@ -0,0 +1,19 @@ +// 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 TestBridgesList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "bridges", "list", + ) + }) +} diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 41a766b..4b22dc0 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -5,19 +5,18 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) -var chatsCreate = requestflag.WithInnerFlags(cli.Command{ +var chatsCreate = cli.Command{ Name: "create", - Usage: "Create a single/group chat (mode='create') or start a direct chat from merged\nuser data (mode='start').", + Usage: "Create a direct or group chat from participant IDs. Returns the created chat.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ @@ -26,74 +25,32 @@ var chatsCreate = requestflag.WithInnerFlags(cli.Command{ 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: "participant-id", + Usage: "User IDs to include in the new chat.", + Required: true, + BodyPath: "participantIDs", + }, + &requestflag.Flag[string]{ + Name: "type", + Usage: "'single' requires exactly one participantID; 'group' supports multiple participants and optional title.", + Required: true, + BodyPath: "type", }, &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.", + Usage: "Optional title for group chats; ignored for single chats on most networks.", 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", @@ -101,14 +58,15 @@ var chatsRetrieve = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*int64]{ Name: "max-participant-count", - Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0–500. Defaults to all (-1).", - Default: -1, + Usage: "Maximum number of participants to return. Use -1 for all; otherwise 0-500. Defaults to 100. List and search endpoints return up to 20 participants per chat.", + Default: requestflag.Ptr[int64](100), QueryPath: "maxParticipantCount", }, }, @@ -116,6 +74,80 @@ var chatsRetrieve = cli.Command{ HideHelpCommand: true, } +var chatsUpdate = requestflag.WithInnerFlags(cli.Command{ + Name: "update", + Usage: "Update supported chat fields. Non-empty draft objects are accepted only when the\ncurrent draft is empty. Send draft=null to clear the draft before setting new\ndraft text or attachments.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[*string]{ + Name: "description", + Usage: "Group chat description/topic. Support depends on the chat account and chat permissions.", + BodyPath: "description", + }, + &requestflag.Flag[map[string]any]{ + Name: "draft", + Usage: "Draft object to set or clear. Non-empty drafts are only accepted when the current draft is empty. Send draft=null to clear text and attachments together before setting a new draft.", + BodyPath: "draft", + }, + &requestflag.Flag[*string]{ + Name: "img-url", + Usage: "Local filesystem path to a group chat avatar image. Support depends on the chat account and chat permissions.", + BodyPath: "imgURL", + }, + &requestflag.Flag[bool]{ + Name: "is-archived", + Usage: "Archive or unarchive the chat.", + BodyPath: "isArchived", + }, + &requestflag.Flag[bool]{ + Name: "is-low-priority", + Usage: "Mark or unmark the chat as low priority when supported by the account.", + BodyPath: "isLowPriority", + }, + &requestflag.Flag[bool]{ + Name: "is-muted", + Usage: "Mute or unmute the chat.", + BodyPath: "isMuted", + }, + &requestflag.Flag[bool]{ + Name: "is-pinned", + Usage: "Pin or unpin the chat when supported by the account.", + BodyPath: "isPinned", + }, + &requestflag.Flag[*int64]{ + Name: "message-expiry-seconds", + Usage: "Disappearing-message timer in seconds, or null to clear when supported.", + BodyPath: "messageExpirySeconds", + }, + &requestflag.Flag[*string]{ + Name: "title", + Usage: "Custom chat title. Support depends on the chat account and chat permissions.", + BodyPath: "title", + }, + }, + Action: handleChatsUpdate, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "draft": { + &requestflag.InnerFlag[string]{ + Name: "draft.text", + Usage: "Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.", + InnerField: "text", + }, + &requestflag.InnerFlag[map[string]any]{ + Name: "draft.attachments", + Usage: "Draft attachments keyed by attachment ID. Each attachment must reference an uploadID returned by the upload file endpoint.", + InnerField: "attachments", + }, + }, +}) + 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.", @@ -151,9 +183,10 @@ var chatsArchive = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[bool]{ Name: "archived", @@ -166,9 +199,67 @@ var chatsArchive = cli.Command{ HideHelpCommand: true, } +var chatsMarkRead = cli.Command{ + Name: "mark-read", + Usage: "Mark a chat as read, optionally through a specific message ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Optional message ID to mark read through.", + BodyPath: "messageID", + }, + }, + Action: handleChatsMarkRead, + HideHelpCommand: true, +} + +var chatsMarkUnread = cli.Command{ + Name: "mark-unread", + Usage: "Mark a chat as unread, optionally from a specific message ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Optional message ID to mark unread from.", + BodyPath: "messageID", + }, + }, + Action: handleChatsMarkUnread, + HideHelpCommand: true, +} + +var chatsNotifyAnyway = cli.Command{ + Name: "notify-anyway", + Usage: "Force a delivery notification when supported by the underlying network.\nCurrently intended for iMessage on macOS; unsupported networks return an error.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + }, + Action: handleChatsNotifyAnyway, + HideHelpCommand: true, +} + var chatsSearch = cli.Command{ Name: "search", - Usage: "Search chats by title/network or participants using Beeper Desktop's renderer\nalgorithm.", + Usage: "Search chats by title, network, or participant names.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[[]string]{ @@ -191,10 +282,10 @@ var chatsSearch = cli.Command{ 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]{ + &requestflag.Flag[*bool]{ 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, + Default: requestflag.Ptr[bool](true), QueryPath: "includeMuted", }, &requestflag.Flag[any]{ @@ -230,7 +321,7 @@ var chatsSearch = cli.Command{ Default: "any", QueryPath: "type", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ Name: "unread-only", Usage: "Set to true to only retrieve chats that have unread messages", QueryPath: "unreadOnly", @@ -244,6 +335,67 @@ var chatsSearch = cli.Command{ HideHelpCommand: true, } +var chatsStart = requestflag.WithInnerFlags(cli.Command{ + Name: "start", + Usage: "Resolve a user/contact and open a direct chat. Reuses and returns an existing\ndirect chat when one is found. Available in Beeper Desktop v4.2.808+.", + 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[map[string]any]{ + Name: "user", + Usage: "Merged user-like contact payload used to resolve the best identifier.", + Required: true, + BodyPath: "user", + }, + &requestflag.Flag[bool]{ + Name: "allow-invite", + Usage: "Whether invite-based DM creation is allowed when required by the platform.", + 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", + }, + }, + Action: handleChatsStart, + 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", + }, + }, +}) + func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -252,8 +404,6 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatNewParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -265,6 +415,8 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatNewParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.New(ctx, params, options...) @@ -274,8 +426,15 @@ func handleChatsCreate(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats create", + Transform: transform, + }) } func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -289,8 +448,6 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatGetParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -302,6 +459,8 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatGetParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Get( @@ -314,10 +473,69 @@ func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { return err } + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats retrieve", + Transform: transform, + }) +} + +func handleChatsUpdate(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatUpdateParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.Update( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats update", + Transform: transform, + }) } func handleChatsList(ctx context.Context, cmd *cli.Command) error { @@ -328,8 +546,6 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -341,7 +557,13 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + params := beeperdesktopapi.ChatListParams{} + + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -351,14 +573,26 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats list", + Transform: 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) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats list", + Transform: transform, + }) } } @@ -373,8 +607,6 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatArchiveParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -386,6 +618,8 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatArchiveParams{} + return client.Chats.Archive( ctx, cmd.Value("chat-id").(string), @@ -394,15 +628,114 @@ func handleChatsArchive(ctx context.Context, cmd *cli.Command) error { ) } -func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { +func handleChatsMarkRead(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatMarkReadParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.MarkRead( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats mark-read", + Transform: transform, + }) +} + +func handleChatsMarkUnread(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.ChatSearchParams{} + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatMarkUnreadParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.MarkUnread( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats mark-unread", + Transform: transform, + }) +} + +func handleChatsNotifyAnyway(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, @@ -415,7 +748,59 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatNotifyAnywayParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.NotifyAnyway( + ctx, + cmd.Value("chat-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats notify-anyway", + Transform: transform, + }) +} + +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) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatSearchParams{} + + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -425,13 +810,66 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "chats search", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats search", + Transform: 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) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats search", + Transform: transform, + }) } } + +func handleChatsStart(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.ChatStartParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Chats.Start(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats start", + Transform: transform, + }) +} diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index ff041f7..01c710b 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -16,37 +16,10 @@ func TestChatsCreate(t *testing.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", ) }) @@ -54,19 +27,11 @@ func TestChatsCreate(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") + "messageText: messageText\n" + + "title: title\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", @@ -82,7 +47,82 @@ func TestChatsRetrieve(t *testing.T) { "--access-token", "string", "chats", "retrieve", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--max-participant-count", "50", + "--max-participant-count", "100", + ) + }) +} + +func TestChatsUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--description", "description", + "--draft", "{text: text, attachments: {foo: {uploadID: uploadID, id: id, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}}}", + "--img-url", "imgURL", + "--is-archived=true", + "--is-low-priority=true", + "--is-muted=true", + "--is-pinned=true", + "--message-expiry-seconds", "0", + "--title", "title", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsUpdate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--description", "description", + "--draft.text", "text", + "--draft.attachments", "{foo: {uploadID: uploadID, id: id, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}}", + "--img-url", "imgURL", + "--is-archived=true", + "--is-low-priority=true", + "--is-muted=true", + "--is-pinned=true", + "--message-expiry-seconds", "0", + "--title", "title", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "description: description\n" + + "draft:\n" + + " text: text\n" + + " attachments:\n" + + " foo:\n" + + " uploadID: uploadID\n" + + " id: id\n" + + " duration: 0\n" + + " fileName: fileName\n" + + " mimeType: mimeType\n" + + " size:\n" + + " height: 0\n" + + " width: 0\n" + + " type: image\n" + + "imgURL: imgURL\n" + + "isArchived: true\n" + + "isLowPriority: true\n" + + "isMuted: true\n" + + "isPinned: true\n" + + "messageExpirySeconds: 0\n" + + "title: title\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "update", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", ) }) } @@ -94,8 +134,9 @@ func TestChatsList(t *testing.T) { "--access-token", "string", "chats", "list", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", "--cursor", "1725489123456|c29tZUltc2dQYWdl", "--direction", "before", ) @@ -125,6 +166,63 @@ func TestChatsArchive(t *testing.T) { }) } +func TestChatsMarkRead(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "mark-read", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("messageID: '1343993'") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "mark-read", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + +func TestChatsMarkUnread(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "mark-unread", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("messageID: '1343993'") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "mark-unread", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + +func TestChatsNotifyAnyway(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "notify-anyway", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + ) + }) +} + func TestChatsSearch(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -132,8 +230,9 @@ func TestChatsSearch(t *testing.T) { "--access-token", "string", "chats", "search", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-telegram_ba_QFrb5lrLPhO3OT5MFBeTWv0x4BI", "--cursor", "1725489123456|c29tZUltc2dQYWdl", "--direction", "before", "--inbox", "primary", @@ -148,3 +247,56 @@ func TestChatsSearch(t *testing.T) { ) }) } + +func TestChatsStart(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "start", + "--account-id", "accountID", + "--user", "{id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}", + "--allow-invite=true", + "--message-text", "messageText", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(chatsStart) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "chats", "start", + "--account-id", "accountID", + "--user.id", "id", + "--user.email", "email", + "--user.full-name", "fullName", + "--user.phone-number", "phoneNumber", + "--user.username", "username", + "--allow-invite=true", + "--message-text", "messageText", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "accountID: accountID\n" + + "user:\n" + + " id: id\n" + + " email: email\n" + + " fullName: fullName\n" + + " phoneNumber: phoneNumber\n" + + " username: username\n" + + "allowInvite: true\n" + + "messageText: messageText\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "chats", "start", + ) + }) +} diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index f72f189..e1976b9 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -5,35 +5,37 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/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.", + Usage: "Remove the reaction added by the authenticated user from an existing message.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "reaction-key", - Usage: "Reaction key to remove", + Usage: "Reaction key to remove (emoji, shortcode, or custom emoji key)", Required: true, - QueryPath: "reactionKey", + PathParam: "reactionKey", }, }, Action: handleChatsMessagesReactionsDelete, @@ -46,13 +48,16 @@ var chatsMessagesReactionsAdd = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "reaction-key", @@ -62,7 +67,7 @@ var chatsMessagesReactionsAdd = cli.Command{ }, &requestflag.Flag[string]{ Name: "transaction-id", - Usage: "Optional transaction ID for deduplication and local echo tracking", + Usage: "Optional transaction ID for deduplication and send tracking", BodyPath: "transactionID", }, }, @@ -73,18 +78,14 @@ var chatsMessagesReactionsAdd = cli.Command{ 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]) + if !cmd.IsSet("reaction-key") && len(unusedArgs) > 0 { + cmd.Set("reaction-key", 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, @@ -96,11 +97,16 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e return err } + params := beeperdesktopapi.ChatMessageReactionDeleteParams{ + ChatID: cmd.Value("chat-id").(string), + MessageID: cmd.Value("message-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Messages.Reactions.Delete( ctx, - cmd.Value("message-id").(string), + cmd.Value("reaction-key").(string), params, options..., ) @@ -110,8 +116,15 @@ func handleChatsMessagesReactionsDelete(ctx context.Context, cmd *cli.Command) e obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats:messages:reactions delete", + Transform: transform, + }) } func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error { @@ -125,10 +138,6 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatMessageReactionAddParams{ - ChatID: cmd.Value("chat-id").(string), - } - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -140,6 +149,10 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro return err } + params := beeperdesktopapi.ChatMessageReactionAddParams{ + ChatID: cmd.Value("chat-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Chats.Messages.Reactions.Add( @@ -154,6 +167,13 @@ func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) erro obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "chats:messages:reactions add", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "chats:messages:reactions add", + Transform: transform, + }) } diff --git a/pkg/cmd/chatmessagereaction_test.go b/pkg/cmd/chatmessagereaction_test.go index 74168cd..16a0157 100644 --- a/pkg/cmd/chatmessagereaction_test.go +++ b/pkg/cmd/chatmessagereaction_test.go @@ -15,7 +15,7 @@ func TestChatsMessagesReactionsDelete(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "delete", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--reaction-key", "x", ) }) @@ -28,7 +28,7 @@ func TestChatsMessagesReactionsAdd(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "add", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--reaction-key", "x", "--transaction-id", "transactionID", ) @@ -44,7 +44,7 @@ func TestChatsMessagesReactionsAdd(t *testing.T) { "--access-token", "string", "chats:messages:reactions", "add", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", ) }) } diff --git a/pkg/cmd/chatreminder.go b/pkg/cmd/chatreminder.go index 5f288e1..535ee8a 100644 --- a/pkg/cmd/chatreminder.go +++ b/pkg/cmd/chatreminder.go @@ -8,7 +8,7 @@ import ( "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/v5" "github.com/urfave/cli/v3" ) @@ -18,9 +18,10 @@ var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[map[string]any]{ Name: "reminder", @@ -33,10 +34,10 @@ var chatsRemindersCreate = requestflag.WithInnerFlags(cli.Command{ 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[any]{ + Name: "reminder.remind-at", + Usage: "Timestamp when the reminder should trigger.", + InnerField: "remindAt", }, &requestflag.InnerFlag[bool]{ Name: "reminder.dismiss-on-incoming-message", @@ -52,9 +53,10 @@ var chatsRemindersDelete = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, }, Action: handleChatsRemindersDelete, @@ -72,8 +74,6 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.ChatReminderNewParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -85,6 +85,8 @@ func handleChatsRemindersCreate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.ChatReminderNewParams{} + return client.Chats.Reminders.New( ctx, cmd.Value("chat-id").(string), diff --git a/pkg/cmd/chatreminder_test.go b/pkg/cmd/chatreminder_test.go index 84f08fb..9ef55ab 100644 --- a/pkg/cmd/chatreminder_test.go +++ b/pkg/cmd/chatreminder_test.go @@ -16,7 +16,7 @@ func TestChatsRemindersCreate(t *testing.T) { "--access-token", "string", "chats:reminders", "create", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder", "{remindAtMs: 0, dismissOnIncomingMessage: true}", + "--reminder", "{remindAt: '2025-08-31T23:30:12.520Z', dismissOnIncomingMessage: true}", ) }) @@ -30,7 +30,7 @@ func TestChatsRemindersCreate(t *testing.T) { "--access-token", "string", "chats:reminders", "create", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--reminder.remind-at-ms", "0", + "--reminder.remind-at", "2025-08-31T23:30:12.520Z", "--reminder.dismiss-on-incoming-message=true", ) }) @@ -39,7 +39,7 @@ func TestChatsRemindersCreate(t *testing.T) { // Test piping YAML data over stdin pipeData := []byte("" + "reminder:\n" + - " remindAtMs: 0\n" + + " remindAt: '2025-08-31T23:30:12.520Z'\n" + " dismissOnIncomingMessage: true\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 3351e7f..e45969c 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -39,11 +39,14 @@ func init() { Name: "base-url", DefaultText: "url", Usage: "Override the base URL for API requests", + Validator: func(baseURL string) error { + return ValidateBaseURL(baseURL, "--base-url") + }, }, &cli.StringFlag{ Name: "format", Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", + Value: "json", Validator: func(format string) error { if !slices.Contains(OutputFormats, strings.ToLower(format)) { return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) @@ -54,7 +57,7 @@ func init() { &cli.StringFlag{ Name: "format-error", Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "pretty", + Value: "json", Validator: func(format string) error { if !slices.Contains(OutputFormats, strings.ToLower(format)) { return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) @@ -70,15 +73,85 @@ func init() { Name: "transform-error", Usage: "The GJSON transformation for errors.", }, + &cli.BoolFlag{ + Name: "raw-output", + Aliases: []string{"r"}, + Usage: "If the result is a string, print it without JSON quotes. This can be useful for making output transforms talk to non-JSON-based systems.", + }, &requestflag.Flag[string]{ Name: "access-token", - Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations.", + Usage: "Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for authenticated API operations.", Sources: cli.EnvVars("BEEPER_ACCESS_TOKEN"), }, }, Commands: []*cli.Command{ &focus, &search, + { + Name: "app", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appStatus, + }, + }, + { + Name: "app:login", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appLoginEmail, + &appLoginRegister, + &appLoginResponse, + &appLoginStart, + }, + }, + { + Name: "app:e2ee:recovery-code", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeRecoveryCodeMarkBackedUp, + &appE2eeRecoveryCodeVerify, + }, + }, + { + Name: "app:e2ee:recovery-code:reset", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeRecoveryCodeResetCreate, + &appE2eeRecoveryCodeResetConfirm, + }, + }, + { + Name: "app:e2ee:verification", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationCreate, + &appE2eeVerificationAccept, + &appE2eeVerificationCancel, + }, + }, + { + Name: "app:e2ee:verification:qr", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationQrConfirmScanned, + &appE2eeVerificationQrScan, + }, + }, + { + Name: "app:e2ee:verification:sas", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &appE2eeVerificationSasConfirm, + &appE2eeVerificationSasStart, + }, + }, { Name: "accounts", Category: "API RESOURCE", @@ -96,6 +169,116 @@ func init() { &accountsContactsSearch, }, }, + { + Name: "bridges", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &bridgesList, + }, + }, + { + Name: "matrix:users", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixUsersRetrieveProfile, + }, + }, + { + Name: "matrix:users:account-data", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixUsersAccountDataRetrieve, + &matrixUsersAccountDataUpdate, + }, + }, + { + Name: "matrix:rooms", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsCreate, + &matrixRoomsJoin, + &matrixRoomsLeave, + }, + }, + { + Name: "matrix:rooms:account-data", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsAccountDataRetrieve, + &matrixRoomsAccountDataUpdate, + }, + }, + { + Name: "matrix:rooms:state", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsStateRetrieve, + &matrixRoomsStateList, + }, + }, + { + Name: "matrix:rooms:events", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixRoomsEventsRetrieve, + }, + }, + { + Name: "matrix:bridges:auth", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesAuthListFlows, + &matrixBridgesAuthListLogins, + &matrixBridgesAuthLogout, + &matrixBridgesAuthStartLogin, + &matrixBridgesAuthSubmitCookies, + &matrixBridgesAuthSubmitUserInput, + &matrixBridgesAuthWaitForStep, + &matrixBridgesAuthWhoami, + }, + }, + { + Name: "matrix:bridges:contacts", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesContactsList, + }, + }, + { + Name: "matrix:bridges:users", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesUsersResolve, + &matrixBridgesUsersSearch, + }, + }, + { + Name: "matrix:bridges:rooms", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesRoomsCreateDm, + &matrixBridgesRoomsCreateGroup, + }, + }, + { + Name: "matrix:bridges:capabilities", + Category: "API RESOURCE", + Suggest: true, + Commands: []*cli.Command{ + &matrixBridgesCapabilitiesRetrieve, + }, + }, { Name: "chats", Category: "API RESOURCE", @@ -103,9 +286,14 @@ func init() { Commands: []*cli.Command{ &chatsCreate, &chatsRetrieve, + &chatsUpdate, &chatsList, &chatsArchive, + &chatsMarkRead, + &chatsMarkUnread, + &chatsNotifyAnyway, &chatsSearch, + &chatsStart, }, }, { @@ -131,8 +319,10 @@ func init() { Category: "API RESOURCE", Suggest: true, Commands: []*cli.Command{ + &messagesRetrieve, &messagesUpdate, &messagesList, + &messagesDelete, &messagesSearch, &messagesSend, }, diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 9b24177..f36172c 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -17,7 +17,7 @@ import ( "syscall" "github.com/beeper/desktop-api-cli/internal/jsonview" - "github.com/beeper/desktop-api-go/option" + "github.com/beeper/desktop-api-go/v5/option" "github.com/charmbracelet/x/term" "github.com/itchyny/json2yaml" @@ -29,6 +29,15 @@ import ( var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} +// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better +// error message than the person would see otherwise if it doesn't. +func ValidateBaseURL(value, source string) error { + if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { + return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) + } + return nil +} + func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { opts := []option.RequestOption{ option.WithHeader("User-Agent", fmt.Sprintf("BeeperDesktop/CLI %s", Version)), @@ -184,7 +193,10 @@ func streamToStdout(generateOutput func(w *os.File) error) error { return err } -func writeBinaryResponse(response *http.Response, outfile string) (string, error) { +// writeBinaryResponse writes a binary response to stdout or a file. +// +// Takes in a stdout reference so we can test this function without overriding os.Stdout in tests. +func writeBinaryResponse(response *http.Response, stdout io.Writer, outfile string) (string, error) { defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { @@ -192,13 +204,13 @@ func writeBinaryResponse(response *http.Response, outfile string) (string, error } switch outfile { case "-", "/dev/stdout": - _, err := os.Stdout.Write(body) + _, err := 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) + _, err := stdout.Write(body) return "", err } @@ -299,21 +311,29 @@ func shouldUseColors(w io.Writer) bool { 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) +func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { + if opts.Transform != "" { + transformed := res.Get(opts.Transform) if transformed.Exists() { res = transformed } } - switch strings.ToLower(format) { + // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that + // it's easier to pipe into other programs. + if opts.RawOutput && res.Type == gjson.String { + return []byte(res.Str + "\n"), nil + } + switch strings.ToLower(opts.Format) { case "auto": - return formatJSON(expectedOutput, title, res, "json", "") + autoOpts := opts + autoOpts.Format = "json" + autoOpts.Transform = "" + return formatJSON(res, autoOpts) case "pretty": - return []byte(jsonview.RenderJSON(title, res) + "\n"), nil + return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil case "json": prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { return pretty.Color(prettyJSON, pretty.TerminalStyle), nil } else { return prettyJSON, nil @@ -321,7 +341,7 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format case "jsonl": // @ugly is gjson syntax for "no whitespace", so it fits on one line oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(expectedOutput) { + if shouldUseColors(opts.Stdout) { bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') return bytes, nil } else { @@ -335,34 +355,67 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format if err := json2yaml.Convert(&yaml, input); err != nil { return nil, err } - _, err := expectedOutput.Write([]byte(yaml.String())) + _, err := opts.Stdout.Write([]byte(yaml.String())) return nil, err default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", format, strings.Join(OutputFormats, ", ")) + return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", opts.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 - } +const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + +// ShowJSONOpts configures how JSON output is displayed. +type ShowJSONOpts struct { + ExplicitFormat bool // true if the user explicitly passed --format + Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) + RawOutput bool // like jq -r: print strings without JSON quotes + Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr + Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout + Title string // display title + Transform string // GJSON path to extract before displaying +} + +func (o *ShowJSONOpts) setDefaults() { + if o.Stderr == nil { + o.Stderr = os.Stderr } + if o.Stdout == nil { + o.Stdout = os.Stdout + } +} - switch strings.ToLower(format) { +// ShowJSON displays a single JSON result to the user. +func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { + opts.setDefaults() + + switch strings.ToLower(opts.Format) { case "auto": - return ShowJSON(out, title, res, "json", "") + autoOpts := opts + autoOpts.Format = "json" + return ShowJSON(res, autoOpts) case "explore": - return jsonview.ExploreJSON(title, res) + if !isTerminal(opts.Stdout) { + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) + } + jsonOpts := opts + jsonOpts.Format = "json" + return ShowJSON(res, jsonOpts) + } + if opts.Transform != "" { + transformed := res.Get(opts.Transform) + if transformed.Exists() { + res = transformed + } + } + return jsonview.ExploreJSON(opts.Title, res) default: - bytes, err := formatJSON(out, title, res, format, transform) + bytes, err := formatJSON(res, opts) if err != nil { return err } - _, err = out.Write(bytes) + _, err = opts.Stdout.Write(bytes) return err } } @@ -372,15 +425,22 @@ func countTerminalLines(data []byte, terminalWidth int) int { return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) } -type HasRawJSON interface { +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) +// ShowJSONIterator displays an iterator of values to the user. Use itemsToDisplay = -1 for no limit. +func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { + opts.setDefaults() + + if opts.Format == "explore" { + if isTerminal(opts.Stdout) { + return jsonview.ExploreJSONStream(opts.Title, iter) + } + if opts.ExplicitFormat { + fmt.Fprint(opts.Stderr, warningExploreNotSupported) + } + opts.Format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -393,13 +453,11 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat usePager := false output := []byte{} numberOfNewlines := 0 - for iter.Next() { - if itemsToDisplay == 0 { - break - } + // -1 is used to signal no limit of items to display + for itemsToDisplay != 0 && iter.Next() { item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) @@ -408,7 +466,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } obj = gjson.ParseBytes(jsonData) } - json, err := formatJSON(stdout, title, obj, format, transform) + json, err := formatJSON(obj, opts) if err != nil { return err } @@ -425,7 +483,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } if !usePager { - _, err := stdout.Write(output) + _, err := opts.Stdout.Write(output) if err != nil { return err } @@ -433,20 +491,22 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat return iter.Err() } - return streamOutput(title, func(pager *os.File) error { - // Write the output we used during the initial terminal size computation + return streamOutput(opts.Title, func(pager *os.File) error { _, err := pager.Write(output) if err != nil { return err } + pagerOpts := opts + pagerOpts.Stdout = pager + for iter.Next() { if itemsToDisplay == 0 { break } item := iter.Current() var obj gjson.Result - if hasRaw, ok := any(item).(HasRawJSON); ok { + if hasRaw, ok := any(item).(hasRawJSON); ok { obj = gjson.Parse(hasRaw.RawJSON()) } else { jsonData, err := json.Marshal(item) @@ -455,7 +515,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } obj = gjson.ParseBytes(jsonData) } - if err := ShowJSON(pager, title, obj, format, transform); err != nil { + if err := ShowJSON(obj, pagerOpts); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..2c500d7 100644 --- a/pkg/cmd/cmdutil_test.go +++ b/pkg/cmd/cmdutil_test.go @@ -10,6 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/beeper/desktop-api-cli/internal/jsonview" ) func TestStreamOutput(t *testing.T) { @@ -32,7 +35,7 @@ func TestWriteBinaryResponse(t *testing.T) { Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, outfile) + msg, err := writeBinaryResponse(resp, os.Stdout, outfile) require.NoError(t, err) assert.Contains(t, msg, outfile) @@ -43,34 +46,24 @@ func TestWriteBinaryResponse(t *testing.T) { }) t.Run("write to stdout", func(t *testing.T) { - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + t.Parallel() + var buf bytes.Buffer body := []byte("stdout content") resp := &http.Response{ Body: io.NopCloser(bytes.NewReader(body)), } - msg, err := writeBinaryResponse(resp, "-") - - w.Close() - os.Stdout = oldStdout + msg, err := writeBinaryResponse(resp, &buf, "-") 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) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ @@ -96,10 +89,7 @@ func TestCreateDownloadFile(t *testing.T) { }) 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) + t.Chdir(t.TempDir()) resp := &http.Response{Header: http.Header{}} file, err := createDownloadFile(resp, []byte("test content")) @@ -109,10 +99,7 @@ func TestCreateDownloadFile(t *testing.T) { }) t.Run("prevents directory traversal", func(t *testing.T) { - tmpDir := t.TempDir() - oldWd, _ := os.Getwd() - os.Chdir(tmpDir) - defer os.Chdir(oldWd) + t.Chdir(t.TempDir()) resp := &http.Response{ Header: http.Header{ @@ -125,3 +112,277 @@ func TestCreateDownloadFile(t *testing.T) { assert.Equal(t, "passwd", filepath.Base(file.Name())) }) } + +func TestValidateBaseURL(t *testing.T) { + t.Parallel() + + t.Run("ValidHTTPS", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) + }) + + t.Run("ValidHTTP", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) + }) + + t.Run("Empty", func(t *testing.T) { + t.Parallel() + + require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) + }) + + t.Run("MissingScheme", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") + require.Error(t, err) + assert.Contains(t, err.Error(), "MY_BASE_URL") + assert.Contains(t, err.Error(), "missing a scheme") + }) + + t.Run("HostOnly", func(t *testing.T) { + t.Parallel() + + err := ValidateBaseURL("api.example.com", "--base-url") + require.Error(t, err) + assert.Contains(t, err.Error(), "--base-url") + }) +} + +func TestFormatJSON(t *testing.T) { + t.Parallel() + + t.Run("RawWithTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "id"}) + require.NoError(t, err) + require.Equal(t, `"abc123"`+"\n", string(formatted)) + }) + + t.Run("RawWithoutTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) + require.NoError(t, err) + require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) + }) + + t.Run("RawWithNestedTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "data.items"}) + require.NoError(t, err) + require.Equal(t, "[1,2,3]\n", string(formatted)) + }) + + t.Run("RawWithNonexistentTransform", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "missing"}) + require.NoError(t, err) + // Transform path doesn't exist, so original result is returned + require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) + }) + + t.Run("RawOutputString", func(t *testing.T) { + t.Parallel() + + res := gjson.Parse(`{"id":"abc123","name":"test"}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "json", Stdout: os.Stdout, Transform: "id", RawOutput: true}) + require.NoError(t, err) + require.Equal(t, "abc123\n", string(formatted)) + }) + + t.Run("RawOutputNonString", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on non-string values + res := gjson.Parse(`{"count":42}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: true}) + require.NoError(t, err) + require.Equal(t, "42\n", string(formatted)) + }) + + t.Run("RawOutputObject", func(t *testing.T) { + t.Parallel() + + // --raw-output has no effect on objects + res := gjson.Parse(`{"nested":{"a":1}}`) + formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) + require.NoError(t, err) + require.Equal(t, `{"a":1}`+"\n", string(formatted)) + }) +} + +func TestShowJSONIterator(t *testing.T) { + t.Parallel() + + t.Run("RawMultipleItems", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc", "name": "first"}, + {"id": "def", "name": "second"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "", -1) + assert.Equal(t, `{"id":"abc","name":"first"}`+"\n"+`{"id":"def","name":"second"}`+"\n", captured) + }) + + t.Run("RawWithTransform", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc", "name": "first"}, + {"id": "def", "name": "second"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "id", -1) + assert.Equal(t, `"abc"`+"\n"+`"def"`+"\n", captured) + }) + + t.Run("LimitItems", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + {"id": "def"}, + {"id": "ghi"}, + }} + captured := captureShowJSONIterator(t, iter, "raw", "", 2) + assert.Equal(t, `{"id":"abc"}`+"\n"+`{"id":"def"}`+"\n", captured) + }) +} + +func TestExploreFallback(t *testing.T) { + t.Parallel() + + t.Run("ShowJSONFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + // os.Pipe() produces a *os.File that isn't a terminal, so explore should fall back. + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + assert.Contains(t, buf.String(), `"id"`) + assert.Contains(t, buf.String(), `"abc"`) + }) + + t.Run("ShowJSONIteratorFallsBackToJsonOnNonTTY", func(t *testing.T) { + t.Parallel() + + iter := &sliceIterator[map[string]any]{items: []map[string]any{ + {"id": "abc"}, + }} + captured := captureShowJSONIterator(t, iter, "explore", "", -1) + assert.Contains(t, captured, `"id"`) + assert.Contains(t, captured, `"abc"`) + }) + + t.Run("ShowJSONWarnsWhenExplicitFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(res, ShowJSONOpts{ + ExplicitFormat: true, + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + w.Close() + require.NoError(t, err) + + assert.Equal(t, warningExploreNotSupported, stderr.String()) + }) + + t.Run("ShowJSONSilentWhenDefaultFormatOnNonTTY", func(t *testing.T) { + t.Parallel() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + var stderr bytes.Buffer + res := gjson.Parse(`{"id":"abc"}`) + err = ShowJSON(res, ShowJSONOpts{ + Format: "explore", + Stderr: &stderr, + Stdout: w, + Title: "test", + }) + w.Close() + require.NoError(t, err) + + assert.Empty(t, stderr.String(), "no warning expected when format was not explicit") + }) +} + +// sliceIterator is a simple iterator over a slice for testing. +type sliceIterator[T any] struct { + index int + items []T +} + +func (it *sliceIterator[T]) Next() bool { + it.index++ + return it.index <= len(it.items) +} + +func (it *sliceIterator[T]) Current() T { + return it.items[it.index-1] +} + +func (it *sliceIterator[T]) Err() error { + return nil +} + +var _ jsonview.Iterator[any] = (*sliceIterator[any])(nil) + +// captureShowJSONIterator runs ShowJSONIterator and captures the output written to a file. +func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], format, transform string, itemsToDisplay int64) string { + t.Helper() + + r, w, err := os.Pipe() + require.NoError(t, err) + defer r.Close() + + err = ShowJSONIterator(iter, itemsToDisplay, ShowJSONOpts{ + Format: format, + Stderr: io.Discard, + Stdout: w, + Title: "test", + Transform: transform, + }) + w.Close() + require.NoError(t, err) + + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go index 4a39cd0..2d9c0a5 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -7,9 +7,11 @@ import ( "fmt" "io" "maps" + "mime" "mime/multipart" "net/http" "os" + "path/filepath" "reflect" "strings" "unicode/utf8" @@ -18,7 +20,7 @@ import ( "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/beeper/desktop-api-go/v5/option" "github.com/goccy/go-yaml" "github.com/urfave/cli/v3" @@ -36,16 +38,59 @@ const ( type FileEmbedStyle int const ( + // EmbedText reads referenced files fully into memory and substitutes the file's contents back into the + // value as a string. Binary files are base64-encoded. Used for JSON request bodies and for headers and + // query parameters, where the file contents need to be serialized inline. EmbedText FileEmbedStyle = iota + + // EmbedIOReader replaces file references with an io.Reader that streams the file's contents. Used for + // `multipart/form-data` and `application/octet-stream` request bodies, where files are uploaded as binary + // parts rather than embedded into a text value. EmbedIOReader ) -func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { +// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most +// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin +// is unavailable and read() returns an error explaining why. +type onceStdinReader struct { + stdinReader io.Reader + failureReason string +} + +func (o *onceStdinReader) read() (io.Reader, error) { + if o.failureReason != "" { + return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) + } + if o.stdinReader == nil { + return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") + } + r := o.stdinReader + o.stdinReader = nil + return r, nil +} + +func (o *onceStdinReader) readAll() ([]byte, error) { + r, err := o.read() + if err != nil { + return nil, err + } + return io.ReadAll(r) +} + +func isStdinPath(s string) bool { + switch s { + case "-", "/dev/fd/0", "/dev/stdin": + return true + } + return false +} + +func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { if obj == nil { return obj, nil } v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle) + result, err := embedFilesValue(v, embedStyle, stdin) if err != nil { return nil, err } @@ -53,7 +98,7 @@ func embedFiles(obj any, embedStyle FileEmbedStyle) (any, error) { } // Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, error) { +func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { // Unwrap interface values to get the concrete type if v.Kind() == reflect.Interface { if v.IsNil() { @@ -74,7 +119,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, for iter.Next() { key := iter.Key() val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle) + newVal, err := embedFilesValue(val, embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -89,7 +134,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // 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) + newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) if err != nil { return reflect.Value{}, err } @@ -98,6 +143,42 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, return result, nil case reflect.String: + // FilePathValue is always treated as a file path without needing the "@" prefix. + // These only appear on binary upload parameters (multipart/octet-stream), which + // always use EmbedIOReader. + if v.Type() == reflect.TypeOf(FilePathValue("")) { + s := v.String() + if s == "" { + return v, nil + } + if embedStyle == EmbedIOReader { + if isStdinPath(s) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + upload, err := openFileUpload(s) + if err != nil { + return v, err + } + return reflect.ValueOf(upload), nil + } + if isStdinPath(s) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } + content, err := os.ReadFile(s) + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } + 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 @@ -108,6 +189,13 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, 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) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { return v, err @@ -117,12 +205,29 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, // The "@file://" prefix is for files that you explicitly want to // upload as a string literal with backslash escapes (not base64 // encoded) + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + return reflect.ValueOf(string(content)), nil + } 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 { + if isStdinPath(filename) { + content, err := stdin.readAll() + if err != nil { + return v, err + } + if isUTF8TextFile(content) { + return reflect.ValueOf(string(content)), nil + } + return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil + } content, err := os.ReadFile(filename) if err != nil { // If the string is "@username", it's probably supposed to be a @@ -160,7 +265,15 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } - file, err := os.Open(filename) + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + + upload, err := openFileUpload(filename) if err != nil { if !expectsFile { // For strings that start with "@" and don't look like a filename, return the string @@ -168,7 +281,7 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, } return v, err } - return reflect.ValueOf(file), nil + return reflect.ValueOf(upload), nil } } return v, nil @@ -219,65 +332,116 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) - if (bodyType == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { + // Translate inner-field aliases in YAML values that came from flags (e.g. + // `--parent '{"alias": val}'` resolving to the canonical inner field). + if bodyMap, ok := requestContents.Body.(map[string]any); ok { + applyDataAliases(cmd, bodyMap) + } + + stdinConsumedByPipe := false + if bodyType != ApplicationOctetStream && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { return nil, err } if len(pipeData) > 0 { + stdinConsumedByPipe = true 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 + applyDataAliases(cmd, bodyMap) + // Apply any matching keys from the piped data to path, query, and header flags + // that have not already been set via the command line. + if err := requestflag.ApplyStdinDataToFlags(cmd, bodyMap); err != nil { + return nil, err + } + // Re-extract request contents now that flags may have been updated. + requestContents = requestflag.ExtractRequestContents(cmd) + // Remove keys that were consumed as query, header, or path params so they + // don't also leak into the request body via the maps.Copy merge below. + // We delete both the canonical key and any aliases since the user may have + // piped data using an alias name rather than the canonical API name. + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !flag.IsSet() { + continue + } + if inReq.GetQueryPath() != "" || inReq.GetHeaderPath() != "" || inReq.GetPathParam() != "" { + delete(bodyMap, inReq.GetQueryPath()) + delete(bodyMap, inReq.GetHeaderPath()) + delete(bodyMap, inReq.GetPathParam()) + for _, alias := range inReq.GetDataAliases() { + delete(bodyMap, alias) + } + } + } + if bodyType != EmptyBody { + if flagMap, ok := requestContents.Body.(map[string]any); ok { + maps.Copy(bodyMap, flagMap) + requestContents.Body = bodyMap + } else { + bodyData = requestContents.Body + } + } + } else if bodyType != EmptyBody { + 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 { - bodyData = requestContents.Body + requestContents.Body = bodyData } - } 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]) + return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) } 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, ", ")) + return nil, fmt.Errorf("Required flags %q not set\nRun '%s --help' for usage information", strings.Join(names, ", "), cmd.FullName()) } } + // For flags marked as FileInput (type: string, format: binary), the value is always + // a file path. Wrap with FilePathValue so embedFiles reads the file automatically + // without requiring the user to type the "@" prefix. This handles both values set + // via explicit CLI flags and values that arrived via piped YAML/JSON data. + wrapFileInputValues(cmd, &requestContents) + + // Determine stdin availability for FileInput params that use "-". + var stdinReader onceStdinReader + if ignoreStdin { + stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} + } else if stdinConsumedByPipe { + stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} + } else { + stdinReader = onceStdinReader{stdinReader: os.Stdin} + } + // 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 { + if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { return nil, err } else { requestContents.Body = embedded } - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText); err != nil { + if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Headers = headersWithFiles.(map[string]any) } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText); err != nil { + if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { return nil, err } else { requestContents.Queries = queriesWithFiles.(map[string]any) @@ -373,3 +537,156 @@ func flagOptions( return options, nil } + +// FilePathValue is a string wrapper that marks a value as a file path whose contents should be read +// and embedded in the request. Unlike a regular string, embedFilesValue always treats a FilePathValue +// as a file path without needing the "@" prefix. +type FilePathValue string + +// fileUpload wraps an io.Reader with filename and content-type metadata for +// use as a multipart form part. The apiform encoder detects the Filename and +// ContentType methods and uses them to populate the Content-Disposition +// filename and the Content-Type header on the part. +type fileUpload struct { + io.Reader // apiform checks for reader and reads its contents during encode + filename string + contentType string +} + +func (f fileUpload) Filename() string { return f.filename } +func (f fileUpload) ContentType() string { return f.contentType } +func (f fileUpload) Close() error { + if c, ok := f.Reader.(io.Closer); ok { + return c.Close() + } + return nil +} + +// openFileUpload opens the file at path and returns a fileUpload whose filename +// is the path's basename and whose content type is derived from the file +// extension (falling back to application/octet-stream when unknown). +func openFileUpload(path string) (fileUpload, error) { + file, err := os.Open(path) + if err != nil { + return fileUpload{}, err + } + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + return fileUpload{ + Reader: file, + filename: filepath.Base(path), + contentType: contentType, + }, nil +} + +// applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, +// `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag +// via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's +// body path, so values like `--parent '{"alias": val}'` resolve to the canonical inner field name. +func applyDataAliases(cmd *cli.Command, bodyMap map[string]any) { + for _, flag := range cmd.Flags { + // Inner flags: rewrite aliases inside the nested map under the outer flag's body path. + if inner, ok := flag.(requestflag.HasOuterFlag); ok { + outer, outerOk := inner.GetOuterFlag().(requestflag.InRequest) + if !outerOk { + continue + } + if nested, ok := bodyMap[outer.GetBodyPath()].(map[string]any); ok && inner.GetInnerField() != "" { + rewriteAliases(nested, inner.GetInnerField(), inner.GetDataAliases()) + } + continue + } + // Top-level flags: rewrite aliases in the body map. + if inReq, ok := flag.(requestflag.InRequest); ok && inReq.GetBodyPath() != "" { + rewriteAliases(bodyMap, inReq.GetBodyPath(), inReq.GetDataAliases()) + } + } +} + +// rewriteAliases replaces each alias key in m with the canonical key, preserving the value. The +// "canonical" key is the name the API itself expects (the OpenAPI property/field name) — e.g. for +// a top-level flag, the parameter's BodyPath; for an inner flag, the inner field name. Aliases are +// the user-facing alternate names declared via x-stainless-cli-data-alias. +func rewriteAliases(m map[string]any, canonical string, aliases []string) { + for _, alias := range aliases { + if alias == "" || alias == canonical { + continue + } + if val, exists := m[alias]; exists { + m[canonical] = val + delete(m, alias) + } + } +} + +// wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with +// FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents +// directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit +// CLI flags and values that arrived via piped YAML/JSON data. +func wrapFileInputValues(cmd *cli.Command, contents *requestflag.RequestContents) { + bodyMap, _ := contents.Body.(map[string]any) + + for _, flag := range cmd.Flags { + inReq, ok := flag.(requestflag.InRequest) + if !ok || !inReq.IsFileInput() || inReq.IsBodyRoot() { + continue + } + + // Wrap values set via explicit CLI flags. + if flag.IsSet() { + if wrapped, changed := wrapFileInputValue(flag.Get()); changed { + if bodyPath := inReq.GetBodyPath(); bodyPath != "" { + if bodyMap != nil { + bodyMap[bodyPath] = wrapped + } + } else if queryPath := inReq.GetQueryPath(); queryPath != "" { + contents.Queries[queryPath] = wrapped + } else if headerPath := inReq.GetHeaderPath(); headerPath != "" { + contents.Headers[headerPath] = wrapped + } + } + } + + // Wrap values that arrived via piped YAML/JSON data in the body map. + if bodyPath := inReq.GetBodyPath(); bodyPath != "" && bodyMap != nil { + if value, exists := bodyMap[bodyPath]; exists { + if wrapped, changed := wrapFileInputValue(value); changed { + bodyMap[bodyPath] = wrapped + } + } + } + } +} + +func wrapFileInputValue(value any) (any, bool) { + switch v := value.(type) { + case string: + if v == "" { + return value, false + } + return FilePathValue(v), true + + case []string: + result := make([]any, len(v)) + for i, s := range v { + result[i] = FilePathValue(s) + } + return result, true + + case []any: + result := make([]any, len(v)) + for i, elem := range v { + if s, ok := elem.(string); ok { + result[i] = FilePathValue(s) + } else { + result[i] = elem + } + } + return result, true + + default: + return value, false + } +} diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go index e5dad4b..00734ca 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,15 +2,18 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() + tests := []struct { content []byte expected bool @@ -27,11 +30,13 @@ func TestIsUTF8TextFile(t *testing.T) { } for _, tt := range tests { - assert.Equal(t, tt.expected, isUTF8TextFile(tt.content)) + require.Equal(t, tt.expected, isUTF8TextFile(tt.content)) } } func TestEmbedFiles(t *testing.T) { + t.Parallel() + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,19 +221,23 @@ func TestEmbedFiles(t *testing.T) { for _, tt := range tests { t.Run(tt.name+" text", func(t *testing.T) { - got, err := embedFiles(tt.input, EmbedText) + t.Parallel() + + got, err := embedFiles(tt.input, EmbedText, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) - assert.Equal(t, tt.want, got) + require.Equal(t, tt.want, got) } }) t.Run(tt.name+" io.Reader", func(t *testing.T) { - _, err := embedFiles(tt.input, EmbedIOReader) + t.Parallel() + + _, err := embedFiles(tt.input, EmbedIOReader, nil) if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) } else { require.NoError(t, err) } @@ -236,9 +245,148 @@ func TestEmbedFiles(t *testing.T) { } } +func TestEmbedFilesStdin(t *testing.T) { + t.Parallel() + + t.Run("FilePathValueDash", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("FilePathValueDevStdin", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) + }) + + t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} + + _, err := embedFiles(map[string]any{ + "file1": FilePathValue("-"), + "file2": FilePathValue("-"), + }, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "already been read") + }) + + t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} + + _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) + require.Error(t, err) + require.Contains(t, err.Error(), "cannot read from stdin") + require.Contains(t, err.Error(), "request body") + }) + + t.Run("AtDashEmbedText", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) + }) + + t.Run("AtDashEmbedIOReader", func(t *testing.T) { + t.Parallel() + + stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} + + withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) + require.NoError(t, err) + + withEmbeddedMap := withEmbedded.(map[string]any) + r := withEmbeddedMap["data"].(io.ReadCloser) + + content, err := io.ReadAll(r) + require.NoError(t, err) + require.Equal(t, "piped content", string(content)) + }) + + t.Run("FilePathValueRealFile", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "test.txt", "file content") + + stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} + + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) + require.NoError(t, err) + require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) + }) +} + +// TestEmbedFilesUploadMetadata verifies that EmbedIOReader mode wraps file readers with filename and +// content-type metadata so the multipart encoder populates `Content-Disposition` and `Content-Type` headers. +func TestEmbedFilesUploadMetadata(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + writeTestFile(t, tmpDir, "hello.txt", "hi") + writeTestFile(t, tmpDir, "page.html", "") + writeTestFile(t, tmpDir, "blob.bin", "\x00\x01") + + cases := []struct { + basename string + wantContentType string + }{ + {"hello.txt", "text/plain; charset=utf-8"}, + {"page.html", "text/html; charset=utf-8"}, + {"blob.bin", "application/octet-stream"}, + } + + for _, tc := range cases { + t.Run("AtPrefix_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": "@" + path}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + + t.Run("FilePathValue_"+tc.basename, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(tmpDir, tc.basename) + withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(path)}, EmbedIOReader, nil) + require.NoError(t, err) + + upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) + require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) + require.Equal(t, tc.basename, upload.Filename()) + require.Equal(t, upload.ContentType(), tc.wantContentType) + require.NoError(t, upload.Close()) + }) + } +} + 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 index 2b46d49..fa4ea4d 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -5,18 +5,17 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/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.", + Usage: "Returns app, platform, server, endpoint discovery, OAuth, and WebSocket metadata\nfor this Beeper Desktop instance.", Suggest: true, Flags: []cli.Flag{}, Action: handleInfoRetrieve, @@ -50,7 +49,17 @@ func handleInfoRetrieve(ctx context.Context, cmd *cli.Command) error { } obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "info retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "info retrieve", + Transform: transform, + }) } diff --git a/pkg/cmd/matrixbridgeauth.go b/pkg/cmd/matrixbridgeauth.go new file mode 100644 index 0000000..ea8deb5 --- /dev/null +++ b/pkg/cmd/matrixbridgeauth.go @@ -0,0 +1,584 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesAuthListFlows = cli.Command{ + Name: "list-flows", + Usage: "Get the available login flows.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthListFlows, + HideHelpCommand: true, +} + +var matrixBridgesAuthListLogins = cli.Command{ + Name: "list-logins", + Usage: "Get the login IDs of the current user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthListLogins, + HideHelpCommand: true, +} + +var matrixBridgesAuthLogout = cli.Command{ + Name: "logout", + Usage: "Log out of an existing login.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "The unique ID of a login. Defined by the network connector.", + Required: true, + PathParam: "loginID", + }, + }, + Action: handleMatrixBridgesAuthLogout, + HideHelpCommand: true, +} + +var matrixBridgesAuthStartLogin = cli.Command{ + Name: "start-login", + Usage: "This endpoint starts a new login process, which is used to log into the bridge.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "flow-id", + Required: true, + PathParam: "flowID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An existing login ID to re-login as. If this is specified and the user logs into a different account, the provided ID will be logged out.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesAuthStartLogin, + HideHelpCommand: true, +} + +var matrixBridgesAuthSubmitCookies = cli.Command{ + Name: "submit-cookies", + Usage: "Submit extracted cookies in a login process.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + &requestflag.Flag[map[string]any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixBridgesAuthSubmitCookies, + HideHelpCommand: true, +} + +var matrixBridgesAuthSubmitUserInput = cli.Command{ + Name: "submit-user-input", + Usage: "Submit user input in a login process.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + &requestflag.Flag[map[string]any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixBridgesAuthSubmitUserInput, + HideHelpCommand: true, +} + +var matrixBridgesAuthWaitForStep = cli.Command{ + Name: "wait-for-step", + Usage: "Wait for the next step after displaying data to the user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-process-id", + Required: true, + PathParam: "loginProcessID", + }, + &requestflag.Flag[string]{ + Name: "step-id", + Required: true, + PathParam: "stepID", + }, + }, + Action: handleMatrixBridgesAuthWaitForStep, + HideHelpCommand: true, +} + +var matrixBridgesAuthWhoami = cli.Command{ + Name: "whoami", + Usage: "Get all info that is useful for presenting this bridge in a manager interface.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesAuthWhoami, + HideHelpCommand: true, +} + +func handleMatrixBridgesAuthListFlows(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.ListFlows(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth list-flows", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthListLogins(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.ListLogins(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth list-logins", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthLogout(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("login-id") && len(unusedArgs) > 0 { + cmd.Set("login-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 + } + + params := beeperdesktopapi.MatrixBridgeAuthLogoutParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.Logout( + ctx, + cmd.Value("login-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth logout", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthStartLogin(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("flow-id") && len(unusedArgs) > 0 { + cmd.Set("flow-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 + } + + params := beeperdesktopapi.MatrixBridgeAuthStartLoginParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.StartLogin( + ctx, + cmd.Value("flow-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth start-login", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthSubmitCookies(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthSubmitCookiesParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.SubmitCookies( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth submit-cookies", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthSubmitUserInput(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeAuthSubmitUserInputParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.SubmitUserInput( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth submit-user-input", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthWaitForStep(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("step-id") && len(unusedArgs) > 0 { + cmd.Set("step-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 + } + + params := beeperdesktopapi.MatrixBridgeAuthWaitForStepParams{ + BridgeID: cmd.Value("bridge-id").(string), + LoginProcessID: cmd.Value("login-process-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.WaitForStep( + ctx, + cmd.Value("step-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth wait-for-step", + Transform: transform, + }) +} + +func handleMatrixBridgesAuthWhoami(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Auth.Whoami(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:auth whoami", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeauth_test.go b/pkg/cmd/matrixbridgeauth_test.go new file mode 100644 index 0000000..1776eda --- /dev/null +++ b/pkg/cmd/matrixbridgeauth_test.go @@ -0,0 +1,134 @@ +// 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 TestMatrixBridgesAuthListFlows(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "list-flows", + "--bridge-id", "bridgeID", + ) + }) +} + +func TestMatrixBridgesAuthListLogins(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "list-logins", + "--bridge-id", "bridgeID", + ) + }) +} + +func TestMatrixBridgesAuthLogout(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "logout", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesAuthStartLogin(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "start-login", + "--bridge-id", "bridgeID", + "--flow-id", "qr", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesAuthSubmitCookies(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "submit-cookies", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + "--body", "{foo: string}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("foo: string") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:auth", "submit-cookies", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthSubmitUserInput(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "submit-user-input", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + "--body", "{foo: string}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("foo: string") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:auth", "submit-user-input", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthWaitForStep(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "wait-for-step", + "--bridge-id", "bridgeID", + "--login-process-id", "loginProcessID", + "--step-id", "stepID", + ) + }) +} + +func TestMatrixBridgesAuthWhoami(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:auth", "whoami", + "--bridge-id", "bridgeID", + ) + }) +} diff --git a/pkg/cmd/matrixbridgecapability.go b/pkg/cmd/matrixbridgecapability.go new file mode 100644 index 0000000..0accdfe --- /dev/null +++ b/pkg/cmd/matrixbridgecapability.go @@ -0,0 +1,75 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesCapabilitiesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get bridge capabilities", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + }, + Action: handleMatrixBridgesCapabilitiesRetrieve, + HideHelpCommand: true, +} + +func handleMatrixBridgesCapabilitiesRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Capabilities.Get(ctx, cmd.Value("bridge-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:capabilities retrieve", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgecapability_test.go b/pkg/cmd/matrixbridgecapability_test.go new file mode 100644 index 0000000..d90cd36 --- /dev/null +++ b/pkg/cmd/matrixbridgecapability_test.go @@ -0,0 +1,20 @@ +// 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 TestMatrixBridgesCapabilitiesRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:capabilities", "retrieve", + "--bridge-id", "bridgeID", + ) + }) +} diff --git a/pkg/cmd/matrixbridgecontact.go b/pkg/cmd/matrixbridgecontact.go new file mode 100644 index 0000000..55c3eb7 --- /dev/null +++ b/pkg/cmd/matrixbridgecontact.go @@ -0,0 +1,87 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesContactsList = cli.Command{ + Name: "list", + Usage: "Get a list of contacts.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesContactsList, + HideHelpCommand: true, +} + +func handleMatrixBridgesContactsList(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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 + } + + params := beeperdesktopapi.MatrixBridgeContactListParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Contacts.List( + ctx, + cmd.Value("bridge-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:contacts list", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgecontact_test.go b/pkg/cmd/matrixbridgecontact_test.go new file mode 100644 index 0000000..df14d10 --- /dev/null +++ b/pkg/cmd/matrixbridgecontact_test.go @@ -0,0 +1,21 @@ +// 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 TestMatrixBridgesContactsList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:contacts", "list", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixbridgeroom.go b/pkg/cmd/matrixbridgeroom.go new file mode 100644 index 0000000..20d611d --- /dev/null +++ b/pkg/cmd/matrixbridgeroom.go @@ -0,0 +1,240 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesRoomsCreateDm = cli.Command{ + Name: "create-dm", + Usage: "Create a direct chat with a user on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "identifier", + Required: true, + PathParam: "identifier", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesRoomsCreateDm, + HideHelpCommand: true, +} + +var matrixBridgesRoomsCreateGroup = requestflag.WithInnerFlags(cli.Command{ + Name: "create-group", + Usage: "Create a group chat on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "group-type", + Required: true, + PathParam: "groupType", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + &requestflag.Flag[map[string]any]{ + Name: "avatar", + Usage: "The `m.room.avatar` event content for the room.", + BodyPath: "avatar", + }, + &requestflag.Flag[map[string]any]{ + Name: "disappear", + Usage: "The `com.beeper.disappearing_timer` event content for the room.", + BodyPath: "disappear", + }, + &requestflag.Flag[map[string]any]{ + Name: "name", + Usage: "The `m.room.name` event content for the room.", + BodyPath: "name", + }, + &requestflag.Flag[any]{ + Name: "parent", + BodyPath: "parent", + }, + &requestflag.Flag[[]string]{ + Name: "participant", + Usage: "The users to add to the group initially.", + BodyPath: "participants", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Usage: "An existing Matrix room ID to bridge to.\nThe other parameters must be already in sync with the room state when using this parameter.\n", + BodyPath: "room_id", + }, + &requestflag.Flag[map[string]any]{ + Name: "topic", + Usage: "The `m.room.topic` event content for the room.", + BodyPath: "topic", + }, + &requestflag.Flag[string]{ + Name: "type", + Usage: "The type of group to create.", + BodyPath: "type", + }, + &requestflag.Flag[string]{ + Name: "username", + Usage: "The public username for the created group.", + BodyPath: "username", + }, + }, + Action: handleMatrixBridgesRoomsCreateGroup, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "avatar": { + &requestflag.InnerFlag[string]{ + Name: "avatar.url", + InnerField: "url", + }, + }, + "disappear": { + &requestflag.InnerFlag[float64]{ + Name: "disappear.timer", + InnerField: "timer", + }, + &requestflag.InnerFlag[string]{ + Name: "disappear.type", + InnerField: "type", + }, + }, + "name": { + &requestflag.InnerFlag[string]{ + Name: "name.name", + InnerField: "name", + }, + }, + "topic": { + &requestflag.InnerFlag[string]{ + Name: "topic.topic", + InnerField: "topic", + }, + }, +}) + +func handleMatrixBridgesRoomsCreateDm(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("identifier") && len(unusedArgs) > 0 { + cmd.Set("identifier", 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 + } + + params := beeperdesktopapi.MatrixBridgeRoomNewDmParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Rooms.NewDm( + ctx, + cmd.Value("identifier").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:rooms create-dm", + Transform: transform, + }) +} + +func handleMatrixBridgesRoomsCreateGroup(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("group-type") && len(unusedArgs) > 0 { + cmd.Set("group-type", 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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeRoomNewGroupParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Rooms.NewGroup( + ctx, + cmd.Value("group-type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:rooms create-group", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeroom_test.go b/pkg/cmd/matrixbridgeroom_test.go new file mode 100644 index 0000000..4e4c59e --- /dev/null +++ b/pkg/cmd/matrixbridgeroom_test.go @@ -0,0 +1,98 @@ +// 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 TestMatrixBridgesRoomsCreateDm(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-dm", + "--bridge-id", "bridgeID", + "--identifier", "identifier", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesRoomsCreateGroup(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--avatar", "{url: url}", + "--disappear", "{timer: 0, type: type}", + "--name", "{name: name}", + "--parent", "{}", + "--participant", "string", + "--room-id", "room_id", + "--topic", "{topic: topic}", + "--type", "channel", + "--username", "username", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixBridgesRoomsCreateGroup) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--avatar.url", "url", + "--disappear.timer", "0", + "--disappear.type", "type", + "--name.name", "name", + "--parent", "{}", + "--participant", "string", + "--room-id", "room_id", + "--topic.topic", "topic", + "--type", "channel", + "--username", "username", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "avatar:\n" + + " url: url\n" + + "disappear:\n" + + " timer: 0\n" + + " type: type\n" + + "name:\n" + + " name: name\n" + + "parent: {}\n" + + "participants:\n" + + " - string\n" + + "room_id: room_id\n" + + "topic:\n" + + " topic: topic\n" + + "type: channel\n" + + "username: username\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:rooms", "create-group", + "--bridge-id", "bridgeID", + "--group-type", "groupType", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixbridgeuser.go b/pkg/cmd/matrixbridgeuser.go new file mode 100644 index 0000000..4596e5c --- /dev/null +++ b/pkg/cmd/matrixbridgeuser.go @@ -0,0 +1,168 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixBridgesUsersResolve = cli.Command{ + Name: "resolve", + Usage: "Resolve an identifier to a user on the remote network.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "identifier", + Required: true, + PathParam: "identifier", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + }, + Action: handleMatrixBridgesUsersResolve, + HideHelpCommand: true, +} + +var matrixBridgesUsersSearch = cli.Command{ + Name: "search", + Usage: "Search for users on the remote network", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "bridge-id", + Required: true, + PathParam: "bridgeID", + }, + &requestflag.Flag[string]{ + Name: "login-id", + Usage: "An optional explicit login ID to do the action through.", + QueryPath: "login_id", + }, + &requestflag.Flag[string]{ + Name: "query", + Usage: "The search query to send to the remote network", + BodyPath: "query", + }, + }, + Action: handleMatrixBridgesUsersSearch, + HideHelpCommand: true, +} + +func handleMatrixBridgesUsersResolve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("identifier") && len(unusedArgs) > 0 { + cmd.Set("identifier", 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 + } + + params := beeperdesktopapi.MatrixBridgeUserResolveParams{ + BridgeID: cmd.Value("bridge-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Users.Resolve( + ctx, + cmd.Value("identifier").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:users resolve", + Transform: transform, + }) +} + +func handleMatrixBridgesUsersSearch(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("bridge-id") && len(unusedArgs) > 0 { + cmd.Set("bridge-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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixBridgeUserSearchParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Bridges.Users.Search( + ctx, + cmd.Value("bridge-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:bridges:users search", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixbridgeuser_test.go b/pkg/cmd/matrixbridgeuser_test.go new file mode 100644 index 0000000..c3f11aa --- /dev/null +++ b/pkg/cmd/matrixbridgeuser_test.go @@ -0,0 +1,47 @@ +// 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 TestMatrixBridgesUsersResolve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:users", "resolve", + "--bridge-id", "bridgeID", + "--identifier", "identifier", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} + +func TestMatrixBridgesUsersSearch(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:bridges:users", "search", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + "--query", "query", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("query: query") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:bridges:users", "search", + "--bridge-id", "bridgeID", + "--login-id", "bcc68892-b180-414f-9516-b4aadf7d0496", + ) + }) +} diff --git a/pkg/cmd/matrixroom.go b/pkg/cmd/matrixroom.go new file mode 100644 index 0000000..f870eaa --- /dev/null +++ b/pkg/cmd/matrixroom.go @@ -0,0 +1,337 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsCreate = requestflag.WithInnerFlags(cli.Command{ + Name: "create", + Usage: "Create a new room with various configuration options.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[any]{ + Name: "creation-content", + Usage: "Extra keys, such as `m.federate`, to be added to the content\nof the [`m.room.create`](https://spec.matrix.org/v1.18/client-server-api/#mroomcreate) event.\n\nThe server will overwrite the following\nkeys: `creator`, `room_version`. Future versions of the specification\nmay allow the server to overwrite other keys.\n\nWhen using the `trusted_private_chat` preset, the server SHOULD combine\n`additional_creators` specified here and the `invite` array into the\neventual `m.room.create` event's `additional_creators`, deduplicating\nbetween the two parameters.", + BodyPath: "creation_content", + }, + &requestflag.Flag[[]map[string]any]{ + Name: "initial-state", + Usage: "A list of state events to set in the new room. This allows\nthe user to override the default state events set in the new\nroom. The expected format of the state events are an object\nwith type, state_key and content keys set.\n\nTakes precedence over events set by `preset`, but gets\noverridden by `name` and `topic` keys.", + BodyPath: "initial_state", + }, + &requestflag.Flag[[]string]{ + Name: "invite", + Usage: "A list of user IDs to invite to the room. This will tell the\nserver to invite everyone in the list to the newly created room.", + BodyPath: "invite", + }, + &requestflag.Flag[[]map[string]any]{ + Name: "invite-3pid", + Usage: "A list of objects representing third-party IDs to invite into\nthe room.", + BodyPath: "invite_3pid", + }, + &requestflag.Flag[bool]{ + Name: "is-direct", + Usage: "This flag makes the server set the `is_direct` flag on the\n`m.room.member` events sent to the users in `invite` and\n`invite_3pid`. See [Direct Messaging](https://spec.matrix.org/v1.18/client-server-api/#direct-messaging) for more information.", + BodyPath: "is_direct", + }, + &requestflag.Flag[string]{ + Name: "name", + Usage: "If this is included, an [`m.room.name`](https://spec.matrix.org/v1.18/client-server-api/#mroomname) event\nwill be sent into the room to indicate the name for the room.\nThis overwrites any [`m.room.name`](https://spec.matrix.org/v1.18/client-server-api/#mroomname)\nevent in `initial_state`.", + BodyPath: "name", + }, + &requestflag.Flag[any]{ + Name: "power-level-content-override", + Usage: "The power level content to override in the default power level\nevent. This object is applied on top of the generated\n[`m.room.power_levels`](https://spec.matrix.org/v1.18/client-server-api/#mroompower_levels)\nevent content prior to it being sent to the room. Defaults to\noverriding nothing.", + BodyPath: "power_level_content_override", + }, + &requestflag.Flag[string]{ + Name: "preset", + Usage: "Convenience parameter for setting various default state events\nbased on a preset.\n\nIf unspecified, the server should use the `visibility` to determine\nwhich preset to use. A visibility of `public` equates to a preset of\n`public_chat` and `private` visibility equates to a preset of\n`private_chat`.", + BodyPath: "preset", + }, + &requestflag.Flag[string]{ + Name: "room-alias-name", + Usage: "The desired room alias **local part**. If this is included, a\nroom alias will be created and mapped to the newly created\nroom. The alias will belong on the *same* homeserver which\ncreated the room. For example, if this was set to \"foo\" and\nsent to the homeserver \"example.com\" the complete room alias\nwould be `#foo:example.com`.\n\nThe complete room alias will become the canonical alias for\nthe room and an `m.room.canonical_alias` event will be sent\ninto the room.", + BodyPath: "room_alias_name", + }, + &requestflag.Flag[string]{ + Name: "room-version", + Usage: "The room version to set for the room. If not provided, the homeserver is\nto use its configured default. If provided, the homeserver will return a\n400 error with the errcode `M_UNSUPPORTED_ROOM_VERSION` if it does not\nsupport the room version.", + BodyPath: "room_version", + }, + &requestflag.Flag[string]{ + Name: "topic", + Usage: "If this is included, an [`m.room.topic`](https://spec.matrix.org/v1.18/client-server-api/#mroomtopic)\nevent with a `text/plain` mimetype will be sent into the room\nto indicate the topic for the room. This overwrites any\n[`m.room.topic`](https://spec.matrix.org/v1.18/client-server-api/#mroomtopic) event in `initial_state`.", + BodyPath: "topic", + }, + &requestflag.Flag[string]{ + Name: "visibility", + Usage: "The room's visibility in the server's\n[published room directory](https://spec.matrix.org/v1.18/client-server-api#published-room-directory).\nDefaults to `private`.", + BodyPath: "visibility", + }, + }, + Action: handleMatrixRoomsCreate, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "initial-state": { + &requestflag.InnerFlag[any]{ + Name: "initial-state.content", + Usage: "The content of the event.", + InnerField: "content", + }, + &requestflag.InnerFlag[string]{ + Name: "initial-state.type", + Usage: "The type of event to send.", + InnerField: "type", + }, + &requestflag.InnerFlag[string]{ + Name: "initial-state.state-key", + Usage: "The state_key of the state event. Defaults to an empty string.", + InnerField: "state_key", + }, + }, + "invite-3pid": { + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.address", + Usage: "The invitee's third-party identifier.", + InnerField: "address", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.id-access-token", + Usage: "An access token previously registered with the identity server. Servers\ncan treat this as optional to distinguish between r0.5-compatible clients\nand this specification version.", + InnerField: "id_access_token", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.id-server", + Usage: "The hostname+port of the identity server which should be used for third-party identifier lookups.", + InnerField: "id_server", + }, + &requestflag.InnerFlag[string]{ + Name: "invite-3pid.medium", + Usage: "The kind of address being passed in the address field, for example `email`\n(see [the list of recognised values](https://spec.matrix.org/v1.18/appendices/#3pid-types)).", + InnerField: "medium", + }, + }, +}) + +var matrixRoomsJoin = requestflag.WithInnerFlags(cli.Command{ + Name: "join", + Usage: "_Note that this API takes either a room ID or alias, unlike_\n`/rooms/{roomId}/join`.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id-or-alias", + Required: true, + PathParam: "roomIdOrAlias", + }, + &requestflag.Flag[[]string]{ + Name: "via", + Usage: "The servers to attempt to join the room through. One of the servers\nmust be participating in the room.", + QueryPath: "via", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional reason to be included as the `reason` on the subsequent\nmembership event.", + BodyPath: "reason", + }, + &requestflag.Flag[map[string]any]{ + Name: "third-party-signed", + Usage: "A signature of an `m.third_party_invite` token to prove that this user\nowns a third-party identity which has been invited to the room.", + BodyPath: "third_party_signed", + }, + }, + Action: handleMatrixRoomsJoin, + HideHelpCommand: true, +}, map[string][]requestflag.HasOuterFlag{ + "third-party-signed": { + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.token", + Usage: "The state key of the m.third_party_invite event.", + InnerField: "token", + }, + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.mxid", + Usage: "The Matrix ID of the invitee.", + InnerField: "mxid", + }, + &requestflag.InnerFlag[string]{ + Name: "third-party-signed.sender", + Usage: "The Matrix ID of the user who issued the invite.", + InnerField: "sender", + }, + &requestflag.InnerFlag[map[string]any]{ + Name: "third-party-signed.signatures", + Usage: "A signatures object containing a signature of the entire signed object.", + InnerField: "signatures", + }, + }, +}) + +var matrixRoomsLeave = cli.Command{ + Name: "leave", + Usage: "This API stops a user participating in a particular room.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "reason", + Usage: "Optional reason to be included as the `reason` on the subsequent\nmembership event.", + BodyPath: "reason", + }, + }, + Action: handleMatrixRoomsLeave, + HideHelpCommand: true, +} + +func handleMatrixRoomsCreate(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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomNewParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.New(ctx, params, options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms create", + Transform: transform, + }) +} + +func handleMatrixRoomsJoin(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id-or-alias") && len(unusedArgs) > 0 { + cmd.Set("room-id-or-alias", 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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomJoinParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Join( + ctx, + cmd.Value("room-id-or-alias").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms join", + Transform: transform, + }) +} + +func handleMatrixRoomsLeave(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id") && len(unusedArgs) > 0 { + cmd.Set("room-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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomLeaveParams{} + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Leave( + ctx, + cmd.Value("room-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms leave", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroom_test.go b/pkg/cmd/matrixroom_test.go new file mode 100644 index 0000000..241ec53 --- /dev/null +++ b/pkg/cmd/matrixroom_test.go @@ -0,0 +1,168 @@ +// 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 TestMatrixRoomsCreate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "create", + "--creation-content", "{m.federate: false}", + "--initial-state", "{content: {}, type: type, state_key: state_key}", + "--invite", "string", + "--invite-3pid", "{address: cheeky@monkey.com, id_access_token: abc123_OpaqueString, id_server: matrix.org, medium: email}", + "--is-direct=true", + "--name", "The Grand Duke Pub", + "--power-level-content-override", "{}", + "--preset", "public_chat", + "--room-alias-name", "thepub", + "--room-version", "1", + "--topic", "All about happy hour", + "--visibility", "public", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixRoomsCreate) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "create", + "--creation-content", "{m.federate: false}", + "--initial-state.content", "{}", + "--initial-state.type", "type", + "--initial-state.state-key", "state_key", + "--invite", "string", + "--invite-3pid.address", "cheeky@monkey.com", + "--invite-3pid.id-access-token", "abc123_OpaqueString", + "--invite-3pid.id-server", "matrix.org", + "--invite-3pid.medium", "email", + "--is-direct=true", + "--name", "The Grand Duke Pub", + "--power-level-content-override", "{}", + "--preset", "public_chat", + "--room-alias-name", "thepub", + "--room-version", "1", + "--topic", "All about happy hour", + "--visibility", "public", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "creation_content:\n" + + " m.federate: false\n" + + "initial_state:\n" + + " - content: {}\n" + + " type: type\n" + + " state_key: state_key\n" + + "invite:\n" + + " - string\n" + + "invite_3pid:\n" + + " - address: cheeky@monkey.com\n" + + " id_access_token: abc123_OpaqueString\n" + + " id_server: matrix.org\n" + + " medium: email\n" + + "is_direct: true\n" + + "name: The Grand Duke Pub\n" + + "power_level_content_override: {}\n" + + "preset: public_chat\n" + + "room_alias_name: thepub\n" + + "room_version: '1'\n" + + "topic: All about happy hour\n" + + "visibility: public\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "create", + ) + }) +} + +func TestMatrixRoomsJoin(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + "--reason", "Looking for support", + "--third-party-signed", "{token: random8nonce, mxid: bob, sender: alice, signatures: {example.org: {ed25519:0: some9signature}}}", + ) + }) + + t.Run("inner flags", func(t *testing.T) { + // Check that inner flags have been set up correctly + requestflag.CheckInnerFlags(matrixRoomsJoin) + + // Alternative argument passing style using inner flags + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + "--reason", "Looking for support", + "--third-party-signed.token", "random8nonce", + "--third-party-signed.mxid", "bob", + "--third-party-signed.sender", "alice", + "--third-party-signed.signatures", "{example.org: {ed25519:0: some9signature}}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("" + + "reason: Looking for support\n" + + "third_party_signed:\n" + + " token: random8nonce\n" + + " mxid: bob\n" + + " sender: alice\n" + + " signatures:\n" + + " example.org:\n" + + " ed25519:0: some9signature\n") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "join", + "--room-id-or-alias", "!monkeys:matrix.org", + "--via", "string", + ) + }) +} + +func TestMatrixRoomsLeave(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms", "leave", + "--room-id", "!nkl290a:matrix.org", + "--reason", "Saying farewell - thanks for the support!", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("reason: Saying farewell - thanks for the support!") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms", "leave", + "--room-id", "!nkl290a:matrix.org", + ) + }) +} diff --git a/pkg/cmd/matrixroomaccountdata.go b/pkg/cmd/matrixroomaccountdata.go new file mode 100644 index 0000000..0d78680 --- /dev/null +++ b/pkg/cmd/matrixroomaccountdata.go @@ -0,0 +1,177 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsAccountDataRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get some account data for the client on a given room. This config is only\nvisible to the user that set the account data.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + }, + Action: handleMatrixRoomsAccountDataRetrieve, + HideHelpCommand: true, +} + +var matrixRoomsAccountDataUpdate = cli.Command{ + Name: "update", + Usage: "Set some account data for the client on a given room. This config is only\nvisible to the user that set the account data. The config will be delivered to\nclients in the per-room entries via\n[/sync](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv3sync).", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + &requestflag.Flag[any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixRoomsAccountDataUpdate, + HideHelpCommand: true, +} + +func handleMatrixRoomsAccountDataRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", 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 + } + + params := beeperdesktopapi.MatrixRoomAccountDataGetParams{ + UserID: cmd.Value("user-id").(string), + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.AccountData.Get( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:account-data retrieve", + Transform: transform, + }) +} + +func handleMatrixRoomsAccountDataUpdate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", 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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixRoomAccountDataUpdateParams{ + UserID: cmd.Value("user-id").(string), + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.AccountData.Update( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:account-data update", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomaccountdata_test.go b/pkg/cmd/matrixroomaccountdata_test.go new file mode 100644 index 0000000..08d8c65 --- /dev/null +++ b/pkg/cmd/matrixroomaccountdata_test.go @@ -0,0 +1,49 @@ +// 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 TestMatrixRoomsAccountDataRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:account-data", "retrieve", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + ) + }) +} + +func TestMatrixRoomsAccountDataUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:account-data", "update", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + "--body", "{custom_account_data_key: custom_account_data_value}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("custom_account_data_key: custom_account_data_value") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:rooms:account-data", "update", + "--user-id", "@alice:example.com", + "--room-id", "!726s6s6q:example.com", + "--type", "org.example.custom.room.config", + ) + }) +} diff --git a/pkg/cmd/matrixroomevent.go b/pkg/cmd/matrixroomevent.go new file mode 100644 index 0000000..072ae10 --- /dev/null +++ b/pkg/cmd/matrixroomevent.go @@ -0,0 +1,89 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsEventsRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get a single event based on `roomId/eventId`. You must have permission to\nretrieve this event e.g. by being a member in the room for this event.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "event-id", + Required: true, + PathParam: "eventId", + }, + }, + Action: handleMatrixRoomsEventsRetrieve, + HideHelpCommand: true, +} + +func handleMatrixRoomsEventsRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("event-id") && len(unusedArgs) > 0 { + cmd.Set("event-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 + } + + params := beeperdesktopapi.MatrixRoomEventGetParams{ + RoomID: cmd.Value("room-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.Events.Get( + ctx, + cmd.Value("event-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:events retrieve", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomevent_test.go b/pkg/cmd/matrixroomevent_test.go new file mode 100644 index 0000000..31d4483 --- /dev/null +++ b/pkg/cmd/matrixroomevent_test.go @@ -0,0 +1,21 @@ +// 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 TestMatrixRoomsEventsRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:events", "retrieve", + "--room-id", "!636q39766251:matrix.org", + "--event-id", "$asfDuShaf7Gafaw:matrix.org", + ) + }) +} diff --git a/pkg/cmd/matrixroomstate.go b/pkg/cmd/matrixroomstate.go new file mode 100644 index 0000000..f09b100 --- /dev/null +++ b/pkg/cmd/matrixroomstate.go @@ -0,0 +1,160 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixRoomsStateRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Looks up the contents of a state event in a room. If the user is joined to the\nroom then the state is taken from the current state of the room. If the user has\nleft the room then the state is taken from the state of the room when they left.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + &requestflag.Flag[string]{ + Name: "event-type", + Required: true, + PathParam: "eventType", + }, + &requestflag.Flag[string]{ + Name: "state-key", + Required: true, + PathParam: "stateKey", + }, + &requestflag.Flag[string]{ + Name: "format", + Usage: "The format to use for the returned data. `content` (the default) will\nreturn only the content of the state event. `event` will return the entire\nevent in the usual format suitable for clients, including fields like event\nID, sender and timestamp.", + QueryPath: "format", + }, + }, + Action: handleMatrixRoomsStateRetrieve, + HideHelpCommand: true, +} + +var matrixRoomsStateList = cli.Command{ + Name: "list", + Usage: "Get the state events for the current state of a room.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "room-id", + Required: true, + PathParam: "roomId", + }, + }, + Action: handleMatrixRoomsStateList, + HideHelpCommand: true, +} + +func handleMatrixRoomsStateRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("state-key") && len(unusedArgs) > 0 { + cmd.Set("state-key", 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 + } + + params := beeperdesktopapi.MatrixRoomStateGetParams{ + RoomID: cmd.Value("room-id").(string), + EventType: cmd.Value("event-type").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.State.Get( + ctx, + cmd.Value("state-key").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:state retrieve", + Transform: transform, + }) +} + +func handleMatrixRoomsStateList(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("room-id") && len(unusedArgs) > 0 { + cmd.Set("room-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Rooms.State.List(ctx, cmd.Value("room-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:rooms:state list", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixroomstate_test.go b/pkg/cmd/matrixroomstate_test.go new file mode 100644 index 0000000..8ff3c71 --- /dev/null +++ b/pkg/cmd/matrixroomstate_test.go @@ -0,0 +1,34 @@ +// 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 TestMatrixRoomsStateRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:state", "retrieve", + "--room-id", "!636q39766251:example.com", + "--event-type", "m.room.name", + "--state-key", "state_key", + "--format", "content", + ) + }) +} + +func TestMatrixRoomsStateList(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:rooms:state", "list", + "--room-id", "!636q39766251:example.com", + ) + }) +} diff --git a/pkg/cmd/matrixuser.go b/pkg/cmd/matrixuser.go new file mode 100644 index 0000000..50d1fb0 --- /dev/null +++ b/pkg/cmd/matrixuser.go @@ -0,0 +1,75 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixUsersRetrieveProfile = cli.Command{ + Name: "retrieve-profile", + Usage: "Get the complete profile for a user.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + }, + Action: handleMatrixUsersRetrieveProfile, + HideHelpCommand: true, +} + +func handleMatrixUsersRetrieveProfile(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("user-id") && len(unusedArgs) > 0 { + cmd.Set("user-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 + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.GetProfile(ctx, cmd.Value("user-id").(string), options...) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users retrieve-profile", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixuser_test.go b/pkg/cmd/matrixuser_test.go new file mode 100644 index 0000000..6c48b75 --- /dev/null +++ b/pkg/cmd/matrixuser_test.go @@ -0,0 +1,20 @@ +// 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 TestMatrixUsersRetrieveProfile(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users", "retrieve-profile", + "--user-id", "@alice:example.com", + ) + }) +} diff --git a/pkg/cmd/matrixuseraccountdata.go b/pkg/cmd/matrixuseraccountdata.go new file mode 100644 index 0000000..dd0347b --- /dev/null +++ b/pkg/cmd/matrixuseraccountdata.go @@ -0,0 +1,165 @@ +// 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/v5" + "github.com/beeper/desktop-api-go/v5/option" + "github.com/tidwall/gjson" + "github.com/urfave/cli/v3" +) + +var matrixUsersAccountDataRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Get some account data for the client. This config is only visible to the user\nthat set the account data.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + }, + Action: handleMatrixUsersAccountDataRetrieve, + HideHelpCommand: true, +} + +var matrixUsersAccountDataUpdate = cli.Command{ + Name: "update", + Usage: "Set some account data for the client. This config is only visible to the user\nthat set the account data. The config will be available to clients through the\ntop-level `account_data` field in the homeserver response to\n[/sync](https://spec.matrix.org/v1.18/client-server-api/#get_matrixclientv3sync).", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "user-id", + Required: true, + PathParam: "userId", + }, + &requestflag.Flag[string]{ + Name: "type", + Required: true, + PathParam: "type", + }, + &requestflag.Flag[any]{ + Name: "body", + Required: true, + BodyRoot: true, + }, + }, + Action: handleMatrixUsersAccountDataUpdate, + HideHelpCommand: true, +} + +func handleMatrixUsersAccountDataRetrieve(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", 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 + } + + params := beeperdesktopapi.MatrixUserAccountDataGetParams{ + UserID: cmd.Value("user-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.AccountData.Get( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users:account-data retrieve", + Transform: transform, + }) +} + +func handleMatrixUsersAccountDataUpdate(ctx context.Context, cmd *cli.Command) error { + client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) + unusedArgs := cmd.Args().Slice() + if !cmd.IsSet("type") && len(unusedArgs) > 0 { + cmd.Set("type", 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, + ApplicationJSON, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MatrixUserAccountDataUpdateParams{ + UserID: cmd.Value("user-id").(string), + } + + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Matrix.Users.AccountData.Update( + ctx, + cmd.Value("type").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "matrix:users:account-data update", + Transform: transform, + }) +} diff --git a/pkg/cmd/matrixuseraccountdata_test.go b/pkg/cmd/matrixuseraccountdata_test.go new file mode 100644 index 0000000..af50936 --- /dev/null +++ b/pkg/cmd/matrixuseraccountdata_test.go @@ -0,0 +1,46 @@ +// 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 TestMatrixUsersAccountDataRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users:account-data", "retrieve", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + ) + }) +} + +func TestMatrixUsersAccountDataUpdate(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "matrix:users:account-data", "update", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + "--body", "{custom_account_data_key: custom_config_value}", + ) + }) + + t.Run("piping data", func(t *testing.T) { + // Test piping YAML data over stdin + pipeData := []byte("custom_account_data_key: custom_config_value") + mocktest.TestRunMockTestWithPipeAndFlags( + t, pipeData, + "--access-token", "string", + "matrix:users:account-data", "update", + "--user-id", "@alice:example.com", + "--type", "org.example.custom.config", + ) + }) +} diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index b65cef7..3b9b74e 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -5,29 +5,53 @@ 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/beeper/desktop-api-go/v5" + "github.com/beeper/desktop-api-go/v5/option" "github.com/tidwall/gjson" "github.com/urfave/cli/v3" ) +var messagesRetrieve = cli.Command{ + Name: "retrieve", + Usage: "Retrieve a message by final message ID, pendingMessageID, or Matrix event ID.\nChat ID may be a Beeper chat ID or local chat ID.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", + }, + }, + Action: handleMessagesRetrieve, + HideHelpCommand: true, +} + 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, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ - Name: "message-id", - Required: true, + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", }, &requestflag.Flag[string]{ Name: "text", @@ -46,9 +70,10 @@ var messagesList = cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[string]{ Name: "cursor", @@ -69,9 +94,37 @@ var messagesList = cli.Command{ HideHelpCommand: true, } +var messagesDelete = cli.Command{ + Name: "delete", + Usage: "Delete a message by final message ID. Pending message IDs are not accepted\nbecause messages cannot be deleted while sending.", + Suggest: true, + Flags: []cli.Flag{ + &requestflag.Flag[string]{ + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", + }, + &requestflag.Flag[string]{ + Name: "message-id", + Usage: "Message ID.", + Required: true, + PathParam: "messageID", + }, + &requestflag.Flag[*bool]{ + Name: "for-everyone", + Usage: "True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported.", + Default: requestflag.Ptr[bool](true), + QueryPath: "forEveryone", + }, + }, + Action: handleMessagesDelete, + HideHelpCommand: true, +} + var messagesSearch = cli.Command{ Name: "search", - Usage: "Search messages across chats using Beeper's message index", + Usage: "Search messages across chats.", Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[[]string]{ @@ -109,16 +162,16 @@ var messagesSearch = cli.Command{ 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]{ + &requestflag.Flag[*bool]{ Name: "exclude-low-priority", Usage: "Exclude messages marked Low Priority by the user. Default: true. Set to false to include all.", - Default: true, + Default: requestflag.Ptr[bool](true), QueryPath: "excludeLowPriority", }, - &requestflag.Flag[any]{ + &requestflag.Flag[*bool]{ 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, + Default: requestflag.Ptr[bool](true), QueryPath: "includeMuted", }, &requestflag.Flag[int64]{ @@ -157,9 +210,10 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ Suggest: true, Flags: []cli.Flag{ &requestflag.Flag[string]{ - Name: "chat-id", - Usage: "Unique identifier of the chat.", - Required: true, + Name: "chat-id", + Usage: "Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.", + Required: true, + PathParam: "chatID", }, &requestflag.Flag[map[string]any]{ Name: "attachment", @@ -173,7 +227,7 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ }, &requestflag.Flag[string]{ Name: "text", - Usage: "Text content of the message you want to send. You may use markdown.", + Usage: "Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.", BodyPath: "text", }, }, @@ -208,13 +262,13 @@ var messagesSend = requestflag.WithInnerFlags(cli.Command{ }, &requestflag.InnerFlag[string]{ Name: "attachment.type", - Usage: "Special attachment type (gif, voiceNote, sticker). If omitted, auto-detected from mimeType", + Usage: "Attachment type hint (image, video, audio, file, gif, voice-note, sticker). If omitted, auto-detected from mimeType", InnerField: "type", }, }, }) -func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { +func handleMessagesRetrieve(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { @@ -225,10 +279,60 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageUpdateParams{ + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MessageGetParams{ ChatID: cmd.Value("chat-id").(string), } + var res []byte + options = append(options, option.WithResponseBodyInto(&res)) + _, err = client.Messages.Get( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) + if err != nil { + return err + } + + obj := gjson.ParseBytes(res) + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } + transform := cmd.Root().String("transform") + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages retrieve", + Transform: transform, + }) +} + +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) + } + options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -240,6 +344,10 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageUpdateParams{ + ChatID: cmd.Value("chat-id").(string), + } + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Messages.Update( @@ -254,8 +362,15 @@ func handleMessagesUpdate(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "messages update", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages update", + Transform: transform, + }) } func handleMessagesList(ctx context.Context, cmd *cli.Command) error { @@ -269,8 +384,6 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageListParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -282,7 +395,13 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + params := beeperdesktopapi.MessageListParams{} + + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -297,7 +416,13 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages list", + Transform: transform, + }) } else { iter := client.Messages.ListAutoPaging( ctx, @@ -309,10 +434,50 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "messages list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages list", + Transform: transform, + }) } } +func handleMessagesDelete(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) + } + + options, err := flagOptions( + cmd, + apiquery.NestedQueryFormatBrackets, + apiquery.ArrayQueryFormatRepeat, + EmptyBody, + false, + ) + if err != nil { + return err + } + + params := beeperdesktopapi.MessageDeleteParams{ + ChatID: cmd.Value("chat-id").(string), + } + + return client.Messages.Delete( + ctx, + cmd.Value("message-id").(string), + params, + options..., + ) +} + func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { client := beeperdesktopapi.NewClient(getDefaultRequestOptions(cmd)...) unusedArgs := cmd.Args().Slice() @@ -321,8 +486,6 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageSearchParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -334,7 +497,13 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } - format := cmd.Root().String("format") + params := beeperdesktopapi.MessageSearchParams{} + + format := "json" + explicitFormat := cmd.Root().IsSet("format") + if explicitFormat { + format = cmd.Root().String("format") + } transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -344,14 +513,26 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "messages search", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages search", + Transform: 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) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages search", + Transform: transform, + }) } } @@ -366,8 +547,6 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) } - params := beeperdesktopapi.MessageSendParams{} - options, err := flagOptions( cmd, apiquery.NestedQueryFormatBrackets, @@ -379,6 +558,8 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { return err } + params := beeperdesktopapi.MessageSendParams{} + var res []byte options = append(options, option.WithResponseBodyInto(&res)) _, err = client.Messages.Send( @@ -393,6 +574,13 @@ func handleMessagesSend(ctx context.Context, cmd *cli.Command) error { obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "messages send", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "messages send", + Transform: transform, + }) } diff --git a/pkg/cmd/message_test.go b/pkg/cmd/message_test.go index 11fe9a2..913046b 100644 --- a/pkg/cmd/message_test.go +++ b/pkg/cmd/message_test.go @@ -9,6 +9,18 @@ import ( "github.com/beeper/desktop-api-cli/internal/requestflag" ) +func TestMessagesRetrieve(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "messages", "retrieve", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + ) + }) +} + func TestMessagesUpdate(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -16,7 +28,7 @@ func TestMessagesUpdate(t *testing.T) { "--access-token", "string", "messages", "update", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", "--text", "x", ) }) @@ -29,7 +41,7 @@ func TestMessagesUpdate(t *testing.T) { "--access-token", "string", "messages", "update", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", - "--message-id", "messageID", + "--message-id", "1343993", ) }) } @@ -48,6 +60,19 @@ func TestMessagesList(t *testing.T) { }) } +func TestMessagesDelete(t *testing.T) { + t.Run("regular flags", func(t *testing.T) { + mocktest.TestRunMockTestWithFlags( + t, + "--access-token", "string", + "messages", "delete", + "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", + "--message-id", "1343993", + "--for-everyone=true", + ) + }) +} + func TestMessagesSearch(t *testing.T) { t.Run("regular flags", func(t *testing.T) { mocktest.TestRunMockTestWithFlags( @@ -55,8 +80,9 @@ func TestMessagesSearch(t *testing.T) { "--access-token", "string", "messages", "search", "--max-items", "10", + "--account-id", "matrix", + "--account-id", "discordgo", "--account-id", "local-whatsapp_ba_EvYDBBsZbRQAy3UOSWqG0LuTVkc", - "--account-id", "local-instagram_ba_eRfQMmnSNy_p7Ih7HL7RduRpKFU", "--chat-id", "!NCdzlIaMjZUmvmvyHU:beeper.com", "--chat-id", "1231073", "--chat-type", "group", @@ -81,7 +107,7 @@ func TestMessagesSend(t *testing.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}", + "--attachment", "{uploadID: uploadID, duration: 0, fileName: fileName, mimeType: mimeType, size: {height: 0, width: 0}, type: image}", "--reply-to-message-id", "replyToMessageID", "--text", "text", ) @@ -102,7 +128,7 @@ func TestMessagesSend(t *testing.T) { "--attachment.file-name", "fileName", "--attachment.mime-type", "mimeType", "--attachment.size", "{height: 0, width: 0}", - "--attachment.type", "gif", + "--attachment.type", "image", "--reply-to-message-id", "replyToMessageID", "--text", "text", ) @@ -119,7 +145,7 @@ func TestMessagesSend(t *testing.T) { " size:\n" + " height: 0\n" + " width: 0\n" + - " type: gif\n" + + " type: image\n" + "replyToMessageID: replyToMessageID\n" + "text: text\n") mocktest.TestRunMockTestWithPipeAndFlags( diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 5266c84..26ffb4f 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.3.0" // x-release-please-version +const Version = "5.0.0" // x-release-please-version diff --git a/scripts/bootstrap b/scripts/bootstrap index 9ebb7d3..bbc786d 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response diff --git a/scripts/build b/scripts/build index 7eb0308..d267577 100755 --- a/scripts/build +++ b/scripts/build @@ -5,7 +5,7 @@ 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" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" echo "==> Building beeper-desktop-cli" go build ./cmd/beeper-desktop-cli diff --git a/scripts/link b/scripts/link index 2da9715..60c6042 100755 --- a/scripts/link +++ b/scripts/link @@ -5,9 +5,13 @@ 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" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" 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 +if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then + go mod edit -replace github.com/beeper/desktop-api-go/v5="$REPLACEMENT" + go mod tidy -e +else + echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" +fi diff --git a/scripts/lint b/scripts/lint index bfcb7b6..8a18b1d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -5,7 +5,7 @@ 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" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" echo "==> Running Go build" go build ./... diff --git a/scripts/mock b/scripts/mock index 0f82c95..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.6 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.6 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/run b/scripts/run index 5cfec5f..cf54869 100755 --- a/scripts/run +++ b/scripts/run @@ -5,6 +5,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" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" go run ./cmd/beeper-desktop-cli "$@" diff --git a/scripts/test b/scripts/test index 9f448ce..95e0bde 100755 --- a/scripts/test +++ b/scripts/test @@ -5,7 +5,7 @@ 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" +export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go/v5,github.com/stainless-sdks/beeper-desktop-api-go/v5" RED='\033[0;31m' GREEN='\033[0;32m' @@ -46,7 +46,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.6 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=repeat --validator-query-array-format=repeat --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 diff --git a/scripts/unlink b/scripts/unlink index dfb62ae..9ba3798 100755 --- a/scripts/unlink +++ b/scripts/unlink @@ -5,4 +5,4 @@ set -e cd "$(dirname "$0")/.." echo "==> Unlinking with local directory" -go mod edit -dropreplace github.com/beeper/desktop-api-go +go mod edit -dropreplace github.com/beeper/desktop-api-go/v5