From 565472028bc9a70624d86a5cc9ec2b5bd068fdb7 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 15 Jun 2026 16:30:39 -0400 Subject: [PATCH] feat: add cascade init command to scaffold a project Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + docs/src/content/docs/adoption.md | 15 ++ docs/src/content/docs/cli-reference.md | 31 +++ docs/src/content/docs/getting-started.md | 28 +++ go.mod | 2 +- go.sum | 2 + internal/initcmd/command.go | 275 +++++++++++++++++++++++ internal/initcmd/command_test.go | 229 +++++++++++++++++++ 8 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 internal/initcmd/command.go create mode 100644 internal/initcmd/command_test.go diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 8edc0d1..8adce35 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -13,6 +13,7 @@ import ( "github.com/stablekernel/cascade/internal/generate" "github.com/stablekernel/cascade/internal/globals" "github.com/stablekernel/cascade/internal/hotfix" + initcmd "github.com/stablekernel/cascade/internal/initcmd" "github.com/stablekernel/cascade/internal/log" "github.com/stablekernel/cascade/internal/orchestrate" "github.com/stablekernel/cascade/internal/promote" @@ -72,6 +73,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(external.NewCommand()) rootCmd.AddCommand(generate.NewCommand()) rootCmd.AddCommand(hotfix.NewCommand()) + rootCmd.AddCommand(initcmd.NewCommand()) rootCmd.AddCommand(orchestrate.NewCommand()) rootCmd.AddCommand(promote.NewCommand()) rootCmd.AddCommand(release.NewCommand()) diff --git a/docs/src/content/docs/adoption.md b/docs/src/content/docs/adoption.md index 684f5a4..3e8282c 100644 --- a/docs/src/content/docs/adoption.md +++ b/docs/src/content/docs/adoption.md @@ -22,6 +22,21 @@ The flow in one line: you write a manifest plus callback workflows, run `cascade ## Build a pipeline from scratch +**Fast path:** `cascade init` does the first three steps below for you. It +scaffolds the manifest and the callback stubs, verifies them through the real +generator, and writes them into your repository: + +```bash +cascade init --topology two-env # dev, prod +cascade init --envs staging,production # your own ordered names +``` + +Pick a preset with `--topology` (`no-env`, `two-env`, `three-env`, `four-env`) +or supply your own ordered list with `--envs`. Then jump to step 4 to generate +and commit. The walkthrough below explains each piece `init` produces, so you +understand what you are filling in. See the [CLI Reference](/cascade/cli-reference/#init) +for every flag. + ### 1. Choose your environments Environments are **positional**, not named by meaning. cascade attaches no semantics to a name like `prod`; it reads the list by position: diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 1cc5e29..7b55203 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -160,6 +160,37 @@ Breaking changes detected via: - `!` suffix: `feat!: breaking change` - Footer: `BREAKING CHANGE: description` (case-sensitive, line start) +### init + +Scaffold a starter manifest and matching callback workflow stubs, verified through the real generator before anything is written. + +```bash +# Two-environment pipeline (dev, prod) in the current directory +cascade init --topology two-env + +# Custom ordered environments; the last is the release stage +cascade init --envs staging,production --name my-service + +# Preview without writing +cascade init --topology three-env --dry-run +``` + +`init` renders `.github/manifest.yaml` plus build (and, when environments are set, deploy) stubs under `.github/workflows`, runs the manifest through parse, validation, and generation, and only then writes the files. The manifest carries a `$schema` directive for editor autocomplete and validation. After running it, fill in the stub callbacks, commit, and run `cascade generate-workflow`. + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--topology` | string | `two-env` | Preset shape: one of `no-env`, `two-env`, `three-env`, `four-env` | +| `--envs` | string | - | Comma-separated ordered environment names; the last is the release stage (overrides `--topology`) | +| `--name` | string | dir base name | Project name woven into the stubs | +| `--dir` | string | `.` | Target directory to scaffold into | +| `--cli-version` | string | pinned release | cascade CLI version pinned in the manifest | +| `--force`, `-f` | bool | false | Overwrite existing files | +| `--dry-run` | bool | false | Print what would be written without writing anything | + +Environment names are positional, not semantic: the last name is the release stage. An empty list (`--topology no-env`) produces a release-only project with no deploy stub. If any target file already exists and `--force` is not set, `init` aborts and lists the conflicts, writing nothing. + ### generate-workflow Generate the orchestrate and promote workflows from the manifest. diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md index e88eaa4..4690f4d 100644 --- a/docs/src/content/docs/getting-started.md +++ b/docs/src/content/docs/getting-started.md @@ -40,6 +40,34 @@ If you need to invoke it manually: The setup action downloads the release archive (`tar.gz`) from GoReleaser and installs the `cascade` binary on `PATH`. +## Fast path: scaffold with `cascade init` + +If you want a working configuration in one step, run `cascade init`. It renders +the manifest and the callback workflow stubs for you, verifies them through the +real generator, and writes them into your repository: + +```bash +# Two-environment pipeline (dev, prod) in the current directory +cascade init --topology two-env + +# Or choose your own ordered environments; the last is the release stage +cascade init --envs staging,production --name my-service + +# Preview without writing anything +cascade init --topology two-env --dry-run +``` + +This produces `.github/manifest.yaml` plus build and deploy stubs under +`.github/workflows`. The manifest already carries a `$schema` directive, so your +editor gives you autocomplete and validation while you fill in the stubs. If a +target file already exists, `init` aborts and lists the conflicts unless you +pass `--force`. + +Once scaffolded, skip ahead to [Step 3](#step-3-create-callback-workflows) to +fill in the callbacks, then generate the orchestration workflows. The manual +walkthrough below covers the same files step by step if you would rather build +them yourself. + ## Step 2: Create the manifest Create `.github/manifest.yaml` in your repository: diff --git a/go.mod b/go.mod index 4f9ed9c..7178752 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stablekernel/cascade go 1.23 require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 @@ -12,7 +13,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/pflag v1.0.9 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 9124392..7e6533a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/initcmd/command.go b/internal/initcmd/command.go new file mode 100644 index 0000000..f7092bf --- /dev/null +++ b/internal/initcmd/command.go @@ -0,0 +1,275 @@ +// Package initcmd implements the "cascade init" command, which scaffolds a new +// project: it renders a starter manifest and matching reusable-workflow stubs, +// self-checks them through the real generator, and writes them to disk so a +// repository can adopt cascade with a working configuration on the first try. +package initcmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/scaffold" +) + +// defaultTopology is the preset used when neither --topology nor --envs is +// given. A two-environment pipeline is the most common starting point. +const defaultTopology = "two-env" + +// options holds the resolved inputs for a single init invocation. +type options struct { + topology string + envs string + name string + dir string + cliVersion string + force bool + dryRun bool +} + +// NewCommand creates the "init" command. +func NewCommand() *cobra.Command { + opts := options{} + + cmd := &cobra.Command{ + Use: "init", + Short: "Scaffold a starter manifest and callback workflow stubs", + Long: `Scaffold a new cascade project. + +init renders a starter .github/manifest.yaml plus matching reusable-workflow +stubs for your build and deploy callbacks, verifies them through the real +generator, and writes them into your repository. The generated manifest carries +a $schema directive so editors give you autocomplete and validation while you +fill in the stubs. + +Choose a shape with --topology (a named preset) or --envs (your own ordered +environment names). Environment names are positional: the last one is the +release stage. When the environment list is empty the project is release-only, +with no deploy stub. + +Examples: + # Two-environment pipeline (dev, prod) in the current directory + cascade init --topology two-env + + # Custom ordered environments; "production" is the release stage + cascade init --envs staging,production --name my-service + + # Preview what would be written without touching the filesystem + cascade init --topology three-env --dry-run + + # Overwrite existing files + cascade init --topology two-env --force`, + Args: cobra.NoArgs, + // Runtime failures (refuse-on-existing, scaffold self-check) are not + // usage errors, so suppress the usage dump on RunE error. + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd.OutOrStdout(), opts) + }, + } + + presets := strings.Join(topologyNames(), ", ") + cmd.Flags().StringVar(&opts.topology, "topology", "", "Preset environment shape; one of: "+presets+" (default \""+defaultTopology+"\")") + cmd.Flags().StringVar(&opts.envs, "envs", "", "Comma-separated ordered environment names; the last is the release stage (overrides --topology)") + cmd.Flags().StringVar(&opts.name, "name", "", "Project name woven into the stubs (default: target directory base name)") + cmd.Flags().StringVar(&opts.dir, "dir", ".", "Target directory to scaffold into") + cmd.Flags().StringVar(&opts.cliVersion, "cli-version", "", "cascade CLI version pinned in the manifest (default: the built-in pinned release)") + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Overwrite existing files") + cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Print what would be written without writing anything") + + return cmd +} + +// run resolves inputs, scaffolds (which self-checks before returning), then +// either previews or writes the result. Nothing is written when scaffolding +// fails, when an existing file would be overwritten without --force, or in +// dry-run mode. +func run(out io.Writer, opts options) error { + envs, err := resolveEnvs(opts.topology, opts.envs) + if err != nil { + return err + } + + name, err := resolveName(opts.name, opts.dir) + if err != nil { + return err + } + + // Scaffold self-checks (parse + validate + generate) before returning, so a + // broken scaffold never reaches the filesystem. + files, err := scaffold.Scaffold(name, "main", envs, scaffold.WithCLIVersion(opts.cliVersion)) + if err != nil { + return fmt.Errorf("scaffolding %q: %w", name, err) + } + + paths := sortedPaths(files) + + if opts.dryRun { + return writeReport(out, dryRunReport(opts.dir, name, envs, paths)) + } + + if !opts.force { + if conflicts := existingFiles(opts.dir, paths); len(conflicts) > 0 { + return fmt.Errorf( + "refusing to overwrite existing files (use --force to overwrite):\n %s", + strings.Join(conflicts, "\n "), + ) + } + } + + if err := writeFiles(opts.dir, files); err != nil { + return err + } + + return writeReport(out, summaryReport(opts.dir, name, envs, paths)) +} + +// writeReport writes a fully-rendered report to out, surfacing any write error. +func writeReport(out io.Writer, report string) error { + if _, err := io.WriteString(out, report); err != nil { + return fmt.Errorf("writing output: %w", err) + } + return nil +} + +// resolveEnvs turns the --topology / --envs flags into an ordered environment +// list. --envs wins when set; otherwise the named (or default) preset is used. +func resolveEnvs(topology, envs string) ([]string, error) { + if strings.TrimSpace(envs) != "" { + parts := strings.Split(envs, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if trimmed := strings.TrimSpace(p); trimmed != "" { + out = append(out, trimmed) + } + } + if len(out) == 0 { + return nil, fmt.Errorf("--envs was set but contained no environment names") + } + return out, nil + } + + name := topology + if name == "" { + name = defaultTopology + } + presets := scaffold.Topologies() + list, ok := presets[name] + if !ok { + return nil, fmt.Errorf("unknown topology %q; valid presets: %s", name, strings.Join(topologyNames(), ", ")) + } + // Return a copy so callers cannot mutate the preset slice. + return append([]string(nil), list...), nil +} + +// resolveName returns the project name, defaulting to the base name of the +// resolved target directory. +func resolveName(name, dir string) (string, error) { + if strings.TrimSpace(name) != "" { + return name, nil + } + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolving target directory %q: %w", dir, err) + } + base := filepath.Base(abs) + if base == "" || base == "." || base == string(filepath.Separator) { + return "", fmt.Errorf("could not derive a project name from %q; pass --name", dir) + } + return base, nil +} + +// topologyNames returns the preset names in a stable order for help text and +// error messages. +func topologyNames() []string { + names := make([]string, 0, len(scaffold.Topologies())) + for n := range scaffold.Topologies() { + names = append(names, n) + } + sort.Strings(names) + return names +} + +// sortedPaths returns the file paths of a scaffold output in stable order. +func sortedPaths(files map[string]string) []string { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + sort.Strings(paths) + return paths +} + +// existingFiles returns the subset of paths (joined under dir) that already +// exist on disk. +func existingFiles(dir string, paths []string) []string { + var conflicts []string + for _, p := range paths { + abs := filepath.Join(dir, p) + if _, err := os.Stat(abs); err == nil { + conflicts = append(conflicts, abs) + } + } + return conflicts +} + +// writeFiles materializes the scaffold output under dir, creating parent +// directories as needed. +func writeFiles(dir string, files map[string]string) error { + for rel, content := range files { + abs := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return fmt.Errorf("creating directory for %q: %w", abs, err) + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + return fmt.Errorf("writing %q: %w", abs, err) + } + } + return nil +} + +// shape describes the chosen environment list for human-readable output. +func shape(envs []string) string { + if len(envs) == 0 { + return "release-only (no environments)" + } + return strings.Join(envs, " -> ") +} + +// dryRunReport renders what init would write, without touching the filesystem. +func dryRunReport(dir, name string, envs, paths []string) string { + var b strings.Builder + fmt.Fprintf(&b, "Dry run: would scaffold project %q in %q\n", name, dir) + fmt.Fprintf(&b, "Environments: %s\n", shape(envs)) + b.WriteString("Files that would be written:\n") + for _, p := range paths { + fmt.Fprintf(&b, " %s\n", filepath.Join(dir, p)) + } + b.WriteString("No files were written.\n") + return b.String() +} + +// summaryReport renders what init created and the recommended next steps. +func summaryReport(dir, name string, envs, paths []string) string { + var b strings.Builder + fmt.Fprintf(&b, "Scaffolded project %q in %q\n", name, dir) + fmt.Fprintf(&b, "Environments: %s\n", shape(envs)) + b.WriteString("Created:\n") + for _, p := range paths { + fmt.Fprintf(&b, " %s\n", filepath.Join(dir, p)) + } + b.WriteString("\n") + b.WriteString("The manifest already carries a $schema directive, so editors give you\n") + b.WriteString("autocomplete and validation as you edit it.\n") + b.WriteString("\n") + b.WriteString("Next steps:\n") + b.WriteString(" 1. Review and fill in the stub callback workflows under .github/workflows.\n") + b.WriteString(" 2. Commit the manifest and workflows.\n") + b.WriteString(" 3. Run 'cascade generate-workflow' to produce the orchestration workflows.\n") + return b.String() +} diff --git a/internal/initcmd/command_test.go b/internal/initcmd/command_test.go new file mode 100644 index 0000000..4c92a5d --- /dev/null +++ b/internal/initcmd/command_test.go @@ -0,0 +1,229 @@ +package initcmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/scaffold" +) + +// runInit executes the command with args against a fresh buffer and returns +// stdout plus any error. +func runInit(t *testing.T, args ...string) (string, error) { + t.Helper() + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +// manifestPath is the on-disk relative path of the generated manifest. +func manifestPathFor(dir string) string { + return filepath.Join(dir, config.DefaultManifestFile) +} + +func TestResolveEnvs_TopologyPreset(t *testing.T) { + for name, want := range scaffold.Topologies() { + t.Run(name, func(t *testing.T) { + got, err := resolveEnvs(name, "") + require.NoError(t, err) + if len(want) == 0 { + assert.Empty(t, got) + return + } + assert.Equal(t, want, got) + }) + } +} + +func TestResolveEnvs_DefaultsToTwoEnv(t *testing.T) { + got, err := resolveEnvs("", "") + require.NoError(t, err) + assert.Equal(t, []string{"dev", "prod"}, got) +} + +func TestResolveEnvs_CustomList(t *testing.T) { + got, err := resolveEnvs("", " staging , production ") + require.NoError(t, err) + assert.Equal(t, []string{"staging", "production"}, got) +} + +func TestResolveEnvs_EnvsOverridesTopology(t *testing.T) { + got, err := resolveEnvs("four-env", "only") + require.NoError(t, err) + assert.Equal(t, []string{"only"}, got) +} + +func TestResolveEnvs_UnknownTopology(t *testing.T) { + _, err := resolveEnvs("five-env", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown topology") +} + +func TestResolveEnvs_EmptyEnvsValue(t *testing.T) { + _, err := resolveEnvs("", " , , ") + require.Error(t, err) + assert.Contains(t, err.Error(), "no environment names") +} + +func TestResolveName_DefaultsToDirBase(t *testing.T) { + dir := filepath.Join(t.TempDir(), "my-service") + require.NoError(t, os.MkdirAll(dir, 0o755)) + got, err := resolveName("", dir) + require.NoError(t, err) + assert.Equal(t, "my-service", got) +} + +func TestResolveName_ExplicitWins(t *testing.T) { + got, err := resolveName("explicit", "/tmp/whatever") + require.NoError(t, err) + assert.Equal(t, "explicit", got) +} + +func TestRun_SuccessWritesValidScaffold(t *testing.T) { + dir := t.TempDir() + out, err := runInit(t, "--topology", "two-env", "--name", "demo", "--dir", dir) + require.NoError(t, err) + + assert.Contains(t, out, "Scaffolded project \"demo\"") + assert.Contains(t, out, "generate-workflow") + assert.Contains(t, out, "$schema") + + // Expected file set on disk. + for _, rel := range []string{ + config.DefaultManifestFile, + ".github/workflows/build.yaml", + ".github/workflows/deploy.yaml", + } { + _, statErr := os.Stat(filepath.Join(dir, rel)) + require.NoError(t, statErr, "expected file %s", rel) + } + + // The written manifest must parse, validate, and generate. + parsed, err := config.ParseManifestFile(manifestPathFor(dir), config.DefaultManifestKey) + require.NoError(t, err) + require.NotNil(t, parsed.Config) + + problems := config.Validate(parsed.Config) + require.Empty(t, problems, "validation problems: %v", problems) + + // Re-run the full self-check (parse + validate + generate) on the bytes + // that actually landed on disk, proving the written scaffold survives the + // real generator. + onDisk := map[string]string{} + for _, rel := range []string{ + config.DefaultManifestFile, + ".github/workflows/build.yaml", + ".github/workflows/deploy.yaml", + } { + b, readErr := os.ReadFile(filepath.Join(dir, rel)) + require.NoError(t, readErr) + onDisk[rel] = string(b) + } + require.NoError(t, scaffold.SelfCheck(onDisk)) + + assert.Equal(t, []string{"dev", "prod"}, parsed.Config.Environments) +} + +func TestRun_NoEnvOmitsDeployStub(t *testing.T) { + dir := t.TempDir() + _, err := runInit(t, "--topology", "no-env", "--name", "demo", "--dir", dir) + require.NoError(t, err) + + _, statErr := os.Stat(filepath.Join(dir, ".github/workflows/deploy.yaml")) + assert.True(t, os.IsNotExist(statErr), "deploy stub must be absent for release-only") +} + +func TestRun_RefusesOnExistingWithoutForce(t *testing.T) { + dir := t.TempDir() + manifest := manifestPathFor(dir) + require.NoError(t, os.MkdirAll(filepath.Dir(manifest), 0o755)) + require.NoError(t, os.WriteFile(manifest, []byte("pre-existing\n"), 0o644)) + + _, err := runInit(t, "--topology", "two-env", "--name", "demo", "--dir", dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to overwrite") + assert.Contains(t, err.Error(), "--force") + + // The pre-existing file is untouched and no new files were written. + got, readErr := os.ReadFile(manifest) + require.NoError(t, readErr) + assert.Equal(t, "pre-existing\n", string(got)) + _, statErr := os.Stat(filepath.Join(dir, ".github/workflows/build.yaml")) + assert.True(t, os.IsNotExist(statErr), "no files should be written when refusing") +} + +func TestRun_ForceOverwrites(t *testing.T) { + dir := t.TempDir() + manifest := manifestPathFor(dir) + require.NoError(t, os.MkdirAll(filepath.Dir(manifest), 0o755)) + require.NoError(t, os.WriteFile(manifest, []byte("pre-existing\n"), 0o644)) + + _, err := runInit(t, "--topology", "two-env", "--name", "demo", "--dir", dir, "--force") + require.NoError(t, err) + + got, readErr := os.ReadFile(manifest) + require.NoError(t, readErr) + assert.NotEqual(t, "pre-existing\n", string(got)) + assert.Contains(t, string(got), "trunk_branch") +} + +func TestRun_DryRunWritesNothing(t *testing.T) { + dir := t.TempDir() + out, err := runInit(t, "--topology", "three-env", "--name", "demo", "--dir", dir, "--dry-run") + require.NoError(t, err) + + assert.Contains(t, out, "Dry run") + assert.Contains(t, out, "No files were written.") + assert.Contains(t, out, config.DefaultManifestFile) + assert.Contains(t, out, "deploy.yaml") + + // Nothing on disk. + entries, readErr := os.ReadDir(dir) + require.NoError(t, readErr) + assert.Empty(t, entries, "dry-run must not write anything") +} + +func TestRun_CustomEnvsListedAndReleaseStageLast(t *testing.T) { + dir := t.TempDir() + out, err := runInit(t, "--envs", "staging,production", "--name", "svc", "--dir", dir) + require.NoError(t, err) + assert.Contains(t, out, "staging -> production") + + parsed, err := config.ParseManifestFile(manifestPathFor(dir), config.DefaultManifestKey) + require.NoError(t, err) + assert.Equal(t, []string{"staging", "production"}, parsed.Config.Environments) +} + +func TestRun_CLIVersionOverride(t *testing.T) { + dir := t.TempDir() + _, err := runInit(t, "--topology", "two-env", "--name", "demo", "--dir", dir, "--cli-version", "v9.9.9") + require.NoError(t, err) + + got, readErr := os.ReadFile(manifestPathFor(dir)) + require.NoError(t, readErr) + assert.Contains(t, string(got), "v9.9.9") +} + +func TestRun_ScaffoldFailureWritesNothing(t *testing.T) { + dir := t.TempDir() + // An environment name containing a dot is not job-ID-safe; the scaffold + // SelfCheck rejects it, so nothing must reach disk. + _, err := runInit(t, "--envs", "dev.bad,prod", "--name", "demo", "--dir", dir) + require.Error(t, err) + assert.True(t, strings.Contains(strings.ToLower(err.Error()), "scaffolding")) + + entries, readErr := os.ReadDir(dir) + require.NoError(t, readErr) + assert.Empty(t, entries, "no files should be written when the scaffold fails") +}