From 7ff2ec76be6871eeb9de46a5b27ae062833d6e32 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 06:55:31 -0500 Subject: [PATCH 1/7] refactor(cmd): unify CLI output format under --result-format Previously, 15 subcommands defined their own local --output flag that shadowed the inherited global --result-format. The result: passing --result-format json to devsy provider options ssh (and 14 other commands) was silently ignored. - Drop the local --output flag from status, list, provider {options,list}, machine {status,list}, ide {options,list}, context {options,list}, pro list, and features {info,info-tags,info-manifest,resolvedeps, generatedocs,package}. Route all of them through output.ResolveMode(cmd.ResultFormat). - Flip the global --result-format default from json to auto so interactive users get a readable table and piped/scripted callers get JSON (matches the gh/kubectl UX convention). - Make output.ResolveMode return an error on unrecognized values instead of silently falling back to JSON. Update all 22 call sites (cmd/build, cmd/exec, cmd/setup, cmd/up plus the listing/options commands) to propagate the error. - Merge features/* legacy "text" format into "plain" to align with the global vocabulary. - Update desktop TS (cli.ts, ipc.ts, tests, mock binary) and Go e2e tests that previously passed --output json to use --result-format json. BREAKING: --output is no longer accepted as a format flag on the affected subcommands; use --result-format json|plain|auto instead. cmd/build's --output (build target type: docker/oci) is unchanged. --- cmd/build.go | 6 +- cmd/context/list.go | 20 ++-- cmd/context/options.go | 20 ++-- cmd/exec.go | 12 ++- cmd/features/generatedocs.go | 16 +-- cmd/features/generatedocs_test.go | 7 +- cmd/features/info.go | 18 ++-- cmd/features/info_manifest.go | 20 ++-- cmd/features/info_manifest_test.go | 18 +--- cmd/features/info_tags.go | 37 ++++--- cmd/features/info_tags_test.go | 18 +--- cmd/features/info_test.go | 19 +--- cmd/features/output.go | 16 --- cmd/features/package.go | 11 +- cmd/features/package_test.go | 18 +--- cmd/features/resolvedeps.go | 16 ++- cmd/features/resolvedeps_test.go | 21 +--- cmd/flags/flags.go | 4 +- cmd/ide/list.go | 20 ++-- cmd/ide/options.go | 20 ++-- cmd/list.go | 19 ++-- cmd/machine/list.go | 20 ++-- cmd/machine/status.go | 19 ++-- cmd/pro/list.go | 21 ++-- cmd/provider/list.go | 20 ++-- cmd/provider/options.go | 119 ++++++++++++--------- cmd/setup.go | 6 +- cmd/status.go | 18 ++-- cmd/up/up.go | 6 +- desktop/e2e/fixtures/mock-devsy.cjs | 4 +- desktop/src/main/__tests__/cli.test.ts | 4 +- desktop/src/main/cli.ts | 2 +- desktop/src/main/ipc.ts | 4 +- e2e/framework/command.go | 6 +- e2e/tests/context/context.go | 6 +- e2e/tests/features/features.go | 26 +---- e2e/tests/features/features_package_cmd.go | 2 +- pkg/output/mode.go | 21 ++-- pkg/output/mode_test.go | 29 +++-- 39 files changed, 307 insertions(+), 382 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 9668e3f20..27889c9a4 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -226,7 +226,11 @@ func (cmd *BuildCmd) build( log.Debugf("done building devcontainer") log.Infof("cleaning up temporary workspace") }() - emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + emitJSON := mode == output.ModeJSON result, err := clientimplementation.BuildAgentClient( ctx, diff --git a/cmd/context/list.go b/cmd/context/list.go index 1e602dbcc..036e5ab9c 100644 --- a/cmd/context/list.go +++ b/cmd/context/list.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -16,8 +17,6 @@ import ( // ListCmd holds the list cmd flags. type ListCmd struct { *flags.GlobalFlags - - Output string } // NewListCmd creates a new command. @@ -34,8 +33,6 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return listCmd } @@ -52,8 +49,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for contextName := range devsyConfig.Contexts { tableEntries = append(tableEntries, []string{ @@ -69,7 +70,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { "Name", "Default", }, tableEntries) - case "json": + case output.ModeJSON: ides := []ContextWithDefault{} for contextName := range devsyConfig.Contexts { ides = append(ides, ContextWithDefault{ @@ -83,11 +84,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/context/options.go b/cmd/context/options.go index 55db46a3a..165411e0d 100644 --- a/cmd/context/options.go +++ b/cmd/context/options.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -15,8 +16,6 @@ import ( // OptionsCmd holds the options cmd flags. type OptionsCmd struct { *flags.GlobalFlags - - Output string } // NewOptionsCmd creates a new command. @@ -32,8 +31,6 @@ func NewOptionsCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - optionsCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return optionsCmd } @@ -55,8 +52,12 @@ func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { entryOptions = map[string]config.OptionValue{} } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for _, entry := range config.ContextOptions { value := entryOptions[entry.Name].Value @@ -78,7 +79,7 @@ func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { "Default", "Value", }, tableEntries) - case "json": + case output.ModeJSON: options := map[string]optionWithValue{} for _, entry := range config.ContextOptions { options[entry.Name] = optionWithValue{ @@ -92,11 +93,6 @@ func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/exec.go b/cmd/exec.go index 53dd84116..e7b0ae55d 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -173,7 +173,11 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { probedEnv := probeContainerEnv(ctx, target, userEnvProbe) envMap := buildExecEnv(result, cmd.RemoteEnv, probedEnv) - emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + emitJSON := mode == output.ModeJSON err = cmd.execInContainer(ctx, execOpts{ target: target, @@ -228,7 +232,11 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error workdir := containerDetails.Config.WorkingDir - emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + emitJSON := mode == output.ModeJSON err = cmd.execInContainer(ctx, execOpts{ target: target, diff --git a/cmd/features/generatedocs.go b/cmd/features/generatedocs.go index 70289cdf7..d7262adcb 100644 --- a/cmd/features/generatedocs.go +++ b/cmd/features/generatedocs.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/spf13/cobra" ) @@ -18,7 +19,6 @@ type GenerateDocsCmd struct { ProjectFolder string OutputFolder string Namespace string - Output string } func NewGenerateDocsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { @@ -44,16 +44,14 @@ documentation for each feature based on its devcontainer-feature.json.`, generateDocsCmd.Flags().StringVar( &cmd.Namespace, "namespace", "", "Registry namespace for linking (e.g. ghcr.io/myorg/features)", ) - generateDocsCmd.Flags().StringVar( - &cmd.Output, "output", "text", "Output format (text or json)", - ) _ = generateDocsCmd.MarkFlagRequired("project-folder") return generateDocsCmd } func (cmd *GenerateDocsCmd) Run() error { - if err := validateOutputFormat(cmd.Output); err != nil { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { return err } @@ -72,11 +70,13 @@ func (cmd *GenerateDocsCmd) Run() error { return err } - if cmd.Output == outputJSON { + switch mode { + case output.ModeJSON: return cmd.writeJSON(features) + case output.ModePlain: + return cmd.writeDocs(features, outputFolder) } - - return cmd.writeDocs(features, outputFolder) + return nil } func (cmd *GenerateDocsCmd) resolveOutputFolder( diff --git a/cmd/features/generatedocs_test.go b/cmd/features/generatedocs_test.go index a7e28e33d..477567a41 100644 --- a/cmd/features/generatedocs_test.go +++ b/cmd/features/generatedocs_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -40,7 +41,7 @@ func TestGenerateDocsCmd_Run(t *testing.T) { ProjectFolder: projectDir, OutputFolder: outputDir, Namespace: "ghcr.io/test/features", - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() @@ -78,7 +79,7 @@ func TestGenerateDocsCmd_NoFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() @@ -110,7 +111,7 @@ func TestGenerateDocsCmd_MultipleFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, OutputFolder: outputDir, - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() diff --git a/cmd/features/info.go b/cmd/features/info.go index e1555d03e..7b0022942 100644 --- a/cmd/features/info.go +++ b/cmd/features/info.go @@ -7,6 +7,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -17,7 +18,6 @@ import ( type InfoCmd struct { *flags.GlobalFlags - Output string ShowTags bool ShowDependencies bool } @@ -55,7 +55,6 @@ to display metadata.`, }, } - infoCmd.Flags().StringVar(&cmd.Output, "output", "text", "Output format (text or json)") infoCmd.Flags().BoolVar( &cmd.ShowTags, "show-tags", false, "List available tags from the registry", ) @@ -70,10 +69,6 @@ to display metadata.`, } func (cmd *InfoCmd) Run(featureID string) error { - if err := validateOutputFormat(cmd.Output); err != nil { - return err - } - ref, err := name.ParseReference(featureID) if err != nil { return fmt.Errorf("invalid feature reference %q: %w", featureID, err) @@ -92,10 +87,17 @@ func (cmd *InfoCmd) Run(featureID string) error { info.Tags = tags } - if cmd.Output == outputJSON { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModeJSON: return writeJSON(os.Stdout, info) + case output.ModePlain: + return cmd.printText(info) } - return cmd.printText(info) + return nil } func (cmd *InfoCmd) fetchInfo( diff --git a/cmd/features/info_manifest.go b/cmd/features/info_manifest.go index 1a59c12df..30433cd65 100644 --- a/cmd/features/info_manifest.go +++ b/cmd/features/info_manifest.go @@ -7,6 +7,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" @@ -15,8 +16,6 @@ import ( type InfoManifestCmd struct { *flags.GlobalFlags - - Output string } func NewInfoManifestCmd(globalFlags *flags.GlobalFlags) *cobra.Command { @@ -36,16 +35,10 @@ ghcr.io/devcontainers/features/go:1 and outputs the OCI image manifest.`, }, } - manifestCmd.Flags().StringVar(&cmd.Output, "output", "json", "Output format (text or json)") - return manifestCmd } func (cmd *InfoManifestCmd) Run(featureID string) error { - if err := validateOutputFormat(cmd.Output); err != nil { - return err - } - ref, err := name.ParseReference(featureID) if err != nil { return fmt.Errorf("invalid feature reference %q: %w", featureID, err) @@ -56,10 +49,17 @@ func (cmd *InfoManifestCmd) Run(featureID string) error { return fmt.Errorf("fetch manifest: %w", err) } - if cmd.Output == outputJSON { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModeJSON: return writeJSON(os.Stdout, manifest) + case output.ModePlain: + return cmd.printText(manifest) } - return cmd.printText(manifest) + return nil } func (cmd *InfoManifestCmd) printText(manifest *v1.Manifest) error { diff --git a/cmd/features/info_manifest_test.go b/cmd/features/info_manifest_test.go index a05910dc7..3070d09ae 100644 --- a/cmd/features/info_manifest_test.go +++ b/cmd/features/info_manifest_test.go @@ -3,18 +3,11 @@ package features import ( "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInfoManifestCmd_FlagDefaults(t *testing.T) { - cmd := NewInfoManifestCmd(nil) - - outputFlag := cmd.Flags().Lookup("output") - require.NotNil(t, outputFlag) - assert.Equal(t, "json", outputFlag.DefValue) -} - func TestInfoManifestCmd_RequiresExactlyOneArg(t *testing.T) { cmd := NewInfoManifestCmd(nil) assert.NotNil(t, cmd.Args) @@ -40,15 +33,8 @@ func TestInfoManifestCmd_RequiresExactlyOneArg(t *testing.T) { } } -func TestInfoManifestCmd_InvalidOutputFormat(t *testing.T) { - cmd := &InfoManifestCmd{Output: "xml"} - err := cmd.Run("ghcr.io/devcontainers/features/go:1") - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") -} - func TestInfoManifestCmd_InvalidFeatureReference(t *testing.T) { - cmd := &InfoManifestCmd{Output: outputJSON} + cmd := &InfoManifestCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: "json"}} err := cmd.Run("not a valid reference!!!") require.Error(t, err) assert.Contains(t, err.Error(), "invalid feature reference") diff --git a/cmd/features/info_tags.go b/cmd/features/info_tags.go index f89ae5392..0f21ef962 100644 --- a/cmd/features/info_tags.go +++ b/cmd/features/info_tags.go @@ -5,6 +5,7 @@ import ( "os" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/cobra" @@ -12,8 +13,6 @@ import ( type InfoTagsCmd struct { *flags.GlobalFlags - - Output string } type tagsOutput struct { @@ -37,16 +36,10 @@ queries the registry for all available image tags.`, }, } - tagsCmd.Flags().StringVar(&cmd.Output, "output", "text", "Output format (text or json)") - return tagsCmd } func (cmd *InfoTagsCmd) Run(featureID string) error { - if err := validateOutputFormat(cmd.Output); err != nil { - return err - } - ref, err := name.ParseReference(featureID) if err != nil { return fmt.Errorf("invalid feature reference %q: %w", featureID, err) @@ -57,20 +50,26 @@ func (cmd *InfoTagsCmd) Run(featureID string) error { return fmt.Errorf("list tags: %w", err) } - if cmd.Output == outputJSON { - return writeJSON(os.Stdout, &tagsOutput{Tags: tags}) + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err } + switch mode { + case output.ModeJSON: + return writeJSON(os.Stdout, &tagsOutput{Tags: tags}) + case output.ModePlain: + if len(tags) == 0 { + _, _ = fmt.Fprintln(os.Stdout, "No tags found.") + return nil + } - if len(tags) == 0 { - _, _ = fmt.Fprintln(os.Stdout, "No tags found.") + _, _ = fmt.Fprintln(os.Stdout, "Available Tags:") + rows := make([][]string, 0, len(tags)) + for _, tag := range tags { + rows = append(rows, []string{tag}) + } + table.Print([]string{"Tag"}, rows) return nil } - - _, _ = fmt.Fprintln(os.Stdout, "Available Tags:") - rows := make([][]string, 0, len(tags)) - for _, tag := range tags { - rows = append(rows, []string{tag}) - } - table.Print([]string{"Tag"}, rows) return nil } diff --git a/cmd/features/info_tags_test.go b/cmd/features/info_tags_test.go index 962553c97..d1bd58f83 100644 --- a/cmd/features/info_tags_test.go +++ b/cmd/features/info_tags_test.go @@ -3,18 +3,11 @@ package features import ( "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInfoTagsCmd_FlagDefaults(t *testing.T) { - cmd := NewInfoTagsCmd(nil) - - outputFlag := cmd.Flags().Lookup("output") - require.NotNil(t, outputFlag) - assert.Equal(t, outputText, outputFlag.DefValue) -} - func TestInfoTagsCmd_RequiresExactlyOneArg(t *testing.T) { cmd := NewInfoTagsCmd(nil) assert.NotNil(t, cmd.Args) @@ -23,15 +16,8 @@ func TestInfoTagsCmd_RequiresExactlyOneArg(t *testing.T) { assert.Error(t, cmd.Args(cmd, []string{"first", "second"})) } -func TestInfoTagsCmd_InvalidOutputFormat(t *testing.T) { - cmd := &InfoTagsCmd{Output: "csv"} - err := cmd.Run("ghcr.io/devcontainers/features/go:1") - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") -} - func TestInfoTagsCmd_InvalidFeatureReference(t *testing.T) { - cmd := &InfoTagsCmd{Output: outputText} + cmd := &InfoTagsCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}} err := cmd.Run("not a valid reference!!!") require.Error(t, err) assert.Contains(t, err.Error(), "invalid feature reference") diff --git a/cmd/features/info_test.go b/cmd/features/info_test.go index d311657b5..6b15a2fc1 100644 --- a/cmd/features/info_test.go +++ b/cmd/features/info_test.go @@ -3,6 +3,7 @@ package features import ( "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -10,10 +11,6 @@ import ( func TestInfoCmd_FlagDefaults(t *testing.T) { cmd := NewInfoCmd(nil) - outputFlag := cmd.Flags().Lookup("output") - require.NotNil(t, outputFlag) - assert.Equal(t, "text", outputFlag.DefValue) - showTagsFlag := cmd.Flags().Lookup("show-tags") require.NotNil(t, showTagsFlag) assert.Equal(t, "false", showTagsFlag.DefValue) @@ -25,7 +22,7 @@ func TestInfoCmd_FlagDefaults(t *testing.T) { func TestInfoCmd_AllFlagsRegistered(t *testing.T) { cmd := NewInfoCmd(nil) - expected := []string{"output", "show-tags", "show-dependencies"} + expected := []string{"show-tags", "show-dependencies"} for _, name := range expected { assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) } @@ -39,19 +36,9 @@ func TestInfoCmd_RequiresExactlyOneArg(t *testing.T) { assert.Error(t, cmd.Args(cmd, []string{"one", "two"})) } -func TestInfoCmd_InvalidOutputFormat(t *testing.T) { - infoCmd := &InfoCmd{ - Output: "yaml", - } - err := infoCmd.Run("ghcr.io/devcontainers/features/go:1") - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") - assert.Contains(t, err.Error(), "yaml") -} - func TestInfoCmd_InvalidFeatureReference(t *testing.T) { infoCmd := &InfoCmd{ - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := infoCmd.Run("not a valid reference!!!") require.Error(t, err) diff --git a/cmd/features/output.go b/cmd/features/output.go index 19d5dce20..a34f1f9cc 100644 --- a/cmd/features/output.go +++ b/cmd/features/output.go @@ -7,26 +7,10 @@ import ( ) const ( - outputJSON = "json" - outputText = "text" - outputYAML = "yaml" - headerFeature = "Feature" headerValue = "Value" ) -func validateOutputFormat(format string) error { - if format != outputText && format != outputJSON { - return fmt.Errorf( - "invalid output format %q: must be %q or %q", - format, - outputText, - outputJSON, - ) - } - return nil -} - func writeJSON(w io.Writer, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/cmd/features/package.go b/cmd/features/package.go index ae9a7b1fe..0b687dfe6 100644 --- a/cmd/features/package.go +++ b/cmd/features/package.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/extract" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -20,7 +21,6 @@ type PackageCmd struct { Target string OutputFolder string ForceCleanOutputFolder bool - Output string } type packageResult struct { @@ -58,17 +58,14 @@ and creates gzipped tar archives suitable for OCI distribution.`, &cmd.ForceCleanOutputFolder, "force-clean-output-folder", false, "Clean output folder before writing", ) - packageCmd.Flags().StringVar( - &cmd.Output, "output", "text", - "Output format (text or json)", - ) _ = packageCmd.MarkFlagRequired("target") return packageCmd } func (cmd *PackageCmd) Run() error { - if err := validateOutputFormat(cmd.Output); err != nil { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { return err } @@ -96,7 +93,7 @@ func (cmd *PackageCmd) Run() error { results = append(results, result) } - if cmd.Output == outputJSON { + if mode == output.ModeJSON { return writeJSON(os.Stdout, results) } diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go index 0475abf24..5c6365eb8 100644 --- a/cmd/features/package_test.go +++ b/cmd/features/package_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,10 +30,6 @@ func TestPackageCmd_FlagDefaults(t *testing.T) { forceCleanFlag := cmd.Flags().Lookup("force-clean-output-folder") require.NotNil(t, forceCleanFlag) assert.Equal(t, "false", forceCleanFlag.DefValue) - - outputFlag := cmd.Flags().Lookup("output") - require.NotNil(t, outputFlag) - assert.Equal(t, "text", outputFlag.DefValue) } func TestPackageCmd_AllFlagsRegistered(t *testing.T) { @@ -41,7 +38,6 @@ func TestPackageCmd_AllFlagsRegistered(t *testing.T) { "target", "output-folder", "force-clean-output-folder", - "output", } for _, name := range expected { assert.NotNil(t, cmd.Flags().Lookup(name), "flag %q should be registered", name) @@ -164,7 +160,7 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { Target: targetDir, OutputFolder: outputDir, ForceCleanOutputFolder: true, - Output: outputText, + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() @@ -176,16 +172,6 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { assert.FileExists(t, filepath.Join(outputDir, "devcontainer-feature-feat.tgz")) } -func TestPackageCmd_InvalidOutputFormat(t *testing.T) { - cmd := &PackageCmd{ - Target: "/tmp", - Output: outputYAML, - } - err := cmd.Run() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") -} - func TestPackageCmd_InvalidFeatureID(t *testing.T) { tests := []struct { name string diff --git a/cmd/features/resolvedeps.go b/cmd/features/resolvedeps.go index 622de36d8..fc4cebfa9 100644 --- a/cmd/features/resolvedeps.go +++ b/cmd/features/resolvedeps.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -18,7 +19,6 @@ type ResolveDepsCmd struct { WorkspaceFolder string Config string - Output string } type resolvedFeature struct { @@ -51,16 +51,14 @@ install order based on dependency declarations and install ordering.`, &cmd.Config, "config", "", "Path to specific devcontainer.json (optional)", ) - resolveDepsCmd.Flags().StringVar( - &cmd.Output, "output", "text", "Output format (text or json)", - ) _ = resolveDepsCmd.MarkFlagRequired("workspace-folder") return resolveDepsCmd } func (cmd *ResolveDepsCmd) Run() error { - if err := validateOutputFormat(cmd.Output); err != nil { + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { return err } @@ -76,7 +74,7 @@ func (cmd *ResolveDepsCmd) Run() error { } if len(devContainerConfig.Features) == 0 { - return cmd.printEmpty() + return cmd.printEmpty(mode) } sorted, err := feature.ResolveFeatureOrder(devContainerConfig) @@ -86,14 +84,14 @@ func (cmd *ResolveDepsCmd) Run() error { resolved := buildResolvedList(sorted) - if cmd.Output == outputJSON { + if mode == output.ModeJSON { return writeJSON(os.Stdout, resolved) } return cmd.printText(resolved) } -func (cmd *ResolveDepsCmd) printEmpty() error { - if cmd.Output == outputJSON { +func (cmd *ResolveDepsCmd) printEmpty(mode string) error { + if mode == output.ModeJSON { _, err := fmt.Fprintln(os.Stdout, "[]") return err } diff --git a/cmd/features/resolvedeps_test.go b/cmd/features/resolvedeps_test.go index a972dce1f..cf6c94a42 100644 --- a/cmd/features/resolvedeps_test.go +++ b/cmd/features/resolvedeps_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/devsy-org/devsy/cmd/flags" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -25,7 +26,7 @@ func TestResolveDepsCmd_NoFeatures(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: workspaceDir, - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() @@ -35,7 +36,7 @@ func TestResolveDepsCmd_NoFeatures(t *testing.T) { func TestResolveDepsCmd_MissingWorkspace(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: "/nonexistent/path/12345", - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() @@ -55,25 +56,13 @@ func TestResolveDepsCmd_ExplicitConfig(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: tmpDir, Config: configPath, - Output: outputJSON, + GlobalFlags: &flags.GlobalFlags{ResultFormat: "json"}, } err := cmd.Run() require.NoError(t, err) } -func TestResolveDepsCmd_InvalidOutputFormat(t *testing.T) { - cmd := &ResolveDepsCmd{ - WorkspaceFolder: t.TempDir(), - Output: "yaml", - } - - err := cmd.Run() - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid output format") - assert.Contains(t, err.Error(), "yaml") -} - func TestResolveDepsCmd_WithOptions(t *testing.T) { workspaceDir := t.TempDir() devcontainerDir := filepath.Join(workspaceDir, ".devcontainer") @@ -103,7 +92,7 @@ func TestResolveDepsCmd_WithOptions(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: workspaceDir, - Output: "text", + GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, } err := cmd.Run() diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go index f4e90adbc..b7568391a 100644 --- a/cmd/flags/flags.go +++ b/cmd/flags/flags.go @@ -33,8 +33,8 @@ func SetGlobalFlags(flags *flag.FlagSet) *GlobalFlags { flags.StringVar( &globalFlags.ResultFormat, "result-format", - "json", - "The result output format. Can be json, plain, or auto", + "auto", + "The result output format. Can be json, plain, or auto (auto picks plain on a TTY and json when piped)", ) flags.StringVar( &globalFlags.LogOutput, diff --git a/cmd/ide/list.go b/cmd/ide/list.go index 3304ff7a8..5d57e47a7 100644 --- a/cmd/ide/list.go +++ b/cmd/ide/list.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/ide/ideparse" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -17,8 +18,6 @@ import ( // ListCmd holds the list cmd flags. type ListCmd struct { *flags.GlobalFlags - - Output string } // NewListCmd creates a new command. @@ -36,8 +35,6 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return listCmd } @@ -54,8 +51,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for _, entry := range ideparse.AllowedIDEs { tableEntries = append(tableEntries, []string{ @@ -71,7 +72,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { "Name", "Default", }, tableEntries) - case "json": + case output.ModeJSON: ides := []IDEWithDefault{} for _, entry := range ideparse.AllowedIDEs { ides = append(ides, IDEWithDefault{ @@ -85,11 +86,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/ide/options.go b/cmd/ide/options.go index 30b49c68a..dbcedd98f 100644 --- a/cmd/ide/options.go +++ b/cmd/ide/options.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/ideparse" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -17,8 +18,6 @@ import ( // OptionsCmd holds the options cmd flags. type OptionsCmd struct { *flags.GlobalFlags - - Output string } // NewOptionsCmd creates a new command. @@ -38,8 +37,6 @@ func NewOptionsCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - optionsCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return optionsCmd } @@ -62,8 +59,12 @@ func (cmd *OptionsCmd) Run(ctx context.Context, ide string) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for optionName, entry := range ideOptions { value := values[optionName].Value @@ -84,7 +85,7 @@ func (cmd *OptionsCmd) Run(ctx context.Context, ide string) error { "Default", "Value", }, tableEntries) - case "json": + case output.ModeJSON: options := map[string]optionWithValue{} for optionName, entry := range ideOptions { options[optionName] = optionWithValue{ @@ -98,11 +99,6 @@ func (cmd *OptionsCmd) Run(ctx context.Context, ide string) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/list.go b/cmd/list.go index c5a490ca4..f6c9ce279 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" @@ -18,7 +19,6 @@ import ( type ListCmd struct { *flags.GlobalFlags - Output string SkipPro bool } @@ -37,8 +37,6 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") listCmd.Flags().BoolVar(&cmd.SkipPro, "skip-pro", false, "Don't list pro workspaces") return listCmd } @@ -55,8 +53,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - switch cmd.Output { - case "json": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModeJSON: sort.SliceStable(workspaces, func(i, j int) bool { return workspaces[i].LastUsedTimestamp.Unix() > workspaces[j].LastUsedTimestamp.Unix() }) @@ -65,7 +67,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - case "plain": + case output.ModePlain: tableEntries := [][]string{} sort.SliceStable(workspaces, func(i, j int) bool { return workspaces[i].LastUsedTimestamp.Unix() > workspaces[j].LastUsedTimestamp.Unix() @@ -97,11 +99,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { "Age", "Pro", }, tableEntries) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/machine/list.go b/cmd/machine/list.go index be4975f39..23ad0bed1 100644 --- a/cmd/machine/list.go +++ b/cmd/machine/list.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" @@ -18,8 +19,6 @@ import ( // ListCmd holds the configuration. type ListCmd struct { *flags.GlobalFlags - - Output string } // NewListCmd creates a new list command. @@ -36,8 +35,6 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return listCmd } @@ -58,8 +55,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for _, entry := range entries { machineConfig, err := provider.LoadMachineConfig( @@ -85,7 +86,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { "Provider", "Age", }, tableEntries) - case "json": + case output.ModeJSON: tableEntries := []*provider.Machine{} for _, entry := range entries { machineConfig, err := provider.LoadMachineConfig( @@ -106,11 +107,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/machine/status.go b/cmd/machine/status.go index acf8882f6..bfb5a729f 100644 --- a/cmd/machine/status.go +++ b/cmd/machine/status.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -16,8 +17,6 @@ import ( // StatusCmd holds the configuration. type StatusCmd struct { *flags.GlobalFlags - - Output string } // NewStatusCmd creates a new status command. @@ -33,7 +32,6 @@ func NewStatusCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - statusCmd.Flags().StringVar(&cmd.Output, "output", "plain", "Output format: plain or json") return statusCmd } @@ -55,8 +53,12 @@ func (cmd *StatusCmd) Run(ctx context.Context, args []string) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: switch machineStatus { case client.StatusStopped: log.Infof( @@ -77,7 +79,7 @@ func (cmd *StatusCmd) Run(ctx context.Context, args []string) error { default: log.Infof("Machine '%s' is '%s'", machineClient.Machine(), machineStatus) } - case "json": + case output.ModeJSON: out, err := json.Marshal(struct { ID string `json:"id,omitempty"` Context string `json:"context,omitempty"` @@ -94,11 +96,6 @@ func (cmd *StatusCmd) Run(ctx context.Context, args []string) error { } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/pro/list.go b/cmd/pro/list.go index d9e47c99b..b8674b955 100644 --- a/cmd/pro/list.go +++ b/cmd/pro/list.go @@ -9,6 +9,7 @@ import ( proflags "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/workspace" @@ -19,8 +20,7 @@ import ( type ListCmd struct { proflags.GlobalFlags - Output string - Login bool + Login bool } // NewListCmd creates a new command. @@ -38,8 +38,6 @@ func NewListCmd(flags *proflags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") listCmd.Flags(). BoolVar(&cmd.Login, "login", false, "Check if the user is logged into the pro instance") return listCmd @@ -57,8 +55,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for _, proInstance := range proInstances { entry := []string{ @@ -87,7 +89,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } table.Print(tableHeaders, tableEntries) - case "json": + case output.ModeJSON: tableEntries := []*proTableEntry{} for _, proInstance := range proInstances { entry := &proTableEntry{ @@ -112,11 +114,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/provider/list.go b/cmd/provider/list.go index 9a0a6c6e4..658134334 100644 --- a/cmd/provider/list.go +++ b/cmd/provider/list.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/types" "github.com/devsy-org/devsy/pkg/workspace" @@ -18,8 +19,6 @@ import ( // ListCmd holds the list cmd flags. type ListCmd struct { *flags.GlobalFlags - - Output string } // NewListCmd creates a new command. @@ -37,8 +36,6 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { }, } - listCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return listCmd } @@ -65,8 +62,12 @@ func (cmd *ListCmd) Run(ctx context.Context) error { configuredProviders = map[string]*config.ProviderConfig{} } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: tableEntries := [][]string{} for _, entry := range providers { tableEntries = append(tableEntries, []string{ @@ -88,7 +89,7 @@ func (cmd *ListCmd) Run(ctx context.Context) error { "Initialized", "Description", }, tableEntries) - case "json": + case output.ModeJSON: retMap := map[string]ProviderWithDefault{} for k, entry := range providers { var dynamicOptions map[string]*types.Option @@ -109,11 +110,6 @@ func (cmd *ListCmd) Run(ctx context.Context) error { return err } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/provider/options.go b/cmd/provider/options.go index 3a8ef97d3..ecd829c2c 100644 --- a/cmd/provider/options.go +++ b/cmd/provider/options.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "maps" + "os" "sort" "strconv" @@ -12,6 +13,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/types" "github.com/devsy-org/devsy/pkg/workspace" @@ -23,7 +25,6 @@ type OptionsCmd struct { *flags.GlobalFlags Hidden bool - Output string } // NewOptionsCmd creates a new command. @@ -51,8 +52,6 @@ func NewOptionsCmd(flags *flags.GlobalFlags) *cobra.Command { optionsCmd.Flags(). BoolVar(&cmd.Hidden, "hidden", false, "If true, will also show hidden options.") - optionsCmd.Flags(). - StringVar(&cmd.Output, "output", "plain", "The output format to use. Can be json or plain") return optionsCmd } @@ -93,7 +92,11 @@ func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { return err } - return printOptions(devsyConfig, providerWithOptions, cmd.Output, cmd.Hidden) + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + return printOptions(devsyConfig, providerWithOptions, mode, cmd.Hidden) } func printOptions( @@ -105,60 +108,76 @@ func printOptions( entryOptions := devsyConfig.ProviderOptions(provider.Config.Name) dynamicOptions := devsyConfig.DynamicProviderOptionDefinitions(provider.Config.Name) srcOptions := MergeDynamicOptions(provider.Config.Options, dynamicOptions) - if format == "plain" { - tableEntries := [][]string{} - for optionName, entry := range srcOptions { - if !showHidden && entry.Hidden { - continue - } - - value := entryOptions[optionName].Value - if value != "" && entry.Password { - value = "********" - } - - tableEntries = append(tableEntries, []string{ - optionName, - strconv.FormatBool(entry.Required), - entry.Description, - entry.Default, - value, - }) + switch format { + case output.ModePlain: + printOptionsPlain(srcOptions, entryOptions, showHidden) + case output.ModeJSON: + return printOptionsJSON(srcOptions, entryOptions, showHidden) + } + + return nil +} + +func printOptionsPlain( + srcOptions map[string]*types.Option, + entryOptions map[string]config.OptionValue, + showHidden bool, +) { + tableEntries := [][]string{} + for optionName, entry := range srcOptions { + if !showHidden && entry.Hidden { + continue } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + + value := entryOptions[optionName].Value + if value != "" && entry.Password { + value = "********" + } + + tableEntries = append(tableEntries, []string{ + optionName, + strconv.FormatBool(entry.Required), + entry.Description, + entry.Default, + value, }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) + + table.Print([]string{ + "Name", + "Required", + "Description", + "Default", + "Value", + }, tableEntries) +} - table.Print([]string{ - "Name", - "Required", - "Description", - "Default", - "Value", - }, tableEntries) - } else if format == "json" { - options := map[string]optionWithValue{} - for optionName, entry := range srcOptions { - if !showHidden && entry.Hidden { - continue - } - - options[optionName] = optionWithValue{ - Option: *entry, - Children: entryOptions[optionName].Children, - Value: entryOptions[optionName].Value, - } +func printOptionsJSON( + srcOptions map[string]*types.Option, + entryOptions map[string]config.OptionValue, + showHidden bool, +) error { + options := map[string]optionWithValue{} + for optionName, entry := range srcOptions { + if !showHidden && entry.Hidden { + continue } - out, err := json.MarshalIndent(options, "", " ") - if err != nil { - return err + options[optionName] = optionWithValue{ + Option: *entry, + Children: entryOptions[optionName].Children, + Value: entryOptions[optionName].Value, } - fmt.Print(string(out)) - } else { - return fmt.Errorf("unexpected output format, choose either json or plain. Got %s", format) } + out, err := json.MarshalIndent(options, "", " ") + if err != nil { + return err + } + _, _ = os.Stdout.Write(out) return nil } diff --git a/cmd/setup.go b/cmd/setup.go index 0c0676c10..33a273c05 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -70,7 +70,11 @@ func NewSetUpCmd(f *flags.GlobalFlags) *cobra.Command { // Run executes the set-up command logic. func (cmd *SetUpCmd) Run(ctx context.Context) error { - emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + emitJSON := mode == output.ModeJSON helper := &docker.DockerHelper{DockerCommand: cmd.resolveDockerPath()} diff --git a/cmd/status.go b/cmd/status.go index f3b20a0c2..0abdf0816 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/output" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -21,7 +22,6 @@ type StatusCmd struct { *flags.GlobalFlags client2.StatusOptions - Output string Timeout string } @@ -73,7 +73,6 @@ func NewStatusCmd(flags *flags.GlobalFlags) *cobra.Command { statusCmd.Flags(). BoolVar(&cmd.ContainerStatus, "container-status", true, "If enabled shows the workspace container status as well") - statusCmd.Flags().StringVar(&cmd.Output, "output", "plain", "Status shows the workspace status") statusCmd.Flags(). StringVar(&cmd.Timeout, "timeout", "30s", "The timeout to wait until the status can be retrieved") return statusCmd @@ -102,8 +101,12 @@ func (cmd *StatusCmd) Run( return err } - switch cmd.Output { - case "plain": + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + switch mode { + case output.ModePlain: switch instanceStatus { case client2.StatusStopped: log.Infof( @@ -129,7 +132,7 @@ func (cmd *StatusCmd) Run( default: log.Infof("Workspace '%s' is '%s'", client.Workspace(), instanceStatus) } - case "json": + case output.ModeJSON: out, err := json.Marshal(&client2.WorkspaceStatus{ ID: client.Workspace(), Context: client.Context(), @@ -141,11 +144,6 @@ func (cmd *StatusCmd) Run( } fmt.Print(string(out)) - default: - return fmt.Errorf( - "unexpected output format, choose either json or plain. Got %s", - cmd.Output, - ) } return nil diff --git a/cmd/up/up.go b/cmd/up/up.go index 9ba00bf35..8ffc1becd 100644 --- a/cmd/up/up.go +++ b/cmd/up/up.go @@ -69,7 +69,11 @@ func (cmd *UpCmd) Run( //nolint:cyclop ) error { cmd.prepareWorkspace(client) - emitJSON := output.ResolveMode(cmd.ResultFormat) == output.ModeJSON + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + emitJSON := mode == output.ModeJSON wctx, err := cmd.executeDevsyUp(ctx, devsyConfig, client) if err != nil { diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 541148a5d..c3e465d93 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -2,7 +2,7 @@ "use strict" // Mock devsy binary for e2e tests (cross-platform Node.js version) // Returns canned JSON responses for each subcommand -// Handles --output json, --skip-pro, and other flags the app sends +// Handles --result-format json, --skip-pro, and other flags the app sends // Persists state to a JSON file so provider/workspace CRUD works across calls const fs = require("node:fs") @@ -104,7 +104,7 @@ let nameFlag = "" let i = 0 while (i < args.length) { const arg = args[i] - if (arg === "--output") { + if (arg === "--result-format") { i += 2 continue } diff --git a/desktop/src/main/__tests__/cli.test.ts b/desktop/src/main/__tests__/cli.test.ts index 5eefb215f..8ea522d9d 100644 --- a/desktop/src/main/__tests__/cli.test.ts +++ b/desktop/src/main/__tests__/cli.test.ts @@ -35,7 +35,7 @@ describe("CliRunner", () => { expect(result).toEqual([{ id: "ws-1" }]) expect(mockExecFile).toHaveBeenCalledWith( "/usr/local/bin/devsy", - ["list", "--skip-pro", "--output", "json"], + ["list", "--skip-pro", "--result-format", "json"], expect.objectContaining({ env: expect.any(Object) }), expect.any(Function), ) @@ -92,7 +92,7 @@ describe("CliRunner", () => { await jsCli.run(["list"]) expect(mockExecFile).toHaveBeenCalledWith( "node", - ["/tmp/mock.cjs", "list", "--output", "json"], + ["/tmp/mock.cjs", "list", "--result-format", "json"], expect.objectContaining({ env: expect.any(Object) }), expect.any(Function), ) diff --git a/desktop/src/main/cli.ts b/desktop/src/main/cli.ts index 608271f96..0d2061b81 100644 --- a/desktop/src/main/cli.ts +++ b/desktop/src/main/cli.ts @@ -71,7 +71,7 @@ export class CliRunner { async run(args: string[]): Promise { await this.acquire() try { - const fullArgs = [...this.prefixArgs, ...args, "--output", "json"] + const fullArgs = [...this.prefixArgs, ...args, "--result-format", "json"] const { stdout } = await execFile(this.execPath, fullArgs, { env: this.env, }) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 5c746810a..0caff7ec0 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -59,7 +59,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M return cli.runRaw([ "status", args.workspaceId, - "--output", + "--result-format", "json", "--timeout", "5s", @@ -219,7 +219,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProcesses: M }) ipcMain.handle("machine_status", async (_event, args: { id: string }) => { - return cli.runRaw(["machine", "status", args.id, "--output", "json"]) + return cli.runRaw(["machine", "status", args.id, "--result-format", "json"]) }) // ── Contexts ── diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 6d92b2542..29f2b46f4 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -46,7 +46,7 @@ func (f *Framework) DevsyListParsed(ctx context.Context) ([]*provider2.Workspace // DevsyList executes the `devsy list` command in the test framework. func (f *Framework) DevsyList(ctx context.Context) (string, error) { - listArgs := []string{"list", "--output", "json"} + listArgs := []string{"list", "--result-format", "json"} out, _, err := f.ExecCommandCapture(ctx, listArgs) if err != nil { @@ -209,7 +209,7 @@ func (f *Framework) DevsyStatus( ctx context.Context, extraArgs ...string, ) (client.WorkspaceStatus, error) { - baseArgs := []string{"status", "--output", "json"} + baseArgs := []string{"status", "--result-format", "json"} baseArgs = append(baseArgs, extraArgs...) stdout, err := f.ExecCommandOutput(ctx, baseArgs) if err != nil { @@ -308,7 +308,7 @@ func (f *Framework) DevsyProviderOptionsJSON( ctx context.Context, providerName string, ) (string, error) { - args := []string{"provider", "options", providerName, "--output", "json"} + args := []string{"provider", "options", providerName, "--result-format", "json"} stdout, _, err := f.ExecCommandCapture(ctx, args) if err != nil { return "", fmt.Errorf("devsy provider options failed: %s", err.Error()) diff --git a/e2e/tests/context/context.go b/e2e/tests/context/context.go index e99cbb95e..18a0615ed 100644 --- a/e2e/tests/context/context.go +++ b/e2e/tests/context/context.go @@ -74,7 +74,7 @@ var _ = ginkgo.Describe( err = f.DevsyIDEUse(ctx, ideIntelliJ, "--context", contextB) framework.ExpectNoError(err) - output, err := f.DevsyIDEList(ctx, "--output", "json") + output, err := f.DevsyIDEList(ctx, "--result-format", "json") framework.ExpectNoError(err) var ides []map[string]any @@ -90,7 +90,7 @@ var _ = ginkgo.Describe( } } - output, err = f.DevsyIDEList(ctx, "--context", contextB, "--output", "json") + output, err = f.DevsyIDEList(ctx, "--context", contextB, "--result-format", "json") framework.ExpectNoError(err) err = json.Unmarshal([]byte(output), &ides) @@ -113,7 +113,7 @@ var _ = ginkgo.Describe( ginkgo.GinkgoT().Setenv("DEVSY_CONTEXT", contextB) - output, err = f.DevsyIDEList(ctx, "--output", "json") + output, err = f.DevsyIDEList(ctx, "--result-format", "json") framework.ExpectNoError(err) err = json.Unmarshal([]byte(output), &ides) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 5ca3665ad..955ffed8b 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -122,29 +122,13 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "resolve-dependencies", "--workspace-folder", workspaceDir, - "--output", "json", + "--result-format", "json", }) framework.ExpectNoError(err) var result []map[string]any gomega.Expect(json.Unmarshal([]byte(stdout), &result)).To(gomega.Succeed()) }, ginkgo.SpecTimeout(framework.TimeoutShort())) - - ginkgo.It("rejects invalid output format", func(ctx context.Context) { - f := framework.NewDefaultFramework(initialDir + "/bin") - - workspaceDir, err := os.MkdirTemp("", "e2e-resolve-deps-invalid-*") - framework.ExpectNoError(err) - ginkgo.DeferCleanup(func() { _ = os.RemoveAll(workspaceDir) }) - - _, stderr, err := f.ExecCommandCapture(ctx, []string{ - "features", "resolve-dependencies", - "--workspace-folder", workspaceDir, - "--output", "yaml", - }) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(stderr).To(gomega.ContainSubstring("invalid output format")) - }, ginkgo.SpecTimeout(framework.TimeoutShort())) }) ginkgo.Describe("features generate-docs", func() { @@ -275,7 +259,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { }) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", featureRef, "--output", "json", + "features", "info", featureRef, "--result-format", "json", }) framework.ExpectNoError(err) @@ -333,7 +317,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { gomega.Expect(manifest).To(gomega.HaveKey("layers")) }, ginkgo.SpecTimeout(framework.TimeoutShort())) - ginkgo.It("returns text output with --output=text", func(ctx context.Context) { + ginkgo.It("returns text output with --result-format=plain", func(ctx context.Context) { f := framework.NewDefaultFramework(initialDir + "/bin") srv := httptest.NewServer(registry.New()) @@ -345,7 +329,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRef, nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", subCmdManifest, featureRef, "--output", "text", + "features", "info", subCmdManifest, featureRef, "--result-format", "plain", }) framework.ExpectNoError(err) @@ -450,7 +434,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", subCmdTags, featureRepo + ":1.0.0", - "--output", "json", + "--result-format", "json", }) framework.ExpectNoError(err) diff --git a/e2e/tests/features/features_package_cmd.go b/e2e/tests/features/features_package_cmd.go index 919dd48bc..d784c74be 100644 --- a/e2e/tests/features/features_package_cmd.go +++ b/e2e/tests/features/features_package_cmd.go @@ -19,7 +19,7 @@ const ( flagTarget = "--target" flagOutputFolder = "--output-folder" flagForceCleanOutput = "--force-clean-output-folder" - flagOutput = "--output" + flagOutput = "--result-format" outputJSON = "json" featureNameGo = "go" featureNameNode = "node" diff --git a/pkg/output/mode.go b/pkg/output/mode.go index c4166fd44..a1e81986c 100644 --- a/pkg/output/mode.go +++ b/pkg/output/mode.go @@ -1,24 +1,31 @@ package output -import "github.com/devsy-org/devsy/pkg/terminal" +import ( + "fmt" + + "github.com/devsy-org/devsy/pkg/terminal" +) const ( ModeJSON = "json" ModePlain = "plain" ) -func ResolveMode(flagValue string) string { +func ResolveMode(flagValue string) (string, error) { switch flagValue { case ModeJSON: - return ModeJSON + return ModeJSON, nil case ModePlain: - return ModePlain + return ModePlain, nil case "auto": if !terminal.IsTerminalOut { - return ModeJSON + return ModeJSON, nil } - return ModePlain + return ModePlain, nil default: - return ModeJSON + return "", fmt.Errorf( + "unexpected output format, choose json, plain, or auto. Got %q", + flagValue, + ) } } diff --git a/pkg/output/mode_test.go b/pkg/output/mode_test.go index fb7b85b53..880e7e44a 100644 --- a/pkg/output/mode_test.go +++ b/pkg/output/mode_test.go @@ -7,14 +7,20 @@ import ( ) func TestResolveMode_JSON(t *testing.T) { - got := ResolveMode("json") + got, err := ResolveMode("json") + if err != nil { + t.Fatalf("ResolveMode(\"json\") returned error: %v", err) + } if got != ModeJSON { t.Errorf("ResolveMode(\"json\") = %q, want %q", got, ModeJSON) } } func TestResolveMode_Plain(t *testing.T) { - got := ResolveMode("plain") + got, err := ResolveMode("plain") + if err != nil { + t.Fatalf("ResolveMode(\"plain\") returned error: %v", err) + } if got != ModePlain { t.Errorf("ResolveMode(\"plain\") = %q, want %q", got, ModePlain) } @@ -25,7 +31,10 @@ func TestResolveMode_Auto_NonTTY(t *testing.T) { terminal.IsTerminalOut = false defer func() { terminal.IsTerminalOut = orig }() - got := ResolveMode("auto") + got, err := ResolveMode("auto") + if err != nil { + t.Fatalf("ResolveMode(\"auto\") returned error: %v", err) + } if got != ModeJSON { t.Errorf("ResolveMode(\"auto\") with non-TTY = %q, want %q", got, ModeJSON) } @@ -36,15 +45,21 @@ func TestResolveMode_Auto_TTY(t *testing.T) { terminal.IsTerminalOut = true defer func() { terminal.IsTerminalOut = orig }() - got := ResolveMode("auto") + got, err := ResolveMode("auto") + if err != nil { + t.Fatalf("ResolveMode(\"auto\") returned error: %v", err) + } if got != ModePlain { t.Errorf("ResolveMode(\"auto\") with TTY = %q, want %q", got, ModePlain) } } func TestResolveMode_InvalidValue(t *testing.T) { - got := ResolveMode("bogus") - if got != ModeJSON { - t.Errorf("ResolveMode(\"bogus\") = %q, want %q", got, ModeJSON) + got, err := ResolveMode("bogus") + if err == nil { + t.Fatalf("ResolveMode(\"bogus\") expected error, got nil (value=%q)", got) + } + if got != "" { + t.Errorf("ResolveMode(\"bogus\") = %q, want empty string", got) } } From 22313391eb634088e1a5f18d090166d5a6f6b7ae Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 06:59:29 -0500 Subject: [PATCH 2/7] fix(cmd): satisfy goconst lint after format refactor Use output.ModePlain/output.ModeJSON constants in features test files instead of repeated "plain"/"json" string literals, and reuse the existing flagOutput constant in e2e features tests. Introduce a flagResultFormat constant in e2e/framework/command.go. --- cmd/features/generatedocs_test.go | 7 ++++--- cmd/features/info_manifest_test.go | 3 ++- cmd/features/info_tags_test.go | 3 ++- cmd/features/info_test.go | 3 ++- cmd/features/package_test.go | 3 ++- cmd/features/resolvedeps_test.go | 9 +++++---- e2e/framework/command.go | 8 +++++--- e2e/tests/features/features.go | 8 ++++---- 8 files changed, 26 insertions(+), 18 deletions(-) diff --git a/cmd/features/generatedocs_test.go b/cmd/features/generatedocs_test.go index 477567a41..028db4756 100644 --- a/cmd/features/generatedocs_test.go +++ b/cmd/features/generatedocs_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -41,7 +42,7 @@ func TestGenerateDocsCmd_Run(t *testing.T) { ProjectFolder: projectDir, OutputFolder: outputDir, Namespace: "ghcr.io/test/features", - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() @@ -79,7 +80,7 @@ func TestGenerateDocsCmd_NoFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() @@ -111,7 +112,7 @@ func TestGenerateDocsCmd_MultipleFeatures(t *testing.T) { cmd := &GenerateDocsCmd{ ProjectFolder: projectDir, OutputFolder: outputDir, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() diff --git a/cmd/features/info_manifest_test.go b/cmd/features/info_manifest_test.go index 3070d09ae..6116f4d9c 100644 --- a/cmd/features/info_manifest_test.go +++ b/cmd/features/info_manifest_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,7 +35,7 @@ func TestInfoManifestCmd_RequiresExactlyOneArg(t *testing.T) { } func TestInfoManifestCmd_InvalidFeatureReference(t *testing.T) { - cmd := &InfoManifestCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: "json"}} + cmd := &InfoManifestCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModeJSON}} err := cmd.Run("not a valid reference!!!") require.Error(t, err) assert.Contains(t, err.Error(), "invalid feature reference") diff --git a/cmd/features/info_tags_test.go b/cmd/features/info_tags_test.go index d1bd58f83..e8d7d8805 100644 --- a/cmd/features/info_tags_test.go +++ b/cmd/features/info_tags_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -17,7 +18,7 @@ func TestInfoTagsCmd_RequiresExactlyOneArg(t *testing.T) { } func TestInfoTagsCmd_InvalidFeatureReference(t *testing.T) { - cmd := &InfoTagsCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}} + cmd := &InfoTagsCmd{GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}} err := cmd.Run("not a valid reference!!!") require.Error(t, err) assert.Contains(t, err.Error(), "invalid feature reference") diff --git a/cmd/features/info_test.go b/cmd/features/info_test.go index 6b15a2fc1..0e10dc741 100644 --- a/cmd/features/info_test.go +++ b/cmd/features/info_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -38,7 +39,7 @@ func TestInfoCmd_RequiresExactlyOneArg(t *testing.T) { func TestInfoCmd_InvalidFeatureReference(t *testing.T) { infoCmd := &InfoCmd{ - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := infoCmd.Run("not a valid reference!!!") require.Error(t, err) diff --git a/cmd/features/package_test.go b/cmd/features/package_test.go index 5c6365eb8..c53713399 100644 --- a/cmd/features/package_test.go +++ b/cmd/features/package_test.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -160,7 +161,7 @@ func TestPackageCmd_ForceCleanOutputFolder(t *testing.T) { Target: targetDir, OutputFolder: outputDir, ForceCleanOutputFolder: true, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() diff --git a/cmd/features/resolvedeps_test.go b/cmd/features/resolvedeps_test.go index cf6c94a42..d805cfbb9 100644 --- a/cmd/features/resolvedeps_test.go +++ b/cmd/features/resolvedeps_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/output" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -26,7 +27,7 @@ func TestResolveDepsCmd_NoFeatures(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: workspaceDir, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() @@ -36,7 +37,7 @@ func TestResolveDepsCmd_NoFeatures(t *testing.T) { func TestResolveDepsCmd_MissingWorkspace(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: "/nonexistent/path/12345", - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() @@ -56,7 +57,7 @@ func TestResolveDepsCmd_ExplicitConfig(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: tmpDir, Config: configPath, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "json"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModeJSON}, } err := cmd.Run() @@ -92,7 +93,7 @@ func TestResolveDepsCmd_WithOptions(t *testing.T) { cmd := &ResolveDepsCmd{ WorkspaceFolder: workspaceDir, - GlobalFlags: &flags.GlobalFlags{ResultFormat: "plain"}, + GlobalFlags: &flags.GlobalFlags{ResultFormat: output.ModePlain}, } err := cmd.Run() diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 29f2b46f4..845ab5ac7 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -13,6 +13,8 @@ import ( "github.com/devsy-org/devsy/pkg/workspace" ) +const flagResultFormat = "--result-format" + func (f *Framework) FindWorkspace(ctx context.Context, id string) (*provider2.Workspace, error) { list, err := f.DevsyListParsed(ctx) if err != nil { @@ -46,7 +48,7 @@ func (f *Framework) DevsyListParsed(ctx context.Context) ([]*provider2.Workspace // DevsyList executes the `devsy list` command in the test framework. func (f *Framework) DevsyList(ctx context.Context) (string, error) { - listArgs := []string{"list", "--result-format", "json"} + listArgs := []string{"list", flagResultFormat, "json"} out, _, err := f.ExecCommandCapture(ctx, listArgs) if err != nil { @@ -209,7 +211,7 @@ func (f *Framework) DevsyStatus( ctx context.Context, extraArgs ...string, ) (client.WorkspaceStatus, error) { - baseArgs := []string{"status", "--result-format", "json"} + baseArgs := []string{"status", flagResultFormat, "json"} baseArgs = append(baseArgs, extraArgs...) stdout, err := f.ExecCommandOutput(ctx, baseArgs) if err != nil { @@ -308,7 +310,7 @@ func (f *Framework) DevsyProviderOptionsJSON( ctx context.Context, providerName string, ) (string, error) { - args := []string{"provider", "options", providerName, "--result-format", "json"} + args := []string{"provider", "options", providerName, flagResultFormat, "json"} stdout, _, err := f.ExecCommandCapture(ctx, args) if err != nil { return "", fmt.Errorf("devsy provider options failed: %s", err.Error()) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index 955ffed8b..ac90933b7 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -122,7 +122,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "resolve-dependencies", "--workspace-folder", workspaceDir, - "--result-format", "json", + flagOutput, outputJSON, }) framework.ExpectNoError(err) @@ -259,7 +259,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { }) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", featureRef, "--result-format", "json", + "features", "info", featureRef, flagOutput, outputJSON, }) framework.ExpectNoError(err) @@ -329,7 +329,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRef, nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", subCmdManifest, featureRef, "--result-format", "plain", + "features", "info", subCmdManifest, featureRef, flagOutput, "plain", }) framework.ExpectNoError(err) @@ -434,7 +434,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", subCmdTags, featureRepo + ":1.0.0", - "--result-format", "json", + flagOutput, outputJSON, }) framework.ExpectNoError(err) From 629e503beae82c637e87ef95165b237acb9eee53 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 07:04:00 -0500 Subject: [PATCH 3/7] fix(e2e): extract formatJSON const for goconst --- e2e/framework/command.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/framework/command.go b/e2e/framework/command.go index 845ab5ac7..ebb81fe80 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -13,7 +13,10 @@ import ( "github.com/devsy-org/devsy/pkg/workspace" ) -const flagResultFormat = "--result-format" +const ( + flagResultFormat = "--result-format" + formatJSON = "json" +) func (f *Framework) FindWorkspace(ctx context.Context, id string) (*provider2.Workspace, error) { list, err := f.DevsyListParsed(ctx) @@ -48,7 +51,7 @@ func (f *Framework) DevsyListParsed(ctx context.Context) ([]*provider2.Workspace // DevsyList executes the `devsy list` command in the test framework. func (f *Framework) DevsyList(ctx context.Context) (string, error) { - listArgs := []string{"list", flagResultFormat, "json"} + listArgs := []string{"list", flagResultFormat, formatJSON} out, _, err := f.ExecCommandCapture(ctx, listArgs) if err != nil { @@ -211,7 +214,7 @@ func (f *Framework) DevsyStatus( ctx context.Context, extraArgs ...string, ) (client.WorkspaceStatus, error) { - baseArgs := []string{"status", flagResultFormat, "json"} + baseArgs := []string{"status", flagResultFormat, formatJSON} baseArgs = append(baseArgs, extraArgs...) stdout, err := f.ExecCommandOutput(ctx, baseArgs) if err != nil { @@ -310,7 +313,7 @@ func (f *Framework) DevsyProviderOptionsJSON( ctx context.Context, providerName string, ) (string, error) { - args := []string{"provider", "options", providerName, flagResultFormat, "json"} + args := []string{"provider", "options", providerName, flagResultFormat, formatJSON} stdout, _, err := f.ExecCommandCapture(ctx, args) if err != nil { return "", fmt.Errorf("devsy provider options failed: %s", err.Error()) From e0fedf109cb6f4f976ecaf1eea5e75e0d128fedc Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 07:09:37 -0500 Subject: [PATCH 4/7] fix(e2e): extract cmdList const for goconst --- e2e/framework/command.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/framework/command.go b/e2e/framework/command.go index ebb81fe80..bf4199523 100644 --- a/e2e/framework/command.go +++ b/e2e/framework/command.go @@ -16,6 +16,7 @@ import ( const ( flagResultFormat = "--result-format" formatJSON = "json" + cmdList = "list" ) func (f *Framework) FindWorkspace(ctx context.Context, id string) (*provider2.Workspace, error) { @@ -51,7 +52,7 @@ func (f *Framework) DevsyListParsed(ctx context.Context) ([]*provider2.Workspace // DevsyList executes the `devsy list` command in the test framework. func (f *Framework) DevsyList(ctx context.Context) (string, error) { - listArgs := []string{"list", flagResultFormat, formatJSON} + listArgs := []string{cmdList, flagResultFormat, formatJSON} out, _, err := f.ExecCommandCapture(ctx, listArgs) if err != nil { @@ -189,7 +190,7 @@ func (f *Framework) DevsyProviderOptionsCheckNamespaceDescription( } func (f *Framework) DevsyProviderList(ctx context.Context, extraArgs ...string) error { - baseArgs := []string{"provider", "list"} + baseArgs := []string{"provider", cmdList} err := f.ExecCommand(ctx, false, true, "", append(baseArgs, extraArgs...)) if err != nil { return fmt.Errorf("devsy provider list failed: %s", err.Error()) @@ -505,7 +506,7 @@ func (f *Framework) DevsyLogs(ctx context.Context, workspace string) (string, er } func (f *Framework) DevsyIDEList(ctx context.Context, extraArgs ...string) (string, error) { - baseArgs := []string{"ide", "list"} + baseArgs := []string{"ide", cmdList} return f.ExecCommandOutput(ctx, append(baseArgs, extraArgs...)) } From 5202d5974dd7c062885de89aa32ad4b7150de5e9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 07:45:06 -0500 Subject: [PATCH 5/7] test(e2e): pin features tests to plain format in non-TTY CI --- e2e/tests/features/features.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index ac90933b7..beb1eb388 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -83,6 +83,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "resolve-dependencies", "--workspace-folder", workspaceDir, + flagOutput, "plain", }) framework.ExpectNoError(err) @@ -162,6 +163,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { "features", "generate-docs", "--project-folder", projectDir, "--output-folder", outputDir, + flagOutput, "plain", }) framework.ExpectNoError(err) @@ -209,6 +211,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { "--project-folder", projectDir, "--output-folder", outputDir, "--namespace", "ghcr.io/test/features", + flagOutput, "plain", }) framework.ExpectNoError(err) @@ -237,6 +240,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", featureRef, + flagOutput, "plain", }) framework.ExpectNoError(err) @@ -411,6 +415,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", subCmdTags, featureRepo + ":1.0.0", + flagOutput, "plain", }) framework.ExpectNoError(err) From 24d039f15ce0f52ce6f2768c38c3a8f83c0bdcec Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 08:11:42 -0500 Subject: [PATCH 6/7] fix(e2e): extract outputPlain const for goconst --- e2e/tests/features/features.go | 12 ++++++------ e2e/tests/features/features_package_cmd.go | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e/tests/features/features.go b/e2e/tests/features/features.go index beb1eb388..4842316aa 100644 --- a/e2e/tests/features/features.go +++ b/e2e/tests/features/features.go @@ -83,7 +83,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "resolve-dependencies", "--workspace-folder", workspaceDir, - flagOutput, "plain", + flagOutput, outputPlain, }) framework.ExpectNoError(err) @@ -163,7 +163,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { "features", "generate-docs", "--project-folder", projectDir, "--output-folder", outputDir, - flagOutput, "plain", + flagOutput, outputPlain, }) framework.ExpectNoError(err) @@ -211,7 +211,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { "--project-folder", projectDir, "--output-folder", outputDir, "--namespace", "ghcr.io/test/features", - flagOutput, "plain", + flagOutput, outputPlain, }) framework.ExpectNoError(err) @@ -240,7 +240,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", featureRef, - flagOutput, "plain", + flagOutput, outputPlain, }) framework.ExpectNoError(err) @@ -333,7 +333,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { pushFeatureWithAnnotations(featureRef, nil) stdout, _, err := f.ExecCommandCapture(ctx, []string{ - "features", "info", subCmdManifest, featureRef, flagOutput, "plain", + "features", "info", subCmdManifest, featureRef, flagOutput, outputPlain, }) framework.ExpectNoError(err) @@ -415,7 +415,7 @@ var _ = ginkgo.Describe("features commands", ginkgo.Label("features"), func() { stdout, _, err := f.ExecCommandCapture(ctx, []string{ "features", "info", subCmdTags, featureRepo + ":1.0.0", - flagOutput, "plain", + flagOutput, outputPlain, }) framework.ExpectNoError(err) diff --git a/e2e/tests/features/features_package_cmd.go b/e2e/tests/features/features_package_cmd.go index d784c74be..7b0cf3e68 100644 --- a/e2e/tests/features/features_package_cmd.go +++ b/e2e/tests/features/features_package_cmd.go @@ -21,6 +21,7 @@ const ( flagForceCleanOutput = "--force-clean-output-folder" flagOutput = "--result-format" outputJSON = "json" + outputPlain = "plain" featureNameGo = "go" featureNameNode = "node" featureVersion100 = "1.0.0" From b67afe7d861963b92da8fbb70eea084134ebf0e8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 25 May 2026 09:14:54 -0500 Subject: [PATCH 7/7] fix(cmd): validate --result-format before mutating workspace state Hoist output.ResolveMode out of the middle of BuildCmd.build and ExecCmd.Run so an invalid format value fails fast instead of after acquiring the workspace lock or probing the running container. Addresses CodeRabbit comments on build.go:229 and exec.go:176. --- cmd/build.go | 15 +++++++-------- cmd/exec.go | 4 ++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 27889c9a4..57ef5210c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -210,14 +210,18 @@ func (cmd *BuildCmd) build( ctx context.Context, workspaceClient client.WorkspaceClient, ) error { - err := workspaceClient.Lock(ctx) + mode, err := output.ResolveMode(cmd.ResultFormat) if err != nil { return err } + emitJSON := mode == output.ModeJSON + + if err = workspaceClient.Lock(ctx); err != nil { + return err + } defer workspaceClient.Unlock() - err = clientimplementation.StartWait(ctx, workspaceClient, true) - if err != nil { + if err = clientimplementation.StartWait(ctx, workspaceClient, true); err != nil { return err } @@ -226,11 +230,6 @@ func (cmd *BuildCmd) build( log.Debugf("done building devcontainer") log.Infof("cleaning up temporary workspace") }() - mode, err := output.ResolveMode(cmd.ResultFormat) - if err != nil { - return err - } - emitJSON := mode == output.ModeJSON result, err := clientimplementation.BuildAgentClient( ctx, diff --git a/cmd/exec.go b/cmd/exec.go index e7b0ae55d..5baba6a42 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -132,6 +132,10 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { return err } + if _, err := output.ResolveMode(cmd.ResultFormat); err != nil { + return err + } + if cmd.ContainerID != "" { return cmd.runWithContainerID(ctx, args) }