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