Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/__tests__/add-dry-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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 <provider> --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 (dry-run is opt-in)", () => {
const dir = mkTmp();
const { stdout, stderr } = runCli(["add", "supabase"], dir);
const out = stdout + stderr;
// 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);
});
});
87 changes: 87 additions & 0 deletions packages/cli/src/__tests__/normalize-args.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
7 changes: 5 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/lib/normalize-args.ts
Original file line number Diff line number Diff line change
@@ -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 <provider> --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 -- <cmd>`), 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;
}
Loading