From 557d15ab68f3ab3211769e87aa0cf139b762ecec Mon Sep 17 00:00:00 2001
From: Olof Mattsson
Date: Wed, 22 Apr 2026 20:12:48 +0200
Subject: [PATCH 1/2] feat: add template delete, orphaned namespace mgmt,
definition update-chart
- template delete: confirmation-prompted deletion via deleteByID pattern (#43)
- orphaned list/delete: manage namespaces with stack-manager label but
no matching DB record (#44)
- definition update: add --branch flag for default_branch (#46)
- definition update-chart: GET-merge-PUT pattern to preserve unspecified
fields; supports --chart-path, --chart-version, --deploy-order, --file (#46)
Closes #43, closes #44, closes #46
Co-Authored-By: Claude Opus 4.6
---
cli/cmd/definition.go | 127 ++++++++++++++++-
cli/cmd/definition_test.go | 275 ++++++++++++++++++++++++++++++++++++-
cli/cmd/orphaned.go | 117 ++++++++++++++++
cli/cmd/orphaned_test.go | 223 ++++++++++++++++++++++++++++++
cli/cmd/template.go | 28 ++++
cli/cmd/template_test.go | 108 +++++++++++++++
cli/pkg/client/client.go | 50 ++++++-
cli/pkg/types/types.go | 23 +++-
8 files changed, 941 insertions(+), 10 deletions(-)
create mode 100644 cli/cmd/orphaned.go
create mode 100644 cli/cmd/orphaned_test.go
diff --git a/cli/cmd/definition.go b/cli/cmd/definition.go
index da36fc6..ce41e4a 100644
--- a/cli/cmd/definition.go
+++ b/cli/cmd/definition.go
@@ -189,6 +189,7 @@ var definitionUpdateCmd = &cobra.Command{
Examples:
stackctl definition update 1 --name new-name
+ stackctl definition update 1 --branch develop
stackctl definition update 1 --from-file definition.json`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
@@ -201,9 +202,10 @@ Examples:
fromFile, _ := cmd.Flags().GetString(flagFromFile)
name, _ := cmd.Flags().GetString("name")
description, _ := cmd.Flags().GetString("description")
+ branch, _ := cmd.Flags().GetString("branch")
- if fromFile == "" && name == "" && description == "" {
- return fmt.Errorf("at least one of --name, --description, or --from-file must be specified")
+ if fromFile == "" && name == "" && description == "" && branch == "" {
+ return fmt.Errorf("at least one of --name, --description, --branch, or --from-file must be specified")
}
var req types.UpdateDefinitionRequest
@@ -228,6 +230,9 @@ Examples:
if description != "" {
req.Description = description
}
+ if branch != "" {
+ req.DefaultBranch = branch
+ }
}
c, err := newClient()
@@ -368,6 +373,116 @@ Examples:
},
}
+var definitionUpdateChartCmd = &cobra.Command{
+ Use: "update-chart ",
+ Short: "Update a chart config within a definition",
+ Long: `Update a chart configuration's settings within a stack definition.
+
+The command fetches the current chart config and merges your changes,
+so unspecified fields are preserved.
+
+Examples:
+ stackctl definition update-chart 1 5 --chart-version 0.3.0
+ stackctl definition update-chart 1 5 --chart-path /charts/kvk-core
+ stackctl definition update-chart 1 5 --deploy-order 6
+ stackctl definition update-chart 1 5 --file values.yaml`,
+ Args: cobra.ExactArgs(2),
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ defID, err := parseID(args[0])
+ if err != nil {
+ return fmt.Errorf("invalid definition ID: %w", err)
+ }
+ chartID, err := parseID(args[1])
+ if err != nil {
+ return fmt.Errorf("invalid chart ID: %w", err)
+ }
+
+ chartPath, _ := cmd.Flags().GetString("chart-path")
+ chartVersion, _ := cmd.Flags().GetString("chart-version")
+ deployOrder, _ := cmd.Flags().GetInt("deploy-order")
+ valuesFile, _ := cmd.Flags().GetString("file")
+
+ if chartPath == "" && chartVersion == "" && deployOrder < 0 && valuesFile == "" {
+ return fmt.Errorf("at least one of --chart-path, --chart-version, --deploy-order, or --file must be specified")
+ }
+
+ if valuesFile != "" {
+ for _, segment := range strings.Split(filepath.ToSlash(valuesFile), "/") {
+ if segment == ".." {
+ return errors.New(msgPathTraversal)
+ }
+ }
+ }
+
+ c, err := newClient()
+ if err != nil {
+ return err
+ }
+
+ current, err := c.GetDefinitionChart(defID, chartID)
+ if err != nil {
+ return fmt.Errorf("fetching current chart config: %w", err)
+ }
+
+ req := types.UpdateChartConfigRequest{
+ ChartName: current.ChartName,
+ ChartPath: current.RepoURL,
+ ChartVersion: current.ChartVersion,
+ DefaultValues: current.DefaultValues,
+ }
+
+ if chartPath != "" {
+ req.ChartPath = chartPath
+ }
+ if chartVersion != "" {
+ req.ChartVersion = chartVersion
+ }
+ if deployOrder >= 0 {
+ req.DeployOrder = &deployOrder
+ }
+ if valuesFile != "" {
+ valuesFile = filepath.Clean(valuesFile)
+ data, err := os.ReadFile(valuesFile)
+ if err != nil {
+ return readFileErr(valuesFile, err)
+ }
+ req.DefaultValues = string(data)
+ }
+
+ updated, err := c.UpdateDefinitionChart(defID, chartID, &req)
+ if err != nil {
+ return err
+ }
+
+ return printChartConfig(updated)
+ },
+}
+
+func printChartConfig(ch *types.ChartConfig) error {
+ if printer.Quiet {
+ fmt.Fprintln(printer.Writer, ch.ID)
+ return nil
+ }
+
+ switch printer.Format {
+ case output.FormatJSON:
+ return printer.PrintJSON(ch)
+ case output.FormatYAML:
+ return printer.PrintYAML(ch)
+ default:
+ 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},
+ }
+ return printer.PrintSingle(ch, fields)
+ }
+}
+
// printDefinition prints a stack definition in the configured output format.
func printDefinition(def *types.StackDefinition) error {
if printer.Quiet {
@@ -412,8 +527,15 @@ func init() {
// definition update flags
definitionUpdateCmd.Flags().String("name", "", "New definition name")
definitionUpdateCmd.Flags().String("description", "", "New definition description")
+ definitionUpdateCmd.Flags().String("branch", "", "New default branch")
definitionUpdateCmd.Flags().String(flagFromFile, "", "Update from JSON file")
+ // definition update-chart flags
+ definitionUpdateChartCmd.Flags().String("chart-path", "", "Chart path (e.g. /charts/kvk-core)")
+ definitionUpdateChartCmd.Flags().String("chart-version", "", "Chart version")
+ definitionUpdateChartCmd.Flags().Int("deploy-order", -1, "Deploy order (0+)")
+ definitionUpdateChartCmd.Flags().String("file", "", "File containing default values")
+
// definition delete flags
definitionDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
@@ -432,6 +554,7 @@ func init() {
definitionCmd.AddCommand(definitionDeleteCmd)
definitionCmd.AddCommand(definitionExportCmd)
definitionCmd.AddCommand(definitionImportCmd)
+ definitionCmd.AddCommand(definitionUpdateChartCmd)
rootCmd.AddCommand(definitionCmd)
}
diff --git a/cli/cmd/definition_test.go b/cli/cmd/definition_test.go
index 7e3e93a..cc701d7 100644
--- a/cli/cmd/definition_test.go
+++ b/cli/cmd/definition_test.go
@@ -879,7 +879,7 @@ func TestDefinitionUpdateCmd_NoFlagsSpecified(t *testing.T) {
err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"1"})
require.Error(t, err)
- assert.Contains(t, err.Error(), "at least one of --name, --description, or --from-file must be specified")
+ assert.Contains(t, err.Error(), "at least one of --name, --description, --branch, or --from-file must be specified")
}
// ---------- create --from-file requires name field ----------
@@ -953,3 +953,276 @@ func TestDefinitionImportCmd_InvalidJSON(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
+
+// ---------- definition update --branch ----------
+
+func TestDefinitionUpdateCmd_WithBranch(t *testing.T) {
+ updated := sampleDefinition()
+ updated.DefaultBranch = "develop"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/api/v1/stack-definitions/5", r.URL.Path)
+ require.Equal(t, http.MethodPut, r.Method)
+
+ var body types.UpdateDefinitionRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.Equal(t, "develop", body.DefaultBranch)
+ assert.Empty(t, body.Name)
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(updated)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ definitionUpdateCmd.Flags().Set("branch", "develop")
+ t.Cleanup(func() {
+ definitionUpdateCmd.Flags().Set("name", "")
+ definitionUpdateCmd.Flags().Set("description", "")
+ definitionUpdateCmd.Flags().Set("branch", "")
+ definitionUpdateCmd.Flags().Set("from-file", "")
+ })
+
+ err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"5"})
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "develop")
+}
+
+func TestDefinitionUpdateCmd_NoFlags(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("API should not be called when no flags provided")
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ t.Cleanup(func() {
+ definitionUpdateCmd.Flags().Set("name", "")
+ definitionUpdateCmd.Flags().Set("description", "")
+ definitionUpdateCmd.Flags().Set("branch", "")
+ definitionUpdateCmd.Flags().Set("from-file", "")
+ })
+
+ err := definitionUpdateCmd.RunE(definitionUpdateCmd, []string{"5"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one of")
+}
+
+// ---------- definition update-chart ----------
+
+func sampleChartConfig() types.ChartConfig {
+ return types.ChartConfig{
+ Base: types.Base{ID: "1", Version: "1"},
+ Name: "api",
+ RepoURL: "https://charts.example.com",
+ ChartName: "api-chart",
+ ChartVersion: "2.0.0",
+ ReleaseName: "api-release",
+ DefaultValues: "replicas: 1\nimage: app:latest",
+ }
+}
+
+func TestDefinitionUpdateChartCmd_ChartVersion(t *testing.T) {
+ chart := sampleChartConfig()
+ reqCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Contains(t, r.URL.Path, "/api/v1/stack-definitions/5/charts/1")
+ reqCount++
+ w.Header().Set("Content-Type", "application/json")
+
+ if r.Method == http.MethodGet {
+ json.NewEncoder(w).Encode(chart)
+ return
+ }
+
+ require.Equal(t, http.MethodPut, r.Method)
+ var body types.UpdateChartConfigRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.Equal(t, "3.0.0", body.ChartVersion)
+ assert.Equal(t, chart.ChartName, body.ChartName)
+
+ chart.ChartVersion = "3.0.0"
+ json.NewEncoder(w).Encode(chart)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0")
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.NoError(t, err)
+ assert.Equal(t, 2, reqCount)
+ assert.Contains(t, buf.String(), "api-chart")
+}
+
+func TestDefinitionUpdateChartCmd_ValuesFromFile(t *testing.T) {
+ chart := sampleChartConfig()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ json.NewEncoder(w).Encode(chart)
+ return
+ }
+ var body types.UpdateChartConfigRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ assert.Equal(t, "replicas: 3\nimage: app:v2\n", body.DefaultValues)
+
+ chart.DefaultValues = body.DefaultValues
+ json.NewEncoder(w).Encode(chart)
+ }))
+ defer server.Close()
+
+ tmpDir := t.TempDir()
+ valuesPath := filepath.Join(tmpDir, "values.yaml")
+ require.NoError(t, os.WriteFile(valuesPath, []byte("replicas: 3\nimage: app:v2\n"), 0644))
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ definitionUpdateChartCmd.Flags().Set("file", valuesPath)
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "api-chart")
+}
+
+func TestDefinitionUpdateChartCmd_DeployOrder(t *testing.T) {
+ chart := sampleChartConfig()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ json.NewEncoder(w).Encode(chart)
+ return
+ }
+ var body types.UpdateChartConfigRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ require.NotNil(t, body.DeployOrder)
+ assert.Equal(t, 6, *body.DeployOrder)
+
+ json.NewEncoder(w).Encode(chart)
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "6")
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.NoError(t, err)
+}
+
+func TestDefinitionUpdateChartCmd_NoFlags(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("API should not be called when no flags provided")
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "at least one of")
+}
+
+func TestDefinitionUpdateChartCmd_JSONOutput(t *testing.T) {
+ chart := sampleChartConfig()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ json.NewEncoder(w).Encode(chart)
+ return
+ }
+ chart.ChartVersion = "3.0.0"
+ json.NewEncoder(w).Encode(chart)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Format = output.FormatJSON
+
+ definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0")
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.NoError(t, err)
+
+ var result types.ChartConfig
+ require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
+ assert.Equal(t, "1", result.ID)
+}
+
+func TestDefinitionUpdateChartCmd_QuietOutput(t *testing.T) {
+ chart := sampleChartConfig()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if r.Method == http.MethodGet {
+ json.NewEncoder(w).Encode(chart)
+ return
+ }
+ json.NewEncoder(w).Encode(chart)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Quiet = true
+
+ definitionUpdateChartCmd.Flags().Set("chart-version", "3.0.0")
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("chart-version", "")
+ definitionUpdateChartCmd.Flags().Set("chart-path", "")
+ definitionUpdateChartCmd.Flags().Set("deploy-order", "-1")
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.NoError(t, err)
+ assert.Equal(t, "1\n", buf.String())
+}
+
+func TestDefinitionUpdateChartCmd_PathTraversal(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("API should not be called for path traversal")
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ definitionUpdateChartCmd.Flags().Set("file", "../../../etc/passwd")
+ t.Cleanup(func() {
+ definitionUpdateChartCmd.Flags().Set("file", "")
+ })
+
+ err := definitionUpdateChartCmd.RunE(definitionUpdateChartCmd, []string{"5", "1"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "path")
+}
diff --git a/cli/cmd/orphaned.go b/cli/cmd/orphaned.go
new file mode 100644
index 0000000..10b424f
--- /dev/null
+++ b/cli/cmd/orphaned.go
@@ -0,0 +1,117 @@
+package cmd
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/omattsson/stackctl/cli/pkg/output"
+ "github.com/spf13/cobra"
+)
+
+var orphanedCmd = &cobra.Command{
+ Use: "orphaned",
+ Short: "Manage orphaned Kubernetes namespaces",
+ Long: "List and clean up namespaces that have the stack-manager label but no matching database record.",
+}
+
+var orphanedListCmd = &cobra.Command{
+ Use: "list",
+ Short: "List orphaned namespaces",
+ Long: `List Kubernetes namespaces that have the stack-manager label but no matching stack instance in the database.
+
+Examples:
+ stackctl orphaned list
+ stackctl orphaned list -o json`,
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ c, err := newClient()
+ if err != nil {
+ return err
+ }
+
+ namespaces, err := c.ListOrphanedNamespaces()
+ if err != nil {
+ return err
+ }
+
+ if printer.Quiet {
+ for _, ns := range namespaces {
+ fmt.Fprintln(printer.Writer, ns.Namespace)
+ }
+ return nil
+ }
+
+ switch printer.Format {
+ case output.FormatJSON:
+ return printer.PrintJSON(namespaces)
+ case output.FormatYAML:
+ return printer.PrintYAML(namespaces)
+ default:
+ if len(namespaces) == 0 {
+ printer.PrintMessage("No orphaned namespaces found.")
+ return nil
+ }
+ headers := []string{"NAMESPACE", "CLUSTER", "CREATED"}
+ rows := make([][]string, len(namespaces))
+ for i, ns := range namespaces {
+ rows[i] = []string{ns.Namespace, ns.Cluster, ns.CreatedAt}
+ }
+ return printer.PrintTable(headers, rows)
+ }
+ },
+}
+
+var orphanedDeleteCmd = &cobra.Command{
+ Use: "delete ",
+ Short: "Delete an orphaned namespace",
+ Long: `Remove an orphaned Kubernetes namespace.
+
+This is a destructive operation. You will be prompted for confirmation
+unless --yes is specified.
+
+Examples:
+ stackctl orphaned delete stack-old-namespace
+ stackctl orphaned delete stack-old-namespace --yes`,
+ Args: cobra.ExactArgs(1),
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ namespace := strings.TrimSpace(args[0])
+ if namespace == "" {
+ return fmt.Errorf("namespace must not be empty")
+ }
+
+ confirmed, err := confirmAction(cmd, fmt.Sprintf("This will delete orphaned namespace %q. Continue? (y/n): ", namespace))
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ printer.PrintMessage("Aborted.")
+ return nil
+ }
+
+ c, err := newClient()
+ if err != nil {
+ return err
+ }
+
+ if err := c.DeleteOrphanedNamespace(namespace); err != nil {
+ return err
+ }
+
+ if printer.Quiet {
+ fmt.Fprintln(printer.Writer, namespace)
+ return nil
+ }
+
+ printer.PrintMessage("Deleted orphaned namespace %q", namespace)
+ return nil
+ },
+}
+
+func init() {
+ orphanedDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
+
+ orphanedCmd.AddCommand(orphanedListCmd)
+ orphanedCmd.AddCommand(orphanedDeleteCmd)
+ rootCmd.AddCommand(orphanedCmd)
+}
diff --git a/cli/cmd/orphaned_test.go b/cli/cmd/orphaned_test.go
new file mode 100644
index 0000000..e67b31e
--- /dev/null
+++ b/cli/cmd/orphaned_test.go
@@ -0,0 +1,223 @@
+package cmd
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "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"
+)
+
+// Tests in this file are NOT parallelized because they mutate package-level
+// globals (cfg, printer, flagAPIURL) via setupStackTestCmd. Do not add t.Parallel().
+
+func sampleOrphanedNamespaces() []types.OrphanedNamespace {
+ return []types.OrphanedNamespace{
+ {Namespace: "stack-old-app", Cluster: "dev-cluster", CreatedAt: "2025-06-01T10:00:00Z"},
+ {Namespace: "stack-leftover", Cluster: "dev-cluster", CreatedAt: "2025-05-20T08:00:00Z"},
+ }
+}
+
+// ---------- orphaned list ----------
+
+func TestOrphanedListCmd_TableOutput(t *testing.T) {
+ ns := sampleOrphanedNamespaces()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/api/v1/orphaned-namespaces", r.URL.Path)
+ require.Equal(t, http.MethodGet, r.Method)
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ns)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ err := orphanedListCmd.RunE(orphanedListCmd, []string{})
+ require.NoError(t, err)
+
+ out := buf.String()
+ assert.Contains(t, out, "NAMESPACE")
+ assert.Contains(t, out, "CLUSTER")
+ assert.Contains(t, out, "stack-old-app")
+ assert.Contains(t, out, "stack-leftover")
+}
+
+func TestOrphanedListCmd_JSONOutput(t *testing.T) {
+ ns := sampleOrphanedNamespaces()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ns)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Format = output.FormatJSON
+
+ err := orphanedListCmd.RunE(orphanedListCmd, []string{})
+ require.NoError(t, err)
+
+ var result []types.OrphanedNamespace
+ require.NoError(t, json.Unmarshal(buf.Bytes(), &result))
+ assert.Len(t, result, 2)
+ assert.Equal(t, "stack-old-app", result[0].Namespace)
+}
+
+func TestOrphanedListCmd_QuietOutput(t *testing.T) {
+ ns := sampleOrphanedNamespaces()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(ns)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Quiet = true
+
+ err := orphanedListCmd.RunE(orphanedListCmd, []string{})
+ require.NoError(t, err)
+
+ lines := strings.TrimSpace(buf.String())
+ assert.Equal(t, "stack-old-app\nstack-leftover", lines)
+}
+
+func TestOrphanedListCmd_Empty(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode([]types.OrphanedNamespace{})
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ err := orphanedListCmd.RunE(orphanedListCmd, []string{})
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "No orphaned namespaces found")
+}
+
+func TestOrphanedListCmd_APIError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ json.NewEncoder(w).Encode(types.ErrorResponse{})
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ err := orphanedListCmd.RunE(orphanedListCmd, []string{})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Not authenticated")
+}
+
+// ---------- orphaned delete ----------
+
+func TestOrphanedDeleteCmd_WithYesFlag(t *testing.T) {
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ require.Equal(t, "/api/v1/orphaned-namespaces/stack-old-app", r.URL.Path)
+ require.Equal(t, http.MethodDelete, r.Method)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ orphanedDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") })
+
+ err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"})
+ require.NoError(t, err)
+ assert.True(t, called)
+ assert.Contains(t, buf.String(), "Deleted orphaned namespace")
+}
+
+func TestOrphanedDeleteCmd_WithConfirmation(t *testing.T) {
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ orphanedDeleteCmd.Flags().Set("yes", "false")
+ t.Cleanup(func() {
+ orphanedDeleteCmd.Flags().Set("yes", "false")
+ orphanedDeleteCmd.SetIn(nil)
+ orphanedDeleteCmd.SetErr(nil)
+ })
+
+ orphanedDeleteCmd.SetIn(strings.NewReader("y\n"))
+ orphanedDeleteCmd.SetErr(&bytes.Buffer{})
+
+ err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"})
+ require.NoError(t, err)
+ assert.True(t, called)
+ assert.Contains(t, buf.String(), "Deleted orphaned namespace")
+}
+
+func TestOrphanedDeleteCmd_Declined(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("API should NOT be called when user declines")
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ orphanedDeleteCmd.Flags().Set("yes", "false")
+ t.Cleanup(func() {
+ orphanedDeleteCmd.Flags().Set("yes", "false")
+ orphanedDeleteCmd.SetIn(nil)
+ orphanedDeleteCmd.SetErr(nil)
+ })
+
+ orphanedDeleteCmd.SetIn(strings.NewReader("n\n"))
+ orphanedDeleteCmd.SetErr(&bytes.Buffer{})
+
+ err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"})
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "Aborted")
+}
+
+func TestOrphanedDeleteCmd_NotFound(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(types.ErrorResponse{Error: "namespace not found"})
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ orphanedDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") })
+
+ err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"nonexistent"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "namespace not found")
+}
+
+func TestOrphanedDeleteCmd_QuietOutput(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Quiet = true
+
+ orphanedDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { orphanedDeleteCmd.Flags().Set("yes", "false") })
+
+ err := orphanedDeleteCmd.RunE(orphanedDeleteCmd, []string{"stack-old-app"})
+ require.NoError(t, err)
+ assert.Equal(t, "stack-old-app\n", buf.String())
+}
diff --git a/cli/cmd/template.go b/cli/cmd/template.go
index 2d7c452..cd37146 100644
--- a/cli/cmd/template.go
+++ b/cli/cmd/template.go
@@ -4,6 +4,7 @@ import (
"fmt"
"strconv"
+ "github.com/omattsson/stackctl/cli/pkg/client"
"github.com/omattsson/stackctl/cli/pkg/output"
"github.com/omattsson/stackctl/cli/pkg/types"
"github.com/spf13/cobra"
@@ -229,6 +230,29 @@ Examples:
},
}
+var templateDeleteCmd = &cobra.Command{
+ Use: "delete ",
+ Short: "Delete a stack template",
+ Long: `Permanently delete a stack template.
+
+This is a destructive operation. You will be prompted for confirmation
+unless --yes is specified.
+
+Examples:
+ stackctl template delete 1
+ stackctl template delete 1 --yes`,
+ Args: cobra.ExactArgs(1),
+ SilenceUsage: true,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ return deleteByID(cmd, args,
+ "This will permanently delete template %s. Continue? (y/n): ",
+ passthroughID,
+ func(c *client.Client, id string) error { return c.DeleteTemplate(id) },
+ "Deleted template %s",
+ )
+ },
+}
+
func init() {
// template list flags
templateListCmd.Flags().Bool("published", false, "Show only published templates")
@@ -247,10 +271,14 @@ func init() {
templateQuickDeployCmd.Flags().String("cluster", "", "Target cluster ID")
_ = templateQuickDeployCmd.MarkFlagRequired("name")
+ // template delete flags
+ templateDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
+
// Wire up subcommands
templateCmd.AddCommand(templateListCmd)
templateCmd.AddCommand(templateGetCmd)
templateCmd.AddCommand(templateInstantiateCmd)
templateCmd.AddCommand(templateQuickDeployCmd)
+ templateCmd.AddCommand(templateDeleteCmd)
rootCmd.AddCommand(templateCmd)
}
diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go
index 1b1ea65..14b4c21 100644
--- a/cli/cmd/template_test.go
+++ b/cli/cmd/template_test.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -539,3 +540,110 @@ func TestTemplateInstantiateCmd_Forbidden(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "Permission denied")
}
+
+// ---------- template delete ----------
+
+func TestTemplateDeleteCmd_WithYesFlag(t *testing.T) {
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ require.Equal(t, "/api/v1/templates/10", r.URL.Path)
+ require.Equal(t, http.MethodDelete, r.Method)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ templateDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") })
+
+ err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"})
+ require.NoError(t, err)
+ assert.True(t, called)
+ assert.Contains(t, buf.String(), "Deleted template 10")
+}
+
+func TestTemplateDeleteCmd_WithConfirmation(t *testing.T) {
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ templateDeleteCmd.Flags().Set("yes", "false")
+ t.Cleanup(func() {
+ templateDeleteCmd.Flags().Set("yes", "false")
+ templateDeleteCmd.SetIn(nil)
+ templateDeleteCmd.SetErr(nil)
+ })
+
+ templateDeleteCmd.SetIn(strings.NewReader("y\n"))
+ templateDeleteCmd.SetErr(&bytes.Buffer{})
+
+ err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"})
+ require.NoError(t, err)
+ assert.True(t, called)
+ assert.Contains(t, buf.String(), "Deleted template 10")
+}
+
+func TestTemplateDeleteCmd_Declined(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("API should NOT be called when user declines")
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+
+ templateDeleteCmd.Flags().Set("yes", "false")
+ t.Cleanup(func() {
+ templateDeleteCmd.Flags().Set("yes", "false")
+ templateDeleteCmd.SetIn(nil)
+ templateDeleteCmd.SetErr(nil)
+ })
+
+ templateDeleteCmd.SetIn(strings.NewReader("n\n"))
+ templateDeleteCmd.SetErr(&bytes.Buffer{})
+
+ err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"})
+ require.NoError(t, err)
+ assert.Contains(t, buf.String(), "Aborted")
+}
+
+func TestTemplateDeleteCmd_NotFound(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"})
+ }))
+ defer server.Close()
+
+ _ = setupStackTestCmd(t, server.URL)
+
+ templateDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") })
+
+ err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"999"})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "template not found")
+}
+
+func TestTemplateDeleteCmd_QuietOutput(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ buf := setupStackTestCmd(t, server.URL)
+ printer.Quiet = true
+
+ templateDeleteCmd.Flags().Set("yes", "true")
+ t.Cleanup(func() { templateDeleteCmd.Flags().Set("yes", "false") })
+
+ err := templateDeleteCmd.RunE(templateDeleteCmd, []string{"10"})
+ require.NoError(t, err)
+ assert.Equal(t, "10\n", buf.String())
+}
diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go
index fea5b8f..ef7f59e 100644
--- a/cli/pkg/client/client.go
+++ b/cli/pkg/client/client.go
@@ -17,10 +17,12 @@ const defaultTimeout = 30 * time.Second
const maxServerMessageLen = 256
const (
- pathDefinition = "/api/v1/stack-definitions/%s"
- pathOverride = "/api/v1/stack-instances/%s/overrides/%s"
- pathBranchOverride = "/api/v1/stack-instances/%s/branches/%s"
- pathQuotaOverride = "/api/v1/stack-instances/%s/quota-overrides"
+ pathDefinition = "/api/v1/stack-definitions/%s"
+ pathDefinitionChart = "/api/v1/stack-definitions/%s/charts/%s"
+ pathTemplate = "/api/v1/templates/%s"
+ pathOverride = "/api/v1/stack-instances/%s/overrides/%s"
+ pathBranchOverride = "/api/v1/stack-instances/%s/branches/%s"
+ pathQuotaOverride = "/api/v1/stack-instances/%s/quota-overrides"
)
// Client is the HTTP client for the k8s-stack-manager API.
@@ -444,6 +446,46 @@ func (c *Client) QuickDeployTemplate(id string, req *types.QuickDeployRequest) (
return &instance, nil
}
+// DeleteTemplate deletes a stack template by ID.
+func (c *Client) DeleteTemplate(id string) error {
+ return c.Delete(fmt.Sprintf(pathTemplate, id))
+}
+
+// ListOrphanedNamespaces returns namespaces that have the stack-manager label but no matching DB record.
+func (c *Client) ListOrphanedNamespaces() ([]types.OrphanedNamespace, error) {
+ var ns []types.OrphanedNamespace
+ err := c.Get("/api/v1/orphaned-namespaces", &ns)
+ if err != nil {
+ return nil, err
+ }
+ return ns, nil
+}
+
+// DeleteOrphanedNamespace removes an orphaned namespace.
+func (c *Client) DeleteOrphanedNamespace(namespace string) error {
+ return c.Delete(fmt.Sprintf("/api/v1/orphaned-namespaces/%s", namespace))
+}
+
+// GetDefinitionChart returns a single chart config within a definition.
+func (c *Client) GetDefinitionChart(defID, chartID string) (*types.ChartConfig, error) {
+ var chart types.ChartConfig
+ err := c.Get(fmt.Sprintf(pathDefinitionChart, defID, chartID), &chart)
+ if err != nil {
+ return nil, err
+ }
+ return &chart, nil
+}
+
+// UpdateDefinitionChart updates a chart config within a definition.
+func (c *Client) UpdateDefinitionChart(defID, chartID string, req *types.UpdateChartConfigRequest) (*types.ChartConfig, error) {
+ var chart types.ChartConfig
+ err := c.Put(fmt.Sprintf(pathDefinitionChart, defID, chartID), req, &chart)
+ if err != nil {
+ return nil, err
+ }
+ return &chart, nil
+}
+
// ListDefinitions returns a paginated list of stack definitions, filtered by query params.
func (c *Client) ListDefinitions(params map[string]string) (*types.ListResponse[types.StackDefinition], error) {
var resp types.ListResponse[types.StackDefinition]
diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go
index ac0b252..cd7c897 100644
--- a/cli/pkg/types/types.go
+++ b/cli/pkg/types/types.go
@@ -211,9 +211,26 @@ type CreateDefinitionRequest struct {
// UpdateDefinitionRequest is the request body for PUT /api/v1/stack-definitions/:id.
type UpdateDefinitionRequest struct {
- Name string `json:"name,omitempty" yaml:"name,omitempty"`
- Description string `json:"description,omitempty" yaml:"description,omitempty"`
- Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"`
+ Name string `json:"name,omitempty" yaml:"name,omitempty"`
+ Description string `json:"description,omitempty" yaml:"description,omitempty"`
+ DefaultBranch string `json:"default_branch,omitempty" yaml:"default_branch,omitempty"`
+ Charts []ChartConfig `json:"charts,omitempty" yaml:"charts,omitempty"`
+}
+
+// UpdateChartConfigRequest is the request body for PUT /api/v1/stack-definitions/:id/charts/:chartID.
+type UpdateChartConfigRequest struct {
+ ChartName string `json:"chart_name,omitempty" yaml:"chart_name,omitempty"`
+ ChartPath string `json:"chart_path,omitempty" yaml:"chart_path,omitempty"`
+ ChartVersion string `json:"chart_version,omitempty" yaml:"chart_version,omitempty"`
+ DeployOrder *int `json:"deploy_order,omitempty" yaml:"deploy_order,omitempty"`
+ DefaultValues string `json:"default_values,omitempty" yaml:"default_values,omitempty"`
+}
+
+// OrphanedNamespace represents a Kubernetes namespace with no matching stack record.
+type OrphanedNamespace struct {
+ Namespace string `json:"namespace" yaml:"namespace"`
+ Cluster string `json:"cluster,omitempty" yaml:"cluster,omitempty"`
+ CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"`
}
// BulkRequest is the request body for bulk operations.
From a17c67d48dec6878d81fe96ff824594007c9d67b Mon Sep 17 00:00:00 2001
From: Olof Mattsson
Date: Wed, 22 Apr 2026 20:16:49 +0200
Subject: [PATCH 2/2] docs: update README with new commands and name-based
resolution
- Add template delete, orphaned namespace list/delete sections
- Add definition update (--branch), update-chart examples
- Document name-based resolution in stack and bulk commands
- Add stack history/rollback/history-values examples
- Update project structure (orphaned.go, resolve.go)
Co-Authored-By: Claude Opus 4.6
---
README.md | 71 +++++++++++++++++++++++++++++++++++++++++++------------
1 file changed, 56 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index de5969a..5e4f64b 100644
--- a/README.md
+++ b/README.md
@@ -130,6 +130,8 @@ Configuration values are resolved in this order (highest priority first):
+All stack commands accept a **name or ID** — e.g. `stackctl stack deploy my-app` or `stackctl stack deploy 42`.
+
```bash
# List instances
stackctl stack list
@@ -138,22 +140,27 @@ stackctl stack list --cluster 1 -o json
# Create and deploy
stackctl stack create --definition 1 --name my-app --branch feature/xyz --ttl 480
-stackctl stack deploy 42
+stackctl stack deploy my-app
# Monitor
-stackctl stack status 42
-stackctl stack logs 42
+stackctl stack status my-app
+stackctl stack logs my-app
# Lifecycle
-stackctl stack stop 42
-stackctl stack clean 42
-stackctl stack delete 42
+stackctl stack stop my-app
+stackctl stack clean my-app
+stackctl stack delete my-app
# Clone an existing instance
-stackctl stack clone 42
+stackctl stack clone my-app
# Extend TTL
-stackctl stack extend 42 --minutes 120
+stackctl stack extend my-app --minutes 120
+
+# Deployment history and rollback
+stackctl stack history my-app
+stackctl stack history-values my-app
+stackctl stack rollback my-app --target
```
### Templates
@@ -168,6 +175,9 @@ stackctl template quick-deploy 1
# Or step by step
stackctl template instantiate 1 --name my-stack --branch main
+
+# Delete a template
+stackctl template delete 1
```
### Stack Definitions
@@ -180,6 +190,20 @@ stackctl definition get 5
# Create from file
stackctl definition create --from-file definition.json
+# Update metadata
+stackctl definition update 5 --name new-name
+stackctl definition update 5 --branch develop
+stackctl definition update 5 --description "Updated description"
+
+# Update a chart config (GET-merge-PUT preserves unspecified fields)
+stackctl definition update-chart 5 1 --chart-version 0.3.0
+stackctl definition update-chart 5 1 --chart-path /charts/kvk-core
+stackctl definition update-chart 5 1 --deploy-order 6
+stackctl definition update-chart 5 1 --file values.yaml
+
+# Delete
+stackctl definition delete 5
+
# Export / import
stackctl definition export 5 > backup.json
stackctl definition import --file backup.json
@@ -212,10 +236,12 @@ stackctl stack compare 42 43
### Bulk Operations
+Bulk commands accept **names or IDs** (up to 50 at a time).
+
```bash
-# Bulk deploy/stop/clean/delete (up to 50 instances)
-stackctl bulk deploy --ids 1,2,3,4,5
-stackctl bulk deploy 1 2 3 4 5 # positional args also work
+# Bulk deploy/stop/clean/delete
+stackctl bulk deploy --ids my-app,other-app,3
+stackctl bulk deploy my-app other-app 3 # positional args also work
stackctl bulk stop --ids 1,2,3
stackctl bulk clean --ids 1,2,3
@@ -223,6 +249,19 @@ stackctl bulk clean --ids 1,2,3
stackctl stack list --status stopped --mine -q | xargs stackctl bulk deploy
```
+### Orphaned Namespaces
+
+Manage Kubernetes namespaces that have the stack-manager label but no matching database record.
+
+```bash
+# List orphaned namespaces
+stackctl orphaned list
+stackctl orphaned list -o json
+
+# Delete an orphaned namespace
+stackctl orphaned delete stack-old-namespace
+```
+
### Scripting Examples
```bash
@@ -326,11 +365,13 @@ cli/
cmd/ # Cobra commands (one file per command group)
config.go # config set/get/list/use-context/current-context/delete-context
login.go # login, logout, whoami
- stack.go # stack lifecycle (13 subcommands)
- template.go # template list/get/instantiate/quick-deploy
- definition.go # definition CRUD + export/import
+ stack.go # stack lifecycle (create, deploy, stop, clean, delete, clone, extend, status, logs, history, rollback, compare, values)
+ template.go # template list/get/instantiate/quick-deploy/delete
+ definition.go # definition CRUD + export/import + update-chart
override.go # value, branch, and quota overrides
- bulk.go # bulk deploy/stop/clean/delete
+ orphaned.go # orphaned namespace list/delete
+ bulk.go # bulk deploy/stop/clean/delete (names or IDs)
+ resolve.go # name/ID resolution helpers
git.go # git branches/validate
cluster.go # cluster list/get
completion.go # shell completion (bash/zsh/fish/powershell)