mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
feat: interactive module picker for stanza add and stanza remove (#33)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"stanza-cli": minor
|
||||
---
|
||||
|
||||
`stanza add <category>` 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 <category>` 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).
|
||||
+37
-8
@@ -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` + `--<category>=<id>` flags). |
|
||||
| `stanza add <category> [@<ns>/]<module>` | 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=<id>` targets a specific app. |
|
||||
| `stanza remove <category> [[@<ns>/]<id>]` | Remove a module and clean up its files, deps, and codemods. The id is optional for single-choice categories. |
|
||||
| `stanza add <category> [[@<ns>/]<module>]` | 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=<id>` targets a specific app. |
|
||||
| `stanza remove <category> [[@<ns>/]<id>]` | 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 <category>` 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
|
||||
|
||||
|
||||
+215
-42
@@ -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<void> {
|
||||
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 <category> [@<namespace>/]<module>");
|
||||
if (!slot) {
|
||||
p.log.error("Usage: stanza add <category> [[@<namespace>/]<module>]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -55,33 +61,6 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
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}/<id>\`?`,
|
||||
);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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}/<id>\`?`,
|
||||
);
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
}): 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} <module>\`. 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<string, number>();
|
||||
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=<id>` flag wins.
|
||||
|
||||
@@ -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<typeof import("@clack/prompts")>();
|
||||
return {
|
||||
...actual,
|
||||
select: vi.fn<typeof actual.select>(),
|
||||
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<string, unknown>) {
|
||||
const file = path.join(projectRoot, "stanza.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
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,6 +120,24 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
let installed: StanzaModuleRecord;
|
||||
if (isMulti(category)) {
|
||||
if (!moduleId) {
|
||||
// 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: ` +
|
||||
@@ -128,6 +146,7 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const record = records.find((r) => r.id === moduleId);
|
||||
if (!record) {
|
||||
p.log.warn(
|
||||
|
||||
@@ -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<Record<CategoryId, Module>>;
|
||||
targetAppId?: string;
|
||||
targetAppKind?: AppKind;
|
||||
installedIds?: ReadonlySet<string>;
|
||||
}): 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";
|
||||
}
|
||||
+11
-14
@@ -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<Record<CategoryId, Module>>,
|
||||
): 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<typeof emptyManifest>,
|
||||
pending: Partial<Record<CategoryId, Module>>,
|
||||
): 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: {
|
||||
|
||||
@@ -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 <category> <module> [--app=<id>]
|
||||
npx stanza-cli add <category> [module] [--app=<id>]
|
||||
```
|
||||
|
||||
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 <category>` 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=<id>` — 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 <category> [id] [--app=<id>]
|
||||
```
|
||||
|
||||
For single-choice categories the `id` is optional; for multi-choice categories (like `testing`) it's required. `--app=<id>` 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 <category>` over available modules. Off a TTY (CI, piped input), omitting the id errors with the installed list instead of hanging. `--app=<id>` 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/<dir>/`) strips the `workspace:*` dep from _every_ app's `package.json`, not just the first one.
|
||||
|
||||
|
||||
@@ -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 <category> [@<ns>/]<module> [--app=<id>]` 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 `@<ns>/` to install from a third-party registry the project has declared under `registries` in `stanza.json`.
|
||||
- `stanza remove <category> [[@<ns>/]<id>] [--app=<id>]` 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 `@<ns>/` prefix is accepted for readability but not required — `remove` matches against the stored `id`.
|
||||
- `stanza add <category> [[@<ns>/]<module>] [--app=<id>]` 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=<id>` 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 `@<ns>/` to install from a third-party registry declared under `registries` in `stanza.json`.
|
||||
- `stanza remove <category> [[@<ns>/]<id>] [--app=<id>]` 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 `@<ns>/` 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.
|
||||
|
||||
Reference in New Issue
Block a user