From 365a1f5b4848190f9903c7a93dc6ebe0ed2e9a80 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 6 May 2026 03:11:21 -0500 Subject: [PATCH 1/2] feat(cmd): add --output-format json flag for CI/CD result envelope Gate JSON envelope emission behind --output-format json global flag. When not set, no envelope is written to stdout (backward compatible). Fix exec command to write envelope to stdout instead of stderr. --- cmd/build.go | 22 +++++++++++++--------- cmd/exec.go | 16 ++++++++++++---- cmd/exec_test.go | 20 ++++++++++++++++++++ cmd/flag_aliases_test.go | 16 ++++++++++++++++ cmd/flags/flags.go | 17 +++++++++++++---- cmd/up/up.go | 28 ++++++++++++++++++---------- cmd/up/up_test.go | 8 ++++++++ 7 files changed, 100 insertions(+), 27 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 8458656c8..3340d7250 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -234,19 +234,23 @@ func (cmd *BuildCmd) build( }, ) if err != nil { - _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + } return err } + if cmd.OutputFormat != flags.OutputFormatJSON { + return nil + } + containerID := "" - workdir := "" // uses substituted path directly; build doesn't support git subpath overrides - if result != nil { - if result.ContainerDetails != nil { - containerID = result.ContainerDetails.ID - } - if result.SubstitutionContext != nil { - workdir = result.SubstitutionContext.ContainerWorkspaceFolder - } + workdir := "" + if result != nil && result.ContainerDetails != nil { + containerID = result.ContainerDetails.ID + } + if result != nil && result.SubstitutionContext != nil { + workdir = result.SubstitutionContext.ContainerWorkspaceFolder } user := devcconfig.GetRemoteUser(result) _ = devcconfig.WriteResultJSON(os.Stdout, containerID, user, workdir, nil) diff --git a/cmd/exec.go b/cmd/exec.go index ceb745abb..77c045703 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -175,11 +175,15 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { envMap: envMap, }, args) if err != nil { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + } return err } - _ = devcconfig.WriteResultJSON(os.Stderr, containerDetails.ID, user, workdir, nil) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = devcconfig.WriteResultJSON(os.Stdout, containerDetails.ID, user, workdir, nil) + } return nil } @@ -224,11 +228,15 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error envMap: envMap, }, args) if err != nil { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = devcconfig.WriteErrorJSON(os.Stdout, err.Error()) + } return err } - _ = devcconfig.WriteResultJSON(os.Stderr, containerDetails.ID, "", workdir, nil) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = devcconfig.WriteResultJSON(os.Stdout, containerDetails.ID, "", workdir, nil) + } return nil } diff --git a/cmd/exec_test.go b/cmd/exec_test.go index a8ba16f29..0ef5ba82f 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -140,3 +140,23 @@ func TestExecCmd_SkipPostCreateFlagParsesValue(t *testing.T) { require.NoError(t, err) assert.True(t, val) } + +func TestExecCmd_NoEnvelopeWithoutOutputFormat(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{OutputFormat: ""}, + ContainerID: "nonexistent-container-id-99999", + } + err := cmd.runWithContainerID(t.Context(), []string{"echo", "hello"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent-container-id-99999") +} + +func TestExecCmd_EnvelopeWithOutputFormatJSON(t *testing.T) { + cmd := &ExecCmd{ + GlobalFlags: &flags.GlobalFlags{OutputFormat: "json"}, + ContainerID: "nonexistent-container-id-88888", + } + err := cmd.runWithContainerID(t.Context(), []string{"echo", "hello"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "nonexistent-container-id-88888") +} diff --git a/cmd/flag_aliases_test.go b/cmd/flag_aliases_test.go index 3e59d9ead..22ab4dd63 100644 --- a/cmd/flag_aliases_test.go +++ b/cmd/flag_aliases_test.go @@ -121,3 +121,19 @@ func TestRemoveExistingContainerAlias_IsHidden(t *testing.T) { require.NotNil(t, f) assert.True(t, f.Hidden, flagRemoveExistingContainer+" alias should be hidden") } + +func TestGlobalFlags_OutputFormat(t *testing.T) { + rootCmd := BuildRoot() + rootCmd.SetArgs([]string{"--output-format", formatJSON, "version"}) + err := rootCmd.Execute() + require.NoError(t, err) + assert.Equal(t, formatJSON, globalFlags.OutputFormat) +} + +func TestGlobalFlags_OutputFormatDefault(t *testing.T) { + rootCmd := BuildRoot() + rootCmd.SetArgs([]string{"version"}) + err := rootCmd.Execute() + require.NoError(t, err) + assert.Equal(t, "", globalFlags.OutputFormat) +} diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go index f882d3144..062c132b4 100644 --- a/cmd/flags/flags.go +++ b/cmd/flags/flags.go @@ -6,6 +6,8 @@ import ( flag "github.com/spf13/pflag" ) +const OutputFormatJSON = "json" + type GlobalFlags struct { Context string Provider string @@ -14,10 +16,11 @@ type GlobalFlags struct { UID string Owner platform.OwnerFilter - LogOutput string - Verbosity int - Quiet bool - Debug bool + LogOutput string + OutputFormat string + Verbosity int + Quiet bool + Debug bool } // SetGlobalFlags applies the global flags. @@ -59,6 +62,12 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags { "Suppress all log output except fatal errors", ) flags.BoolVar(&globalFlags.Debug, "debug", false, "Enable debug logging (equivalent to -vv)") + flags.StringVar( + &globalFlags.OutputFormat, + "output-format", + "", + "Machine-readable output format for command results (json)", + ) flags.Var(&globalFlags.Owner, "owner", "Show pro workspaces for owner") _ = flags.MarkHidden("owner") diff --git a/cmd/up/up.go b/cmd/up/up.go index 7d9f07562..5cb4c05a9 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -68,7 +68,9 @@ func (cmd *UpCmd) Run( wctx, err := cmd.executeDevsyUp(ctx, devsyConfig, client) if err != nil { - _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + } return err } if wctx == nil { @@ -80,24 +82,30 @@ func (cmd *UpCmd) Run( } if err := cmd.configureWorkspace(devsyConfig, client, wctx); err != nil { - _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + } return err } if err := cmd.openIDE(ctx, devsyConfig, client, wctx); err != nil { - _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + if cmd.OutputFormat == flags.OutputFormatJSON { + _ = config2.WriteErrorJSON(os.Stdout, err.Error()) + } return err } - containerID := "" - var warnings []string - if wctx.result != nil { - if wctx.result.ContainerDetails != nil { - containerID = wctx.result.ContainerDetails.ID + if cmd.OutputFormat == flags.OutputFormatJSON { + containerID := "" + var warnings []string + if wctx.result != nil { + if wctx.result.ContainerDetails != nil { + containerID = wctx.result.ContainerDetails.ID + } + warnings = wctx.result.HostWarnings } - warnings = wctx.result.HostWarnings + _ = config2.WriteResultJSON(os.Stdout, containerID, wctx.user, wctx.workdir, warnings) } - _ = config2.WriteResultJSON(os.Stdout, containerID, wctx.user, wctx.workdir, warnings) return nil } diff --git a/cmd/up/up_test.go b/cmd/up/up_test.go index 030203f70..6313b0510 100644 --- a/cmd/up/up_test.go +++ b/cmd/up/up_test.go @@ -187,3 +187,11 @@ func TestUpCmd_RemoteUserFlagParsesValue(t *testing.T) { flag := upCmd.Flags().Lookup("remote-user") assert.Equal(t, "vscode", flag.Value.String()) } + +func TestUpCmd_OutputFormatGatesEnvelope(t *testing.T) { + cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{OutputFormat: ""}} + assert.Empty(t, cmd.OutputFormat, "default should be empty (no envelope)") + + cmd2 := &UpCmd{GlobalFlags: &flags.GlobalFlags{OutputFormat: "json"}} + assert.Equal(t, "json", cmd2.OutputFormat) +} From e4061bed6de078c60f29ad9abadec592833f5c8b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 6 May 2026 03:24:29 -0500 Subject: [PATCH 2/2] fix(cmd): use formatJSON constant in exec_test instead of string literal --- cmd/exec_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/exec_test.go b/cmd/exec_test.go index 0ef5ba82f..aa71a5322 100644 --- a/cmd/exec_test.go +++ b/cmd/exec_test.go @@ -153,7 +153,7 @@ func TestExecCmd_NoEnvelopeWithoutOutputFormat(t *testing.T) { func TestExecCmd_EnvelopeWithOutputFormatJSON(t *testing.T) { cmd := &ExecCmd{ - GlobalFlags: &flags.GlobalFlags{OutputFormat: "json"}, + GlobalFlags: &flags.GlobalFlags{OutputFormat: formatJSON}, ContainerID: "nonexistent-container-id-88888", } err := cmd.runWithContainerID(t.Context(), []string{"echo", "hello"})