diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4e556fb..265eb1e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -10,9 +10,11 @@ on: push: tags: - "v*" + workflow_dispatch: {} jobs: goreleaser: runs-on: ubuntu-latest + environment: production steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml deleted file mode 100644 index 311e6e3..0000000 --- a/.github/workflows/release-doctor.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Release Doctor -on: - push: - branches: - - main - workflow_dispatch: - -jobs: - release_doctor: - name: release doctor - runs-on: ubuntu-latest - environment: production - if: github.repository == 'dedalus-labs/dedalus-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 - - - name: Check release environment - run: | - bash ./bin/check-release-environment diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3d2ac0b..10f3091 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.0" + ".": "0.2.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index df4c089..aa2fad7 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 29 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/dedalus-labs%2Fdedalus-018f649c40452f64a7454d2841a410297ef931793d4f6dbfebab541b060a2b21.yml openapi_spec_hash: 7fd462f39c9dcf835904c06fea51ef46 -config_hash: 89f7a7d317d1c89fd73ebaf74c5918cf +config_hash: 816359ffd81767484efabdc39744bf77 diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9cf0d..97b2f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 0.2.0 (2026-04-22) + +Full Changelog: [v0.1.0...v0.2.0](https://github.com/dedalus-labs/dedalus-cli/compare/v0.1.0...v0.2.0) + +### Features + +* allow `-` as value representing stdin to binary-only file parameters in CLIs ([7da15e2](https://github.com/dedalus-labs/dedalus-cli/commit/7da15e2f6c1d715fc55e26fa2bd7be95eba5ff0e)) +* better error message if scheme forgotten in CLI `*_BASE_URL`/`--base-url` ([5edec04](https://github.com/dedalus-labs/dedalus-cli/commit/5edec044b3dda53824fa2981ffe94b557fc3ce65)) +* binary-only parameters become CLI flags that take filenames only ([0875d6c](https://github.com/dedalus-labs/dedalus-cli/commit/0875d6c6e2426509185f56022f763b2927b07af2)) +* **cli:** add `--raw-output`/`-r` option to print raw (non-JSON) strings ([d7201c6](https://github.com/dedalus-labs/dedalus-cli/commit/d7201c60f8794ec1c0fe6fcfdd85ece68996c7f9)) +* **cli:** alias parameters in data with `x-stainless-cli-data-alias` ([5a62637](https://github.com/dedalus-labs/dedalus-cli/commit/5a6263723604bb1a35fbc53c5f99e7fd7fd0463a)) +* **cli:** send filename and content type when reading input from files ([21ec6dd](https://github.com/dedalus-labs/dedalus-cli/commit/21ec6dd8f92d9feafd9e43abd08f9f9fddddf64a)) + + +### Bug Fixes + +* fall back to main branch if linking fails in CI ([87613ec](https://github.com/dedalus-labs/dedalus-cli/commit/87613ecfe5030a1a76be6dcfdb29161ff58a2550)) +* fix for failing to drop invalid module replace in link script ([547c3e1](https://github.com/dedalus-labs/dedalus-cli/commit/547c3e146cf035cee1b466a85a06529c0117edf2)) +* fix quoting typo ([575418c](https://github.com/dedalus-labs/dedalus-cli/commit/575418c4b4a58aa2496fef880d038c59da9e7fb8)) + + +### Chores + +* add documentation for ./scripts/link ([6d33e1a](https://github.com/dedalus-labs/dedalus-cli/commit/6d33e1af76dc27b3c70c252b1d28894050b0a7dc)) +* add install.ps1 for Windows ([#14](https://github.com/dedalus-labs/dedalus-cli/issues/14)) ([bcc4657](https://github.com/dedalus-labs/dedalus-cli/commit/bcc46570a2f7da106880f864936e0e95bf0cb52b)) +* **ci:** add github env support for goreleaser ([158e705](https://github.com/dedalus-labs/dedalus-cli/commit/158e705d900c2cda1e52b8984e821044a7ac38fe)) +* **ci:** remove release-doctor workflow ([2a1a9d8](https://github.com/dedalus-labs/dedalus-cli/commit/2a1a9d8c0b94a6c06c2ab828c1c8a6fbf1382f39)) +* **ci:** support manually triggering release workflow ([cf0bd76](https://github.com/dedalus-labs/dedalus-cli/commit/cf0bd764b2563d4ab838a99c6ebbf1c45b7ae3e3)) +* **cli:** additional test cases for `ShowJSONIterator` ([c64d07c](https://github.com/dedalus-labs/dedalus-cli/commit/c64d07ce2e2819b489ee60e500379bb3d3b4fc67)) +* **cli:** default to jsonl format ([4a34328](https://github.com/dedalus-labs/dedalus-cli/commit/4a3432861ff1ba54f4d1b13d7481d9bef18ceb49)) +* **cli:** fall back to JSON when using default "explore" with non-TTY ([3b62e8d](https://github.com/dedalus-labs/dedalus-cli/commit/3b62e8d1b6b6305b4cb36aec45abf4f88d3b6a2c)) +* **cli:** let `--format raw` be used in conjunction with `--transform` ([c913d27](https://github.com/dedalus-labs/dedalus-cli/commit/c913d2739ca2f58b5498e7aea3b644b4068a1fe8)) +* **cli:** switch long lists of positional args over to param structs ([86bdc85](https://github.com/dedalus-labs/dedalus-cli/commit/86bdc85c82d6f81d2d0614cb7e705b55ece80fe7)) +* **cli:** use `ShowJSONOpts` as argument to `formatJSON` instead of many positionals ([a19782c](https://github.com/dedalus-labs/dedalus-cli/commit/a19782cd7fed5d41883ed7800b6d2c4c0bde7ced)) +* **internal:** more robust bootstrap script ([6fbf8f5](https://github.com/dedalus-labs/dedalus-cli/commit/6fbf8f509d44d42ad50ccc7cb59b62d443ff6a7e)) +* mark all CLI-related tests in Go with `t.Parallel()` ([7d1a565](https://github.com/dedalus-labs/dedalus-cli/commit/7d1a565862ae3190b48e40ff892073eca20a5480)) +* modify CLI tests to inject stdout so mutating `os.Stdout` isn't necessary ([6073189](https://github.com/dedalus-labs/dedalus-cli/commit/6073189d85a64ac3d53c0f8b08899a2b617740b8)) +* switch some CLI Go tests from `os.Chdir` to `t.Chdir` ([ba52bbe](https://github.com/dedalus-labs/dedalus-cli/commit/ba52bbea1667ea208f169c8b1b768e714ad3eeb7)) +* **tests:** bump steady to v0.22.1 ([544f575](https://github.com/dedalus-labs/dedalus-cli/commit/544f575622b41ceb09a9491a1c28898e09142757)) + ## 0.1.0 (2026-04-02) Full Changelog: [v0.0.4...v0.1.0](https://github.com/dedalus-labs/dedalus-cli/compare/v0.0.4...v0.1.0) diff --git a/README.md b/README.md index 47e35ab..ca1111b 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,23 @@ base64-encoding). Note that absolute paths will begin with `@file://` or ```bash dedalus --arg @data://file.txt ``` + +## Linking different Go SDK versions + +You can link the CLI against a different version of the Dedalus 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/dedalus-go +``` + +If you run the link script without any arguments, it will default to `../dedalus-go`. diff --git a/bin/check-release-environment b/bin/check-release-environment deleted file mode 100644 index 1e951e9..0000000 --- a/bin/check-release-environment +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -errors=() - -lenErrors=${#errors[@]} - -if [[ lenErrors -gt 0 ]]; then - echo -e "Found the following errors in the release environment:\n" - - for error in "${errors[@]}"; do - echo -e "- $error\n" - done - - exit 1 -fi - -echo "The environment is ready to push releases!" diff --git a/cmd/dedalus/main.go b/cmd/dedalus/main.go index a3d3182..7551ab0 100644 --- a/cmd/dedalus/main.go +++ b/cmd/dedalus/main.go @@ -23,6 +23,13 @@ func main() { prepareForAutocomplete(app) } + if baseURL, ok := os.LookupEnv("DEDALUS_BASE_URL"); ok { + if err := cmd.ValidateBaseURL(baseURL, "DEDALUS_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/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_test.go b/internal/jsonview/explorer_test.go index c559254..67ee730 100644 --- a/internal/jsonview/explorer_test.go +++ b/internal/jsonview/explorer_test.go @@ -10,6 +10,8 @@ import ( ) 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) @@ -38,6 +40,8 @@ type rawJSONItem struct { 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"}`}, @@ -49,6 +53,8 @@ func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { } func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { + t.Parallel() + items := []any{ map[string]any{"id": 1, "name": "alice"}, map[string]any{"id": 2, "name": "bob"}, 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 32c13f5..bfaf064 100644 --- a/internal/requestflag/requestflag.go +++ b/internal/requestflag/requestflag.go @@ -43,6 +43,15 @@ type Flag[ // 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 @@ -59,6 +68,8 @@ type InRequest interface { GetHeaderPath() string GetBodyPath() string IsBodyRoot() bool + IsFileInput() bool + GetDataAliases() []string } func (f Flag[T]) GetQueryPath() string { @@ -77,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 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/cmd.go b/pkg/cmd/cmd.go index a3e45d6..9fe8646 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: "jsonl", 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: "jsonl", 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,6 +73,11 @@ 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: "api-key", Usage: "Dedalus API key sent as Authorization Bearer.", diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go index c43bf85..d1bf75c 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("Dedalus/CLI %s", Version)), @@ -190,7 +199,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 { @@ -198,13 +210,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 } @@ -305,21 +317,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 @@ -327,7 +347,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 { @@ -341,34 +361,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 } +} + +// ShowJSON displays a single JSON result to the user. +func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { + opts.setDefaults() - switch strings.ToLower(format) { + 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 } } @@ -382,12 +435,18 @@ type hasRawJSON interface { RawJSON() string } -// For an iterator over different value types, display its values to the user in -// different formats. -// -1 is used to signal no limit of items to display -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()) @@ -413,7 +472,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 } @@ -430,7 +489,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 } @@ -438,13 +497,15 @@ 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 @@ -460,7 +521,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..e91cb10 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/dedalus-labs/dedalus-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 c8cdf74..13dcfc3 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" @@ -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,6 +332,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 +346,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 @@ -258,24 +380,40 @@ func flagOptions( } } + // 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) @@ -371,3 +509,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/machine.go b/pkg/cmd/machine.go index 97d1aa0..9b49b1d 100644 --- a/pkg/cmd/machine.go +++ b/pkg/cmd/machine.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -222,8 +221,15 @@ func handleMachinesCreate(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, "machines create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines create", + Transform: transform, + }) } func handleMachinesRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -258,8 +264,15 @@ func handleMachinesRetrieve(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, "machines retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines retrieve", + Transform: transform, + }) } func handleMachinesUpdate(ctx context.Context, cmd *cli.Command) error { @@ -294,8 +307,15 @@ func handleMachinesUpdate(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, "machines update", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines update", + Transform: transform, + }) } func handleMachinesList(ctx context.Context, cmd *cli.Command) error { @@ -320,6 +340,7 @@ func handleMachinesList(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 @@ -329,14 +350,26 @@ func handleMachinesList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines list", + Transform: transform, + }) } else { iter := client.Machines.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines list", + Transform: transform, + }) } } @@ -372,8 +405,15 @@ func handleMachinesDelete(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, "machines delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines delete", + Transform: transform, + }) } func handleMachinesSleep(ctx context.Context, cmd *cli.Command) error { @@ -408,8 +448,15 @@ func handleMachinesSleep(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, "machines sleep", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines sleep", + Transform: transform, + }) } func handleMachinesWake(ctx context.Context, cmd *cli.Command) error { @@ -444,8 +491,15 @@ func handleMachinesWake(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, "machines wake", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines wake", + Transform: transform, + }) } func handleMachinesWatch(ctx context.Context, cmd *cli.Command) error { @@ -472,11 +526,18 @@ func handleMachinesWatch(ctx context.Context, cmd *cli.Command) error { } format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") stream := client.Machines.WatchStreaming(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines watch", stream, format, transform, maxItems) + return ShowJSONIterator(stream, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines watch", + Transform: transform, + }) } diff --git a/pkg/cmd/machineartifact.go b/pkg/cmd/machineartifact.go index ef4a227..6af9b01 100644 --- a/pkg/cmd/machineartifact.go +++ b/pkg/cmd/machineartifact.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -110,8 +109,15 @@ func handleMachinesArtifactsRetrieve(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, "machines:artifacts retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:artifacts retrieve", + Transform: transform, + }) } func handleMachinesArtifactsList(ctx context.Context, cmd *cli.Command) error { @@ -138,6 +144,7 @@ func handleMachinesArtifactsList(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 @@ -147,14 +154,26 @@ func handleMachinesArtifactsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:artifacts list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:artifacts list", + Transform: transform, + }) } else { iter := client.Machines.Artifacts.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:artifacts list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:artifacts list", + Transform: transform, + }) } } @@ -191,6 +210,13 @@ func handleMachinesArtifactsDelete(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, "machines:artifacts delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:artifacts delete", + Transform: transform, + }) } diff --git a/pkg/cmd/machineexecution.go b/pkg/cmd/machineexecution.go index d3c42de..4574daf 100644 --- a/pkg/cmd/machineexecution.go +++ b/pkg/cmd/machineexecution.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -192,8 +191,15 @@ func handleMachinesExecutionsCreate(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, "machines:executions create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions create", + Transform: transform, + }) } func handleMachinesExecutionsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -229,8 +235,15 @@ func handleMachinesExecutionsRetrieve(ctx context.Context, cmd *cli.Command) err obj := gjson.ParseBytes(res) format := cmd.Root().String("format") + explicitFormat := cmd.Root().IsSet("format") transform := cmd.Root().String("transform") - return ShowJSON(os.Stdout, "machines:executions retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions retrieve", + Transform: transform, + }) } func handleMachinesExecutionsList(ctx context.Context, cmd *cli.Command) error { @@ -257,6 +270,7 @@ func handleMachinesExecutionsList(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 @@ -266,14 +280,26 @@ func handleMachinesExecutionsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:executions list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions list", + Transform: transform, + }) } else { iter := client.Machines.Executions.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:executions list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions list", + Transform: transform, + }) } } @@ -310,8 +336,15 @@ func handleMachinesExecutionsDelete(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, "machines:executions delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions delete", + Transform: transform, + }) } func handleMachinesExecutionsEvents(ctx context.Context, cmd *cli.Command) error { @@ -339,6 +372,7 @@ func handleMachinesExecutionsEvents(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 @@ -348,14 +382,26 @@ func handleMachinesExecutionsEvents(ctx context.Context, cmd *cli.Command) error return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:executions events", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions events", + Transform: transform, + }) } else { iter := client.Machines.Executions.EventsAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:executions events", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions events", + Transform: transform, + }) } } @@ -392,6 +438,13 @@ func handleMachinesExecutionsOutput(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, "machines:executions output", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:executions output", + Transform: transform, + }) } diff --git a/pkg/cmd/machinepreview.go b/pkg/cmd/machinepreview.go index 3d7c187..b4c8739 100644 --- a/pkg/cmd/machinepreview.go +++ b/pkg/cmd/machinepreview.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -138,8 +137,15 @@ func handleMachinesPreviewsCreate(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, "machines:previews create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:previews create", + Transform: transform, + }) } func handleMachinesPreviewsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -175,8 +181,15 @@ func handleMachinesPreviewsRetrieve(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, "machines:previews retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:previews retrieve", + Transform: transform, + }) } func handleMachinesPreviewsList(ctx context.Context, cmd *cli.Command) error { @@ -203,6 +216,7 @@ func handleMachinesPreviewsList(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 @@ -212,14 +226,26 @@ func handleMachinesPreviewsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:previews list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:previews list", + Transform: transform, + }) } else { iter := client.Machines.Previews.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:previews list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:previews list", + Transform: transform, + }) } } @@ -256,6 +282,13 @@ func handleMachinesPreviewsDelete(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, "machines:previews delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:previews delete", + Transform: transform, + }) } diff --git a/pkg/cmd/machinessh.go b/pkg/cmd/machinessh.go index 8a63203..49d7fe5 100644 --- a/pkg/cmd/machinessh.go +++ b/pkg/cmd/machinessh.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -128,8 +127,15 @@ func handleMachinesSSHCreate(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, "machines:ssh create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:ssh create", + Transform: transform, + }) } func handleMachinesSSHRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -165,8 +171,15 @@ func handleMachinesSSHRetrieve(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, "machines:ssh retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:ssh retrieve", + Transform: transform, + }) } func handleMachinesSSHList(ctx context.Context, cmd *cli.Command) error { @@ -193,6 +206,7 @@ func handleMachinesSSHList(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 @@ -202,14 +216,26 @@ func handleMachinesSSHList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:ssh list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:ssh list", + Transform: transform, + }) } else { iter := client.Machines.SSH.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:ssh list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:ssh list", + Transform: transform, + }) } } @@ -246,6 +272,13 @@ func handleMachinesSSHDelete(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, "machines:ssh delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:ssh delete", + Transform: transform, + }) } diff --git a/pkg/cmd/machineterminal.go b/pkg/cmd/machineterminal.go index 01f2714..9cc14a2 100644 --- a/pkg/cmd/machineterminal.go +++ b/pkg/cmd/machineterminal.go @@ -5,7 +5,6 @@ package cmd import ( "context" "fmt" - "os" "github.com/dedalus-labs/dedalus-cli/internal/apiquery" "github.com/dedalus-labs/dedalus-cli/internal/requestflag" @@ -145,8 +144,15 @@ func handleMachinesTerminalsCreate(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, "machines:terminals create", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:terminals create", + Transform: transform, + }) } func handleMachinesTerminalsRetrieve(ctx context.Context, cmd *cli.Command) error { @@ -182,8 +188,15 @@ func handleMachinesTerminalsRetrieve(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, "machines:terminals retrieve", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:terminals retrieve", + Transform: transform, + }) } func handleMachinesTerminalsList(ctx context.Context, cmd *cli.Command) error { @@ -210,6 +223,7 @@ func handleMachinesTerminalsList(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 @@ -219,14 +233,26 @@ func handleMachinesTerminalsList(ctx context.Context, cmd *cli.Command) error { return err } obj := gjson.ParseBytes(res) - return ShowJSON(os.Stdout, "machines:terminals list", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:terminals list", + Transform: transform, + }) } else { iter := client.Machines.Terminals.ListAutoPaging(ctx, params, options...) maxItems := int64(-1) if cmd.IsSet("max-items") { maxItems = cmd.Value("max-items").(int64) } - return ShowJSONIterator(os.Stdout, "machines:terminals list", iter, format, transform, maxItems) + return ShowJSONIterator(iter, maxItems, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:terminals list", + Transform: transform, + }) } } @@ -263,6 +289,13 @@ func handleMachinesTerminalsDelete(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, "machines:terminals delete", obj, format, transform) + return ShowJSON(obj, ShowJSONOpts{ + ExplicitFormat: explicitFormat, + Format: format, + RawOutput: cmd.Root().Bool("raw-output"), + Title: "machines:terminals delete", + Transform: transform, + }) } diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 9bb8168..10d2893 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -2,4 +2,4 @@ package cmd -const Version = "0.1.0" // x-release-please-version +const Version = "0.2.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/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..044080a --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,301 @@ +<# +.SYNOPSIS + The installer for the Dedalus CLI on Windows. + +.DESCRIPTION + Detects the host architecture, downloads the matching release archive from + https://github.com/dedalus-labs/dedalus-cli/releases, extracts dedalus.exe, + and installs it to $HOME\.local\bin (overridable). Optionally adds the + install directory to the user PATH via the registry. + + Mirrors scripts/install.sh 1:1 so docs, env vars, and muscle memory are + symmetric across macOS, Linux, and Windows. + +.PARAMETER InstallDir + Install directory. Defaults to $env:DEDALUS_INSTALL_DIR, then $HOME\.local\bin. + +.PARAMETER Version + Version tag to install (e.g. v0.1.0). Defaults to $env:DEDALUS_VERSION, + then the latest GitHub release. + +.PARAMETER NoModifyPath + Skip modifying the user PATH. Also honored via $env:DEDALUS_NO_MODIFY_PATH=1. + +.EXAMPLE + irm https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1 | iex + +.EXAMPLE + # Pin a version + iex "& {$(irm https://raw.githubusercontent.com/dedalus-labs/dedalus-cli/main/scripts/install.ps1)} -Version v0.1.0" +#> + +[CmdletBinding()] +param ( + [string]$InstallDir, + [string]$Version, + [switch]$NoModifyPath +) + +$ErrorActionPreference = 'Stop' + +$Repo = 'dedalus-labs/dedalus-cli' +$Binary = 'dedalus' + +function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } +function Write-Ok { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } +function Write-Warn { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } +function Write-Err { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } + +function Show-Banner { + $art = @' + + .. + .. + ... + .... + .... + ..... + ..... + ...... + ........ + ........ + ......... + ........... ... + ............ .... + ............. .... + .............. ..... + .............. ...... + .............. ....... + ............ ....... + ......... ........ + ........ ......... + ...... ........... +...... ............ +..... .. +.... .... +... .... +.. ....... +. .......... +. .................... +. .................. + + Dedalus CLI - dedaluslabs.ai + +'@ + Write-Host $art +} + +function Assert-Environment { + if ($PSVersionTable.PSVersion.Major -lt 5) { + Write-Err "PowerShell 5.1 or newer is required (found $($PSVersionTable.PSVersion))." + Write-Err "Upgrade: https://learn.microsoft.com/powershell/scripting/install/installing-powershell" + exit 1 + } + + $policy = Get-ExecutionPolicy + $allowed = @('Unrestricted', 'RemoteSigned', 'Bypass') + if ($policy -notin $allowed) { + Write-Err "PowerShell execution policy is '$policy'; need one of: $($allowed -join ', ')." + Write-Err "Run: Set-ExecutionPolicy RemoteSigned -Scope CurrentUser" + exit 1 + } + + # GitHub requires TLS 1.2. PS 5.1 defaults to TLS 1.0/1.1 on older Windows. + if ([Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } +} + +function Get-Arch { + $raw = $null + try { + $raw = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + } catch { + $raw = $env:PROCESSOR_ARCHITECTURE + } + + switch -Regex ($raw) { + '^(X64|AMD64)$' { return 'amd64' } + '^ARM64$' { return 'arm64' } + '^(X86|IA32)$' { return '386' } + default { + Write-Err "Unsupported architecture: $raw" + exit 1 + } + } +} + +function Get-LatestVersion { + # The /releases/latest URL 302-redirects to /releases/tag/. + # PowerShell treats a 302 as terminating when MaximumRedirection is 0, so wrap in try/catch. + $url = "https://github.com/$Repo/releases/latest" + try { + $response = Invoke-WebRequest -Uri $url -MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop + } catch { + $response = $_.Exception.Response + } + + $location = $null + if ($response -and $response.Headers) { + if ($response.Headers -is [System.Collections.IDictionary]) { + $location = $response.Headers['Location'] + } else { + $location = $response.Headers.Location + } + } + + if ($location -is [System.Array]) { $location = $location[0] } + if ($location -is [System.Uri]) { $location = $location.ToString() } + + if (-not $location) { + Write-Err "Could not determine latest version from $url" + exit 1 + } + + $tag = ($location -split '/tag/')[-1].Trim() + if (-not $tag) { + Write-Err "Could not parse version tag from redirect: $location" + exit 1 + } + return $tag +} + +function Install-Dedalus { + param( + [string]$Arch, + [string]$VersionTag, + [string]$Destination + ) + + $versionNum = $VersionTag.TrimStart('v') + $archiveName = "${Binary}_${versionNum}_windows_${Arch}.zip" + $url = "https://github.com/$Repo/releases/download/$VersionTag/$archiveName" + + $tmpdir = New-Item -ItemType Directory -Path (Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())) + try { + $archivePath = Join-Path $tmpdir.FullName $archiveName + Write-Info "Downloading $url ..." + try { + Invoke-WebRequest -Uri $url -OutFile $archivePath -UseBasicParsing + } catch { + Write-Err "Download failed. Check that a release exists for windows/$Arch." + Write-Err $_.Exception.Message + exit 1 + } + + Write-Info "Extracting..." + Expand-Archive -Path $archivePath -DestinationPath $tmpdir.FullName -Force + + $extracted = Join-Path $tmpdir.FullName "$Binary.exe" + if (-not (Test-Path $extracted)) { + Write-Err "Archive did not contain $Binary.exe" + exit 1 + } + + if (-not (Test-Path $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + } + + $target = Join-Path $Destination "$Binary.exe" + Move-Item -Path $extracted -Destination $target -Force + Write-Ok "Installed $Binary to $target" + } finally { + Remove-Item -Path $tmpdir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Test-PathContains { + param([string]$Directory) + + $sep = [System.IO.Path]::PathSeparator + $current = ($env:PATH -split $sep) | Where-Object { $_ } + foreach ($entry in $current) { + if ([string]::Equals($entry.TrimEnd('\'), $Directory.TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } + return $false +} + +function Add-UserPath { + param([string]$Directory) + + $registryPath = 'registry::HKEY_CURRENT_USER\Environment' + $existing = (Get-Item -LiteralPath $registryPath).GetValue('Path', '', 'DoNotExpandEnvironmentNames') + $entries = @() + if ($existing) { + $entries = $existing -split ';' | Where-Object { $_ } + } + + foreach ($entry in $entries) { + if ([string]::Equals($entry.TrimEnd('\'), $Directory.TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase)) { + return $false + } + } + + $newPath = (@($Directory) + $entries) -join ';' + Set-ItemProperty -Type ExpandString -LiteralPath $registryPath -Name Path -Value $newPath + + # Broadcast WM_SETTINGCHANGE so explorer/new shells pick up the change without reboot. + # Uses a dummy env var round-trip, same trick uv uses. + $dummy = 'DEDALUS_CLI_PATH_' + [guid]::NewGuid().ToString('N') + [Environment]::SetEnvironmentVariable($dummy, '1', 'User') + [Environment]::SetEnvironmentVariable($dummy, [NullString]::Value, 'User') + + $env:PATH = "$Directory;$env:PATH" + return $true +} + +function Main { + Show-Banner + Assert-Environment + + if (-not $InstallDir) { + if ($env:DEDALUS_INSTALL_DIR) { + $InstallDir = $env:DEDALUS_INSTALL_DIR + } else { + $InstallDir = Join-Path $HOME '.local\bin' + } + } + + if (-not $Version) { + if ($env:DEDALUS_VERSION) { + $Version = $env:DEDALUS_VERSION + } + } + + if (-not $NoModifyPath -and $env:DEDALUS_NO_MODIFY_PATH) { + $NoModifyPath = $true + } + + $arch = Get-Arch + Write-Info "Detected windows/$arch" + + if (-not $Version) { + $Version = Get-LatestVersion + } + Write-Info "Version: $Version" + + Install-Dedalus -Arch $arch -VersionTag $Version -Destination $InstallDir + + if (-not (Test-PathContains -Directory $InstallDir)) { + if ($NoModifyPath) { + Write-Warn "$InstallDir is not in your PATH" + Write-Host " Add it manually in PowerShell:" + Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$InstallDir;`" + [Environment]::GetEnvironmentVariable('Path','User'), 'User')" + Write-Host "" + } else { + if (Add-UserPath -Directory $InstallDir) { + Write-Ok "Added $InstallDir to your user PATH" + Write-Host " Restart your shell for the change to take effect in new sessions." + Write-Host "" + } + } + } + + Write-Ok "Ready! Run:" + Write-Host " $Binary --help" + Write-Host "" +} + +Main diff --git a/scripts/install.sh b/scripts/install.sh index f00ee95..c00ff36 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,7 @@ set -euo pipefail REPO="dedalus-labs/dedalus-cli" BINARY="dedalus" INSTALL_DIR="${DEDALUS_INSTALL_DIR:-$HOME/.local/bin}" +TMPDIR_CLEANUP="" RED='\033[0;31m' GREEN='\033[0;32m' @@ -16,6 +17,15 @@ info() { echo -e "${BLUE}[INFO]${NC} $1"; } success() { echo -e "${GREEN}[OK]${NC} $1"; } warning() { echo -e "${YELLOW}[WARN]${NC} $1"; } +cleanup_tmpdir() { + if [[ -n "${TMPDIR_CLEANUP:-}" ]]; then + rm -rf -- "${TMPDIR_CLEANUP}" + TMPDIR_CLEANUP="" + fi +} + +trap cleanup_tmpdir EXIT + detect_platform() { local os arch @@ -65,9 +75,10 @@ download_and_install() { fi local url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}.${ext}" - local tmpdir - tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + + cleanup_tmpdir + TMPDIR_CLEANUP=$(mktemp -d) + local tmpdir="${TMPDIR_CLEANUP}" info "Downloading ${url}..." if ! curl -fsSL "$url" -o "${tmpdir}/archive.${ext}"; then diff --git a/scripts/link b/scripts/link index 62ee8f1..c9d0047 100755 --- a/scripts/link +++ b/scripts/link @@ -9,5 +9,9 @@ export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/dedalus-labs/dedalus-go,gi REPLACEMENT="${1:-"../Dedalus-go"}" echo "==> Replacing Go SDK with $REPLACEMENT" -go mod edit -replace github.com/dedalus-labs/dedalus-go="$REPLACEMENT" -go mod tidy -e +if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then + go mod edit -replace github.com/dedalus-labs/dedalus-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 7c58865..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.20.2 -- steady --version + npm exec --package=@stdy/cli@0.22.1 -- steady --version - 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 & + 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.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" + 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/test b/scripts/test index c72d64c..a967031 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.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 -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