diff --git a/internal/commands/activity.go b/internal/commands/activity.go index 86c6a2d..c509399 100644 --- a/internal/commands/activity.go +++ b/internal/commands/activity.go @@ -37,7 +37,7 @@ func newActivityListCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(events, fmt.Sprintf("%d events", len(events)), output.Breadcrumb{Action: "stats", Cmd: "dhq activity stats"}, )) @@ -76,7 +76,7 @@ func newActivityStatsCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, "Account activity with stats", output.Breadcrumb{Action: "events", Cmd: "dhq activity list"}, )) diff --git a/internal/commands/assist.go b/internal/commands/assist.go index 8d65364..4b1dc57 100644 --- a/internal/commands/assist.go +++ b/internal/commands/assist.go @@ -85,7 +85,7 @@ All data stays on your machine — nothing is sent to external services.`, messages := assist.BuildMessages(contextStr, question) // Stream to TTY, or return complete response for JSON/pipe - if env.IsTTY && !env.JSONMode && !noStream { + if env.IsTTY && !env.WantsJSON() && !noStream { env.Status("") fmt.Fprint(env.Stderr, "✨ ") //nolint:errcheck return ollama.ChatStream(cliCtx.Background(), messages, env.Stderr) @@ -96,7 +96,7 @@ All data stays on your machine — nothing is sent to external services.`, return err } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse( map[string]string{"answer": response, "model": ollama.Model}, "assist response", diff --git a/internal/commands/auto_deploys.go b/internal/commands/auto_deploys.go index cdbdcca..182693e 100644 --- a/internal/commands/auto_deploys.go +++ b/internal/commands/auto_deploys.go @@ -34,7 +34,7 @@ Use these commands to enable, disable, and list auto-deploy configuration per se return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, fmt.Sprintf("Auto deploy URL: %s", result.WebhookURL))) } env.Status("Webhook URL: %s", result.WebhookURL) @@ -80,7 +80,7 @@ func newAutoDeploysEnableCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { action := "Enabled" if disable { action = "Disabled" diff --git a/internal/commands/build_cache_files.go b/internal/commands/build_cache_files.go index 0901a2a..f9bace1 100644 --- a/internal/commands/build_cache_files.go +++ b/internal/commands/build_cache_files.go @@ -33,7 +33,7 @@ The cache can be bypassed for a single deploy with "dhq deployments create --use return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(files, fmt.Sprintf("%d build cache files", len(files)))) } rows := make([][]string, len(files)) @@ -91,7 +91,7 @@ func newBuildCacheFilesCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(f, fmt.Sprintf("Created: %s", f.Path))) } env.Status("Created build cache file: %s", f.Path) diff --git a/internal/commands/build_commands.go b/internal/commands/build_commands.go index bd9f27f..d754360 100644 --- a/internal/commands/build_commands.go +++ b/internal/commands/build_commands.go @@ -35,7 +35,7 @@ The output of build commands becomes the artifact that gets deployed. Each comma return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(cmds, fmt.Sprintf("%d build commands", len(cmds)), output.Breadcrumb{Action: "update", Cmd: "dhq build-commands update -p --command "}, output.Breadcrumb{Action: "delete", Cmd: "dhq build-commands delete -p "}, diff --git a/internal/commands/build_configs.go b/internal/commands/build_configs.go index 1af2584..21a3b01 100644 --- a/internal/commands/build_configs.go +++ b/internal/commands/build_configs.go @@ -32,7 +32,7 @@ func newBuildConfigsCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(configs, fmt.Sprintf("%d build configs", len(configs)))) } rows := make([][]string, len(configs)) @@ -131,7 +131,7 @@ func newBuildConfigsCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(config, fmt.Sprintf("Created: %s", config.Identifier))) } env.Status("Created build config: %s", config.Identifier) diff --git a/internal/commands/build_known_hosts.go b/internal/commands/build_known_hosts.go index 37e7f49..00b9415 100644 --- a/internal/commands/build_known_hosts.go +++ b/internal/commands/build_known_hosts.go @@ -31,7 +31,7 @@ func newBuildKnownHostsCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(hosts, fmt.Sprintf("%d build known hosts", len(hosts)))) } rows := make([][]string, len(hosts)) @@ -88,7 +88,7 @@ func newBuildKnownHostsCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(h, fmt.Sprintf("Created: %s", h.Hostname))) } env.Status("Created build known host: %s", h.Hostname) diff --git a/internal/commands/build_languages.go b/internal/commands/build_languages.go index 2a06476..1d23414 100644 --- a/internal/commands/build_languages.go +++ b/internal/commands/build_languages.go @@ -55,7 +55,7 @@ func newBuildLanguagesSetCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(lang, lang.Name+" "+lang.Version)) } env.Status("Set %s to version %s", lang.Name, lang.Version) diff --git a/internal/commands/config_files.go b/internal/commands/config_files.go index 310963a..b3538bf 100644 --- a/internal/commands/config_files.go +++ b/internal/commands/config_files.go @@ -43,7 +43,7 @@ func newConfigFilesListCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(files, fmt.Sprintf("%d config files", len(files)))) } rows := make([][]string, len(files)) @@ -73,7 +73,7 @@ func newConfigFilesShowCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(f, f.Path)) } env.WriteTable([]string{"Field", "Value"}, [][]string{ diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 37abb72..9402238 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -410,7 +410,7 @@ func newDeployCmd() *cobra.Command { return err } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(preview, fmt.Sprintf("Preview %s created (status: %s)", preview.Identifier, preview.Status), output.Breadcrumb{Action: "execute", Cmd: deployExecuteCmd(projectID, server, branch)}, @@ -431,7 +431,7 @@ func newDeployCmd() *cobra.Command { return err } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "watch", Cmd: fmt.Sprintf("dhq deployments watch %s -p %s", dep.Identifier, projectID), Resource: "deployment", ID: dep.Identifier}, @@ -512,7 +512,7 @@ func newRetryCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Retry deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "status", Cmd: fmt.Sprintf("dhq deployments show %s -p %s", dep.Identifier, projectID)}, @@ -548,7 +548,7 @@ func newRollbackCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Rollback deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "status", Cmd: fmt.Sprintf("dhq deployments show %s -p %s", dep.Identifier, projectID)}, diff --git a/internal/commands/deployment_checks.go b/internal/commands/deployment_checks.go index fd6fce8..642a20a 100644 --- a/internal/commands/deployment_checks.go +++ b/internal/commands/deployment_checks.go @@ -36,7 +36,7 @@ Three check types are supported: return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(checks, fmt.Sprintf("%d deployment checks", len(checks)))) } rows := make([][]string, len(checks)) diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index 9eb1f20..a9ee4fe 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -63,7 +63,7 @@ func newDeploymentsListCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewPaginatedResponse(result.Records, output.Pagination{ CurrentPage: result.Pagination.CurrentPage, @@ -77,6 +77,15 @@ func newDeploymentsListCmd() *cobra.Command { )) } + if env.QuietMode { + identifiers := make([]string, len(result.Records)) + for i, d := range result.Records { + identifiers[i] = d.Identifier + } + env.WriteQuiet(identifiers) + return nil + } + columns := []string{"Identifier", "Status", "Branch", "Deployer", "Queued"} rows := make([][]string, len(result.Records)) for i, d := range result.Records { @@ -137,7 +146,7 @@ func newDeploymentsShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { crumbs := []output.Breadcrumb{ {Action: "logs", Cmd: fmt.Sprintf("dhq deployments logs %s -p %s", dep.Identifier, projectID)}, } @@ -263,7 +272,7 @@ func newDeploymentsCreateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "status", Cmd: fmt.Sprintf("dhq deployments show %s -p %s", dep.Identifier, projectID)}, @@ -306,7 +315,7 @@ func newDeploymentsRetryCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Retry deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "status", Cmd: fmt.Sprintf("dhq deployments show %s -p %s", dep.Identifier, projectID)}, @@ -367,7 +376,7 @@ func newDeploymentsRollbackCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Rollback deployment %s queued", dep.Identifier), output.Breadcrumb{Action: "status", Cmd: fmt.Sprintf("dhq deployments show %s -p %s", dep.Identifier, projectID)}, @@ -436,7 +445,7 @@ func newDeploymentsLogsCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(logs, fmt.Sprintf("%d log entries", len(logs)))) } diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 0a41f01..e4fc8e7 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -75,7 +75,7 @@ func newDoctorCmd() *cobra.Command { } // Output - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(checks, "Health check complete")) } diff --git a/internal/commands/env_vars.go b/internal/commands/env_vars.go index 1547424..df8526e 100644 --- a/internal/commands/env_vars.go +++ b/internal/commands/env_vars.go @@ -61,7 +61,7 @@ func newEnvVarsListCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(vars, fmt.Sprintf("%d environment variables", len(vars)))) } rows := make([][]string, len(vars)) @@ -149,7 +149,7 @@ func newEnvVarsCreateCmd() *cobra.Command { if err != nil { return err } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(v, fmt.Sprintf("Created: %s", v.Name))) } env.Status("Created environment variable: %s", v.Name) @@ -238,7 +238,7 @@ Project-level variables ("dhq env-vars") with the same name override globals for return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(vars, fmt.Sprintf("%d global env vars", len(vars)))) } rows := make([][]string, len(vars)) @@ -319,7 +319,7 @@ func newGlobalEnvVarsCreateCmd() *cobra.Command { if err != nil { return err } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(v, fmt.Sprintf("Created: %s", v.Name))) } env.Status("Created global env var: %s", v.Name) diff --git a/internal/commands/excluded_files.go b/internal/commands/excluded_files.go index db6c4e8..4491778 100644 --- a/internal/commands/excluded_files.go +++ b/internal/commands/excluded_files.go @@ -33,7 +33,7 @@ Patterns are evaluated against repository paths and apply per-project. Each excl return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(files, fmt.Sprintf("%d excluded files", len(files)))) } rows := make([][]string, len(files)) diff --git a/internal/commands/global_config_files.go b/internal/commands/global_config_files.go index 0c83c7f..07c4ff4 100644 --- a/internal/commands/global_config_files.go +++ b/internal/commands/global_config_files.go @@ -27,7 +27,7 @@ func newGlobalConfigFilesCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(files, fmt.Sprintf("%d global config files", len(files)))) } rows := make([][]string, len(files)) @@ -50,7 +50,7 @@ func newGlobalConfigFilesCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(f, f.Name)) } env.WriteTable([]string{"Field", "Value"}, [][]string{ @@ -100,7 +100,7 @@ func newGlobalConfigFilesCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(f, fmt.Sprintf("Created: %s", f.Name))) } env.Status("Created global config file: %s (%s)", f.Name, f.Identifier) diff --git a/internal/commands/global_servers.go b/internal/commands/global_servers.go index 04e47be..3c27f97 100644 --- a/internal/commands/global_servers.go +++ b/internal/commands/global_servers.go @@ -29,7 +29,7 @@ Useful for shared infrastructure (e.g., a single CDN bucket or a shared Heroku s return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(servers, fmt.Sprintf("%d global servers", len(servers)))) } rows := make([][]string, len(servers)) @@ -94,7 +94,7 @@ func newGlobalServersCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(s, fmt.Sprintf("Created: %s", s.Name))) } env.Status("Created global server: %s (%s)", s.Name, s.Identifier) @@ -180,7 +180,7 @@ func newIntegrationsCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(integrations, fmt.Sprintf("%d integrations", len(integrations)))) } rows := make([][]string, len(integrations)) @@ -256,7 +256,7 @@ func newIntegrationsCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(ig, fmt.Sprintf("Created: %s", ig.Name))) } env.Status("Created integration: %s (%s)", ig.Name, ig.Identifier) diff --git a/internal/commands/insights.go b/internal/commands/insights.go index f22853a..62113ab 100644 --- a/internal/commands/insights.go +++ b/internal/commands/insights.go @@ -35,7 +35,7 @@ func newInsightsCmd() *cobra.Command { func renderInsights(insights map[string]interface{}, projectID string) error { env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(insights, fmt.Sprintf("Insights for project: %s", projectID), )) diff --git a/internal/commands/language_versions.go b/internal/commands/language_versions.go index 6a4747d..7c3c3f9 100644 --- a/internal/commands/language_versions.go +++ b/internal/commands/language_versions.go @@ -32,7 +32,7 @@ func newLanguageVersionsCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(versions, fmt.Sprintf("%d languages available", len(versions)))) } diff --git a/internal/commands/network_agents.go b/internal/commands/network_agents.go index feec968..6d6d5b6 100644 --- a/internal/commands/network_agents.go +++ b/internal/commands/network_agents.go @@ -29,9 +29,17 @@ Manage agents at the account level here, then attach one to an individual server return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(agents, fmt.Sprintf("%d agents", len(agents)))) } + if env.QuietMode { + identifiers := make([]string, len(agents)) + for i, a := range agents { + identifiers[i] = a.Identifier + } + env.WriteQuiet(identifiers) + return nil + } rows := make([][]string, len(agents)) for i, a := range agents { online := "offline" diff --git a/internal/commands/phase3_test.go b/internal/commands/phase3_test.go index 865ce1b..add3e67 100644 --- a/internal/commands/phase3_test.go +++ b/internal/commands/phase3_test.go @@ -36,6 +36,71 @@ func TestCommandTree_AllRegistered(t *testing.T) { assert.Contains(t, out, "doctor") } +func TestParseJSONFlag(t *testing.T) { + cases := []struct { + raw string + wantJSON bool + wantFields []string + }{ + // Not set → auto behaviour + {"", false, nil}, + + // Truthy → force JSON, no field selection + {"true", true, nil}, + {"TRUE", true, nil}, + {"1", true, nil}, + {"yes", true, nil}, + {"on", true, nil}, + + // Falsy → explicit opt-out, preserves auto behaviour (the bug fix: + // previously --json=false silently filtered to {} because "false" + // was treated as a field name). + {"false", false, nil}, + {"False", false, nil}, + {"0", false, nil}, + {"no", false, nil}, + {"off", false, nil}, + + // Field selection → force JSON, pick fields + {"name", true, []string{"name"}}, + {"name,permalink", true, []string{"name", "permalink"}}, + {"name,permalink,zone", true, []string{"name", "permalink", "zone"}}, + + // Whitespace tolerance — quoted args can pick up stray spaces + {"name, permalink", true, []string{"name", "permalink"}}, + {" name , permalink ", true, []string{"name", "permalink"}}, + {"name,,permalink", true, []string{"name", "permalink"}}, + {"name,permalink,", true, []string{"name", "permalink"}}, + + // Whitespace-only / commas-only → treat as bare --json + {" ", true, nil}, + {",,,", true, nil}, + } + for _, tc := range cases { + t.Run("raw="+tc.raw, func(t *testing.T) { + gotJSON, gotFields := parseJSONFlag(tc.raw) + assert.Equal(t, tc.wantJSON, gotJSON, "JSON mode mismatch") + assert.Equal(t, tc.wantFields, gotFields, "fields mismatch") + }) + } +} + +func TestGlobalOutputFlags_Registered(t *testing.T) { + cmd := NewRootCmd("test") + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetArgs([]string{"projects", "list", "--help"}) + + err := cmd.Execute() + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "--table", "table flag should be available globally") + assert.Contains(t, out, "--quiet", "quiet flag should be available globally") + assert.Contains(t, out, "-q,", "quiet should have -q shorthand") + assert.Contains(t, out, "--json=false to opt out", "json help should mention opt-out") +} + func TestProjectsSubcommands(t *testing.T) { cmd := NewRootCmd("test") var stdout bytes.Buffer diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 2dacac0..d148908 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -59,7 +59,7 @@ func newProjectsListCmd() *cobra.Command { }) env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(projects, fmt.Sprintf("%d projects", len(projects)), output.Breadcrumb{Action: "show", Cmd: "dhq projects show "}, @@ -68,6 +68,15 @@ func newProjectsListCmd() *cobra.Command { )) } + if env.QuietMode { + permalinks := make([]string, len(projects)) + for i, p := range projects { + permalinks[i] = p.Permalink + } + env.WriteQuiet(permalinks) + return nil + } + columns := []string{"*", "Name", "Permalink", "Zone", "Last Deployed"} rows := make([][]string, len(projects)) for i, p := range projects { @@ -115,7 +124,7 @@ func newProjectsShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(project, fmt.Sprintf("Project: %s", project.Name), output.Breadcrumb{Action: "servers", Cmd: fmt.Sprintf("dhq servers list -p %s", project.Permalink)}, @@ -197,7 +206,7 @@ func newProjectsCreateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(project, fmt.Sprintf("Created project: %s", project.Name), output.Breadcrumb{Action: "servers", Cmd: fmt.Sprintf("dhq servers create -p %s --name --protocol-type ssh", project.Permalink)}, @@ -275,7 +284,7 @@ func newProjectsUpdateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(project, fmt.Sprintf("Updated project: %s", project.Name))) } env.Status("Updated project: %s", project.Name) @@ -408,7 +417,7 @@ func newProjectsUploadKeyCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(project, fmt.Sprintf("Uploaded key for project: %s", project.Name))) } env.Status("Uploaded custom key for project: %s", project.Name) @@ -443,7 +452,7 @@ func newProjectsBadgeCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse( map[string]string{"svg": string(badge)}, fmt.Sprintf("Status badge for project: %s", projectID), diff --git a/internal/commands/repos.go b/internal/commands/repos.go index adfeb82..149ca8b 100644 --- a/internal/commands/repos.go +++ b/internal/commands/repos.go @@ -53,7 +53,7 @@ func newReposShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(repo, fmt.Sprintf("Repository: %s (%s)", repo.URL, repo.ScmType), output.Breadcrumb{Action: "branches", Cmd: fmt.Sprintf("dhq repos branches -p %s", projectID)}, @@ -116,7 +116,7 @@ func newReposCreateCmd() *cobra.Command { project, projErr := client.GetProject(cliCtx.Background(), projectID) env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { breadcrumbs := []output.Breadcrumb{} if projErr == nil && project.PublicKey != "" { breadcrumbs = append(breadcrumbs, deployKeyBreadcrumbs(url, project.PublicKey, projectID)...) @@ -165,7 +165,7 @@ func newReposUpdateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(repo, "Repository updated", output.Breadcrumb{Action: "show", Cmd: fmt.Sprintf("dhq repos show -p %s", projectID)}, output.Breadcrumb{Action: "branches", Cmd: fmt.Sprintf("dhq repos branches -p %s", projectID)}, @@ -203,7 +203,7 @@ func newReposBranchesCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(branches, fmt.Sprintf("%d branches", len(branches)))) } @@ -240,7 +240,7 @@ func newReposCommitsCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, fmt.Sprintf("%d commits, %d tags", len(result.Commits), len(result.Tags)))) } @@ -289,7 +289,7 @@ func newReposCommitInfoCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(commit, fmt.Sprintf("Commit: %s", commit.Ref), output.Breadcrumb{Action: "deploy", Cmd: fmt.Sprintf("dhq deploy -p %s --revision %s", projectID, commit.Ref)}, @@ -335,7 +335,7 @@ func newReposLatestRevisionCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(map[string]string{"ref": rev}, rev, output.Breadcrumb{Action: "deploy", Cmd: fmt.Sprintf("dhq deploy -p %s --revision %s", projectID, rev)}, )) diff --git a/internal/commands/root.go b/internal/commands/root.go index 031bb29..3dac90c 100644 --- a/internal/commands/root.go +++ b/internal/commands/root.go @@ -24,6 +24,8 @@ var ( flagAPIKey string flagProject string flagJSON string + flagTable bool + flagQuiet bool flagCwd string flagHost string flagNonInteractive bool @@ -76,12 +78,15 @@ Support: support@deployhq.com`, logger := output.NewLogger() env := output.NewEnvelope(logger) - // Handle --json flag - if flagJSON != "" { - env.JSONMode = true - if flagJSON != "true" && flagJSON != "1" { - env.JSONFields = strings.Split(flagJSON, ",") - } + env.JSONMode, env.JSONFields = parseJSONFlag(flagJSON) + + // --table forces table output even when piped. --quiet prints + // only key identifiers, one per line, suitable for piping to + // grep/xargs/etc. --quiet wins if both are set. + env.TableMode = flagTable + env.QuietMode = flagQuiet + if flagTable && flagQuiet { + env.TableMode = false } // Detect agent mode @@ -151,8 +156,10 @@ Support: support@deployhq.com`, pf.StringVar(&flagEmail, "email", "", "Authentication email") pf.StringVar(&flagAPIKey, "api-key", "", "API key") pf.StringVarP(&flagProject, "project", "p", "", "Project permalink or identifier") - pf.StringVar(&flagJSON, "json", "", "Output as JSON (optionally specify fields: --json name,status)") + pf.StringVar(&flagJSON, "json", "", "Output as JSON (optionally specify fields: --json name,status). Use --json=false to opt out.") pf.Lookup("json").NoOptDefVal = "true" + pf.BoolVar(&flagTable, "table", false, "Force table output, even when piped") + pf.BoolVarP(&flagQuiet, "quiet", "q", false, "Print only the key identifier of each row, one per line (for grep/xargs)") pf.StringVarP(&flagCwd, "cwd", "C", "", "Change working directory before running") pf.BoolVar(&flagNonInteractive, "non-interactive", false, "Never prompt; fail with actionable errors on ambiguity") pf.StringVar(&flagHost, "host", "", "API host override (e.g. deployhq.dev for staging)") @@ -241,10 +248,48 @@ Support: support@deployhq.com`, return root } +// parseJSONFlag interprets the --json flag value. +// +// - "" → not set; auto behaviour (TTY=table, pipe=JSON) +// - "true"/"1"/"yes"/"on" → force JSON, all fields +// - "false"/"0"/"no"/"off" → explicit opt-out; auto behaviour +// - "name, permalink" → force JSON, only those fields (whitespace trimmed) +// +// The opt-out values matter because cobra parses --json=false as flagJSON="false" +// (a string), not as a bool. Without this branch, "false" would be treated as a +// field name and silently return {} from every command. +// +// Field tokens are trimmed and empty tokens dropped, so "--json= name , permalink ," +// becomes ["name", "permalink"] rather than silently losing a field to whitespace. +func parseJSONFlag(raw string) (jsonMode bool, fields []string) { + if raw == "" { + return false, nil + } + switch strings.ToLower(strings.TrimSpace(raw)) { + case "false", "0", "no", "off": + return false, nil + case "true", "1", "yes", "on": + return true, nil + } + parts := strings.Split(raw, ",") + fields = fields[:0:0] + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + fields = append(fields, p) + } + } + if len(fields) == 0 { + // All tokens were whitespace — treat as bare --json + return true, nil + } + return true, fields +} + // IsJSONMode returns true if --json was passed or output is piped (non-TTY). +// Respects --table and --quiet overrides via Envelope.WantsJSON(). func IsJSONMode() bool { if cliCtx != nil { - return cliCtx.Envelope.JSONMode || !cliCtx.Envelope.IsTTY + return cliCtx.Envelope.WantsJSON() } return flagJSON != "" } diff --git a/internal/commands/scheduled_deploys.go b/internal/commands/scheduled_deploys.go index 580d702..ab3004f 100644 --- a/internal/commands/scheduled_deploys.go +++ b/internal/commands/scheduled_deploys.go @@ -31,7 +31,7 @@ func newScheduledDeploysCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, fmt.Sprintf("%d scheduled deployments", len(result)))) } rows := make([][]string, len(result)) @@ -138,7 +138,7 @@ func newScheduledDeploysUpdateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(s, fmt.Sprintf("Updated scheduled deployment: %s", s.Identifier))) } env.Status("Updated scheduled deployment: %s (%s at %s)", s.Identifier, s.Frequency, s.At) @@ -202,7 +202,7 @@ func newScheduledDeploysCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(s, fmt.Sprintf("Created scheduled deployment: %s", s.Identifier))) } env.Status("Created scheduled deployment: %s (%s at %s)", s.Identifier, s.Frequency, s.At) diff --git a/internal/commands/server_groups.go b/internal/commands/server_groups.go index 5f31070..9a8bbc5 100644 --- a/internal/commands/server_groups.go +++ b/internal/commands/server_groups.go @@ -52,10 +52,19 @@ func newServerGroupsListCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(groups, fmt.Sprintf("%d server groups", len(groups)))) } + if env.QuietMode { + identifiers := make([]string, len(groups)) + for i, g := range groups { + identifiers[i] = g.Identifier + } + env.WriteQuiet(identifiers) + return nil + } + columns := []string{"Name", "Identifier", "Servers", "Environment"} rows := make([][]string, len(groups)) for i, g := range groups { @@ -96,7 +105,7 @@ func newServerGroupsShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(group, fmt.Sprintf("Server group: %s", group.Name))) } @@ -153,7 +162,7 @@ func newServerGroupsCreateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(group, fmt.Sprintf("Created server group: %s", group.Name))) } env.Status("Created server group: %s (%s)", group.Name, group.Identifier) @@ -189,7 +198,7 @@ func newServerGroupsUpdateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(group, fmt.Sprintf("Updated server group: %s", group.Name))) } env.Status("Updated server group: %s", group.Name) diff --git a/internal/commands/servers.go b/internal/commands/servers.go index e427350..8376b12 100644 --- a/internal/commands/servers.go +++ b/internal/commands/servers.go @@ -56,7 +56,7 @@ func newServersListCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(servers, fmt.Sprintf("%d servers", len(servers)), output.Breadcrumb{Action: "show", Cmd: fmt.Sprintf("dhq servers show -p %s", projectID)}, @@ -64,6 +64,15 @@ func newServersListCmd() *cobra.Command { )) } + if env.QuietMode { + identifiers := make([]string, len(servers)) + for i, s := range servers { + identifiers[i] = s.Identifier + } + env.WriteQuiet(identifiers) + return nil + } + columns := []string{"Name", "Identifier", "Protocol", "Branch", "Enabled"} rows := make([][]string, len(servers)) for i, s := range servers { @@ -109,7 +118,7 @@ func newServersShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(server, fmt.Sprintf("Server: %s", server.Name), output.Breadcrumb{Action: "deploy", Cmd: fmt.Sprintf("dhq deploy -p %s", projectID)}, @@ -316,7 +325,7 @@ func newServersCreateCmd() *cobra.Command { } } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(server, fmt.Sprintf("Created server: %s", server.Name))) } env.Status("Created server: %s (%s)", server.Name, server.Identifier) @@ -396,7 +405,7 @@ func newServersUpdateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(server, fmt.Sprintf("Updated server: %s", server.Name))) } env.Status("Updated server: %s", server.Name) diff --git a/internal/commands/signup.go b/internal/commands/signup.go index 63c8279..af1e7cd 100644 --- a/internal/commands/signup.go +++ b/internal/commands/signup.go @@ -91,7 +91,7 @@ func newSignupCmd() *cobra.Command { env.Status("API key: %s", result.APIKey) } - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, fmt.Sprintf("Account created: %s", result.Account.Subdomain), output.Breadcrumb{Action: "login", Cmd: "dhq auth login"}, diff --git a/internal/commands/ssh_commands.go b/internal/commands/ssh_commands.go index 5362000..a337190 100644 --- a/internal/commands/ssh_commands.go +++ b/internal/commands/ssh_commands.go @@ -33,7 +33,7 @@ Build commands run on the build server; SSH commands run on the deploy target.`, return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(cmds, fmt.Sprintf("%d SSH commands", len(cmds)))) } rows := make([][]string, len(cmds)) diff --git a/internal/commands/ssh_keys.go b/internal/commands/ssh_keys.go index 264ebb0..503f4ad 100644 --- a/internal/commands/ssh_keys.go +++ b/internal/commands/ssh_keys.go @@ -29,7 +29,7 @@ Centralizing keys here means rotation is one update instead of touching every se return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(keys, fmt.Sprintf("%d SSH keys", len(keys)))) } rows := make([][]string, len(keys)) @@ -78,7 +78,7 @@ func newSSHKeysCreateCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(k, fmt.Sprintf("Created: %s", k.Title))) } env.Status("Created SSH key: %s (%s)", k.Title, k.KeyType) diff --git a/internal/commands/status.go b/internal/commands/status.go index 98ac2f6..72183fd 100644 --- a/internal/commands/status.go +++ b/internal/commands/status.go @@ -22,7 +22,7 @@ func newStatusCmd() *cobra.Command { return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(result, "Account status", output.Breadcrumb{Action: "activity", Cmd: "dhq activity list"}, output.Breadcrumb{Action: "projects", Cmd: "dhq projects list"}, diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 01bfdd3..230452b 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -47,7 +47,7 @@ func newTemplatesListCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(templates, fmt.Sprintf("%d templates", len(templates)), output.Breadcrumb{Action: "create", Cmd: "dhq templates create --name "}, @@ -88,7 +88,7 @@ func newTemplatesPublicCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(templates, fmt.Sprintf("%d public templates", len(templates)), )) @@ -195,7 +195,7 @@ func newTemplatesCreateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(tmpl, fmt.Sprintf("Created template: %s", tmpl.Name), )) @@ -241,7 +241,7 @@ func newTemplatesUpdateCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(tmpl, fmt.Sprintf("Updated template: %s", tmpl.Name))) } env.Status("Updated template: %s", tmpl.Name) diff --git a/internal/commands/test_access.go b/internal/commands/test_access.go index c9cd179..3ba2b59 100644 --- a/internal/commands/test_access.go +++ b/internal/commands/test_access.go @@ -58,7 +58,7 @@ func newTestAccessCmd() *cobra.Command { } if !wait { - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(run, fmt.Sprintf("Test access %s started", run.Identifier), output.Breadcrumb{Action: "results", Cmd: fmt.Sprintf("dhq test-access show %s -p %s", run.Identifier, projectID)}, @@ -90,7 +90,7 @@ func newTestAccessCmd() *cobra.Command { } // Render results - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(run, formatTestSummary(run))) } @@ -129,7 +129,7 @@ func newTestAccessShowCmd() *cobra.Command { } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(run, formatTestSummary(run))) } diff --git a/internal/commands/url.go b/internal/commands/url.go index c4fbf53..0151fef 100644 --- a/internal/commands/url.go +++ b/internal/commands/url.go @@ -46,7 +46,7 @@ Examples: } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(parsed, fmt.Sprintf("Parsed: %s", parsed.Resource))) } diff --git a/internal/commands/watch.go b/internal/commands/watch.go index 72f5a41..4072121 100644 --- a/internal/commands/watch.go +++ b/internal/commands/watch.go @@ -13,7 +13,7 @@ const pollInterval = 3 * time.Second // watchDeployment uses the TUI in interactive terminals, falls back to append-only otherwise. func watchDeployment(ctx context.Context, client *sdk.Client, env *output.Envelope, projectID, deploymentID string) error { - if env.IsTTY && !env.JSONMode { + if env.IsTTY && !env.WantsJSON() { return watchDeploymentTUI(ctx, client, env, projectID, deploymentID) } return watchDeploymentPlain(ctx, client, env, projectID, deploymentID) diff --git a/internal/commands/zones.go b/internal/commands/zones.go index 9b9ef3d..efeaf24 100644 --- a/internal/commands/zones.go +++ b/internal/commands/zones.go @@ -28,7 +28,7 @@ Pin a zone per project at creation: "dhq projects create --zone ", or change return err } env := cliCtx.Envelope - if env.JSONMode || !env.IsTTY { + if env.WantsJSON() { return env.WriteJSON(output.NewResponse(zones, fmt.Sprintf("%d zones", len(zones)))) } rows := make([][]string, len(zones)) diff --git a/internal/output/envelope.go b/internal/output/envelope.go index 805f603..3283bef 100644 --- a/internal/output/envelope.go +++ b/internal/output/envelope.go @@ -24,21 +24,37 @@ var ( // Envelope is the output engine that routes data to stdout and messages to stderr. // // Behaviour modes: -// - TTY + no --json flag: table output to stdout, status to stderr -// - TTY + --json flag: JSON to stdout, status to stderr -// - Pipe (non-TTY): JSON to stdout, status to stderr -// - DEPLOYHQ_OUTPUT_FILE: JSONL appended to the specified file +// - TTY + no --json flag: table output to stdout, status to stderr +// - TTY + --json flag: JSON to stdout, status to stderr +// - Pipe (non-TTY): JSON to stdout (auto-switch), status to stderr +// - --table: force table output even when piped +// - --quiet: print only the key column (one per line, no header) +// - DEPLOYHQ_OUTPUT_FILE: JSONL appended to the specified file type Envelope struct { Stdout io.Writer Stderr io.Writer Logger *Logger JSONMode bool JSONFields []string // field selection for --json + TableMode bool // --table: force table output, override auto-JSON-on-pipe + QuietMode bool // --quiet/-q: only print key column, suitable for piping OutputFile *os.File // DEPLOYHQ_OUTPUT_FILE JSONL writer IsTTY bool NonInteractive bool // when true, never prompt — fail with actionable errors } +// WantsJSON reports whether output should be JSON. +// +// Precedence: --table and --quiet force non-JSON output (table or plain text). +// Otherwise: --json (JSONMode) forces JSON, and a non-TTY stdout auto-switches +// to JSON so piped output stays machine-readable by default. +func (e *Envelope) WantsJSON() bool { + if e.TableMode || e.QuietMode { + return false + } + return e.JSONMode || !e.IsTTY +} + // NewEnvelope creates an Envelope with auto-detected TTY and output file. func NewEnvelope(logger *Logger) *Envelope { e := &Envelope{ @@ -214,10 +230,19 @@ func (e *Envelope) WriteTable(columns []string, rows [][]string) { } } +// WriteQuiet prints one identifier per line to stdout. Use for --quiet mode +// when piping a list of permalinks/identifiers to other commands. No header, +// no padding — just newline-separated values. +func (e *Envelope) WriteQuiet(items []string) { + for _, item := range items { + fmt.Fprintln(e.Stdout, item) //nolint:errcheck // best-effort stdout + } +} + // WriteData writes data as either JSON or table depending on the mode. // If in JSON mode or non-TTY, outputs JSON. Otherwise, uses the table formatter. func (e *Envelope) WriteData(data interface{}, columns []string, toRow func(interface{}) []string) error { - if e.JSONMode || !e.IsTTY { + if e.WantsJSON() { return e.WriteJSON(data) } diff --git a/internal/output/envelope_test.go b/internal/output/envelope_test.go index f4b627c..c092c48 100644 --- a/internal/output/envelope_test.go +++ b/internal/output/envelope_test.go @@ -99,6 +99,48 @@ func TestEnvelope_WriteJSON_ResponseEnvelopeFieldSelection(t *testing.T) { assert.Nil(t, result[0]["status"], "status should be filtered out") } +func TestEnvelope_WantsJSON(t *testing.T) { + cases := []struct { + name string + env Envelope + want bool + }{ + {"tty default", Envelope{IsTTY: true}, false}, + {"pipe default", Envelope{IsTTY: false}, true}, + {"tty + json", Envelope{IsTTY: true, JSONMode: true}, true}, + {"tty + table", Envelope{IsTTY: true, TableMode: true}, false}, + {"pipe + table", Envelope{IsTTY: false, TableMode: true}, false}, + {"pipe + json + table", Envelope{IsTTY: false, JSONMode: true, TableMode: true}, false}, + {"pipe + quiet", Envelope{IsTTY: false, QuietMode: true}, false}, + {"pipe + json + quiet", Envelope{IsTTY: false, JSONMode: true, QuietMode: true}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.env.WantsJSON()) + }) + } +} + +func TestEnvelope_WriteQuiet(t *testing.T) { + var stdout bytes.Buffer + env := &Envelope{Stdout: &stdout, Stderr: os.Stderr, Logger: &Logger{}} + + env.WriteQuiet([]string{"alpha", "bravo", "charlie"}) + + assert.Equal(t, "alpha\nbravo\ncharlie\n", stdout.String()) +} + +func TestEnvelope_WriteQuiet_Empty(t *testing.T) { + var stdout bytes.Buffer + env := &Envelope{Stdout: &stdout, Stderr: os.Stderr, Logger: &Logger{}} + + env.WriteQuiet(nil) + assert.Empty(t, stdout.String()) + + env.WriteQuiet([]string{}) + assert.Empty(t, stdout.String()) +} + func TestEnvelope_WriteTable(t *testing.T) { var stdout bytes.Buffer env := &Envelope{Stdout: &stdout, Stderr: os.Stderr, Logger: &Logger{}}