From 751a22b0a2f8382dae490815016a94dfcc4c247e Mon Sep 17 00:00:00 2001 From: MasonStation Date: Tue, 9 Jun 2026 21:35:01 -0400 Subject: [PATCH 1/3] fix(cli): parse kebab-case flags (--dry-run) instead of silently ignoring them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit citty 0.1.6 maps kebab CLI tokens onto camelCase arg keys via a Proxy whose fallback uses `??`. A boolean arg with `default: false` shadows the kebab-parsed value, so `--dry-run` was silently ignored while `--dryRun` worked. For `stack add` this fell through to the REAL provisioning flow (live OAuth / network / vault writes) when the user asked for the documented no-op preview. Normalize raw argv (`--dry-run` -> `--dryRun`) before citty parses it. This fixes every command at once — add/init/import/swap/upgrade `--dry-run`, plus `--all-orphans`, `--keep-remote`, `--keep-from`, `--continue-on-error`, `--recipe-id` — while preserving camelCase, citty `--no-*` negation (the disable path for default-true `continueOnError`), and `--` passthrough. Bumps VERSION to 0.2.1. Tests: 9 parser-level unit tests + 3 add --dry-run integration tests (kebab works / camelCase regression guard / no-flag opt-in guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 + .../cli/src/__tests__/add-dry-run.test.ts | 83 ++++++++++++++++++ .../cli/src/__tests__/normalize-args.test.ts | 87 +++++++++++++++++++ packages/cli/src/index.ts | 7 +- packages/cli/src/lib/normalize-args.ts | 53 +++++++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/__tests__/add-dry-run.test.ts create mode 100644 packages/cli/src/__tests__/normalize-args.test.ts create mode 100644 packages/cli/src/lib/normalize-args.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8784ad2..b9edac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug fixes + +- **Kebab-case flags now parse (`--dry-run`, `--keep-from`, `--all-orphans`, …)** — citty 0.1.6 silently dropped the documented kebab spelling of multi-word flags: a boolean arg with `default: false` shadowed the kebab-parsed value, so `--dry-run` was ignored while only `--dryRun` worked. For `stack add` this was high-severity — the documented "safe preview" form fell through to the **real** provisioning flow (live OAuth/network/vault writes). Raw argv is now normalized (`--dry-run` → `--dryRun`) before parsing, fixing every command at once while preserving camelCase, `--no-*` negation, and `--` passthrough. (`packages/cli/src/lib/normalize-args.ts`) + ### SEO + GEO content surface (major) - **29 programmatic provider pages** — new dynamic route at `/providers/[slug]` driven by `packages/core/src/catalog.ts`. Adding a provider to catalog auto-mints a page at build time. Each page: hero with brand logo, auth-flow explainer, secret-slot breakdown, MCP wiring status, `stack add` snippet, `stack recommend` trigger, related providers, templates that include it, FAQ, outbound dashboard + docs links. JSON-LD: TechArticle + BreadcrumbList + FAQPage. diff --git a/packages/cli/src/__tests__/add-dry-run.test.ts b/packages/cli/src/__tests__/add-dry-run.test.ts new file mode 100644 index 0000000..4c26d30 --- /dev/null +++ b/packages/cli/src/__tests__/add-dry-run.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CLI_ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts"); + +function runCli(args: string[], cwd?: string): { stdout: string; stderr: string; code: number } { + const result = spawnSync("bun", [CLI_ENTRY, ...args], { + encoding: "utf8", + cwd, + env: { ...process.env, NO_COLOR: "1", CI: "1" }, + }); + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + code: result.status ?? 0, + }; +} + +const createdDirs: string[] = []; +afterEach(() => { + while (createdDirs.length > 0) { + const dir = createdDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function mkTmp(): string { + const dir = mkdtempSync(join(tmpdir(), "stack-add-")); + createdDirs.push(dir); + writeFileSync(join(dir, ".stack.toml"), '[project]\nname = "t"\ntemplate = "custom"\n'); + return dir; +} + +/** + * `add --dry-run` is the documented "safe, no-op" form. Regression + * guard: citty 0.1.6 silently dropped the kebab spelling (the arg key is + * `dryRun`), so `--dry-run` fell through to the real provisioning flow — + * `provider.login()` and live network/vault writes — instead of the preview. + * + * We use `supabase`: its `login()` throws SUPABASE_AUTH_REQUIRED, so if the + * dry-run branch were skipped the failure is observable and never silently + * provisions. + */ +describe("stack add --dry-run", () => { + const PREVIEW = "dry-run complete — nothing written."; + + it("--dry-run (kebab, the documented form) prints the preview and writes nothing", () => { + const dir = mkTmp(); + const { stdout, stderr, code } = runCli(["add", "supabase", "--dry-run"], dir); + const out = stdout + stderr; + expect(code).toBe(0); + expect(out).toContain("(dry-run)"); + expect(out).toContain(PREVIEW); + // The 5-step plan is described, not executed. + expect(out).toContain("login"); + expect(out).toContain("provision"); + expect(out).toContain("persist"); + // The real login() never ran, so its auth error must not appear. + expect(out).not.toContain("SUPABASE_STACK_CLIENT_ID"); + }); + + it("--dryRun (camelCase) keeps working — no regression", () => { + const dir = mkTmp(); + const { stdout, stderr, code } = runCli(["add", "supabase", "--dryRun"], dir); + const out = stdout + stderr; + expect(code).toBe(0); + expect(out).toContain(PREVIEW); + expect(out).not.toContain("SUPABASE_STACK_CLIENT_ID"); + }); + + it("without the flag, the preview branch does NOT run (real flow is attempted)", () => { + const dir = mkTmp(); + const { stdout, stderr } = runCli(["add", "supabase"], dir); + const out = stdout + stderr; + // No preview, and the real login() guard fires — proving dry-run is opt-in. + expect(out).not.toContain(PREVIEW); + expect(out).toContain("SUPABASE_STACK_CLIENT_ID"); + }); +}); diff --git a/packages/cli/src/__tests__/normalize-args.test.ts b/packages/cli/src/__tests__/normalize-args.test.ts new file mode 100644 index 0000000..4c7f8ad --- /dev/null +++ b/packages/cli/src/__tests__/normalize-args.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "bun:test"; +import { normalizeKebabFlags } from "../lib/normalize-args.ts"; + +/** + * Parser-level guard for the kebab→camel rewrite. citty 0.1.6 silently ignores + * `--dry-run` when the arg key is `dryRun` with `default: false`; we normalize + * the raw argv so the documented kebab spelling maps onto the declared key. + */ +describe("normalizeKebabFlags", () => { + it("rewrites a multi-word kebab flag to camelCase", () => { + expect(normalizeKebabFlags(["add", "supabase", "--dry-run"])).toEqual([ + "add", + "supabase", + "--dryRun", + ]); + }); + + it("rewrites three-word kebab flags", () => { + expect(normalizeKebabFlags(["templates", "apply", "x", "--continue-on-error"])).toEqual([ + "templates", + "apply", + "x", + "--continueOnError", + ]); + }); + + it("preserves the value when the flag uses =", () => { + expect(normalizeKebabFlags(["swap", "a", "b", "--keep-from=clerk"])).toEqual([ + "swap", + "a", + "b", + "--keepFrom=clerk", + ]); + }); + + it("leaves the already-camelCase spelling untouched", () => { + expect(normalizeKebabFlags(["add", "supabase", "--dryRun"])).toEqual([ + "add", + "supabase", + "--dryRun", + ]); + }); + + it("leaves single-word flags untouched", () => { + expect(normalizeKebabFlags(["init", "--force", "--json"])).toEqual([ + "init", + "--force", + "--json", + ]); + }); + + it("does not drag a hyphenated value into the flag name", () => { + // `region` is a single word; only the value has hyphens. + expect(normalizeKebabFlags(["add", "--region=us-east-1"])).toEqual([ + "add", + "--region=us-east-1", + ]); + expect(normalizeKebabFlags(["add", "--use=my-resource-id"])).toEqual([ + "add", + "--use=my-resource-id", + ]); + }); + + it("leaves `--no-*` negation tokens alone", () => { + // citty treats `--no-foo` as negation; rewriting it would break the + // documented disable path for default-true booleans. + expect(normalizeKebabFlags(["templates", "apply", "x", "--no-continue-on-error"])).toEqual([ + "templates", + "apply", + "x", + "--no-continue-on-error", + ]); + }); + + it("does not touch positionals after a bare `--`", () => { + expect(normalizeKebabFlags(["exec", "--", "some-cmd", "--inner-flag"])).toEqual([ + "exec", + "--", + "some-cmd", + "--inner-flag", + ]); + }); + + it("rewrites a kebab flag with a hyphenated value", () => { + expect(normalizeKebabFlags(["swap", "--keep-from=a-b-c"])).toEqual(["swap", "--keepFrom=a-b-c"]); + }); +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ee41028..7e07fc1 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -27,12 +27,13 @@ import { syncCommand } from "./commands/sync.ts"; import { telemetryCommand } from "./commands/telemetry.ts"; import { templatesCommand } from "./commands/templates.ts"; import { upgradeCommand } from "./commands/upgrade.ts"; +import { normalizeKebabFlags } from "./lib/normalize-args.ts"; import { checkForUpdate } from "./lib/update-check.ts"; // Single source of truth for the CLI version. citty wires this into `--help` // and we also use it for `stack --version` (citty ships a standalone flag when // `version` is on the meta object — but older citty needs a fallback, below). -const VERSION = "0.2.0"; +const VERSION = "0.2.1"; const main = defineCommand({ meta: { @@ -117,7 +118,9 @@ const main = defineCommand({ const _startMs = Date.now(); void checkForUpdate(VERSION); -runMain(main).finally(() => { +// Normalize kebab-case flags (`--dry-run`) to the camelCase keys our commands +// declare (`dryRun`) before citty parses them — see lib/normalize-args.ts. +runMain(main, { rawArgs: normalizeKebabFlags(process.argv.slice(2)) }).finally(() => { void emitTelemetry({ type: "command", command: process.argv[2] ?? "unknown", diff --git a/packages/cli/src/lib/normalize-args.ts b/packages/cli/src/lib/normalize-args.ts new file mode 100644 index 0000000..bbfb01e --- /dev/null +++ b/packages/cli/src/lib/normalize-args.ts @@ -0,0 +1,53 @@ +/** + * Rewrite kebab-case long flags to the camelCase keys our citty commands + * declare, so `--dry-run` parses the same as `--dryRun`. + * + * Why this exists: citty 0.1.6 maps kebab CLI tokens onto camelCase arg keys + * through a Proxy whose fallback uses `??`. A boolean arg with `default: false` + * therefore shadows the kebab-parsed value — `out.dryRun` is `false` (the + * default) before the proxy ever checks `out["dry-run"]`, so `??` short-circuits + * and `--dry-run` is silently ignored while `--dryRun` works. Our flags are + * documented in kebab-case (README, --help, the docs site), so the documented + * spelling is the broken one — and for a provider that doesn't gate on a missing + * OAuth client id, `add --dry-run` would run the *real* provisioning + * flow instead of the advertised no-op preview. + * + * Normalizing the raw argv up front fixes every command at once and keeps both + * spellings working, without per-flag aliases (which citty 0.1.6 renders as a + * misleading single-dash `-dry-run` in `--help`). + * + * Deliberately left untouched: + * - `--no-*` tokens. citty treats `--no-foo` as negation (sets `foo=false`), + * which is the documented way to disable a default-true boolean such as + * `templates apply --no-continue-on-error`. Rewriting it would break that. + * - Everything after a bare `--`. Those are passthrough positionals (e.g. + * `stack exec -- `), not flags for us to interpret. + * - Single-word flags and flag *values* (only the name before `=` is touched, + * so `--region=us-east-1` and `--use=my-resource-id` are safe). + */ +export function normalizeKebabFlags(argv: string[]): string[] { + const out: string[] = []; + let passthrough = false; + for (const token of argv) { + if (passthrough) { + out.push(token); + continue; + } + if (token === "--") { + passthrough = true; + out.push(token); + continue; + } + // Match `--word-word[=value]`: a long flag whose name has an interior + // hyphen. The name class excludes `=`, so a hyphenated *value* never drags + // the match past the `=`. + const match = /^--([a-z0-9]+(?:-[a-z0-9]+)+)(=.*)?$/.exec(token); + if (match && !match[1].startsWith("no-")) { + const camel = match[1].replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); + out.push(`--${camel}${match[2] ?? ""}`); + } else { + out.push(token); + } + } + return out; +} From 86bc17ccb74b9a129063b48e5dd30badbac03ec8 Mon Sep 17 00:00:00 2001 From: MasonStation Date: Wed, 10 Jun 2026 00:06:30 -0400 Subject: [PATCH 2/3] test(cli): make add no-flag assertion environment-independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "preview branch does NOT run without --dry-run" test asserted the SUPABASE_STACK_CLIENT_ID OAuth error, but the no-flag path hits requirePhantom() first — on a runner without Phantom installed it fails there instead, so that string never appears (CI was red on this). Assert the real invariant instead: no "(dry-run)" title and no preview line, which proves the dry-run branch was skipped regardless of whether the downstream failure is the Phantom preflight or the OAuth guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/__tests__/add-dry-run.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/add-dry-run.test.ts b/packages/cli/src/__tests__/add-dry-run.test.ts index 4c26d30..dee57ee 100644 --- a/packages/cli/src/__tests__/add-dry-run.test.ts +++ b/packages/cli/src/__tests__/add-dry-run.test.ts @@ -72,12 +72,15 @@ describe("stack add --dry-run", () => { expect(out).not.toContain("SUPABASE_STACK_CLIENT_ID"); }); - it("without the flag, the preview branch does NOT run (real flow is attempted)", () => { + it("without the flag, the preview branch does NOT run (dry-run is opt-in)", () => { const dir = mkTmp(); const { stdout, stderr } = runCli(["add", "supabase"], dir); const out = stdout + stderr; - // No preview, and the real login() guard fires — proving dry-run is opt-in. + // The dry-run/preview branch never runs: no "(dry-run)" title, no preview + // line. (We don't assert the specific downstream error — whether it fails at + // the Phantom preflight or the OAuth guard depends on the environment, e.g. + // whether Phantom is installed on the CI runner.) + expect(out).not.toContain("(dry-run)"); expect(out).not.toContain(PREVIEW); - expect(out).toContain("SUPABASE_STACK_CLIENT_ID"); }); }); From c90b258b2b15957e37c4f20e76bceff568fb2aa5 Mon Sep 17 00:00:00 2001 From: MasonStation Date: Sat, 20 Jun 2026 00:28:25 -0400 Subject: [PATCH 3/3] style(cli): biome-format normalize-args test (wrap long assertion) The new normalize-args test had one assertion exceeding biome's line width; CI Lint failed on the unformatted line. Apply biome format. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/__tests__/normalize-args.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/normalize-args.test.ts b/packages/cli/src/__tests__/normalize-args.test.ts index 4c7f8ad..b843293 100644 --- a/packages/cli/src/__tests__/normalize-args.test.ts +++ b/packages/cli/src/__tests__/normalize-args.test.ts @@ -82,6 +82,9 @@ describe("normalizeKebabFlags", () => { }); it("rewrites a kebab flag with a hyphenated value", () => { - expect(normalizeKebabFlags(["swap", "--keep-from=a-b-c"])).toEqual(["swap", "--keepFrom=a-b-c"]); + expect(normalizeKebabFlags(["swap", "--keep-from=a-b-c"])).toEqual([ + "swap", + "--keepFrom=a-b-c", + ]); }); });