Skip to content

Commit 8b553d7

Browse files
authored
feat(plan): add plan command, completions, and config paths
Implements stackctl plan command with 9 operations, Cliffy CompletionsCommand, and config path tracking. 357 tests.
1 parent ead2925 commit 8b553d7

5 files changed

Lines changed: 1447 additions & 22 deletions

File tree

src/cli/mod.ts

Lines changed: 106 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ import {
3333
import type { EnvDiff } from "../env/types.ts";
3434
import { reloadStacks } from "../compose/reload.ts";
3535
import type { ReloadResult } from "../compose/reload.ts";
36+
import { planOperation } from "../compose/plan.ts";
37+
import type { PlanResult } from "../compose/plan.ts";
38+
import { CompletionsCommand } from "@cliffy/command/completions";
3639
import {
3740
checkTooling,
3841
cleanDecryptedEnvFiles,
@@ -62,10 +65,24 @@ export async function main(args: string[]): Promise<number> {
6265
}
6366
}
6467

68+
/**
69+
* Best-effort stack-name completion provider.
70+
* Returns stack names discovered from the repository.
71+
* Never throws — returns an empty array if config or discovery fails.
72+
*/
73+
async function completeStackNames(): Promise<string[]> {
74+
try {
75+
const config = await resolveConfig({ profile: undefined, cwd: Deno.cwd() });
76+
const repoRoot = config.base.repoRoot ?? Deno.cwd();
77+
const discovery = await discoverComposeFiles({ repoRoot });
78+
return Object.keys(discovery.stacks);
79+
} catch {
80+
return [];
81+
}
82+
}
83+
6584
/**
6685
* Build the stackctl CLI command tree.
67-
* Commands are registered here in their skeleton form;
68-
* full implementations are added in subsequent issues.
6986
*/
7087
export function buildCli(): Command {
7188
const cli = new Command()
@@ -1470,31 +1487,98 @@ export function buildCli(): Command {
14701487
cli.command("plan", "Produce a deterministic plan of what an operation would do.")
14711488
.arguments("<operation:string>")
14721489
.option("--profile <name:string>", "Use a specific profile.")
1473-
.option("--stacks <names:string>", "Comma-separated list of stack names.")
1490+
.option("--stacks <names:string>", "Comma-separated list of stack names.", {
1491+
complete: completeStackNames,
1492+
} as any)
14741493
.option("--override <files:string>", "Comma-separated list of override files.")
14751494
.option("--json", "Output machine-readable JSON.")
1476-
.action(() => {
1477-
console.error("plan: not yet implemented (issue #15)");
1478-
exitCode = 1;
1495+
.description(
1496+
"Shows a structured summary of what the specified operation would do without executing it.\n\n" +
1497+
"Supported operations:\n" +
1498+
" up - Preview stack deployment\n" +
1499+
" down - Preview stack removal\n" +
1500+
" sync - Preview full generate+render+deploy pipeline\n" +
1501+
" generate - Preview stack generation only\n" +
1502+
" render - Preview rendering only\n" +
1503+
" reload - Preview config-first reload\n" +
1504+
" env - Preview env file scaffolding\n" +
1505+
" secrets - Preview secrets workflow\n" +
1506+
" all - Preview everything",
1507+
)
1508+
.example(
1509+
"Preview what would happen during a sync",
1510+
"stackctl plan sync",
1511+
)
1512+
.example(
1513+
"Preview with a specific profile",
1514+
"stackctl plan up --profile staging",
1515+
)
1516+
.example(
1517+
"Preview specific stacks only",
1518+
"stackctl plan generate --stacks api,web",
1519+
)
1520+
.example(
1521+
"Machine-readable JSON output",
1522+
"stackctl plan all --json",
1523+
)
1524+
.action((opts: Record<string, unknown>, operation: string) => {
1525+
const profile = opts.profile as string | undefined;
1526+
const stacks = opts.stacks
1527+
? (opts.stacks as string).split(",").map((s: string) => s.trim())
1528+
: undefined;
1529+
const overrides = opts.override
1530+
? (opts.override as string).split(",").map((s: string) => s.trim())
1531+
: undefined;
1532+
1533+
planOperation({
1534+
operation,
1535+
profile,
1536+
stacks,
1537+
overrides,
1538+
})
1539+
.then((plan: PlanResult) => {
1540+
if (opts.json) {
1541+
console.log(JSON.stringify(plan.json, null, 2));
1542+
return;
1543+
}
1544+
1545+
// Human-readable output
1546+
console.log(`Plan: ${plan.operation}`);
1547+
console.log("=".repeat(40));
1548+
1549+
for (const section of plan.sections) {
1550+
console.log(`\n${section.title}`);
1551+
console.log("-".repeat(section.title.length));
1552+
for (const item of section.items) {
1553+
console.log(item);
1554+
}
1555+
}
1556+
1557+
if (plan.warnings.length > 0) {
1558+
console.log("\nWarnings:");
1559+
for (const w of plan.warnings) {
1560+
console.log(` ! ${w}`);
1561+
}
1562+
}
1563+
1564+
if (plan.errors.length > 0) {
1565+
console.log("\nErrors:");
1566+
for (const e of plan.errors) {
1567+
console.log(` ✗ ${e}`);
1568+
}
1569+
Deno.exit(ExitCode.DriftOrValidation);
1570+
}
1571+
})
1572+
.catch((err: unknown) => {
1573+
console.error(
1574+
`error: ${err instanceof Error ? err.message : String(err)}`,
1575+
);
1576+
Deno.exit(ExitCode.UnexpectedError);
1577+
});
14791578
});
14801579

14811580
// --- completions (issue #10) ---
1482-
const completionsCmd = cli.command("completions", "Generate shell completion scripts.");
1483-
completionsCmd.command("bash", "Generate bash completion script.")
1484-
.action(() => {
1485-
console.error("completions bash: not yet implemented (issue #10)");
1486-
exitCode = 1;
1487-
});
1488-
completionsCmd.command("zsh", "Generate zsh completion script.")
1489-
.action(() => {
1490-
console.error("completions zsh: not yet implemented (issue #10)");
1491-
exitCode = 1;
1492-
});
1493-
completionsCmd.command("fish", "Generate fish completion script.")
1494-
.action(() => {
1495-
console.error("completions fish: not yet implemented (issue #10)");
1496-
exitCode = 1;
1497-
});
1581+
cli.command("completions", new CompletionsCommand());
14981582

14991583
return cli as unknown as Command;
15001584
}

0 commit comments

Comments
 (0)