From 8d4a4ec415108fca0ab7dd06a5a9e9c52565608f Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 13 Jun 2026 14:49:08 -0400 Subject: [PATCH] feat: interactive module picker for `stanza add` and `stanza remove` (#33) --- .changeset/add-interactive-module-picker.md | 7 + apps/cli/README.md | 55 ++++- apps/cli/src/commands/add.ts | 257 ++++++++++++++++---- apps/cli/src/commands/commands.test.ts | 198 ++++++++++++++- apps/cli/src/commands/remove.ts | 35 ++- apps/cli/src/lib/candidates.ts | 98 ++++++++ apps/cli/src/lib/wizard.ts | 25 +- apps/web/content/docs/cli.mdx | 5 +- skills/stanza-cli/SKILL.md | 4 +- 9 files changed, 602 insertions(+), 82 deletions(-) create mode 100644 .changeset/add-interactive-module-picker.md create mode 100644 apps/cli/src/lib/candidates.ts diff --git a/.changeset/add-interactive-module-picker.md b/.changeset/add-interactive-module-picker.md new file mode 100644 index 0000000..1c538f4 --- /dev/null +++ b/.changeset/add-interactive-module-picker.md @@ -0,0 +1,7 @@ +--- +"stanza-cli": minor +--- + +`stanza add ` no longer requires a module id. Omit it and, on a TTY, `add` opens an interactive picker of that category's modules — modules that aren't compatible yet (a peer isn't installed) or are already installed render disabled with the reason shown inline. Off a TTY (CI, piped input) it lists the available ids and exits instead of hanging. Typing an id that doesn't exist now drops a TTY user into the same picker rather than failing with an opaque "module not found". + +`stanza remove ` mirrors this for multi-choice categories: omitting the id on a TTY opens a picker over the installed records (it still errors with the installed list off a TTY). diff --git a/apps/cli/README.md b/apps/cli/README.md index 28ec6a7..24dd700 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ Modular monorepo template CLI — shadcn for full-stack TypeScript stacks. -Pick a framework, ORM, database, auth provider, UI, and more — get a clean monorepo with idiomatic code **vendored into your repo**. There's no runtime to install: generated files land verbatim and are yours to edit. Unlike a one-shot scaffolder, `stanza add` keeps working after `init`, so you can layer modules into an existing project at any time. +Pick a framework, UI system, database, ORM, auth provider, payments adapter, test runner, deployment target, and more — get a clean monorepo with idiomatic code **vendored into your repo**. There's no runtime to install: generated files land verbatim and are yours to edit. Unlike a one-shot scaffolder, `stanza add` keeps working after `init`, so you can layer modules into an existing project at any time. > [!WARNING] > **Major work in progress!** See the [module registry](https://stanza.tools/docs/registry) for what's available today and what's on the roadmap. @@ -19,8 +19,9 @@ npm run dev Or drive the CLI directly: ```sh -npx stanza-cli init my-app # interactive wizard -npx stanza-cli add auth better-auth # add a module to an existing project +npx stanza-cli init my-app # interactive init wizard +cd my-app +npx stanza-cli add auth # interactive module picker on a TTY ``` Non-interactive (CI / agents) — pass each category explicitly: @@ -29,23 +30,51 @@ Non-interactive (CI / agents) — pass each category explicitly: npx stanza-cli init my-app --yes \ --framework=next --ui=tailwind \ --db=postgres --orm=drizzle \ - --auth=better-auth --testing=vitest,playwright --pm=pnpm + --auth=better-auth --testing=vitest,playwright \ + --tooling=biome --monorepo=turbo --pm=pnpm ``` ## Commands -| Command | What it does | -| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `stanza init [name]` | Scaffold a new monorepo via the wizard (or `--yes` + `--=` flags). | -| `stanza add [@/]` | Add one module to an existing project; picks the right adapter for your stack and wires deps, env, scripts, and templates into the correct workspace package. `--app=` targets a specific app. | -| `stanza remove [[@/]]` | Remove a module and clean up its files, deps, and codemods. The id is optional for single-choice categories. | -| `stanza list` | Print installed modules grouped by category. | -| `stanza search [query]` | List registry modules and their `category/id` pairs. | -| `stanza doctor` | Check `stanza.json` against the filesystem for drift (read-only); exits non-zero when something's missing. | +| Command | What it does | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `stanza init [name]` | Scaffold a new monorepo via the wizard (or `--yes` + `--=` flags). | +| `stanza add [[@/]]` | Add one module to an existing project; picks the right adapter for your stack and wires deps, env, scripts, and templates into the correct workspace package. `--app=` targets a specific app. | +| `stanza remove [[@/]]` | Remove a module and clean up its files, deps, and codemods. The id is optional for single-choice categories and interactive TTY removal from multi-choice categories. | +| `stanza list` | Print installed modules grouped by category. | +| `stanza search [query]` | List registry modules and their `category/id` pairs. | +| `stanza doctor` | Check `stanza.json` against the filesystem for drift (read-only); exits non-zero when something's missing. | Run `add` / `remove` / `list` / `doctor` from the project root or any child directory under a `stanza.json`. -Every module fills exactly one **category**. Single-choice categories (`framework`, `ui`, `db`, `orm`, `auth`, `payments`, `email`, `ai`, `tooling`, `monorepo`) hold one module; multi-choice (`testing`, `deploy`) coexist. `auth`/`db`/`orm` and friends install into their own internal workspace packages (`packages/auth/`, `packages/db/`, …); your apps consume them via `workspace:*`, so swapping a provider replaces a package's contents without touching your app imports. +Every module fills exactly one **category**. Single-choice categories (`framework`, `ui`, `api`, `db`, `orm`, `auth`, `payments`, `email`, `ai`, `tooling`, `monorepo`) hold one module; multi-choice categories (`testing`, `deploy`) coexist. `auth`/`db`/`orm` and friends install into their own internal workspace packages (`packages/auth/`, `packages/db/`, …); your apps consume them via `workspace:*`, so swapping a provider replaces a package's contents without touching your app imports. + +## Adding and removing modules + +On a TTY, `stanza add ` opens a picker for that category. Incompatible modules, for example ones missing a required peer like `db` or `framework`, are shown disabled with the reason inline. Already-installed multi-choice modules are disabled too. If you type an unknown explicit id on a TTY, Stanza drops into the same picker so you can recover. + +```sh +stanza add testing # pick Vitest or Playwright interactively +stanza add payments polar # explicit module id +stanza add email resend +stanza add ai vercel-ai-sdk +``` + +Off a TTY, such as in CI or an agent run, `add` never prompts. Pass the module id explicitly; if it is omitted or unknown, Stanza exits with the available ids instead of hanging. + +```sh +stanza add testing vitest +stanza add testing playwright +stanza add payments polar --app=web +``` + +For `remove`, single-choice categories can be removed by category alone. Multi-choice categories need an id in non-interactive runs; on a TTY, omitting the id opens a picker of the installed records. + +```sh +stanza remove payments # single-choice category +stanza remove testing # TTY picker when several testing modules are installed +stanza remove testing vitest # CI-safe / non-interactive form +``` ## Safety diff --git a/apps/cli/src/commands/add.ts b/apps/cli/src/commands/add.ts index 7a17711..4aaf48a 100644 --- a/apps/cli/src/commands/add.ts +++ b/apps/cli/src/commands/add.ts @@ -1,8 +1,9 @@ import * as p from "@clack/prompts"; import { resolveAdapter } from "@withstanza/registry"; -import type { AppSpec, StanzaManifest } from "@withstanza/schema"; +import type { AppKind, AppSpec, CategoryId, StanzaManifest } from "@withstanza/schema"; import { categoryHome, + categoryLabel, DEFAULT_NAMESPACE, isCategoryId, isLikelyNamespaceTypo, @@ -15,12 +16,13 @@ import { import { defineCommand } from "citty"; import pc from "picocolors"; +import { type Candidate, categoryCandidates, moduleInstallKey } from "../lib/candidates"; import { applyModule, RegionConflictError } from "../lib/codemod-runner"; import { ensureCleanWorktree } from "../lib/git"; import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest"; import { formatPlanLines, summarizePlan } from "../lib/plan-format"; import { regenerateReadmeIfUnmodified } from "../lib/readme"; -import { loadRegistries } from "../lib/registry-loader"; +import { loadRegistries, type Registries } from "../lib/registry-loader"; import * as telemetry from "../lib/telemetry"; import { commonArgs, type CliArgs } from "./_args"; @@ -28,7 +30,11 @@ export const add = defineCommand({ meta: { name: "add", description: "Add a module to the current project." }, args: { slot: { type: "positional", required: true, description: "Category." }, - moduleId: { type: "positional", required: true, description: "Module id." }, + moduleId: { + type: "positional", + required: false, + description: "Module id (omit to pick interactively).", + }, app: { type: "string", description: "Target app id (required for multi-app projects; auto-picked otherwise).", @@ -41,8 +47,8 @@ export const add = defineCommand({ export async function cmdAdd(args: CliArgs): Promise { const slot = typeof args.slot === "string" ? args.slot : undefined; const rawModuleId = typeof args.moduleId === "string" ? args.moduleId : undefined; - if (!slot || !rawModuleId) { - p.log.error("Usage: stanza add [@/]"); + if (!slot) { + p.log.error("Usage: stanza add [[@/]]"); process.exitCode = 1; return; } @@ -55,33 +61,6 @@ export async function cmdAdd(args: CliArgs): Promise { const category = slot; const group = category; - // Catch the `@bare` typo before parsing — without this, the spec falls - // through to a literal id of "@bare" and the registry returns an opaque - // 404. Explicit hint is friendlier. - if (isLikelyNamespaceTypo(rawModuleId)) { - p.log.error( - `"${rawModuleId}" looks like a namespace but is missing the module id. ` + - `Did you mean \`${rawModuleId}/\`?`, - ); - process.exitCode = 1; - return; - } - // Split `@ns/id` into a namespace + id. Bare ids implicitly mean `@stanza`, - // which we leave as `undefined` on the record (omitted = default). - const { namespace, id: moduleId } = parseModuleSpec(rawModuleId); - - // The id is about to be interpolated into a registry URL — reject anything - // that could escape its segment (path traversal, query strings, encoded - // bytes). See `isValidModuleId` in @withstanza/registry for the exact shape. - if (!isValidModuleId(moduleId)) { - p.log.error( - `Invalid module id "${moduleId}". Ids must be alphanumeric segments ` + - `(letters, digits, dashes, underscores) joined by "/".`, - ); - process.exitCode = 1; - return; - } - const projectRoot = findProjectRoot(); if (!projectRoot) { p.log.error("No stanza.json found in this or any parent directory."); @@ -98,6 +77,9 @@ export async function cmdAdd(args: CliArgs): Promise { const manifest = readManifest(projectRoot); const home = categoryHome(category); const appFlag = typeof args.app === "string" ? args.app : undefined; + // Loaded before the module id is resolved — the picker needs the registry + // index (plus the target app + manifest below) to compute compatibility. + const registry = await loadRegistries(manifest); // Pick target apps based on the module's home. // - home: "app" — exactly one app, picked via cwd/flag/prompt. @@ -112,7 +94,7 @@ export async function cmdAdd(args: CliArgs): Promise { appFlag, cwd: process.cwd(), projectRoot, - reason: `Which app should ${pc.cyan(`${category}/${moduleId}`)} install into?`, + reason: `Which app should ${pc.cyan(category)} install into?`, }); if (!picked) { process.exitCode = 1; @@ -146,14 +128,9 @@ export async function cmdAdd(args: CliArgs): Promise { // second package-home pick slip past, only to fail on the next read. const cardinalityScopeApp = home.kind === "app" ? pickedAppId : undefined; const existing = selectedAll(manifest, category, cardinalityScopeApp); - if (isMulti(category)) { - if (existing.some((r) => r.id === moduleId)) { - const where = cardinalityScopeApp ? ` in app "${cardinalityScopeApp}"` : ""; - p.log.error(`"${category}/${moduleId}" is already added${where}.`); - process.exitCode = 1; - return; - } - } else if (existing.length > 0) { + // A filled single-choice slot has nothing left to pick — bail before opening + // a doomed picker. + if (!isMulti(category) && existing.length > 0) { const where = cardinalityScopeApp ? ` (app "${cardinalityScopeApp}")` : ""; p.log.error( `Category "${category}"${where} is already filled by "${existing[0]!.id}". ` + @@ -163,7 +140,107 @@ export async function cmdAdd(args: CliArgs): Promise { return; } - const registry = await loadRegistries(manifest); + // Resolve the module spec — an explicit id, or an interactive picker. The + // picker disables incompatible/installed modules, so it shares the same + // compatibility inputs the post-load checks below re-verify for the + // explicit path. + const installedIds = new Set( + existing.map((r) => moduleInstallKey(r.namespace ?? DEFAULT_NAMESPACE, r.id)), + ); + const targetAppKind = home.kind === "app" ? targetApps[0]!.kind : undefined; + let namespace: string | undefined; + let moduleId: string; + if (rawModuleId !== undefined) { + // Catch the `@bare` typo before parsing — without this, the spec falls + // through to a literal id of "@bare" and the registry returns an opaque + // 404. Explicit hint is friendlier. + if (isLikelyNamespaceTypo(rawModuleId)) { + p.log.error( + `"${rawModuleId}" looks like a namespace but is missing the module id. ` + + `Did you mean \`${rawModuleId}/\`?`, + ); + process.exitCode = 1; + return; + } + // Split `@ns/id` into a namespace + id. Bare ids implicitly mean `@stanza`, + // which we leave as `undefined` on the record (omitted = default). + const spec = parseModuleSpec(rawModuleId); + namespace = spec.namespace; + moduleId = spec.id; + // The id is about to be interpolated into a registry URL — reject anything + // that could escape its segment (path traversal, query strings, encoded + // bytes). See `isValidModuleId` in @withstanza/registry for the exact shape. + if (!isValidModuleId(moduleId)) { + p.log.error( + `Invalid module id "${moduleId}". Ids must be alphanumeric segments ` + + `(letters, digits, dashes, underscores) joined by "/".`, + ); + process.exitCode = 1; + return; + } + // If the id's namespace publishes a browsable index and the id isn't in it, + // the id is unknown (typo). On a TTY, drop into the picker so the user can + // recover; otherwise list what's available. Name-only namespaces (no index) + // skip this — the loader below surfaces a 404 for a bad id. + const effectiveNs = namespace ?? DEFAULT_NAMESPACE; + const browsable = registry.searchableIndices().find((s) => s.namespace === effectiveNs); + const known = + !browsable || + browsable.index.modules.some((m) => m.category === category && m.id === moduleId); + if (!known) { + if (process.stdin.isTTY) { + p.log.warn(`No ${category} module "${namespace ? `${namespace}/` : ""}${moduleId}".`); + const picked = await pickModuleInCategory({ + registry, + category, + manifest, + targetAppId: pickedAppId, + targetAppKind, + installedIds, + }); + if (!picked) { + process.exitCode = 1; + return; + } + namespace = picked.namespace; + moduleId = picked.moduleId; + } else { + const ids = browsable.index.modules.filter((m) => m.category === category).map((m) => m.id); + p.log.error( + `No ${category} module "${moduleId}"${namespace ? ` in ${namespace}` : ""}. ` + + `Available: ${ids.length ? ids.join(", ") : "(none)"}.`, + ); + process.exitCode = 1; + return; + } + } + } else { + const picked = await pickModuleInCategory({ + registry, + category, + manifest, + targetAppId: pickedAppId, + targetAppKind, + installedIds, + }); + if (!picked) { + process.exitCode = 1; + return; + } + namespace = picked.namespace; + moduleId = picked.moduleId; + } + + // Multi-choice "already added" guard — single-choice is handled by the filled + // check above, and the picker disables installed ids, so this only catches a + // re-add on the explicit-id path. + if (isMulti(category) && existing.some((r) => r.id === moduleId)) { + const where = cardinalityScopeApp ? ` in app "${cardinalityScopeApp}"` : ""; + p.log.error(`"${category}/${moduleId}" is already added${where}.`); + process.exitCode = 1; + return; + } + let mod; try { mod = await registry.loadModule(group, moduleId, namespace); @@ -271,6 +348,102 @@ export async function cmdAdd(args: CliArgs): Promise { } } +/** + * Resolve which module to add when the id was omitted (or typed wrong on a + * TTY). Lists every module in the category across all configured registries, + * rendering incompatible/installed ones as disabled with a terse reason. + * Returns the chosen `{ namespace, moduleId }`, or null to bail (with the exit + * code left for the caller to set): + * - 0 candidates → error "no modules available". + * - all candidates disabled → error with the dominant reason; no prompt. + * - non-TTY → error listing the available ids; no prompt. + * - TTY → `p.select` (Esc cancels). + */ +async function pickModuleInCategory(args: { + registry: Registries; + category: CategoryId; + manifest: StanzaManifest; + targetAppId: string | undefined; + targetAppKind: AppKind | undefined; + installedIds: ReadonlySet; +}): Promise<{ namespace: string | undefined; moduleId: string } | null> { + const { registry, category, manifest, targetAppId, targetAppKind, installedIds } = args; + const candidates = categoryCandidates({ + indices: registry.searchableIndices(), + category, + manifest, + targetAppId, + targetAppKind, + installedIds, + }); + + if (candidates.length === 0) { + p.log.error(`No "${category}" modules are available.`); + return null; + } + if (!candidates.some((c) => c.compatible)) { + p.log.error(dominantDisabledReason(category, candidates)); + return null; + } + if (!process.stdin.isTTY) { + const ids = candidates.map(specFor).join(", "); + p.log.error(`Pick a module: \`stanza add ${category} \`. Available: ${ids}.`); + return null; + } + + // `select` renders disabled options grayed-out with the hint shown as the + // `(reason)`. `value` is the spec string so it round-trips `parseModuleSpec`. + const picked = await p.select({ + message: `Which ${categoryLabel(category)} module?`, + options: candidates.map((c) => { + const nsTag = c.namespace === DEFAULT_NAMESPACE ? "" : ` ${pc.dim(c.namespace)}`; + return { + value: specFor(c), + label: `${c.entry.label}${nsTag}`, + hint: c.compatible ? c.entry.description : c.reason, + disabled: !c.compatible, + }; + }), + }); + if (p.isCancel(picked)) { + p.cancel("Cancelled."); + return null; + } + const spec = parseModuleSpec(picked); + return { namespace: spec.namespace, moduleId: spec.id }; +} + +/** Registry spec string for a candidate: bare `id` for `@stanza`, else `@ns/id`. */ +function specFor(c: Candidate): string { + return c.namespace === DEFAULT_NAMESPACE ? c.entry.id : `${c.namespace}/${c.entry.id}`; +} + +/** + * When every candidate is disabled, explain why using the most common reason — + * usually a missing peer ("add a framework first"). Falls back to a generic + * line when reasons are mixed or absent. + */ +function dominantDisabledReason(category: CategoryId, candidates: Candidate[]): string { + const counts = new Map(); + for (const c of candidates) { + if (c.compatible || !c.reason) continue; + counts.set(c.reason, (counts.get(c.reason) ?? 0) + 1); + } + let top: string | undefined; + let max = 0; + for (const [reason, n] of counts) { + if (n > max) { + max = n; + top = reason; + } + } + const label = categoryLabel(category).toLowerCase(); + if (top === "already added") return `Every ${label} module is already added.`; + return top + ? `No ${label} module is compatible yet — ${top}.` + : `No ${label} module is compatible yet.`; +} + /** * Resolve which app to target for a `home:"app"` module. Order: * 1. `--app=` flag wins. diff --git a/apps/cli/src/commands/commands.test.ts b/apps/cli/src/commands/commands.test.ts index 3d2b227..155593d 100644 --- a/apps/cli/src/commands/commands.test.ts +++ b/apps/cli/src/commands/commands.test.ts @@ -5,8 +5,18 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import * as clack from "@clack/prompts"; import { CATEGORIES, CURRENT_MANIFEST_VERSION } from "@withstanza/schema"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); @@ -16,6 +26,20 @@ import { cmdDoctor } from "./doctor"; import { cmdInit } from "./init"; import { cmdRemove } from "./remove"; +// The interactive pickers call `select`/`isCancel`; everything else (log, +// spinner, note, …) stays real so the existing exit-code/file assertions are +// unaffected. `--yes` init + explicit-id add/remove never reach `select`. +vi.mock("@clack/prompts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + select: vi.fn(), + isCancel: vi.fn<(value: unknown) => boolean>(() => false), + }; +}); +const mockSelect = vi.mocked(clack.select); +const mockIsCancel = vi.mocked(clack.isCancel); + let tmp: string; let prevCwd: string; let prevExitCode: typeof process.exitCode; @@ -986,6 +1010,178 @@ describe("cmdDoctor", () => { }); }); +describe("cmdAdd interactive picker", () => { + let prevTTY: boolean; + + beforeEach(async () => { + // Only a framework — so `auth`/`orm` have a missing peer to disable, and + // `db` is an open single-choice slot. + await cmdInit(args({ name: "app", yes: true, framework: "next" })); + process.chdir(path.join(tmp, "app")); + prevTTY = process.stdin.isTTY; + }); + + afterEach(() => { + process.stdin.isTTY = prevTTY; + mockSelect.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + }); + + it("omitting the module id without a TTY errors with the available list", async () => { + process.stdin.isTTY = false; + const spy = vi.spyOn(clack.log, "error"); + await cmdAdd(args({ slot: "auth" })); + expect(process.exitCode).toBe(1); + expect(spy).toHaveBeenCalledWith(expect.stringMatching(/Pick a module:[\s\S]*Available:/)); + // Nothing was installed. + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.auth).toBeUndefined(); + spy.mockRestore(); + }); + + it("omitting the module id on a TTY opens a picker and installs the pick", async () => { + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("clerk"); + + await cmdAdd(args({ slot: "auth" })); + expect(process.exitCode).toBeFalsy(); + + // The picker disabled the peer-incompatible option (better-auth needs a db) + // and left the compatible one selectable. + const opts = mockSelect.mock.calls.at(-1)![0].options; + const betterAuth = opts.find((o) => o.value === "better-auth"); + expect(betterAuth?.disabled).toBe(true); + expect(betterAuth?.hint).toMatch(/database/); + const clerk = opts.find((o) => o.value === "clerk"); + expect(clerk?.disabled).toBe(false); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.auth[0].id).toBe("clerk"); + }); + + it("cancels the picker without changing the manifest", async () => { + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("clerk"); + mockIsCancel.mockReturnValueOnce(true); + const before = fs.readFileSync("stanza.json", "utf8"); + + await cmdAdd(args({ slot: "auth" })); + expect(process.exitCode).toBe(1); + expect(mockIsCancel).toHaveBeenCalled(); + expect(fs.readFileSync("stanza.json", "utf8")).toBe(before); + }); + + it("marks an already-installed add-on disabled in a multi-choice picker", async () => { + await cmdAdd(args({ slot: "testing", moduleId: "vitest" })); + process.exitCode = undefined; + + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("playwright"); + await cmdAdd(args({ slot: "testing" })); + expect(process.exitCode).toBeFalsy(); + + const opts = mockSelect.mock.calls.at(-1)![0].options; + const vitest = opts.find((o) => o.value === "vitest"); + expect(vitest?.disabled).toBe(true); + expect(vitest?.hint).toMatch(/already added/); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.testing.map((r: { id: string }) => r.id).toSorted()).toEqual([ + "playwright", + "vitest", + ]); + }); + + it("drops an unknown explicit id into the picker on a TTY", async () => { + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("postgres"); + + await cmdAdd(args({ slot: "db", moduleId: "bogus" })); + expect(process.exitCode).toBeFalsy(); + expect(mockSelect).toHaveBeenCalled(); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.db[0].id).toBe("postgres"); + }); + + it("errors on an unknown explicit id without a TTY (no picker)", async () => { + process.stdin.isTTY = false; + const spy = vi.spyOn(clack.log, "error"); + + await cmdAdd(args({ slot: "db", moduleId: "bogus" })); + expect(process.exitCode).toBe(1); + expect(mockSelect).not.toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith( + expect.stringMatching(/No db module "bogus"[\s\S]*Available:/), + ); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.db).toBeUndefined(); + spy.mockRestore(); + }); +}); + +describe("cmdRemove interactive picker", () => { + let prevTTY: boolean; + + beforeEach(async () => { + await cmdInit( + args({ name: "app", yes: true, framework: "next", testing: "vitest,playwright" }), + ); + process.chdir(path.join(tmp, "app")); + prevTTY = process.stdin.isTTY; + }); + + afterEach(() => { + process.stdin.isTTY = prevTTY; + mockSelect.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + }); + + it("omitting the id on a TTY picks from the installed records", async () => { + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("vitest"); + + await cmdRemove(args({ slot: "testing" })); + expect(process.exitCode).toBeFalsy(); + + // The picker listed exactly what's installed. + const values = mockSelect.mock.calls.at(-1)![0].options.map((o) => o.value); + expect(values).toHaveLength(2); + expect(values).toEqual(expect.arrayContaining(["vitest", "playwright"])); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.testing.map((r: { id: string }) => r.id)).toEqual(["playwright"]); + }); + + it("cancels the picker without changing the manifest", async () => { + process.stdin.isTTY = true; + mockSelect.mockResolvedValueOnce("vitest"); + mockIsCancel.mockReturnValueOnce(true); + const before = fs.readFileSync("stanza.json", "utf8"); + + await cmdRemove(args({ slot: "testing" })); + expect(process.exitCode).toBe(1); + expect(mockIsCancel).toHaveBeenCalled(); + expect(fs.readFileSync("stanza.json", "utf8")).toBe(before); + }); + + it("omitting the id without a TTY still errors", async () => { + process.stdin.isTTY = false; + await cmdRemove(args({ slot: "testing" })); + expect(process.exitCode).toBe(1); + expect(mockSelect).not.toHaveBeenCalled(); + + const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8")); + expect(manifest.modules.testing.map((r: { id: string }) => r.id).toSorted()).toEqual([ + "playwright", + "vitest", + ]); + }); +}); + function writeStanza(projectRoot: string, registries: Record) { const file = path.join(projectRoot, "stanza.json"); const manifest = JSON.parse(fs.readFileSync(file, "utf8")); diff --git a/apps/cli/src/commands/remove.ts b/apps/cli/src/commands/remove.ts index 5c8579b..faa0370 100644 --- a/apps/cli/src/commands/remove.ts +++ b/apps/cli/src/commands/remove.ts @@ -70,7 +70,7 @@ export async function cmdRemove(args: CliArgs): Promise { process.exitCode = 1; return; } - const moduleId = rawModuleId ? parseModuleSpec(rawModuleId).id : undefined; + let moduleId = rawModuleId ? parseModuleSpec(rawModuleId).id : undefined; if (moduleId !== undefined && !isValidModuleId(moduleId)) { p.log.error( `Invalid module id "${moduleId}". Ids must be alphanumeric segments ` + @@ -120,13 +120,32 @@ export async function cmdRemove(args: CliArgs): Promise { let installed: StanzaModuleRecord; if (isMulti(category)) { if (!moduleId) { - const present = records.map((r) => r.id).join(", "); - p.log.error( - `Category "${category}" can hold several modules — specify which: ` + - `\`stanza remove ${category} \`${present ? ` (installed: ${present})` : ""}.`, - ); - process.exitCode = 1; - return; + // Omitted id in a multi-choice category. On a TTY, pick from what's + // installed; otherwise error with the installed list (CI never hangs). + if (process.stdin.isTTY && records.length > 0) { + const picked = await p.select({ + message: `Which ${category} module to remove?`, + options: records.map((r) => { + const nsTag = + r.namespace && r.namespace !== DEFAULT_NAMESPACE ? ` ${pc.dim(r.namespace)}` : ""; + return { value: r.id, label: `${r.id}${nsTag}`, hint: `@${r.version} [${r.adapter}]` }; + }), + }); + if (p.isCancel(picked)) { + process.exitCode = 1; + p.cancel("Cancelled."); + return; + } + moduleId = picked; + } else { + const present = records.map((r) => r.id).join(", "); + p.log.error( + `Category "${category}" can hold several modules — specify which: ` + + `\`stanza remove ${category} \`${present ? ` (installed: ${present})` : ""}.`, + ); + process.exitCode = 1; + return; + } } const record = records.find((r) => r.id === moduleId); if (!record) { diff --git a/apps/cli/src/lib/candidates.ts b/apps/cli/src/lib/candidates.ts new file mode 100644 index 0000000..ef2f7d2 --- /dev/null +++ b/apps/cli/src/lib/candidates.ts @@ -0,0 +1,98 @@ +import { resolveAdapter, type ResolveError } from "@withstanza/registry"; +import type { + AppKind, + CategoryId, + Module, + RegistryIndex, + StanzaManifest, +} from "@withstanza/schema"; +import { categoryLabel } from "@withstanza/schema"; + +/** + * A module in a category, tagged with whether it can be installed against the + * current stack. `compatible: false` carries a terse `reason` suitable for an + * inline disabled-option hint (e.g. clack's `Option.hint`). Shared by the + * `init` wizard (which filters to `compatible`) and the `add`/`remove` pickers + * (which render the incompatible ones as disabled). + */ +export type Candidate = { + namespace: string; + entry: RegistryIndex["modules"][number]; + /** Installable against the current manifest + pending picks + target app. */ + compatible: boolean; + /** Terse disabled-hint text; present only when `compatible` is false. */ + reason?: string; +}; + +export function moduleInstallKey(namespace: string, id: string): string { + return `${namespace}:${id}`; +} + +/** + * List every module in `category` across the given indices, peer-checked + * against the manifest (plus any in-flight `pending` picks) and the target app. + * A module is incompatible when its peers aren't satisfied, its `appKind` + * doesn't match the target app, or it's already installed (`installedIds`). + */ +export function categoryCandidates(args: { + indices: { namespace: string; index: RegistryIndex }[]; + category: CategoryId; + manifest: StanzaManifest; + pending?: Partial>; + targetAppId?: string; + targetAppKind?: AppKind; + installedIds?: ReadonlySet; +}): Candidate[] { + const { + indices, + category, + manifest, + pending = {}, + targetAppId, + targetAppKind, + installedIds, + } = args; + const out: Candidate[] = []; + for (const { namespace, index } of indices) { + for (const entry of index.modules) { + if (entry.category !== category) continue; + + // Definitive states first: an already-installed id, then an app-kind + // mismatch (a native framework can't go into a web app). Either makes the + // peer check moot, and the more specific reason is the more useful hint. + if (installedIds?.has(moduleInstallKey(namespace, entry.id))) { + out.push({ namespace, entry, compatible: false, reason: "already added" }); + continue; + } + if (targetAppKind && entry.appKind && entry.appKind !== targetAppKind) { + out.push({ + namespace, + entry, + compatible: false, + reason: `needs a ${entry.appKind} app`, + }); + continue; + } + + // Peer resolution. The index entry carries summary adapters (key + match) + // — enough for the resolver's peer check. Mirror `add`'s synthetic shape. + const synthetic = { ...entry, adapters: entry.adapters.map((a) => ({ ...a })) } as Module; + const result = resolveAdapter(synthetic, { manifest, pending, targetAppId }); + if (result.ok) { + out.push({ namespace, entry, compatible: true }); + } else { + out.push({ namespace, entry, compatible: false, reason: reasonForError(result.error) }); + } + } + } + return out; +} + +/** Map a resolver error to a terse, user-facing disabled-hint phrase. */ +function reasonForError(error: ResolveError): string { + if (error.kind === "missing-peer") { + return `add a ${categoryLabel(error.category).toLowerCase()} first`; + } + if (error.kind === "incompatible-peer") return `incompatible with ${error.peer}`; + return "no adapter for your stack"; +} diff --git a/apps/cli/src/lib/wizard.ts b/apps/cli/src/lib/wizard.ts index b1e8d77..60f9359 100644 --- a/apps/cli/src/lib/wizard.ts +++ b/apps/cli/src/lib/wizard.ts @@ -13,12 +13,14 @@ import type { import { categoryLabel, defaultWebApp, + DEFAULT_NAMESPACE, emptyManifest, isMulti, KNOWN_CATEGORIES, } from "@withstanza/schema"; import pc from "picocolors"; +import { categoryCandidates } from "./candidates"; import type { Registries } from "./registry-loader"; export type WizardResult = { @@ -175,20 +177,15 @@ function candidatesFor( category: CategoryId, pending: Partial>, ): RegistryIndex["modules"] { - const manifest = emptyManifest({ name: "tmp" }); - return index.modules - .filter((m) => m.category === category) - .filter((m) => peerCheckOk(m, manifest, pending)); -} - -function peerCheckOk( - m: RegistryIndex["modules"][number], - manifest: ReturnType, - pending: Partial>, -): boolean { - // Use the resolver with summary adapters — we only need the peer check. - const synthetic = { ...m, adapters: m.adapters.map((a) => ({ ...a })) } as Module; - return resolveAdapter(synthetic, { manifest, pending }).ok; + return categoryCandidates({ + indices: [{ namespace: DEFAULT_NAMESPACE, index }], + category, + manifest: emptyManifest({ name: "tmp" }), + pending, + targetAppKind: defaultWebApp().kind, + }) + .filter((c) => c.compatible) + .map((c) => c.entry); } async function runNonInteractive(args: { diff --git a/apps/web/content/docs/cli.mdx b/apps/web/content/docs/cli.mdx index 9aea450..ec02461 100644 --- a/apps/web/content/docs/cli.mdx +++ b/apps/web/content/docs/cli.mdx @@ -29,11 +29,12 @@ npx stanza-cli init [name] --yes --framework=next --orm=drizzle --db=postgres -- Add one module to an existing project. ```sh -npx stanza-cli add [--app=] +npx stanza-cli add [module] [--app=] ``` Resolves peers, selects the matching adapter, and writes the module's templates, deps, env, and scripts to the right home. If an apply step fails partway through — including inside a codemod — Stanza rolls the changes back, so a failed `add` leaves your tree as it was. Adding a module to a single-choice category that's already filled fails until you remove the existing one — and that limit is **per app** for `home: "app"` categories (so picking a framework for one app doesn't block a different framework for another). +- `module` — optional. **Omit it to pick interactively**: on a TTY, `add ` opens a menu of every module in that category, with incompatible (a peer isn't installed yet) or already-installed ones shown **disabled** with the reason inline. Off a TTY (CI, piped input) it errors with the list of available ids instead of hanging. If you type an id that doesn't exist, a TTY drops you into the same picker; otherwise you get the available list. - `--app=` — pick which app the install targets. Required for app-relevant modules when the project has multiple apps and you're not running inside one of them. Single-app projects auto-target; multi-app projects also auto-pick when `cwd` is inside one app's directory. Interactive runs (TTY, no `--yes`) fall back to a prompt; non-interactive runs error out asking for the flag. - The flag is meaningless for `home: "repo"` modules like `tooling`. - `--dry-run` — preview the plan (created/modified/skipped files, including codemod edits) without writing. A real `add` prints the same tally as a one-line summary when it finishes. @@ -46,7 +47,7 @@ Remove a module and sweep the files (regions) it owns. npx stanza-cli remove [id] [--app=] ``` -For single-choice categories the `id` is optional; for multi-choice categories (like `testing`) it's required. `--app=` scopes removal in projects with multiple apps — without it, `remove` looks across every app for a matching record. +For single-choice categories the `id` is optional. For multi-choice categories, omit it on a TTY to open an interactive picker over installed records, mirroring `stanza add ` over available modules. Off a TTY (CI, piped input), omitting the id errors with the installed list instead of hanging. `--app=` scopes removal in projects with multiple apps — without it, `remove` looks across every app for a matching record. The package-dir sweep (when removing the last module under `packages//`) strips the `workspace:*` dep from _every_ app's `package.json`, not just the first one. diff --git a/skills/stanza-cli/SKILL.md b/skills/stanza-cli/SKILL.md index d8e322f..a305cb6 100644 --- a/skills/stanza-cli/SKILL.md +++ b/skills/stanza-cli/SKILL.md @@ -63,8 +63,8 @@ pnpm dev ## Commands - `stanza init [name] --yes ...` scaffolds a new project in a child directory of the current working directory. Today every `init` produces a single web app (`apps/web`, id `web`); multi-app init is planned but not yet exposed. -- `stanza add [@/] [--app=]` adds one module to an existing Stanza project. The `--app` flag picks which app receives an app-scoped module (`framework`/`testing`) or which app a package-scoped module's shims (e.g. Better Auth's route handler) target. Single-app projects auto-target. Multi-app projects auto-pick if `cwd` is inside an app's `dir`; otherwise the CLI prompts interactively on a TTY or errors out in non-interactive runs. Prefix the id with `@/` to install from a third-party registry the project has declared under `registries` in `stanza.json`. -- `stanza remove [[@/]] [--app=]` removes a module. For single-choice categories the `id` is optional; for multi-choice categories it is required. `--app` scopes removal in projects with multiple apps. The `@/` prefix is accepted for readability but not required — `remove` matches against the stored `id`. +- `stanza add [[@/]] [--app=]` adds one module to an existing Stanza project. Always pass an explicit `module` id in automation: omitting it (or passing an unknown id) errors non-interactively with the list of available ids for that category. `--app=` picks which app receives an app-scoped module (`framework`/`testing`) or a package-scoped module's shims (e.g. Better Auth's route handler); single-app projects auto-target, and a multi-app project errors without `--app` unless `cwd` is inside an app's `dir`. Prefix the id with `@/` to install from a third-party registry declared under `registries` in `stanza.json`. +- `stanza remove [[@/]] [--app=]` removes a module. The `id` is optional for single-choice categories. For multi-choice categories, omit it on a TTY to pick from installed records, but it is required in non-interactive mode (where omitting it errors with the installed list). `--app` scopes removal in multi-app projects. The `@/` prefix is accepted but optional — `remove` matches against the stored `id`. - `stanza list` prints installed modules grouped by category from the nearest `stanza.json`. - `stanza search [query]` lists registry modules and their `category/id` pairs. - `stanza doctor` checks `stanza.json` against the filesystem for drift (claimed files/deps/scripts/env vars still present, internal packages wired) and reports issues. Read-only; exits non-zero when drift is found.