Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ When reviewing PRs for this project, check:
### Global Flags
- `--output`, `-o` — table (default), json, yaml
- `--quiet`, `-q` — IDs only, one per line
- `--no-color` — disable colored output
- `--no-color` — disable colored output (also off with `NO_COLOR` or when stdout is not a terminal)
- `--api-url` — override API server URL
- `--api-key` — override API key
- `--insecure` — skip TLS verification (prints warning)
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl
**Global flags**: `--output`/`-o table|json|yaml`, `--quiet`/`-q`, `--api-url`, `--api-key`, `--no-color`, `--insecure`, `--debug` (or `STACKCTL_DEBUG=1`; logs HTTP requests and responses to stderr)

**Output modes**:
- `table` (default): human-readable with colored status badges
- `table` (default): human-readable with colored status badges. Colors go only to a terminal: `--no-color`, `NO_COLOR` (non-empty) or a stdout that is not a terminal turn them off (`root.go`: `noColorEnv`, `isTerminalWriter`).
- Embedded structs in `pkg/types` (`Base`, `StackTemplate`, `TemplateVersion`) carry `yaml:",inline"`, so `-o yaml` has the same top-level keys as `-o json`. Add the tag to every new embedded struct.
- `json`: machine-readable, full API response
- `yaml`: machine-readable, full API response
- `--quiet`: IDs only, one per line (pipeable to `xargs`)
Expand Down
2 changes: 1 addition & 1 deletion EXTENDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ that the user exported in the parent shell is kept.
| `STACKCTL_INSECURE` | `--insecure` flag, parent shell, or current context `insecure: true` | `1` to skip TLS verification. stackctl exports `0` when the user sets `--insecure=false`. |
| `STACKCTL_QUIET` | `--quiet` flag | `1` when the user requested quiet output (`0` for `--quiet=false`) |
| `STACKCTL_OUTPUT` | `--output` flag | `table` / `json` / `yaml` / a registered custom format |
| `STACKCTL_NO_COLOR` | `--no-color` flag | `1` when the user disabled colored output (`0` for `--no-color=false`) |
| `STACKCTL_NO_COLOR` | `--no-color` flag, or a non-empty `NO_COLOR` | `1` when the user disabled colored output (`0` for `--no-color=false`). stackctl itself also prints no colors when its stdout is not a terminal; a plugin checks its own stdout the same way. |
| `NO_COLOR` | `--no-color` flag, or parent shell | `1` with `--no-color` ([no-color.org](https://no-color.org) convention for the tools that the plugin runs) |
| `STACKCTL_DEBUG` | `--debug` flag | `1` when the user requested HTTP debug output (`0` for `--debug=false`) |
| `STACKCTL_CONFIG_DIR` | parent shell, or the config directory that stackctl uses | Directory of `config.yaml` and `tokens/`. A plugin that runs `stackctl` again uses the same files |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ These groups have no section above. Run `stackctl <group> --help` for the flags.
Most commands support multiple output formats via the `--output` flag:

```bash
# Table (default) — human-readable with colored status badges
# Table (default) — human-readable with colored status badges (colors only on a terminal)
stackctl stack list

# JSON — machine-readable, full API response
Expand All @@ -679,7 +679,7 @@ stackctl stack list -q
|------|-------|-------------|
| `--output` | `-o` | Output format: `table`, `json`, `yaml` (default `table`) |
| `--quiet` | `-q` | Output only IDs (one per line) |
| `--no-color` | | Disable colored output |
| `--no-color` | | Disable colored output. stackctl also prints no colors when `NO_COLOR` is set or when stdout is not a terminal (a pipe or a file). |
| `--api-url` | | Override API server URL |
| `--api-key` | | Override API key |
| `--insecure` | | Skip TLS certificate verification |
Expand Down
13 changes: 9 additions & 4 deletions cli/cmd/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,13 +545,18 @@ func printChartConfig(ch *types.ChartConfig) error {
case output.FormatYAML:
return printer.PrintYAML(ch)
default:
// The server sends no "name" or "release_name" for a chart: the
// table shows the fields that it sends.
fields := []output.KeyValue{
{Key: "ID", Value: ch.ID},
{Key: "Name", Value: ch.Name},
{Key: "Chart", Value: ch.ChartName},
{Key: "Repository", Value: ch.RepoURL},
{Key: "Version", Value: ch.ChartVersion},
{Key: "Release Name", Value: ch.ReleaseName},
{Key: "Repository", Value: valueOrDash(ch.RepoURL)},
{Key: "Chart Path", Value: valueOrDash(ch.ChartPath)},
{Key: "Version", Value: valueOrDash(ch.ChartVersion)},
{Key: "Deploy Order", Value: strconv.Itoa(ch.DeployOrder)},
}
if ch.Required {
fields = append(fields, output.KeyValue{Key: "Required", Value: "yes"})
}
return printer.PrintSingle(ch, fields)
}
Expand Down
106 changes: 106 additions & 0 deletions cli/cmd/output_fixes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package cmd

import (
"bytes"
"encoding/json"
"net/http"
"os"
"testing"

"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/omattsson/stackctl/cli/pkg/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

func TestStackCloneCmd_OutputModes(t *testing.T) {
tests := []struct {
name string
format output.Format
quiet bool
check func(t *testing.T, out string)
}{
{"json", output.FormatJSON, false, func(t *testing.T, out string) {
var inst types.StackInstance
require.NoError(t, json.Unmarshal([]byte(out), &inst), "stdout is JSON")
assert.Equal(t, "55", inst.ID)
assert.Equal(t, "my-stack-copy", inst.Name)
}},
{"yaml", output.FormatYAML, false, func(t *testing.T, out string) {
var m map[string]any
require.NoError(t, yaml.Unmarshal([]byte(out), &m))
assert.Equal(t, "55", m["id"], "id is a top-level key")
}},
{"quiet", output.FormatTable, true, func(t *testing.T, out string) {
assert.Equal(t, "55\n", out)
}},
{"table", output.FormatTable, false, func(t *testing.T, out string) {
assert.Contains(t, out, "Cloned stack 42")
assert.Contains(t, out, "new stack 55")
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, out := setupChannelCmd(t, stackCloneCmd, map[string]route{
"POST /api/v1/stack-instances/42/clone": {http.StatusCreated, `{"id":"55","name":"my-stack-copy","status":"draft"}`},
})
printer.Format = tt.format
printer.Quiet = tt.quiet
require.NoError(t, stackCloneCmd.RunE(stackCloneCmd, []string{"42"}))
stdout, _ := out()
tt.check(t, stdout)
})
}
}

func TestPrintChartConfig_ShowsServerFields(t *testing.T) {
var buf bytes.Buffer
prev := printer
printer = output.NewPrinter("table", false, true)
printer.Writer = &buf
t.Cleanup(func() { printer = prev })

require.NoError(t, printChartConfig(&types.ChartConfig{
Base: types.Base{ID: "c1"}, ChartName: "api", ChartPath: "/charts/api", ChartVersion: "1.2.0", DeployOrder: 3, Required: true,
}))
out := buf.String()
assert.Regexp(t, `Chart:\s+api`, out)
assert.Regexp(t, `Repository:\s+-`, out)
assert.Regexp(t, `Chart Path:\s+/charts/api`, out)
assert.Regexp(t, `Deploy Order:\s+3`, out)
assert.Regexp(t, `Required:\s+yes`, out)
assert.NotContains(t, out, "Release Name", "the server sends no release name")
assert.NotRegexp(t, `(?m)^Name:`, out, "the server sends no chart name field other than chart_name")

buf.Reset()
require.NoError(t, printChartConfig(&types.ChartConfig{Base: types.Base{ID: "c2"}, ChartName: "db"}))
assert.Regexp(t, `Deploy Order:\s+0`, buf.String())
assert.NotContains(t, buf.String(), "Required", "no Required row for an optional chart")
}

// TestPrinterColor_OnlyOnATerminal: a pipe or a file gets no ANSI colors.
// Tests run without a terminal, so os.Stdout and a buffer are both not a
// terminal here.
func TestPrinterColor_OnlyOnATerminal(t *testing.T) {
assert.False(t, isTerminalWriter(&bytes.Buffer{}))
f, err := os.CreateTemp(t.TempDir(), "out")
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
assert.False(t, isTerminalWriter(f), "a regular file is not a terminal")

t.Setenv("NO_COLOR", "1")
assert.True(t, noColorEnv())
t.Setenv("NO_COLOR", "")
assert.False(t, noColorEnv(), "an empty NO_COLOR does not count (no-color.org)")

// PersistentPreRunE builds the printer: with a buffer as stdout the
// status text has no escape codes.
prev := printer
t.Cleanup(func() { printer = prev; versionCmd.SetOut(nil) })
var buf bytes.Buffer
versionCmd.SetOut(&buf)
require.NoError(t, rootCmd.PersistentPreRunE(versionCmd, nil))
assert.True(t, printer.NoColor)
assert.Equal(t, "running", printer.StatusColor("running"))
}
3 changes: 3 additions & 0 deletions cli/cmd/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ func pluginEnv(cmd *cobra.Command) []string {
env = setEnv(env, "NO_COLOR", "1")
}
}
} else if noColorEnv() {
// NO_COLOR also turns off the colors of stackctl itself.
env = setEnv(env, "STACKCTL_NO_COLOR", "1")
}
if !hasEnv(env, "STACKCTL_CONFIG_DIR") {
// The directory stackctl itself uses, so a plugin that reads the
Expand Down
20 changes: 20 additions & 0 deletions cli/cmd/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,26 @@ func TestPluginEnv_NoColorAndConfigDir(t *testing.T) {
assert.Equal(t, "1", v)
}

// TestPluginEnv_NoColorFromEnv: a non-empty NO_COLOR also sets
// STACKCTL_NO_COLOR, because stackctl itself then prints no colors.
func TestPluginEnv_NoColorFromEnv(t *testing.T) {
t.Setenv("STACKCTL_CONFIG_DIR", t.TempDir())
t.Setenv("STACKCTL_NO_COLOR", "")
require.NoError(t, os.Unsetenv("STACKCTL_NO_COLOR"))
t.Setenv("NO_COLOR", "1")

root := &cobra.Command{Use: "stackctl"}
root.PersistentFlags().Bool("no-color", false, "")

v, _ := envValue(pluginEnv(root), "STACKCTL_NO_COLOR")
assert.Equal(t, "1", v)

// An explicit --no-color=false wins over NO_COLOR.
require.NoError(t, root.PersistentFlags().Set("no-color", "false"))
v, _ = envValue(pluginEnv(root), "STACKCTL_NO_COLOR")
assert.Equal(t, "0", v)
}

func TestPluginEnv_ConfigDirFromDefault(t *testing.T) {
xdg := t.TempDir()
t.Setenv("STACKCTL_CONFIG_DIR", "")
Expand Down
18 changes: 16 additions & 2 deletions cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/omattsson/stackctl/cli/pkg/config"
"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/spf13/cobra"
"golang.org/x/term"
)

var (
Expand Down Expand Up @@ -45,8 +46,10 @@ Get started:
SilenceUsage: true,
SilenceErrors: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Initialize printer with Cobra's configured output writer
printer = output.NewPrinter(flagOutput, flagQuiet, flagNoColor)
// Initialize printer with Cobra's configured output writer. Colors
// only go to a terminal: not with --no-color, NO_COLOR
// (https://no-color.org) or a stdout that is a pipe or a file.
printer = output.NewPrinter(flagOutput, flagQuiet, flagNoColor || noColorEnv() || !isTerminalWriter(cmd.OutOrStdout()))
printer.Writer = cmd.OutOrStdout()

// Skip config loading for commands that should work without a config file
Expand Down Expand Up @@ -261,6 +264,17 @@ func (e *configError) Error() string {
return e.msg
}

// noColorEnv reports whether NO_COLOR is set to a non-empty value.
func noColorEnv() bool {
return os.Getenv("NO_COLOR") != ""
}

// isTerminalWriter reports whether w is a terminal.
func isTerminalWriter(w io.Writer) bool {
f, ok := w.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}

// confirmAction prompts the user for confirmation unless --yes is set.
// Returns true if the action should proceed, false if aborted.
func confirmAction(cmd *cobra.Command, message string) (bool, error) {
Expand Down
8 changes: 5 additions & 3 deletions cli/cmd/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -1017,10 +1017,12 @@ Examples:
}
printServerWarning(cmd, instance.Warning)

if printer.Quiet {
fmt.Fprintln(printer.Writer, instance.ID)
} else {
// Table: a short message. Quiet, JSON, YAML and custom formats:
// the instance, as stack create prints it.
if printer.Format == output.FormatTable && !printer.Quiet {
printer.PrintMessage("Cloned stack %s → new stack %s", id, instance.ID)
} else if err := printInstance(instance); err != nil {
return err
}
return checkStopApplied("stop-at", expiry.stopAt, instance)
},
Expand Down
22 changes: 11 additions & 11 deletions cli/pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type Base struct {
// quota override that is not copied). "stack get" sets Warning to
// ValuesDriftWarning when ValuesDrift is true and the server sends no text.
type StackInstance struct {
Base
Base `yaml:",inline"`
Name string `json:"name" yaml:"name"`
StackDefinitionID string `json:"stack_definition_id" yaml:"stack_definition_id"`
DefinitionName string `json:"definition_name,omitempty" yaml:"definition_name,omitempty"`
Expand Down Expand Up @@ -231,7 +231,7 @@ const ValuesDriftWarning = "The stored overrides differ from the running values.

// StackDefinition represents a stack definition with its chart configurations.
type StackDefinition struct {
Base
Base `yaml:",inline"`
Name string `json:"name" yaml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
DefaultBranch string `json:"default_branch" yaml:"default_branch"`
Expand Down Expand Up @@ -282,7 +282,7 @@ type TemplateLinkRequest struct {
// template has no release) and HasUnpublishedChanges. Older servers do not
// send these fields; HasUnpublishedChanges is then nil.
type StackTemplate struct {
Base
Base `yaml:",inline"`
Name string `json:"name" yaml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Category string `json:"category,omitempty" yaml:"category,omitempty"`
Expand Down Expand Up @@ -311,7 +311,7 @@ type PublishTemplateRequest struct {
// when the working copy equals the latest release (no new version). Older
// servers return only the template; SnapshotCreated is then nil.
type PublishTemplateResponse struct {
StackTemplate
StackTemplate `yaml:",inline"`
SnapshotCreated *bool `json:"snapshot_created,omitempty" yaml:"snapshot_created,omitempty"`
}

Expand All @@ -320,7 +320,7 @@ type PublishTemplateResponse struct {
// charts) and models.TemplateChartConfig (template charts) — LockedValues and
// Required are populated only on template-chart responses.
type ChartConfig struct {
Base
Base `yaml:",inline"`
StackDefinitionID string `json:"stack_definition_id,omitempty" yaml:"stack_definition_id,omitempty"`
Name string `json:"name" yaml:"name"`
RepoURL string `json:"repository_url" yaml:"repository_url"`
Expand Down Expand Up @@ -413,7 +413,7 @@ type UpdateClusterRequest struct {
// - ServiceAccount — when true, this is a non-interactive identity used
// by CI/automation; only admins can create these.
type User struct {
Base
Base `yaml:",inline"`
Username string `json:"username" yaml:"username"`
DisplayName string `json:"display_name,omitempty" yaml:"display_name,omitempty"`
Email string `json:"email,omitempty" yaml:"email,omitempty"`
Expand Down Expand Up @@ -1219,7 +1219,7 @@ type NamespaceResourceUsage struct {
// - PodLimit — 0 means "no pod-count limit"; positive integers cap namespace
// pod count.
type ClusterQuota struct {
Base
Base `yaml:",inline"`
ClusterID string `json:"cluster_id" yaml:"cluster_id"`
CPURequest string `json:"cpu_request,omitempty" yaml:"cpu_request,omitempty"`
CPULimit string `json:"cpu_limit,omitempty" yaml:"cpu_limit,omitempty"`
Expand Down Expand Up @@ -1405,7 +1405,7 @@ type SetQuotaOverrideRequest struct {

// SharedValues represents cluster-level shared Helm values.
type SharedValues struct {
Base
Base `yaml:",inline"`
ClusterID string `json:"cluster_id" yaml:"cluster_id"`
Name string `json:"name" yaml:"name"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Expand Down Expand Up @@ -1658,8 +1658,8 @@ func (v TemplateVersion) CreatedByName() string {

// TemplateVersionDetail is the full version response including the parsed snapshot.
type TemplateVersionDetail struct {
TemplateVersion
Snapshot TemplateSnapshot `json:"snapshot" yaml:"snapshot"`
TemplateVersion `yaml:",inline"`
Snapshot TemplateSnapshot `json:"snapshot" yaml:"snapshot"`
}

// TemplateSnapshot is the state of a template captured at publish time.
Expand Down Expand Up @@ -1741,7 +1741,7 @@ const TemplateVersionWorkingCopy = "working"
// - LastRunAt — nil until the policy has been executed (manually or by the
// scheduler) at least once.
type CleanupPolicy struct {
Base
Base `yaml:",inline"`
Name string `json:"name" yaml:"name"`
ClusterID string `json:"cluster_id" yaml:"cluster_id"`
Action string `json:"action" yaml:"action"`
Expand Down
Loading
Loading