feat: add third-party registry support with @namespace/id module addressing

- Add `packages/registry/src/registry-config.ts` with a Zod schema for the `registries` field in `stanza.json` (string URL prefix or `{ url, indexUrl?, headers?, params? }` object), plus `parseModuleSpec` to split `@ns/id` specs and `expandEnv` to substitute `${VAR}` tokens in header values (dropping the header silently when the env var is unset)
- Replace `loadRegistry` with `loadRegistries(manifest)` in `registry-loader.ts`; the loader reads `registries` from `stanza.json`, builds a per-namespace fetch client with auth headers, and routes module loads to the correct HTTP endpoint — `@stanza` remains the implicit default namespace backed by the existing registry URL
- Update `add`, `remove`, `search`, and `list` commands to accept `[@<namespace>/]<id>` syntax; `namespace` is recorded alongside each module entry in `stanza.json` at install time so `remove` knows which registry to refetch from
- Enforce that codemods are catalog-only: third-party modules may reference first-party codemod IDs but cannot introduce new ones; catalog ID validation runs before any writes so unknown IDs are caught on `--dry-run` too
- Add a full third-party registry test suite in `commands.test.ts` using a stub `node:http` server: covers install + manifest recording, unknown-namespace rejection, remove round-trip (asserts correct namespace URL is hit), `${ENV_VAR}` header expansion, and codemod-catalog enforcement
- Export `DEFAULT_NAMESPACE` and `parseModuleSpec` from `@stanza/registry`; telemetry `cli_module` events now include a `namespace` field so first- vs. third-party installs can be distinguished in PostHog
- Add `apps/web/content/docs/authoring.mdx` covering the full module-authoring reference (schema fields, templates, adapters, codemods, third-party registry config)
This commit is contained in:
2026-05-26 20:12:01 -04:00
parent 659f441343
commit f195505a48
28 changed files with 1386 additions and 111 deletions
+2 -1
View File
@@ -81,7 +81,8 @@ Core workflow:
- **Module `version` field**: every module manifest declares a `version` string, pinned into `stanza.json` at install time. The upcoming `swap`/`update` verbs read it; today it's stored for forward compatibility. Bump it on schema-affecting changes (template additions, dep upgrades) per semver
- **Declarative beats imperative**: prefer `templates`/`dependencies`/`env`/`scripts` over imperative codemods; the runner applies declarative fields generically
- **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by the upcoming `swap`/`update` verbs — do not drop them
- **Web app hosts the canonical registry**: `pnpm --filter @stanza/web build` runs `prebuild` which builds the registry (if missing) and copies it into `apps/web/public/registry/`. The deployed Vercel output therefore ships `index.json` + `modules/*.json` at the same origin. The published CLI's `DEFAULT_REGISTRY_URL` points at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override via `STANZA_REGISTRY` (URL or filesystem path)
- **Web app hosts the canonical registry**: `pnpm --filter @stanza/web build` runs `prebuild` which builds the registry (if missing) and copies it into `apps/web/public/registry/`. The deployed Vercel output therefore ships `index.json` + `modules/*.json` at the same origin. The published CLI's `DEFAULT_REGISTRY_URL` points at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override via `STANZA_REGISTRY` (URL or filesystem path) — this overrides the bundled `@stanza` default namespace's URL only
- **Third-party registries**: namespaces declared in `stanza.json` under `registries` (string URL prefix or `{ url, indexUrl?, headers?, params? }` object). Modules from them are addressed as `<category> @<ns>/<id>` on the CLI. Resolved by [packages/registry/src/registry-config.ts](packages/registry/src/registry-config.ts) (Zod schema, `parseModuleSpec`, `expandEnv`) and the [Registries](apps/cli/src/lib/registry-loader.ts) loader. Codemods still come from the first-party catalog only — third-party modules can invoke catalog ids but can't ship new ones
## Module authoring
+34 -10
View File
@@ -2,9 +2,11 @@ import * as p from "@clack/prompts";
import type { AppSpec, StanzaManifest } from "@stanza/registry";
import {
categoryHome,
DEFAULT_NAMESPACE,
isCategoryId,
isMulti,
KNOWN_CATEGORIES,
parseModuleSpec,
resolveAdapter,
selectedAll,
} from "@stanza/registry";
@@ -15,7 +17,7 @@ import { applyModule } from "../lib/codemod-runner";
import { ensureCleanWorktree } from "../lib/git";
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
import { regenerateReadmeIfUnmodified } from "../lib/readme";
import { loadRegistry, pickRegistryRoot } from "../lib/registry-loader";
import { loadRegistries, pickRegistryRoot } from "../lib/registry-loader";
import * as telemetry from "../lib/telemetry";
import { commonArgs, type CliArgs } from "./_args";
@@ -35,9 +37,9 @@ export const add = defineCommand({
export async function cmdAdd(args: CliArgs): Promise<void> {
const slot = typeof args.slot === "string" ? args.slot : undefined;
const moduleId = typeof args.moduleId === "string" ? args.moduleId : undefined;
if (!slot || !moduleId) {
p.log.error("Usage: stanza add <category> <module>");
const rawModuleId = typeof args.moduleId === "string" ? args.moduleId : undefined;
if (!slot || !rawModuleId) {
p.log.error("Usage: stanza add <category> [@<namespace>/]<module>");
process.exitCode = 1;
return;
}
@@ -50,6 +52,10 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
const category = slot;
const group = category;
// 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);
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found in this or any parent directory.");
@@ -129,10 +135,15 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
return;
}
const registry = await loadRegistry();
const mod = await registry.loadModule(group, moduleId).catch(() => null);
if (!mod) {
p.log.error(`Module not found: ${group}/${moduleId}`);
const registry = await loadRegistries(manifest);
let mod;
try {
mod = await registry.loadModule(group, moduleId, namespace);
} catch (err) {
const where = namespace ? `${namespace}/` : "";
p.log.error(
`Could not load ${group}/${where}${moduleId}: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
return;
}
@@ -156,7 +167,10 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
const spinner = p.spinner();
spinner.start(`Adding ${mod.label}`);
const registryRoot = pickRegistryRoot();
// Third-party modules ship templates inlined and don't need a local
// registry root. For @stanza we still surface the local FS path so dev-mode
// (FS) registry runs can read template files that aren't inlined yet.
const registryRoot = pickRegistryRoot(namespace ?? DEFAULT_NAMESPACE);
let result;
try {
result = await applyModule({
@@ -167,13 +181,23 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
targetApps,
registryRoot,
dryRun,
namespace,
});
} catch (err) {
spinner.stop(`${mod.label} ${pc.red("failed")}`);
throw err;
}
telemetry.capture("cli_module", { action: "install", group, module: mod.id });
// Always counted in the aggregate install total; the `namespace` property
// lets the stats page exclude third-party modules from per-category
// leaderboards (where ranking private/proprietary ids alongside first-party
// ones would be misleading).
telemetry.capture("cli_module", {
action: "install",
group,
module: mod.id,
namespace: namespace ?? DEFAULT_NAMESPACE,
});
spinner.stop(`${pc.green("✓")} ${mod.label} added`);
if (result.bootstrappedPackage) {
const { name } = result.bootstrappedPackage;
+177
View File
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
@@ -418,3 +419,179 @@ describe("tooling-eslint-prettier — framework-conditional rendering", () => {
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeUndefined();
});
});
// Minimal, schema-valid module fixture for the third-party registry tests.
// Multi-cardinality `testing` category so it never collides with first-party
// single-choice slots, and no peer constraints so it installs against any
// framework (or none).
function cosmosModule(extra: Record<string, unknown> = {}) {
return {
category: "testing",
id: "cosmos",
label: "Cosmos",
description: "Component sandbox.",
version: "1.0.0",
devDependencies: { "react-cosmos": "^7.0.0" },
scripts: { cosmos: "cosmos" },
adapters: [{ key: "default", match: {} }],
...extra,
};
}
describe("third-party registries", () => {
// Spin up a stub HTTP registry per test so we can verify namespace-aware
// module resolution, header auth, and codemod-catalog enforcement end-to-end.
// Modules are hand-crafted JSON; the registry build pipeline isn't involved.
type Fixture = {
modules: Record<string, unknown>;
onRequest?: (req: {
url: string;
headers: Record<string, string | string[] | undefined>;
}) => void;
};
let server: Server;
let baseUrl: string;
let fixture: Fixture;
beforeEach(async () => {
fixture = { modules: {} };
server = createServer((req, res) => {
const url = req.url ?? "";
fixture.onRequest?.({ url, headers: req.headers });
// Map `/modules/<category>-<id>.json` → fixture.modules[<category>-<id>].
// `/index.json` is intentionally 404 — we exercise fetch-by-name only.
const match = /^\/modules\/(.+)\.json$/.exec(url);
if (!match) {
res.statusCode = 404;
res.end();
return;
}
const payload = fixture.modules[match[1]!];
if (!payload) {
res.statusCode = 404;
res.end();
return;
}
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
baseUrl = `http://127.0.0.1:${port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function writeStanza(projectRoot: string, registries: Record<string, unknown>) {
const file = path.join(projectRoot, "stanza.json");
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
manifest.registries = registries;
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
}
it("installs a module from a third-party namespace and records its origin", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": baseUrl });
fixture.modules["testing-cosmos"] = cosmosModule();
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
expect(process.exitCode).toBeFalsy();
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toHaveLength(1);
expect(manifest.modules.testing[0]).toMatchObject({
id: "cosmos",
namespace: "@fixture",
});
// The devDep + script landed on the app's package.json.
const appPkg = JSON.parse(fs.readFileSync("apps/web/package.json", "utf8"));
expect(appPkg.devDependencies["react-cosmos"]).toBe("^7.0.0");
expect(appPkg.scripts.cosmos).toBe("cosmos");
});
it("rejects an unknown namespace with a clear error", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
// No `registries` field at all — `@nope` is undeclared.
await cmdAdd(args({ slot: "testing", moduleId: "@nope/cosmos" }));
expect(process.exitCode).toBe(1);
// Manifest unchanged.
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
});
it("refetches from the original namespace on remove", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": baseUrl });
fixture.modules["testing-cosmos"] = cosmosModule();
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
// Track requests during the remove so we can assert the namespace was honored.
const requests: string[] = [];
fixture.onRequest = ({ url }) => requests.push(url);
await cmdRemove(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
expect(process.exitCode).toBeFalsy();
expect(requests).toContain("/modules/testing-cosmos.json");
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
const appPkg = JSON.parse(fs.readFileSync("apps/web/package.json", "utf8"));
expect(appPkg.devDependencies?.["react-cosmos"]).toBeUndefined();
expect(appPkg.scripts?.cosmos).toBeUndefined();
});
it("expands ${ENV_VAR} tokens in headers and drops the header when unset", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), {
"@fixture": {
url: `${baseUrl}/modules/{category}-{id}.json`,
headers: {
Authorization: "Bearer ${STANZA_TEST_TOKEN}",
"X-Missing": "Bearer ${STANZA_UNSET_TOKEN}",
},
},
});
fixture.modules["testing-cosmos"] = cosmosModule();
let captured: Record<string, string | string[] | undefined> | undefined;
fixture.onRequest = ({ url, headers }) => {
if (url === "/modules/testing-cosmos.json") captured = headers;
};
process.env.STANZA_TEST_TOKEN = "secret-xyz";
try {
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
} finally {
delete process.env.STANZA_TEST_TOKEN;
}
expect(process.exitCode).toBeFalsy();
expect(captured?.authorization).toBe("Bearer secret-xyz");
// Headers whose template references an unset env var are dropped silently.
expect(captured?.["x-missing"]).toBeUndefined();
});
it("rejects a third-party module that invokes an unknown codemod", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": baseUrl });
fixture.modules["testing-cosmos"] = cosmosModule({
adapters: [{ key: "default", match: {}, codemods: [{ id: "not-a-real-codemod" }] }],
});
// The runner validates codemod ids against the first-party catalog up
// front, so this throws before any files change.
await expect(cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }))).rejects.toThrow(
/not-a-real-codemod/,
);
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
});
});
+19 -3
View File
@@ -7,6 +7,7 @@ import {
appPackageJsonBase,
categoryHome,
categoryOrder,
DEFAULT_NAMESPACE,
ENV_EXAMPLE_HEADER,
KNOWN_CATEGORIES,
PEER_CATEGORIES,
@@ -24,7 +25,7 @@ import { ensureCleanWorktree } from "../lib/git";
import { initManifest, writeManifest } from "../lib/manifest";
import { resolveExactVersion } from "../lib/npm-version";
import { writeFreshReadme } from "../lib/readme";
import { loadRegistry, pickRegistryRoot } from "../lib/registry-loader";
import { loadRegistries, pickRegistryRoot } from "../lib/registry-loader";
import * as telemetry from "../lib/telemetry";
import { runInitWizard, type WizardOverrides } from "../lib/wizard";
import { commonArgs, type CliArgs } from "./_args";
@@ -57,7 +58,10 @@ export const init = defineCommand({
export async function cmdInit(args: CliArgs): Promise<void> {
const name = typeof args.name === "string" ? args.name : undefined;
const registry = await loadRegistry();
// Init only ever uses the default `@stanza` namespace — third-party
// registries are declared in the project's stanza.json, which doesn't
// exist yet at init time.
const registry = await loadRegistries();
const defaultName = name ?? path.basename(process.cwd());
const dryRun = Boolean(args["dry-run"]);
@@ -100,6 +104,10 @@ export async function cmdInit(args: CliArgs): Promise<void> {
packageManager: result.packageManager,
});
// Init always uses the first-party @stanza registry, so the FS root chain
// (env override → local monorepo → null) applies. `null` is fine — every
// first-party module's templates are also inlined in the registry build,
// so the runner doesn't read from disk when there's no local root.
const registryRoot = pickRegistryRoot();
if (dryRun) p.log.info(pc.yellow("[dry-run] no files will be written"));
@@ -173,7 +181,15 @@ export async function cmdInit(args: CliArgs): Promise<void> {
adapter: adapter.adapter,
} satisfies ResolvedEntry);
applied.push(`${category}/${mod.id}`);
telemetry.capture("cli_module", { action: "install", group: category, module: mod.id });
// Init always installs from the first-party `@stanza` registry.
// Mirrors the event shape `add`/`remove` use so the stats query can
// bucket on `properties.namespace` uniformly.
telemetry.capture("cli_module", {
action: "install",
group: category,
module: mod.id,
namespace: DEFAULT_NAMESPACE,
});
spinner.stop(`${pc.green("✓")} ${mod.label}`);
}
}
+5 -2
View File
@@ -1,5 +1,5 @@
import * as p from "@clack/prompts";
import { categoryOrder, isMulti, selectedAll } from "@stanza/registry";
import { categoryOrder, DEFAULT_NAMESPACE, isMulti, selectedAll } from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
@@ -30,8 +30,11 @@ export async function cmdList(): Promise<void> {
continue;
}
for (const m of records) {
// Show the namespace prefix only for third-party records; first-party
// (`@stanza`, or absent) keeps the listing terse.
const nsTag = m.namespace && m.namespace !== DEFAULT_NAMESPACE ? `${m.namespace}/` : "";
rows.push(
`${pc.cyan(category.padEnd(10))} ${m.id} ${pc.dim(`@${m.version}`)} ${pc.dim(`[${m.adapter}]`)}`,
`${pc.cyan(category.padEnd(10))} ${nsTag}${m.id} ${pc.dim(`@${m.version}`)} ${pc.dim(`[${m.adapter}]`)}`,
);
}
}
+19 -6
View File
@@ -7,9 +7,11 @@ import type { AppSpec, StanzaModuleRecord } from "@stanza/registry";
import {
appsForRecord,
categoryHome,
DEFAULT_NAMESPACE,
isCategoryId,
isMulti,
PACKAGE_DIRS,
parseModuleSpec,
selectedAll,
} from "@stanza/registry";
import { defineCommand } from "citty";
@@ -20,7 +22,7 @@ import { ensureCleanWorktree } from "../lib/git";
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
import { regenerateReadmeIfUnmodified } from "../lib/readme";
import { regionsOwnedBy } from "../lib/region-tracker";
import { loadRegistry } from "../lib/registry-loader";
import { loadRegistries } from "../lib/registry-loader";
import * as telemetry from "../lib/telemetry";
import { commonArgs, type CliArgs } from "./_args";
@@ -47,13 +49,17 @@ export const remove = defineCommand({
export async function cmdRemove(args: CliArgs): Promise<void> {
const slot = typeof args.slot === "string" ? args.slot : undefined;
const moduleId = typeof args.moduleId === "string" ? args.moduleId : undefined;
const rawModuleId = typeof args.moduleId === "string" ? args.moduleId : undefined;
const appFlag = typeof args.app === "string" ? args.app : undefined;
if (!slot) {
p.log.error("Usage: stanza remove <category> [id]");
p.log.error("Usage: stanza remove <category> [[@<namespace>/]<id>]");
process.exitCode = 1;
return;
}
// Accept `@ns/id` so users can disambiguate when two registries ship a
// module under the same id. We match against `record.id` (the namespace
// hint is informational + persisted on the record on install).
const moduleId = rawModuleId ? parseModuleSpec(rawModuleId).id : undefined;
if (!isCategoryId(slot)) {
p.log.error(`Unknown category: ${slot}`);
process.exitCode = 1;
@@ -122,8 +128,8 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
// Step 1: revert imperative codemods first. They modify framework- or
// peer-owned files (root layout, schema barrels, vite config) — reverts
// need to see those files intact, before any template deletions below.
const registry = await loadRegistry();
const mod = await registry.loadModule(group, installed.id).catch(() => null);
const registry = await loadRegistries(manifest);
const mod = await registry.loadModule(group, installed.id, installed.namespace).catch(() => null);
const adapter = mod?.adapters.find((a) => a.key === installed.adapter);
if (mod && adapter) {
const revertResult = await revertCodemods({
@@ -247,7 +253,14 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
p.log.warn("Skipped README.md refresh (user-modified). Delete the file to regenerate.");
}
telemetry.capture("cli_module", { action: "remove", group, module: installed.id });
// Mirrors `add`: the namespace property lets the stats page bucket
// first-party vs third-party correctly without losing the aggregate count.
telemetry.capture("cli_module", {
action: "remove",
group,
module: installed.id,
namespace: installed.namespace ?? DEFAULT_NAMESPACE,
});
p.log.success(`${pc.green("✓")} Removed ${installed.id} from ${group}`);
if (sweptPackages.length > 0) {
p.log.info(`Swept packages/${sweptPackages.join(", packages/")} (no remaining slot owns it).`);
+60 -17
View File
@@ -1,39 +1,82 @@
import { DEFAULT_NAMESPACE, parseModuleSpec } from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
import { loadRegistry } from "../lib/registry-loader";
import { findProjectRoot, readManifest } from "../lib/manifest";
import { loadRegistries } from "../lib/registry-loader";
import { commonArgs, type CliArgs } from "./_args";
export const search = defineCommand({
meta: { name: "search", description: "Search the registry." },
args: {
query: { type: "positional", required: false, description: "Filter modules by text." },
query: {
type: "positional",
required: false,
description: "Filter modules by text. Prefix with @ns/ to restrict to one registry.",
},
...commonArgs,
},
run: ({ args }) => cmdSearch(args),
});
export async function cmdSearch(args: CliArgs): Promise<void> {
const registry = await loadRegistry();
const q = (typeof args.query === "string" ? args.query : "").toLowerCase().trim();
// If we're inside a project, load its manifest so configured third-party
// registries fan out alongside @stanza. Outside a project, only @stanza
// is searchable. A malformed manifest just disables the fan-out — don't
// break `search` because of an unrelated parse error.
const projectRoot = findProjectRoot();
const manifest = projectRoot
? (() => {
try {
return readManifest(projectRoot);
} catch {
return undefined;
}
})()
: undefined;
const registry = await loadRegistries(manifest);
const results = registry.index.modules.filter((m) => {
if (!q) return true;
return (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.category.includes(q)
);
});
const raw = typeof args.query === "string" ? args.query.trim() : "";
const { namespace: nsFilter, id: text } = raw ? parseModuleSpec(raw) : { id: "" };
const q = text.toLowerCase();
if (results.length === 0) {
console.log(pc.dim("No modules found."));
const indices = registry.searchableIndices();
const hits: {
namespace: string;
module: (typeof indices)[number]["index"]["modules"][number];
}[] = [];
for (const { namespace, index } of indices) {
if (nsFilter && namespace !== nsFilter) continue;
for (const m of index.modules) {
if (!q) {
hits.push({ namespace, module: m });
continue;
}
if (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
m.description.toLowerCase().includes(q) ||
m.category.includes(q)
) {
hits.push({ namespace, module: m });
}
}
}
if (hits.length === 0) {
if (nsFilter && !registry.namespaces().includes(nsFilter)) {
console.log(pc.dim(`Unknown registry "${nsFilter}". Add it to stanza.json.`));
} else {
console.log(pc.dim("No modules found."));
}
return;
}
for (const m of results) {
const head = `${pc.bold(m.label)} ${pc.dim(`(${m.category}/${m.id})`)}`;
for (const { namespace, module: m } of hits) {
// Hide the @stanza prefix on first-party hits to keep the common case
// terse — only show the namespace when it's third-party.
const nsTag = namespace === DEFAULT_NAMESPACE ? "" : `${namespace}/`;
const head = `${pc.bold(m.label)} ${pc.dim(`(${m.category}/${nsTag}${m.id})`)}`;
const desc = m.description ? ` ${pc.dim(m.description)}` : "";
console.log(`${head}\n${desc}`);
}
+48 -8
View File
@@ -63,10 +63,22 @@ export async function applyModule(args: {
module: Module;
adapter: ModuleAdapter;
targetApps: AppSpec[];
registryRoot: string;
/**
* Local registry root for sourcing template files when they aren't inlined
* on the module manifest. Third-party HTTP registries always inline
* `tpl.content`, so passing `null` is fine for those; the runner only
* touches disk when a template lacks `content`.
*/
registryRoot: string | null;
/**
* Namespace the module was loaded from (e.g. `"@acme"`). Persisted on the
* `StanzaModuleRecord` so `remove`/`update` can refetch from the original
* registry. `undefined` (default) means the first-party `@stanza` registry.
*/
namespace?: string;
dryRun: boolean;
}): Promise<RunResult> {
const { projectRoot, module, adapter, targetApps, registryRoot, dryRun } = args;
const { projectRoot, module, adapter, targetApps, namespace, registryRoot, dryRun } = args;
let manifest = args.manifest;
const touchedFiles = new Set<string>();
@@ -83,8 +95,14 @@ export async function applyModule(args: {
}
const owner = module.id;
// Module dirs are named `<category>-<id>` (e.g. `testing-vitest`).
const moduleDir = path.join(registryRoot, "modules", `${module.category}-${module.id}`);
// Module dirs are named `<category>-<id>` (e.g. `testing-vitest`). Only used
// as a fallback when a template lacks inlined `content` — `readTemplateSource`
// throws with a clear error if it's needed but the registry root is null
// (which is the typical published-CLI + third-party-registry case).
const moduleDir =
registryRoot !== null
? path.join(registryRoot, "modules", `${module.category}-${module.id}`)
: null;
// Module-level install fields (shared across adapters) are merged with the
// adapter-level ones (variation per peer combination). Adapter wins per-key
@@ -309,7 +327,7 @@ export async function applyModule(args: {
}
if (!dryRun) {
const record = recordFor(module, adapter, targetApps);
const record = recordFor(module, adapter, targetApps, namespace);
// Push into the category's array, replacing any same-(id, apps-key) record
// so re-adds are idempotent. Single-choice categories with home:"app" are
// kept to one record per app id by `add`/`init` validation; other homes
@@ -370,9 +388,20 @@ function recordFor(
module: Module,
adapter: ModuleAdapter,
targetApps: AppSpec[],
): { id: string; version: string; adapter: string; apps?: string[] } {
namespace: string | undefined,
): { id: string; version: string; adapter: string; apps?: string[]; namespace?: string } {
const home = categoryHome(module.category);
const base = { id: module.id, version: module.version, adapter: adapter.key };
const base: {
id: string;
version: string;
adapter: string;
apps?: string[];
namespace?: string;
} = { id: module.id, version: module.version, adapter: adapter.key };
// Record the origin namespace so `remove` / `update` know which registry
// to refetch from. Omit when it's the default `@stanza` to keep manifests
// for first-party-only projects clean.
if (namespace) base.namespace = namespace;
if (home.kind === "repo") return base;
// Both app-home and package-home tag with the consuming apps so `remove`
// can find them and so the schema stays well-formed.
@@ -571,9 +600,20 @@ function renderArgs(
* contents in `tpl.content` (the registry build step bakes them in). Local
* dev (FS-based registry) leaves `content` undefined, so we fall back to
* reading from the module's templates/ directory on disk.
*
* `moduleDir` is null when no local registry is reachable (published CLI
* fetching from the canonical URL, or any third-party namespace). In that
* case the loader guarantees `tpl.content` is set; if it isn't, the module
* is malformed and we error early.
*/
function readTemplateSource(tpl: TemplateRef, moduleDir: string): string {
function readTemplateSource(tpl: TemplateRef, moduleDir: string | null): string {
if (tpl.content !== undefined) return tpl.content;
if (moduleDir === null) {
throw new Error(
`Template "${tpl.src}" has no inlined content and no local registry root is available. ` +
`Third-party modules must inline template content (run the registry build) before publishing.`,
);
}
return fs.readFileSync(path.join(moduleDir, "templates", tpl.src), "utf8");
}
+5 -3
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import type { Resolved, ResolvedEntry, StanzaManifest } from "@stanza/registry";
import { activePeerIds, categoryOrder, synthesizeReadme } from "@stanza/registry";
import type { Registry } from "./registry-loader";
import type { Registries } from "./registry-loader";
const README_FILENAME = "README.md";
@@ -58,7 +58,7 @@ export type ReadmeRegenStatus = "written" | "skipped" | "dry-run";
export async function regenerateReadmeIfUnmodified(args: {
projectRoot: string;
manifest: StanzaManifest;
registry: Registry;
registry: Registries;
dryRun: boolean;
}): Promise<{ manifest: StanzaManifest; status: ReadmeRegenStatus }> {
if (userModifiedReadme(args.projectRoot, args.manifest)) {
@@ -71,7 +71,9 @@ export async function regenerateReadmeIfUnmodified(args: {
if (records.length === 0) continue;
const entries: ResolvedEntry[] = [];
for (const record of records) {
const mod = await args.registry.loadModule(category, record.id).catch(() => null);
const mod = await args.registry
.loadModule(category, record.id, record.namespace)
.catch(() => null);
if (!mod) continue;
const adapter = mod.adapters.find((a) => a.key === record.adapter) ?? mod.adapters[0];
if (!adapter) continue;
+195 -43
View File
@@ -2,45 +2,122 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Module, RegistryIndex } from "@stanza/registry";
import { CATEGORIES, ModuleSchema, RegistryIndexSchema } from "@stanza/registry";
import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@stanza/registry";
import {
CATEGORIES,
DEFAULT_NAMESPACE,
expandEnv,
ModuleSchema,
RegistryIndexSchema,
} from "@stanza/registry";
/**
* In dev (when running from the stanza monorepo), modules are imported
* directly from `registry/modules/<id>/module.ts`. In production (published
* CLI), the registry is a static JSON endpoint served by the canonical
* stanza website — the web builder and CLI consume the same URL.
*
* Resolution order:
* 1. `STANZA_REGISTRY` env var — explicit URL or filesystem path. Used by
* self-hosters and by CI to point at a local registry build.
* 2. Local workspace at `../../registry` (the stanza monorepo dev case).
* 3. The default published URL.
* The published Stanza website hosts the canonical first-party registry under
* `/registry/`. The bundled CLI defaults to fetching from this URL when no
* `STANZA_REGISTRY` env override and no in-repo dev registry is detected.
*/
const DEFAULT_REGISTRY_URL = "https://stanza.tools/registry";
export type Registry = {
index: RegistryIndex;
loadModule(slot: string, id: string): Promise<Module>;
/**
* Multi-namespace registry surface used by every CLI command. The default
* `@stanza` namespace is always present (resolved through the env-var →
* local-FS → default-URL chain); additional namespaces come from the
* project's `stanza.json` `registries` map.
*
* Unknown namespaces fail fast with a hard error to keep private module
* names from accidentally leaking to a public registry — same rule shadcn
* applies.
*/
export type Registries = {
/** Every namespace this resolver knows about, including `@stanza`. */
namespaces(): string[];
/**
* Fetch a module by category + id. `namespace` defaults to `@stanza`;
* pass an explicit string (e.g. `"@acme"`) to route to a user-declared
* registry. Throws when the namespace isn't configured.
*/
loadModule(category: string, id: string, namespace?: string): Promise<Module>;
/**
* The default `@stanza` namespace's index — always present. Used by
* `stanza init`, which never browses third-party registries.
*/
defaultIndex(): RegistryIndex;
/**
* Namespaces that exposed a registry index, for fan-out `search`/`list`.
* Namespaces without an `indexUrl` (or whose index 404s) are omitted —
* they can still serve modules by name, just not by browse.
*/
searchableIndices(): { namespace: string; index: RegistryIndex }[];
};
export async function loadRegistry(): Promise<Registry> {
const envOverride = process.env.STANZA_REGISTRY;
if (envOverride) {
return envOverride.startsWith("http")
? loadHttpRegistry(envOverride)
: loadFsRegistry(envOverride);
type NamespaceLoader = {
index?: RegistryIndex;
loadModule(category: string, id: string): Promise<Module>;
};
/**
* Build a {@link Registries} instance for a project. Pass `manifest` to pick
* up its declared `registries` map; omit it for project-less flows like
* `stanza init` that only ever hit the default namespace.
*/
export async function loadRegistries(manifest?: StanzaManifest): Promise<Registries> {
const loaders = new Map<string, NamespaceLoader>();
loaders.set(DEFAULT_NAMESPACE, await buildDefaultLoader());
for (const [ns, cfg] of Object.entries(manifest?.registries ?? {})) {
if (ns === DEFAULT_NAMESPACE) continue; // schema also forbids this; double-guard.
loaders.set(ns, await buildCustomLoader(cfg));
}
const localPath = resolveLocalRegistry();
if (localPath) return loadFsRegistry(localPath);
return {
namespaces: () => [...loaders.keys()],
loadModule(category, id, namespace) {
const ns = namespace ?? DEFAULT_NAMESPACE;
const loader = loaders.get(ns);
if (!loader) {
throw new Error(`Unknown registry "${ns}". Add it to stanza.json under "registries".`);
}
return loader.loadModule(category, id);
},
defaultIndex() {
const loader = loaders.get(DEFAULT_NAMESPACE)!;
if (!loader.index) {
throw new Error("Default Stanza registry returned no index.");
}
return loader.index;
},
searchableIndices() {
const out: { namespace: string; index: RegistryIndex }[] = [];
for (const [namespace, loader] of loaders) {
if (loader.index) out.push({ namespace, index: loader.index });
}
return out;
},
};
}
return loadHttpRegistry(DEFAULT_REGISTRY_URL);
/**
* Locate the local first-party registry root for sourcing template/codemod
* files. Only valid for the default `@stanza` namespace — third-party
* namespaces ship template bodies inlined in their per-module JSON (the
* registry build inlines `tpl.content`), so the runner never needs a disk
* path for them.
*
* Returns `null` when no local registry is reachable (the typical published-
* CLI case): callers must then rely on inlined `tpl.content` from the HTTP
* loader. The runner handles this with a clear error if a template is missing
* both inlined content and a registry root.
*/
export function pickRegistryRoot(namespace: string = DEFAULT_NAMESPACE): string | null {
if (namespace !== DEFAULT_NAMESPACE) return null;
const override = process.env.STANZA_REGISTRY;
if (override && !override.startsWith("http")) return override;
const local = resolveLocalRegistry();
if (local) return local;
return null;
}
function resolveLocalRegistry(): string | undefined {
// When running from `apps/cli/src/bin.ts`, walk up until we find a sibling
// `registry/modules/` dir — the dev-time monorepo layout.
const here = path.dirname(fileURLToPath(import.meta.url));
let dir = here;
for (let i = 0; i < 6; i++) {
@@ -51,24 +128,99 @@ function resolveLocalRegistry(): string | undefined {
return undefined;
}
/**
* Locate the registry root on disk for sourcing template/codemod files.
* Mirrors `loadRegistry`'s local-path resolution:
* 1. `STANZA_REGISTRY` env var if it points at a filesystem path.
* 2. Walk up from this file looking for a sibling `registry/modules/` dir.
*
* Throws if neither resolves. Used by command handlers (`init`, `add`) that
* need to read template source files alongside the registry index.
*/
export function pickRegistryRoot(): string {
const override = process.env.STANZA_REGISTRY;
if (override && !override.startsWith("http")) return override;
const local = resolveLocalRegistry();
if (local) return local;
throw new Error("Could not locate Stanza registry root.");
async function buildDefaultLoader(): Promise<NamespaceLoader> {
const envOverride = process.env.STANZA_REGISTRY;
if (envOverride) {
return envOverride.startsWith("http")
? loadHttpRegistry(envOverride)
: loadFsRegistry(envOverride);
}
const localPath = resolveLocalRegistry();
if (localPath) return loadFsRegistry(localPath);
return loadHttpRegistry(DEFAULT_REGISTRY_URL);
}
async function loadFsRegistry(rootDir: string): Promise<Registry> {
async function buildCustomLoader(cfg: RegistryConfig): Promise<NamespaceLoader> {
const resolved = resolveConfig(cfg);
// Try to grab an index up front; absent or 404 just means the namespace is
// fetch-by-name only (won't appear in `searchableIndices`).
const index = await tryFetchIndex(resolved);
return {
index,
async loadModule(category, id) {
const url = appendParams(renderModuleUrl(resolved, category, id), resolved.params);
const init: RequestInit = { headers: buildHeaders(resolved.headers) };
const res = await fetch(url, init);
if (!res.ok) {
throw new Error(`Module fetch failed: ${url} (${res.status} ${res.statusText})`);
}
return ModuleSchema.parse(await res.json());
},
};
}
type ResolvedConfig = {
url: string;
indexUrl?: string;
headers?: Record<string, string>;
params?: Record<string, string>;
};
function resolveConfig(cfg: RegistryConfig): ResolvedConfig {
if (typeof cfg === "string") {
const base = cfg.replace(/\/+$/, "");
return {
url: `${base}/modules/{category}-{id}.json`,
indexUrl: `${base}/index.json`,
};
}
return cfg;
}
function renderModuleUrl(cfg: ResolvedConfig, category: string, id: string): string {
return cfg.url.replaceAll("{category}", category).replaceAll("{id}", id);
}
function buildHeaders(headers?: Record<string, string>): Record<string, string> {
if (!headers) return {};
const out: Record<string, string> = {};
for (const [name, template] of Object.entries(headers)) {
const value = expandEnv(template);
// Match shadcn: a header whose template references an unset env var is
// silently dropped (rather than sent literally as `Bearer ${TOKEN}`).
if (value !== null) out[name] = value;
}
return out;
}
function appendParams(url: string, params?: Record<string, string>): string {
if (!params) return url;
const u = new URL(url);
for (const [name, template] of Object.entries(params)) {
const value = expandEnv(template);
if (value === null) {
throw new Error(
`Registry param "${name}" references an unset env var (template "${template}").`,
);
}
u.searchParams.set(name, value);
}
return u.toString();
}
async function tryFetchIndex(cfg: ResolvedConfig): Promise<RegistryIndex | undefined> {
if (!cfg.indexUrl) return undefined;
try {
const url = appendParams(cfg.indexUrl, cfg.params);
const res = await fetch(url, { headers: buildHeaders(cfg.headers) });
if (!res.ok) return undefined;
return RegistryIndexSchema.parse(await res.json());
} catch {
return undefined;
}
}
async function loadFsRegistry(rootDir: string): Promise<NamespaceLoader> {
const modulesDir = path.join(rootDir, "modules");
const ids = fs
.readdirSync(modulesDir, { withFileTypes: true })
@@ -106,7 +258,7 @@ async function loadFsRegistry(rootDir: string): Promise<Registry> {
};
}
async function loadHttpRegistry(baseUrl: string): Promise<Registry> {
async function loadHttpRegistry(baseUrl: string): Promise<NamespaceLoader> {
const indexRes = await fetch(`${baseUrl}/index.json`);
if (!indexRes.ok) {
throw new Error(`Failed to load Stanza registry from ${baseUrl}: ${indexRes.status}`);
+4 -4
View File
@@ -12,7 +12,7 @@ import {
} from "@stanza/registry";
import pc from "picocolors";
import type { Registry } from "./registry-loader";
import type { Registries } from "./registry-loader";
export type WizardResult = {
name: string;
@@ -49,7 +49,7 @@ export type WizardOverrides = {
* from `overrides`. Invalid picks log an error and return null.
*/
export async function runInitWizard(args: {
registry: Registry;
registry: Registries;
defaultName: string;
overrides?: WizardOverrides;
}): Promise<WizardResult | null> {
@@ -77,7 +77,7 @@ export async function runInitWizard(args: {
const pending: Partial<Record<CategoryId, Module>> = {};
for (const category of categoryOrder) {
const candidates = candidatesFor(registry.index, category, pending);
const candidates = candidatesFor(registry.defaultIndex(), category, pending);
if (candidates.length === 0) continue;
if (isMulti(category)) {
@@ -180,7 +180,7 @@ function peerCheckOk(
}
async function runNonInteractive(args: {
registry: Registry;
registry: Registries;
defaultName: string;
overrides: WizardOverrides;
}): Promise<WizardResult | null> {
+435
View File
@@ -0,0 +1,435 @@
---
title: Authoring
description: Build your own Stanza modules and host them in a registry — first-party or third-party.
---
A **module** is a self-contained recipe for adding one piece of functionality to a Stanza project: templates, dependencies, env vars, scripts, and optional codemod invocations. A **registry** is a directory of modules served as static JSON.
This page is for both kinds of authors:
- **First-party contributors** working inside this repo (`registry/modules/<id>/`) — your modules ship under the default `@stanza` namespace.
- **Third-party authors** publishing modules under your own `@scope` for users to pull in via [`registries` in `stanza.json`](/docs/registry#third-party-registries).
The module surface is identical for both. Only distribution differs.
## Module anatomy
A module lives in a directory named `<category>-<id>/` and exports a default `defineModule({ … })` from `module.ts`:
```ts
// registry/modules/testing-vitest/module.ts
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "vitest",
category: "testing",
label: "Vitest",
description: "Fast unit + integration test runner powered by Vite.",
version: "0.1.0",
homepage: "https://vitest.dev",
// Peers this module needs filled. The resolver uses these to pick an adapter
// and to refuse `add` when a required peer category is empty.
peers: { framework: ["next", "tanstack-start"] },
// Shared install fields — apply to every adapter unless overridden.
devDependencies: {
vitest: "^4.1.7",
jsdom: "^29.1.1",
"@testing-library/react": "^16.3.2",
},
scripts: {
test: "vitest run",
"test:watch": "vitest",
},
// One install recipe per peer combination. `match: {}` is the default.
adapters: [
{
key: "next",
match: { framework: "next" },
devDependencies: { "@vitejs/plugin-react": "^6.0.2" },
templates: [
{ src: "vitest.config.ts", dest: "vitest.config.ts", scope: "app" },
{ src: "example.test.ts", dest: "tests/example.test.ts", scope: "app" },
],
},
{
key: "tanstack-start",
match: { framework: "tanstack-start" },
templates: [
{ src: "vitest.config.ts", dest: "vitest.config.ts", scope: "app" },
{ src: "example.test.ts", dest: "tests/example.test.ts", scope: "app" },
],
},
],
});
```
The whole shape is typed and validated at runtime — `defineModule` throws on the structural rules TypeScript can't express (e.g. `app` install-field overlays on `home: "repo"` modules), and `ModuleSchema` parses the JSON form the CLI fetches.
| Field | Required | What it does |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `id` | ✓ | Stable identifier within a category. Used in `stanza add <category> <id>`. |
| `category` | ✓ | One of `KNOWN_CATEGORIES` — see [Categories](/docs/concepts#categories). |
| `label` | ✓ | Display name for the wizard, search results, and the web builder. |
| `description` | ✓ | One-line summary. Surfaced in `stanza search` and module cards. |
| `version` | ✓ | Semver string. Pinned into `stanza.json` at install time so future `update`/`swap` can read it. |
| `peers` | | `Partial<Record<CategoryId, ModuleId[] \| "any">>` — categories this module needs filled. |
| `consumesPackages` | | Dirs of other internal packages this one imports from (see [Cross-package consumption](#cross-package-consumption)). |
| `adapters` | ✓ | At least one. Each carries its own install fields, templates, and codemods. |
| `appKind` | | `"web"` or `"native"`. The runner refuses to install into an incompatible app. |
| `homepage`, `author` | | Surfaced in the web builder; otherwise informational. |
Module-level install fields (`dependencies`, `devDependencies`, `env`, `scripts`) are merged into every adapter — adapter-level values override per key, and `env` merges by `name`. Hoist anything that doesn't vary across adapters.
## Categories and homes
Every module fills exactly one [category](/docs/concepts#categories). A category's **home** (`app`, `repo`, or `package`) decides where the module's output lands; it isn't something the module declares — it's a property of the category:
- **`home: "app"`** (`framework`, `testing`) — templates land in `apps/<id>/`. Module records track which app(s) they installed into via the `apps` field.
- **`home: "repo"`** (`tooling`, `monorepo`, `deploy`) — config + scripts land at the monorepo root.
- **`home: "package"`** (`auth`, `db`, `orm`, `ui`, `api`, `ai`, `payments`, `email`) — the module installs into its own workspace package at `packages/<dir>/`, named `@<project>/<dir>`. Every consuming app gets a `workspace:*` dep.
Template `scope` derives from this. For a package-home module, prefer `scope: "package"` for everything that can live inside the package, and reach for `scope: "app"` only when a framework convention forces a file to the app root (e.g. Next's `middleware.ts`).
## Adapters and peer resolution
When you `add` a module, Stanza picks the **most specific** adapter whose `match` is consistent with your current stack. Better Auth's adapter list has entries like `next+drizzle+postgres` and `tanstack-start+prisma+sqlite`; the resolver scores each adapter by the number of peers it matches and picks the highest.
A module can pin a default adapter with `match: {}` (an empty match wins when no peer-specific adapter applies). If a module declares `peers` but no adapter matches the current selection, `add` fails with `no-adapter` — fall back to a more permissive `match: {}` adapter or narrow your `peers` to what's actually supported.
```ts
adapters: [
// Adapter for projects using Next + Drizzle + Postgres specifically.
{
key: "next+drizzle+postgres",
match: { framework: "next", orm: "drizzle", db: "postgres" },
templates: [/* … */],
},
// Catch-all when no peer-specific adapter applies (or as a fallback).
{ key: "default", match: {} },
],
```
## Templates
Templates are files copied into the user's project. They live under `templates/` next to `module.ts` and are referenced by `src` (path under `templates/`) and `dest` (path in the generated project, resolved against `scope`):
```ts
templates: [
{ src: "vitest.config.ts", dest: "vitest.config.ts", scope: "app" },
{ src: "example.test.ts", dest: "tests/example.test.ts", scope: "app", template: true },
],
```
| `scope` value | Where `dest` resolves |
| ----------------- | --------------------------------------------------------------------------------- |
| `"app"` (default) | Each targeted app's `dir` — emits once per consuming app. |
| `"repo"` | The monorepo root. |
| `"package"` | `packages/<dir>/` where `<dir>` is the category's `home.dir` (package-home only). |
Set `template: true` to run the file through Handlebars before writing. The render context is:
| Token | Value |
| --------------------------------------------- | ----------------------------------------------------------------------------------- |
| `{{project.name}}` | The manifest's `name` (npm-scope-style). |
| `{{app.id}}` / `{{app.dir}}` / `{{app.kind}}` | The active target app. |
| `{{package.name}}` | The active module's own package (e.g. `@my-app/auth`); empty for non-package homes. |
| `{{packages.<dir>.name}}` | Any other package by its dir, e.g. `{{packages.db.name}}` → `@my-app/db`. |
| `{{peers.<category>}}` | Id of the active one-cardinality pick in that category, e.g. `next`. |
| `{{pm}}` | The project's package manager (`pnpm` \| `bun` \| `npm`). |
| `{{env}}` | Sorted list of every env var name declared so far. |
Conditional blocks use Handlebars' built-ins plus an `eq` helper:
```hbs
{{#if (eq peers.framework "next")}}
import { NextResponse } from "next/server";
{{/if}}
```
The render context is rebound per target app, so a package-home module shipped into both a web and a native app renders each correctly.
**Template bodies are inlined by the build.** [`packages/registry/src/build.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/build.ts) reads every template file and bakes its contents onto `tpl.content` in the per-module JSON. HTTP-loaded modules are self-contained — the CLI never makes follow-up requests for template files.
## Codemod invocations
Modules can ask the runner to invoke a generic codemod from Stanza's built-in catalog with module-specific arguments:
```ts
adapters: [
{
key: "next",
match: { framework: "next" },
codemods: [
{
id: "wrap-root-layout",
args: {
providerImport: "{{package.name}}",
providerName: "ClerkProvider",
},
},
],
},
],
```
The catalog lives at [`packages/codemods/src/builtins/`](https://github.com/jakejarvis/stanza/tree/main/packages/codemods/src/builtins). Each codemod is parameterized by its own `args` type so the same generic transformation serves multiple modules (`wrap-root-layout` handles both Clerk and any future provider-style auth/state library).
**Modules cannot ship codemod code** — the registry JSON carries data (`{ id, args }`), never executable code. This is enforced for first-party and third-party modules alike: the runner throws when an adapter references a codemod id that isn't in the catalog. If your module needs a transformation no existing codemod covers:
- **First-party**: design a new generic codemod with the right argument surface and add it to the catalog ([packages/codemods/src/builtins/index.ts](https://github.com/jakejarvis/stanza/blob/main/packages/codemods/src/builtins/index.ts)).
- **Third-party**: open an issue or PR for the codemod you'd need. Per-registry codemod sandboxing isn't on the near-term roadmap.
String values inside `args` go through the same mustache substitution as templates, so you can reference `{{package.name}}`, `{{packages.db.name}}`, etc.
## Install fields
`dependencies`, `devDependencies`, `env`, and `scripts` can sit at either the module level (shared across adapters) or the adapter level (variation per peer combination). Adapter wins per-key on conflicts; `env` merges by `name`.
```ts
defineModule({
// …
// Declared once — every adapter inherits these.
dependencies: { "better-auth": "^1.6.11" },
env: [
{
name: "BETTER_AUTH_SECRET",
example: "change-me-in-prod",
required: true,
description: "Better Auth signing secret.",
},
],
adapters: [
{
key: "next+drizzle",
match: { framework: "next", orm: "drizzle" },
// Only this adapter ships the drizzle adapter package.
dependencies: { "better-auth-drizzle": "^1.0.0" },
// …
},
],
});
```
### The `app` overlay
Package-home modules sometimes need to install a dep into the consuming app, not the package itself — e.g. shadcn's theme provider lives in `packages/ui/` but imports `next-themes` from `apps/web/`. Use the `app:` overlay for that:
```ts
defineModule({
category: "ui",
// …
app: {
dependencies: { "next-themes": "^0.3.0" },
},
});
```
The runner routes overlay fields into every consuming app's `package.json` (vs the main fields, which route to `packages/<dir>/`). It's forbidden on `home: "repo"` modules — no app target to route to — and redundant on `home: "app"` modules.
## Cross-package consumption
If your module's source imports from another internal package (e.g. Better Auth's `auth.ts` reads `db` from the orm package), declare the dependency at the module level:
```ts
defineModule({
category: "auth",
consumesPackages: ["db"],
// …
});
```
The runner adds `@<project>/db: workspace:*` to this module's own `package.json`. Use the substitution token in templates:
```ts
// templates/auth.drizzle.ts
import { db } from "{{packages.db.name}}";
```
Module-level, not adapter-level: source imports are shared infrastructure that doesn't vary across adapters.
## Sidecar files
Drop these next to `module.ts`:
| File | What it does |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `logo.svg` | Theme-agnostic SVG, inlined onto `mod.logo`. Used by the web builder and module cards. |
| `logo-light.svg` + `logo-dark.svg` | Theme pair — inlined as `mod.logo = { light, dark }`. Take precedence over `logo.svg`. |
| `readme.md` | Markdown contribution to the generated project's README. Renders with the same Handlebars context as templates. |
The build runs SVGO on logos and prefixes every `id` so multiple module logos on the same page can't collide. First-party logos generally come from [svgl.app](https://svgl.app).
## Validating
Module authors don't run a separate validation step — the same Zod schemas the CLI uses are the source of truth:
- `defineModule` (in [`packages/registry/src/module.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/module.ts)) throws at runtime on illegal field combinations.
- `ModuleSchema.parse` runs on every HTTP fetch and rejects malformed manifests with structured errors.
- `vp test` runs the whole repo's schema + resolver tests; add a fixture under `registry/modules/<your-id>/` and the existing suite picks it up.
- `vp check` does a type-aware lint pass that catches most authoring mistakes statically.
For a third-party registry, the same `ModuleSchema` ships from `@stanza/registry`. The simplest test loop is to point the consumer's `STANZA_REGISTRY` at a local directory (`STANZA_REGISTRY=./path/to/your/registry stanza search`) and watch for parse errors.
## Building a registry
The build script ([`packages/registry/src/build.ts`](https://github.com/jakejarvis/stanza/blob/main/packages/registry/src/build.ts)) scans `registry/modules/*` and emits:
```
<out>/
├── registry/
│ ├── index.json # categories + per-module summaries
│ └── modules/
│ ├── <category>-<id>.json # one file per module, templates inlined
│ └── …
└── schema.json # JSON Schema for stanza.json
```
Each per-module JSON is self-contained: template bodies, deps, env vars, codemod invocations, the optimized logo SVG, and the rendered readme are all in one document. The CLI never makes follow-up requests for module assets.
To run the build:
```sh
# Default output: <repoRoot>/dist
jiti packages/registry/src/build.ts
# Or point it elsewhere (this is what the web app's prebuild does):
jiti packages/registry/src/build.ts apps/web/public
```
For a third-party registry, fork the build script (or call it as a library) against your own `registry/modules/` tree. The output shape is identical.
## Hosting a registry
A registry is plain static JSON — host it anywhere that serves files: a CDN, Vercel/Netlify/Cloudflare Pages, GitHub Pages, S3 + CloudFront, your own Nginx, etc. No runtime, no DB, no SSR. The two URL shapes Stanza understands:
**Canonical layout** (string shorthand in `stanza.json`):
```
https://reg.acme.dev/
├── index.json
└── modules/
├── testing-cosmos.json
└── auth-something.json
```
Declared as:
```jsonc
{ "registries": { "@acme": "https://reg.acme.dev" } }
```
**Custom layout** (object form with URL template):
```jsonc
{
"registries": {
"@acme": {
"url": "https://api.acme.dev/r/{category}/{id}.json",
"indexUrl": "https://api.acme.dev/r/index.json",
"headers": { "Authorization": "Bearer ${ACME_TOKEN}" },
"params": { "version": "stable" },
},
},
}
```
`{category}` and `{id}` are both required placeholders; `indexUrl` is optional (without it the registry is fetch-by-name only — modules still install, but `stanza search` skips that namespace). Headers and params support `${ENV_VAR}` expansion against `process.env`; a header whose template references an unset variable is silently dropped, while an unset variable in `params` is a hard error.
## Publishing under your own namespace
There's no central registry to list with. Pick an `@scope` (the naming rule is the same as npm scopes — `/^@[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9]$/`), publish your `registry/` to a URL, and document how to consume it:
```jsonc
// users add to their stanza.json
{
"registries": {
"@acme": "https://reg.acme.dev",
},
}
```
```sh
# then install with the @scope/id syntax
stanza add testing @acme/cosmos
```
Unknown namespaces fail fast — there's no implicit fallback to `@stanza`, so a typo can't leak a private module name to the public registry. The chosen namespace is recorded on the manifest module record (`stanza remove` knows where to refetch from).
`@stanza` is reserved. Declaring it under `registries` is a schema error; to override its URL (for a fully-mirrored self-hosted registry or to pin a fixture in CI), set the `STANZA_REGISTRY` env var.
## What third-party modules can do
The full module surface, with one exception:
- ✓ Ship any combination of templates, deps (`dependencies`, `devDependencies`), env vars, and scripts.
- ✓ Declare `peers` and ship multiple adapters keyed to the host's stack.
- ✓ Invoke any codemod from Stanza's built-in catalog with custom args.
- ✓ Ship logos, READMEs, and the `app:` overlay.
- ✓ Declare `consumesPackages` to import from other internal packages.
- ✗ Ship new codemod code. The catalog is the only execution surface — adapters that reference an unknown codemod id are rejected at install time.
A third-party `payments` module that needs to wrap the root layout uses `wrap-root-layout` with its own provider name; one that needs to extend a barrel uses `re-export`; one that needs to register a Vite plugin uses `add-plugin-to-call`. If a real need surfaces a catalog gap, the right fix is to land the new generic codemod upstream — not to grant arbitrary code execution to fetched JSON.
## Worked example: `@acme/cosmos`
A minimal third-party module from scratch.
**1. Author `module.ts`** at `registry/modules/testing-cosmos/module.ts` in your registry repo:
```ts
import { defineModule } from "@stanza/registry";
export default defineModule({
id: "cosmos",
category: "testing",
label: "Cosmos",
description: "Visual component sandbox.",
version: "1.0.0",
homepage: "https://reactcosmos.org",
devDependencies: { "react-cosmos": "^7.0.0" },
scripts: { cosmos: "cosmos" },
adapters: [{ key: "default", match: {} }],
});
```
**2. Build the registry** with the script above. Output:
```
out/registry/
├── index.json
└── modules/
└── testing-cosmos.json
```
**3. Host it.** Push `out/registry/` (or its parent) to wherever you serve static files. Say it ends up at `https://reg.acme.dev/`.
**4. Tell users how to install.** Their `stanza.json`:
```jsonc
{
"registries": {
"@acme": "https://reg.acme.dev",
},
}
```
Then:
```sh
stanza add testing @acme/cosmos
```
The manifest records the install with `namespace: "@acme"` so `stanza remove` refetches from the right registry on rollback.
## Conventions
A few things experienced authors follow to keep registries consistent:
- **Hoist shared install fields** to the module level. Better Auth's `dependencies: { "better-auth": "^1.6.11" }` and its env vars are declared once; only the per-(framework, orm) templates and codemods sit in the adapter blocks.
- **Don't ship a `package.json` template.** Let the runner merge deps into the host's `package.json` (for app/repo homes) or bootstrap the slot package itself (for package homes). Hand-rolled `package.json` templates collide with `addPackageDependency`.
- **Multi-cardinality categories need disjoint regions.** Vitest claims `scripts.test`, Playwright claims `scripts.test:e2e` — both can install into the same `package.json` because their region claims don't overlap.
- **Bump `version` on schema-affecting changes** (new templates, dep upgrades). The upcoming `swap`/`update` verbs read it.
- **Codemod catalog ids are part of the public contract.** Renaming a builtin codemod id breaks every third-party manifest that references it — treat them like npm package names.
+6 -2
View File
@@ -83,7 +83,7 @@ These apply to the mutating verbs (`init`, `add`, `remove`):
## Environment variables
- `STANZA_REGISTRY=<url-or-path>` — use a custom or self-hosted registry (HTTP URL or filesystem path) instead of the default.
- `STANZA_REGISTRY=<url-or-path>` — override the `@stanza` default namespace's URL (or filesystem path). For per-namespace third-party registries, use the `registries` field in `stanza.json` instead — see [Third-party registries](/docs/registry#third-party-registries).
- `STANZA_NO_NPM_LOOKUP=1` — skip npm version lookups and write dependency ranges verbatim.
- `STANZA_NPM_REGISTRY=<url>` — override the npm registry used for version lookups.
- `STANZA_TELEMETRY=0` / `DO_NOT_TRACK=1` — disable telemetry persistently. Telemetry is also auto-skipped in CI.
@@ -97,9 +97,13 @@ Stanza captures a small set of anonymous events to help us see which modules peo
- The command name (`init`, `add`, `remove`, `list`, `search`), its duration in milliseconds, and whether it succeeded.
- CLI version, Node version, OS, and architecture.
- For installs and removes: the module id and its category.
- For installs and removes: the module id, its category, and the namespace it came from (e.g. `@stanza` or `@acme`).
- An ephemeral UUID generated per process so events from the same run can be grouped. It's regenerated next time and never persisted.
**Third-party modules**
Third-party module installs are counted in the aggregate totals at the top of the [Stats page](/stats) (so adoption of Stanza-as-a-platform shows up), but they're filtered out of the per-category leaderboards underneath. A private `@acme/auth-internal` doesn't outrank `@stanza/better-auth` in a public ranking.
**What's never sent**
- File paths, project names, the contents of templates or env files, or anything else that could identify a project or person.
+2 -2
View File
@@ -75,7 +75,7 @@ The `stanza.json` file at your repo root is the source of truth for what's insta
```json
{
"$schema": "https://stanza.tools/schema.json",
"version": "0.3",
"version": "0.4",
"projectShape": "monorepo",
"packageManager": "pnpm",
"name": "acme",
@@ -108,4 +108,4 @@ A **region** is a claim on a file (or a section of one) by a specific module. Th
## Open registry
A registry is just static JSON: an index plus one file per module, each carrying its templates, dependencies, env vars, and codemod invocations. The CLI ships with a default registry, but you can point it anywhere — a URL or a local path — with `STANZA_REGISTRY`. That's the hook for self-hosting your own modules or pinning a fixture in CI.
A registry is just static JSON: an index plus one file per module, each carrying its templates, dependencies, env vars, and codemod invocations. The CLI ships with a default registry under the `@stanza` namespace, and you can publish your own modules under any `@scope` you want and consume them alongside the first-party ones — see [Third-party registries](/docs/registry#third-party-registries) for the manifest field and the `@ns/id` CLI syntax. The full module + registry surface is documented in the [Authoring manual](/docs/authoring).
@@ -79,3 +79,11 @@ npx -y stanza-cli@latest init my-app --yes \
```
`--yes` never fills in defaults for categories you omit — any category without a flag is simply skipped.
## Next steps
- [Concepts](/docs/concepts) — the mental model behind apps, categories, vendoring, peers, and the manifest.
- [CLI reference](/docs/cli) — every verb, flag, and environment variable.
- [Registry](/docs/registry) — the roadmap of available modules and how to pull from [third-party registries](/docs/registry#third-party-registries).
- [Authoring](/docs/authoring) — build your own modules and host them in your own registry.
- [Agents](/docs/agents) — drive Stanza from Claude Code, Codex, Cursor, or any other coding agent.
+1 -1
View File
@@ -1,3 +1,3 @@
{
"pages": ["index", "getting-started", "registry", "cli", "agents", "concepts"]
"pages": ["index", "getting-started", "registry", "cli", "authoring", "agents", "concepts"]
}
+13 -1
View File
@@ -3,7 +3,7 @@ title: Registry
description: The roadmap of first-party modules Stanza ships — what's available today and what's planned.
---
Every module fills exactly one **category**. This page is the canonical roadmap of the first-party modules Stanza ships: what's available today, and what's on the way. Self-hosted registries can add their own modules — see [the open registry](/docs/concepts#open-registry).
Every module fills exactly one **category**. This page is the canonical roadmap of the first-party modules Stanza ships: what's available today, and what's on the way. Third parties can publish their own modules under any `@scope` and have users pull them in alongside these — see the [Authoring manual](/docs/authoring) for the full surface, or jump to [Third-party modules](#third-party-modules) below for a pointer.
## Categories
@@ -166,3 +166,15 @@ Not a category — a top-level field in `stanza.json` (`packageManager: "pnpm" |
| bun | 🟢&nbsp;Available |
| npm | 🟢&nbsp;Available |
| yarn | 🛑&nbsp;Not Planned |
## Third-party modules
The categories above are first-party — Stanza ships them under the default `@stanza` namespace. Anyone can publish additional modules under their own `@scope` and have users install them with `stanza add <category> @scope/<id>`. The category taxonomy itself stays first-party (third-party registries can't redefine it).
For consumers, declaring a registry is a few lines in `stanza.json`:
```jsonc
{ "registries": { "@acme": "https://reg.acme.dev" } }
```
For authors, the full module surface — `defineModule`, templates, codemods, the build pipeline, hosting, namespace conventions, and a worked example — lives in the [Authoring manual](/docs/authoring).
@@ -45,6 +45,23 @@ function defaultPathFor(filePaths: string[]): string | undefined {
return filePaths.includes("package.json") ? "package.json" : filePaths[0];
}
// `@pierre/trees`' public `item.select()` is additive (it calls
// `selectPath` on the controller, not `selectOnlyPath`). To get
// single-select semantics from the React surface, drop sibling selections
// first.
function selectOnly(
model: {
getSelectedPaths(): readonly string[];
getItem(path: string): { select(): void; deselect(): void } | null;
},
path: string,
): void {
for (const selected of model.getSelectedPaths()) {
if (selected !== path) model.getItem(selected)?.deselect();
}
model.getItem(path)?.select();
}
// "a/b/c.ts" → ["a", "a/b"].
function directoryPaths(paths: readonly string[]): string[] {
const dirs = new Set<string>();
@@ -129,8 +146,10 @@ export function FilePreview({
modelRef.current = model;
// Re-seed the model when `filePaths` changes. `resetPaths` collapses
// everything and clears selection, so we replay both manually. Tree → URL
// sync happens automatically via `onSelectionChange` when we call `select()`.
// everything, so we replay expansion manually; it preserves selection for
// surviving paths, but we still pin selection to `next` via `selectOnly`
// so a transiently-empty hash (during a parent navigate) can't bleed the
// default into the selection alongside the open file.
const prevPathsRef = useRef(filePaths);
useEffect(() => {
const expandedSet = new Set(
@@ -163,7 +182,7 @@ export function FilePreview({
...new Set([...preservedExpansion, ...(next ? directoryPaths([next]) : [])]),
].toSorted();
model.resetPaths(filePaths, { initialExpandedPaths: expanded });
if (next) model.getItem(next)?.select();
if (next) selectOnly(model, next);
prevPathsRef.current = filePaths;
}, [model, filePaths, defaultPath]);
@@ -178,7 +197,7 @@ export function FilePreview({
const item = model.getItem(dir);
if (item && "expand" in item) item.expand();
}
model.getItem(hash)?.select();
selectOnly(model, hash);
}, [hash, filePaths, model]);
// Drive the tree's palette from the app theme; otherwise it auto-detects via
@@ -55,6 +55,7 @@ export function Builder({ state, search }: { state: BuilderState; search: Builde
pm: latest.current.pm,
selections: latest.current.optimistic,
}),
hash: true,
replace: true,
resetScroll: false,
});
@@ -70,6 +71,7 @@ export function Builder({ state, search }: { state: BuilderState; search: Builde
pm: next,
selections: latest.current.optimistic,
}),
hash: true,
replace: true,
resetScroll: false,
});
@@ -103,6 +105,7 @@ export function Builder({ state, search }: { state: BuilderState; search: Builde
setOptimistic(next);
await navigate({
search: toSearchParams({ name: snapshot.name, pm: snapshot.pm, selections: next }),
hash: true,
replace: true,
resetScroll: false,
});
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
+4 -1
View File
@@ -6,6 +6,7 @@ import { lazy, Suspense, useEffect, useState } from "react";
import { ModuleLogo } from "@/components/module-logo";
import { BarList } from "@/components/ui/bar-list";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { buildHead } from "@/lib/seo";
import { getStats, type Stats } from "@/server/stats.functions";
@@ -90,7 +91,9 @@ function StatsPage() {
<p className="mt-4 font-mono text-xs text-muted-foreground/70 tabular-nums">
Last refreshed {formatGeneratedAt(stats.generatedAt)} UTC
</p>
) : null}
) : (
<Skeleton className="mt-4 h-4 w-72" />
)}
</header>
<section className="mb-4 grid gap-4 sm:grid-cols-2">
+9
View File
@@ -93,10 +93,19 @@ async function fetchStats(): Promise<{ stats: Stats; ok: boolean }> {
config,
),
// One query for all categories — group by both and bucket client-side.
// The namespace filter excludes third-party module installs from the
// per-category leaderboard (private/proprietary ids shouldn't outrank
// first-party ones in a public ranking). Aggregate counts above still
// include every install, regardless of namespace. The `IS NULL OR = ''`
// pair handles events from before the CLI started emitting
// `properties.namespace` — those are first-party by definition.
runQuery(
`SELECT properties.group AS group, properties.module AS module, count() AS c
FROM events
WHERE event = 'cli_module' AND properties.action = 'install'
AND (properties.namespace IS NULL
OR properties.namespace = ''
OR properties.namespace = '@stanza')
GROUP BY group, module
ORDER BY c DESC`,
config,
+10
View File
@@ -54,6 +54,16 @@ export {
manifestJsonSchema,
} from "./manifest";
export type { RegistryConfig } from "./registry-config";
export {
DEFAULT_NAMESPACE,
expandEnv,
isNamespace,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
} from "./registry-config";
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
+49
View File
@@ -136,6 +136,55 @@ describe("StanzaManifestSchema", () => {
});
});
describe("third-party registries", () => {
it("round-trips a manifest with a registries map and namespaced records", () => {
const manifest = {
...emptyManifest({ name: "acme" }),
modules: {
framework: [{ id: "next", version: "0.1.0", adapter: "default", apps: ["web"] }],
testing: [
{
id: "cosmos",
version: "1.0.0",
adapter: "default",
apps: ["web"],
namespace: "@thirdparty",
},
],
},
registries: {
"@thirdparty": "https://reg.thirdparty.dev",
"@private": {
url: "https://reg.private.io/{category}/{id}.json",
headers: { Authorization: "Bearer ${TOKEN}" },
},
},
};
const parsed = StanzaManifestSchema.parse(manifest);
expect(parsed.modules.testing?.[0]?.namespace).toBe("@thirdparty");
expect(parsed.registries?.["@thirdparty"]).toBe("https://reg.thirdparty.dev");
expect(JSON.parse(JSON.stringify(parsed))).toEqual(manifest);
});
it("rejects @stanza as a user-declared registry", () => {
const result = StanzaManifestSchema.safeParse({
...emptyManifest({ name: "acme" }),
registries: { "@stanza": "https://example.invalid" },
});
expect(result.success).toBe(false);
const issues = result.success ? [] : result.error.issues;
expect(issues.some((i) => /reserved/.test(i.message))).toBe(true);
});
it("rejects malformed namespace keys", () => {
const result = StanzaManifestSchema.safeParse({
...emptyManifest({ name: "acme" }),
registries: { acme: "https://example.invalid" },
});
expect(result.success).toBe(false);
});
});
describe("app-aware selectors", () => {
const base = emptyManifest({
name: "acme",
+20 -1
View File
@@ -9,8 +9,9 @@ import {
KNOWN_CATEGORIES,
type ModuleId,
} from "./module";
import { type RegistryConfig, RegistriesSchema } from "./registry-config";
export const CURRENT_MANIFEST_VERSION = "0.3" as const;
export const CURRENT_MANIFEST_VERSION = "0.4" as const;
/** Canonical public URL of the published `stanza.json` JSON Schema. */
export const MANIFEST_SCHEMA_URL = "https://stanza.tools/schema.json";
@@ -49,6 +50,13 @@ export type StanzaModuleRecord = {
* - `home: "repo"` — must be omitted. Repo-home modules are project-wide.
*/
apps?: string[];
/**
* Namespace this module was installed from, e.g. `"@acme"`. Omitted means
* the default first-party namespace (`"@stanza"`). `stanza remove` and the
* upcoming `update`/`swap` verbs read this to refetch from the original
* registry.
*/
namespace?: string;
};
/**
@@ -82,6 +90,15 @@ export type StanzaManifest = {
*/
modules: Partial<Record<CategoryId, StanzaModuleRecord[]>>;
regions: RegionOwnership;
/**
* Third-party module registries. Keys are namespace strings (e.g. `"@acme"`);
* values are either a URL prefix (Stanza's default layout) or a full
* `RegistryConfig` with URL template, headers, and params. Modules from
* these registries are addressed as `<category> @<ns>/<id>` on the CLI;
* the `@stanza` default namespace is bundled in the CLI and cannot be
* redeclared here (override its URL via the `STANZA_REGISTRY` env var).
*/
registries?: Record<string, RegistryConfig>;
/**
* SHA-256 of the README.md Stanza last wrote. When `stanza add`/`remove`
* regenerate the README they compare the current file's hash against this
@@ -115,10 +132,12 @@ export const StanzaManifestSchema = z
version: z.string(),
adapter: z.string(),
apps: z.array(z.string()).optional(),
namespace: z.string().optional(),
}),
),
),
regions: z.record(z.string(), z.record(z.string(), z.string())),
registries: RegistriesSchema.optional(),
readmeChecksum: z.string().optional(),
})
.superRefine((m, ctx) => {
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vite-plus/test";
import {
DEFAULT_NAMESPACE,
expandEnv,
isNamespace,
parseModuleSpec,
RegistriesSchema,
RegistryConfigSchema,
} from "./registry-config";
describe("parseModuleSpec", () => {
it("splits @ns/id into namespace + id", () => {
expect(parseModuleSpec("@acme/foo")).toEqual({ namespace: "@acme", id: "foo" });
});
it("returns a bare id with no namespace", () => {
expect(parseModuleSpec("vitest")).toEqual({ id: "vitest" });
});
it("preserves slashes inside the id portion", () => {
expect(parseModuleSpec("@acme/foo/bar")).toEqual({ namespace: "@acme", id: "foo/bar" });
});
it("rejects an unscoped @ id (no slash)", () => {
// No slash → treated as a bare id (whoever consumes it will fail on the `@`).
expect(parseModuleSpec("@bare")).toEqual({ id: "@bare" });
});
});
describe("isNamespace", () => {
it("accepts valid scopes", () => {
expect(isNamespace("@stanza")).toBe(true);
expect(isNamespace("@a1")).toBe(true);
expect(isNamespace("@my-org_2")).toBe(true);
});
it("rejects malformed scopes", () => {
expect(isNamespace("stanza")).toBe(false);
expect(isNamespace("@")).toBe(false);
expect(isNamespace("@a")).toBe(false);
expect(isNamespace("@-bad")).toBe(false);
expect(isNamespace("@bad-")).toBe(false);
});
});
describe("expandEnv", () => {
it("replaces ${VAR} tokens from the provided env", () => {
expect(expandEnv("Bearer ${TOKEN}", { TOKEN: "abc" })).toBe("Bearer abc");
});
it("returns null when any var is unset", () => {
expect(expandEnv("a=${A} b=${B}", { A: "1" })).toBeNull();
});
it("passes input through when there are no tokens", () => {
expect(expandEnv("static", {})).toBe("static");
});
});
describe("RegistryConfigSchema", () => {
it("accepts a string shorthand", () => {
expect(RegistryConfigSchema.parse("https://reg.acme.com")).toBe("https://reg.acme.com");
});
it("accepts the full object form with placeholders", () => {
const cfg = {
url: "https://reg.acme.com/{category}/{id}.json",
headers: { Authorization: "Bearer ${TOKEN}" },
};
expect(RegistryConfigSchema.parse(cfg)).toEqual(cfg);
});
it("rejects an object url missing placeholders", () => {
expect(() => RegistryConfigSchema.parse({ url: "https://reg.acme.com/static.json" })).toThrow(
/category.*id|id.*category/,
);
});
});
describe("RegistriesSchema", () => {
it("accepts well-formed namespaces", () => {
expect(
RegistriesSchema.parse({
"@acme": "https://reg.acme.com",
"@private": {
url: "https://reg.private.io/r/{category}/{id}.json",
headers: { Authorization: "Bearer ${TOKEN}" },
},
}),
).toBeTruthy();
});
it("rejects the reserved @stanza namespace", () => {
expect(() => RegistriesSchema.parse({ [DEFAULT_NAMESPACE]: "https://x" })).toThrow(/reserved/);
});
it("rejects malformed namespace keys", () => {
expect(() => RegistriesSchema.parse({ acme: "https://x" })).toThrow(/@scope/);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { z } from "zod";
/**
* Reserved namespace for first-party Stanza modules — the registry bundled
* with the CLI (resolved through the env-var → local-FS → default-URL chain).
* Other namespaces are declared by users in `stanza.json` under `registries`.
*/
export const DEFAULT_NAMESPACE = "@stanza" as const;
/**
* Namespace keys mirror shadcn's regex — a leading `@`, then a single
* scope-like word. Single-character scopes (`@a`) are not allowed; the
* shape requires at least two trailing characters after the `@`.
*/
const NAMESPACE_RE = /^@[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9]$/;
const SPEC_RE = /^(@[a-zA-Z0-9][a-zA-Z0-9-_]*[a-zA-Z0-9])\/(.+)$/;
/**
* Split a CLI module identifier. `"@acme/foo"` →
* `{ namespace: "@acme", id: "foo" }`; a bare id → `{ id }` (default namespace
* applies). The id portion may itself contain slashes.
*/
export function parseModuleSpec(spec: string): { namespace?: string; id: string } {
const m = SPEC_RE.exec(spec);
if (m) return { namespace: m[1]!, id: m[2]! };
return { id: spec };
}
/** True when `value` is a syntactically valid namespace key. */
export function isNamespace(value: string): boolean {
return NAMESPACE_RE.test(value);
}
/**
* Replace `${VAR}` tokens with values from `env`. Returns `null` when any
* referenced variable is unset, leaving the caller to react: header builders
* drop the header (matches shadcn), URL builders treat it as an error.
*/
export function expandEnv(input: string, env: NodeJS.ProcessEnv = process.env): string | null {
let missing = false;
const result = input.replace(/\$\{(\w+)\}/g, (_, name: string) => {
const v = env[name];
if (v === undefined) {
missing = true;
return "";
}
return v;
});
return missing ? null : result;
}
const registryObjectSchema = z
.object({
/**
* URL template — must include both `{category}` and `{id}` placeholders.
* The CLI substitutes them per module fetch (e.g.
* `https://reg.acme.com/{category}/{id}.json` → `.../testing/vitest.json`).
*/
url: z.string().refine((s) => s.includes("{category}") && s.includes("{id}"), {
message: "url must include both {category} and {id} placeholders",
}),
/**
* Optional registry index URL. When set, `stanza search` includes this
* namespace's catalog. Absent → the namespace is fetch-by-name only.
*/
indexUrl: z.string().optional(),
/**
* Headers sent with every request to this registry. Values may contain
* `${ENV_VAR}` tokens; a header whose template references an unset var
* is silently omitted.
*/
headers: z.record(z.string(), z.string()).optional(),
/**
* Query-string params appended to every request. Values may contain
* `${ENV_VAR}` tokens; unset → request fails (the URL would be malformed).
*/
params: z.record(z.string(), z.string()).optional(),
})
.strict();
/**
* Shape of a registry entry in `stanza.json`. Either:
* - a bare URL prefix (uses Stanza's canonical layout — `{base}/index.json`
* and `{base}/modules/{category}-{id}.json`), or
* - a full object with a URL template + optional auth/params.
*/
export const RegistryConfigSchema = z.union([z.string(), registryObjectSchema]);
export type RegistryConfig = z.infer<typeof RegistryConfigSchema>;
/**
* The `registries` field on a Stanza manifest. Keys are namespace strings
* matching {@link NAMESPACE_RE}; values are {@link RegistryConfig} entries.
* `@stanza` is reserved and rejected — the default namespace is configured
* via the `STANZA_REGISTRY` env var, not the manifest.
*/
export const RegistriesSchema = z
.record(z.string(), RegistryConfigSchema)
.superRefine((rec, ctx) => {
for (const key of Object.keys(rec)) {
if (key === DEFAULT_NAMESPACE) {
ctx.addIssue({
code: "custom",
message: `"${DEFAULT_NAMESPACE}" is reserved — set STANZA_REGISTRY to override the default registry`,
path: [key],
});
continue;
}
if (!NAMESPACE_RE.test(key)) {
ctx.addIssue({
code: "custom",
message: `registry key "${key}" must match @scope (letters, digits, dashes, underscores)`,
path: [key],
});
}
}
});
+3 -2
View File
@@ -61,8 +61,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> <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.
- `stanza remove <category> [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.
- `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 list` prints installed modules grouped by category from the nearest `stanza.json`.
- `stanza search [query]` lists registry modules and their `category/id` pairs.
@@ -89,6 +89,7 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
- Use `--dry-run` before a mutating command when the user wants a preview. It writes nothing.
- Mutating commands refuse to run in a dirty git worktree. Ask the user before using `--dangerously-allow-dirty`; it intentionally allows Stanza edits to mix with existing changes.
- Use `STANZA_REGISTRY=<url-or-path>` only when the user asks for a custom/self-hosted registry or test fixture. Otherwise let the published CLI use its default registry.
- For third-party modules from another publisher, declare the registry in `stanza.json` under `registries` (e.g. `"registries": { "@acme": "https://reg.acme.dev" }`) and install with `stanza add <category> @acme/<module>`. Unknown namespaces fail fast — there is no implicit fallback to the default registry.
## Telemetry