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..aa71a5322 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: formatJSON}, + 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) +}