diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2742613..940785a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: 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 @@ -49,7 +49,7 @@ 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 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 6b7b74c..da59f99 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.3.0" + ".": "0.4.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 2b39be6..229f6b5 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 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c +config_hash: 39ed0717b5f415499aaace2468346e1a diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fa2ac..aefda8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 0.4.0 (2026-04-15) + +Full Changelog: [v0.3.0...v0.4.0](https://github.com/beeper/desktop-api-cli/compare/v0.3.0...v0.4.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)) +* 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:** alias parameters in data with `x-stainless-cli-data-alias` ([301f6f2](https://github.com/beeper/desktop-api-cli/commit/301f6f2894cae408e91bb5834d50d6c16b78c28f)) +* set CLI flag constant values automatically where `x-stainless-const` is set ([c332ed8](https://github.com/beeper/desktop-api-cli/commit/c332ed822bc6e94cb521620032bdcf941d4b7593)) + + +### Bug Fixes + +* 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)) +* 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)) +* **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)) +* **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)) +* 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)) + ## 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..de0b813 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,7 @@ beeper-desktop-cli [resource] [flags...] ```sh beeper-desktop-cli chats search \ + --access-token 'My Access Token' \ --include-muted \ --limit 3 \ --type single @@ -119,3 +118,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..a8ca8c2 100644 --- a/cmd/beeper-desktop-cli/main.go +++ b/cmd/beeper-desktop-cli/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("BEEPER_DESKTOP_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "BEEPER_DESKTOP_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,7 @@ 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(os.Stdout, os.Stderr, "Error", json, format, app.IsSet("format-error"), app.String("transform-error")) if show_err != nil { // Just print the original error: fmt.Fprintf(os.Stderr, "%s\n", err.Error()) 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/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..eeeb8bc 100644 --- a/internal/requestflag/innerflag.go +++ b/internal/requestflag/innerflag.go @@ -22,14 +22,29 @@ 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 +} + +// 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) { 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..bfaf064 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -37,6 +37,21 @@ type Flag[ 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 + // 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 hasBeenSet bool // whether the flag has been set from env or file @@ -53,6 +68,8 @@ type InRequest interface { GetHeaderPath() string GetBodyPath() string IsBodyRoot() bool + IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -71,6 +88,14 @@ 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 @@ -229,7 +254,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,6 +280,10 @@ 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 { @@ -268,6 +297,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 } diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go index 9751904..0e86e07 100644 --- a/internal/requestflag/requestflag_test.go +++ b/internal/requestflag/requestflag_test.go @@ -11,6 +11,8 @@ import ( ) func TestDateValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -56,6 +58,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 +74,8 @@ func TestDateValueParse(t *testing.T) { } func TestDateTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -119,6 +125,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 +144,8 @@ func TestDateTimeValueParse(t *testing.T) { } func TestTimeValueParse(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -181,6 +191,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 +207,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 +299,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 +322,8 @@ func TestRequestParams(t *testing.T) { } func TestFlagSet(t *testing.T) { + t.Parallel() + strFlag := &Flag[string]{ Name: "string-flag", Default: "default-string", @@ -327,38 +347,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 +415,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 +436,8 @@ func TestFlagSet(t *testing.T) { } func TestParseTimeWithFormats(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -439,6 +477,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 +492,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 +522,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 +541,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 +555,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 +570,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 +607,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) @@ -563,6 +617,8 @@ func TestYamlHandling(t *testing.T) { } func TestFlagTypeNames(t *testing.T) { + t.Parallel() + tests := []struct { name string flag cli.DocGenerationFlag @@ -583,6 +639,8 @@ 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) }) diff --git a/pkg/cmd/account.go b/pkg/cmd/account.go index 6ee1774..e779c12 100644 --- a/pkg/cmd/account.go +++ b/pkg/cmd/account.go @@ -51,6 +51,7 @@ func handleAccountsList(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, "accounts list", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "accounts list", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/accountcontact.go b/pkg/cmd/accountcontact.go index 76cc862..6ab3c04 100644 --- a/pkg/cmd/accountcontact.go +++ b/pkg/cmd/accountcontact.go @@ -101,6 +101,7 @@ func handleAccountsContactsList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -115,7 +116,7 @@ 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(os.Stdout, os.Stderr, "accounts:contacts list", obj, format, explicitFormat, transform) } else { iter := client.Accounts.Contacts.ListAutoPaging( ctx, @@ -127,7 +128,7 @@ 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(os.Stdout, os.Stderr, "accounts:contacts list", iter, format, explicitFormat, transform, maxItems) } } @@ -169,6 +170,7 @@ func handleAccountsContactsSearch(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, "accounts:contacts search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "accounts:contacts search", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/asset.go b/pkg/cmd/asset.go index d7fc779..160f317 100644 --- a/pkg/cmd/asset.go +++ b/pkg/cmd/asset.go @@ -53,10 +53,11 @@ var assetsUpload = cli.Command{ 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", @@ -129,8 +130,9 @@ 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(os.Stdout, os.Stderr, "assets download", obj, format, explicitFormat, transform) } func handleAssetsServe(ctx context.Context, cmd *cli.Command) error { @@ -187,8 +189,9 @@ 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(os.Stdout, os.Stderr, "assets upload", obj, format, explicitFormat, transform) } func handleAssetsUploadBase64(ctx context.Context, cmd *cli.Command) error { @@ -221,6 +224,7 @@ 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(os.Stdout, os.Stderr, "assets upload-base64", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/asset_test.go b/pkg/cmd/asset_test.go index 2ce574c..696660a 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" @@ -46,18 +47,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..e55d9f2 100644 --- a/pkg/cmd/beeperdesktopapi.go +++ b/pkg/cmd/beeperdesktopapi.go @@ -91,8 +91,9 @@ 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(os.Stdout, os.Stderr, "focus", obj, format, explicitFormat, transform) } func handleSearch(ctx context.Context, cmd *cli.Command) error { @@ -125,6 +126,7 @@ func handleSearch(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, "search", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "search", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/chat.go b/pkg/cmd/chat.go index 41a766b..730ecfd 100644 --- a/pkg/cmd/chat.go +++ b/pkg/cmd/chat.go @@ -15,85 +15,19 @@ import ( "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').", Suggest: true, Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "account-id", - Usage: "Account to create or start the chat on.", - Required: true, - BodyPath: "accountID", - }, - &requestflag.Flag[bool]{ - Name: "allow-invite", - Usage: "Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'.", - Default: true, - BodyPath: "allowInvite", - }, - &requestflag.Flag[string]{ - Name: "message-text", - Usage: "Optional first message content if the platform requires it to create the chat.", - BodyPath: "messageText", - }, - &requestflag.Flag[string]{ - Name: "mode", - Usage: "Operation mode. Defaults to 'create' when omitted.", - BodyPath: "mode", - }, - &requestflag.Flag[[]string]{ - Name: "participant-id", - Usage: "Required when mode='create'. User IDs to include in the new chat.", - BodyPath: "participantIDs", - }, - &requestflag.Flag[string]{ - Name: "title", - Usage: "Optional title for group chats when mode='create'; ignored for single chats on most platforms.", - BodyPath: "title", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title.", - BodyPath: "type", - }, &requestflag.Flag[map[string]any]{ - Name: "user", - Usage: "Required when mode='start'. Merged user-like contact payload used to resolve the best identifier.", - BodyPath: "user", + Name: "params", + BodyRoot: true, }, }, 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", @@ -274,8 +208,9 @@ 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(os.Stdout, os.Stderr, "chats create", obj, format, explicitFormat, transform) } func handleChatsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -316,8 +251,9 @@ func handleChatsRetrieve(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 retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "chats retrieve", obj, format, explicitFormat, transform) } func handleChatsList(ctx context.Context, cmd *cli.Command) error { @@ -342,6 +278,7 @@ func handleChatsList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -351,14 +288,14 @@ 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(os.Stdout, os.Stderr, "chats list", obj, format, explicitFormat, 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(os.Stdout, os.Stderr, "chats list", iter, format, explicitFormat, transform, maxItems) } } @@ -416,6 +353,7 @@ func handleChatsSearch(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -425,13 +363,13 @@ 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(os.Stdout, os.Stderr, "chats search", obj, format, explicitFormat, 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(os.Stdout, os.Stderr, "chats search", iter, format, explicitFormat, transform, maxItems) } } diff --git a/pkg/cmd/chat_test.go b/pkg/cmd/chat_test.go index ff041f7..ea462e1 100644 --- a/pkg/cmd/chat_test.go +++ b/pkg/cmd/chat_test.go @@ -6,7 +6,6 @@ import ( "testing" "github.com/beeper/desktop-api-cli/internal/mocktest" - "github.com/beeper/desktop-api-cli/internal/requestflag" ) func TestChatsCreate(t *testing.T) { @@ -15,38 +14,7 @@ func TestChatsCreate(t *testing.T) { 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", + "--params", "{accountID: accountID, mode: start, user: {id: id, email: email, fullName: fullName, phoneNumber: phoneNumber, username: username}, allowInvite: true, messageText: messageText}", ) }) @@ -54,19 +22,15 @@ 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" + + "mode: start\n" + "user:\n" + " id: id\n" + " email: email\n" + " fullName: fullName\n" + " phoneNumber: phoneNumber\n" + - " username: username\n") + " username: username\n" + + "allowInvite: true\n" + + "messageText: messageText\n") mocktest.TestRunMockTestWithPipeAndFlags( t, pipeData, "--access-token", "string", diff --git a/pkg/cmd/chatmessagereaction.go b/pkg/cmd/chatmessagereaction.go index f72f189..ee0615f 100644 --- a/pkg/cmd/chatmessagereaction.go +++ b/pkg/cmd/chatmessagereaction.go @@ -110,8 +110,9 @@ 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(os.Stdout, os.Stderr, "chats:messages:reactions delete", obj, format, explicitFormat, transform) } func handleChatsMessagesReactionsAdd(ctx context.Context, cmd *cli.Command) error { @@ -154,6 +155,7 @@ 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(os.Stdout, os.Stderr, "chats:messages:reactions add", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 3351e7f..4c91ac7 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -39,6 +39,9 @@ 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", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index 9b24177..5163bc1 100644 --- a/pkg/cmd/cmdutil.go +++ b/pkg/cmd/cmdutil.go @@ -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 } @@ -300,7 +312,7 @@ func shouldUseColors(w io.Writer) bool { } func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format string, transform string) ([]byte, error) { - if format != "raw" && transform != "" { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed @@ -342,9 +354,14 @@ func formatJSON(expectedOutput *os.File, title string, res gjson.Result, format } } -// 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 != "" { +const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" + +// Display JSON to the user in various different formats. The explicitFormat parameter indicates +// whether the format was explicitly set by the user (via --format), which controls whether we +// silently fall back to json when explore is requested on non-terminal output. Warnings are +// written to stderr. +func ShowJSON(out *os.File, stderr io.Writer, title string, res gjson.Result, format string, explicitFormat bool, transform string) error { + if transform != "" { transformed := res.Get(transform) if transformed.Exists() { res = transformed @@ -353,8 +370,14 @@ func ShowJSON(out *os.File, title string, res gjson.Result, format string, trans switch strings.ToLower(format) { case "auto": - return ShowJSON(out, title, res, "json", "") + return ShowJSON(out, stderr, title, res, "json", explicitFormat, "") case "explore": + if !isTerminal(out) { + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + return ShowJSON(out, stderr, title, res, "json", explicitFormat, transform) + } return jsonview.ExploreJSON(title, res) default: bytes, err := formatJSON(out, title, res, format, transform) @@ -372,15 +395,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 { +// -1 is used to signal no limit of items to display +func ShowJSONIterator[T any](stdout *os.File, stderr io.Writer, title string, iter jsonview.Iterator[T], format string, explicitFormat bool, transform string, itemsToDisplay int64) error { if format == "explore" { - return jsonview.ExploreJSONStream(title, iter) + if isTerminal(stdout) { + return jsonview.ExploreJSONStream(title, iter) + } + if explicitFormat { + fmt.Fprint(stderr, warningExploreNotSupported) + } + format = "json" } terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) @@ -393,13 +423,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) @@ -446,7 +474,7 @@ func ShowJSONIterator[T any](stdout *os.File, title string, iter jsonview.Iterat } 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 +483,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(pager, stderr, title, obj, format, explicitFormat, transform); err != nil { return err } itemsToDisplay -= 1 diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go index 0a46fd1..aaf0b1b 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,227 @@ 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(os.Stdout, "test", res, "raw", "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(os.Stdout, "test", res, "raw", "") + 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(os.Stdout, "test", res, "raw", "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(os.Stdout, "test", res, "raw", "missing") + require.NoError(t, err) + // Transform path doesn't exist, so original result is returned + require.Equal(t, `{"id":"abc123"}`+"\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(w, &stderr, "test", res, "explore", false, "") + 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(w, &stderr, "test", res, "explore", true, "") + 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(w, &stderr, "test", res, "explore", false, "") + 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() + + var stderr bytes.Buffer + err = ShowJSONIterator(w, &stderr, "test", iter, format, false, transform, itemsToDisplay) + 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..4cc1965 100644 --- a/pkg/cmd/flagoptions.go +++ b/pkg/cmd/flagoptions.go @@ -40,12 +40,48 @@ const ( 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 +89,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 +110,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 +125,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 +134,28 @@ 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 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 +166,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 +182,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,6 +242,14 @@ func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle) (reflect.Value, expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") } + if isStdinPath(filename) { + r, err := stdin.read() + if err != nil { + return v, err + } + return reflect.ValueOf(io.NopCloser(r)), nil + } + file, err := os.Open(filename) if err != nil { if !expectsFile { @@ -219,6 +309,13 @@ func flagOptions( requestContents := requestflag.ExtractRequestContents(cmd) + // 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 == MultipartFormEncoded || bodyType == ApplicationJSON) && !ignoreStdin && isInputPiped() { pipeData, err := io.ReadAll(os.Stdin) if err != nil { @@ -226,11 +323,13 @@ func flagOptions( } 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 { + applyDataAliases(cmd, bodyMap) if flagMap, ok := requestContents.Body.(map[string]any); ok { maps.Copy(bodyMap, flagMap) requestContents.Body = bodyMap @@ -246,38 +345,52 @@ func flagOptions( } 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 +486,118 @@ 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 + +// 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..039b9ff 100644 --- a/pkg/cmd/flagoptions_test.go +++ b/pkg/cmd/flagoptions_test.go @@ -2,8 +2,10 @@ package cmd import ( "encoding/base64" + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -11,6 +13,8 @@ import ( ) func TestIsUTF8TextFile(t *testing.T) { + t.Parallel() + tests := []struct { content []byte expected bool @@ -32,6 +36,8 @@ func TestIsUTF8TextFile(t *testing.T) { } func TestEmbedFiles(t *testing.T) { + t.Parallel() + // Create temporary directory for test files tmpDir := t.TempDir() @@ -216,7 +222,9 @@ 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) } else { @@ -226,7 +234,9 @@ func TestEmbedFiles(t *testing.T) { }) 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) } else { @@ -236,9 +246,98 @@ 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) + }) +} + 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..2272e2b 100644 --- a/pkg/cmd/info.go +++ b/pkg/cmd/info.go @@ -51,6 +51,7 @@ func handleInfoRetrieve(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, "info retrieve", obj, format, transform) + return ShowJSON(os.Stdout, os.Stderr, "info retrieve", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/message.go b/pkg/cmd/message.go index b65cef7..0e0a097 100644 --- a/pkg/cmd/message.go +++ b/pkg/cmd/message.go @@ -254,8 +254,9 @@ 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(os.Stdout, os.Stderr, "messages update", obj, format, explicitFormat, transform) } func handleMessagesList(ctx context.Context, cmd *cli.Command) error { @@ -283,6 +284,7 @@ func handleMessagesList(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -297,7 +299,7 @@ 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(os.Stdout, os.Stderr, "messages list", obj, format, explicitFormat, transform) } else { iter := client.Messages.ListAutoPaging( ctx, @@ -309,7 +311,7 @@ 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(os.Stdout, os.Stderr, "messages list", iter, format, explicitFormat, transform, maxItems) } } @@ -335,6 +337,7 @@ func handleMessagesSearch(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") if format == "raw" { var res []byte @@ -344,14 +347,14 @@ 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(os.Stdout, os.Stderr, "messages search", obj, format, explicitFormat, 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(os.Stdout, os.Stderr, "messages search", iter, format, explicitFormat, transform, maxItems) } } @@ -393,6 +396,7 @@ 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(os.Stdout, os.Stderr, "messages send", obj, format, explicitFormat, transform) } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 5266c84..b113456 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 = "0.4.0" // x-release-please-version diff --git a/scripts/link b/scripts/link index 2da9715..332584c 100755 --- a/scripts/link +++ b/scripts/link @@ -9,5 +9,9 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/beeper/desktop-api-go,gith 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="$REPLACEMENT" + go mod tidy -e +else + echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" +fi diff --git a/scripts/mock b/scripts/mock index 0f82c95..7c58865 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.20.2 -- 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.20.2 -- 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.20.2 -- 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/test b/scripts/test index 9f448ce..7ba4b3f 100755 --- a/scripts/test +++ b/scripts/test @@ -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.20.2 -- 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