feat: unify registry loading behind a main file, add apply rollback, and add stanza doctor

- **Registry main file.** The CLI now addresses any registry (first-party, third-party, or `STANZA_REGISTRY` override) by the full URL/path to its JSON index file. The `modules/<category>-<id>.json` convention, the in-repo source-tree loader, the directory-based auto-detect, and the inline-template disk fallback are all removed — one loader, no heuristics. `STANZA_REGISTRY` must be the full path/URL to the main file (not a directory); `registries` entries now carry `url` pointing at the main file instead of an `indexUrl`+template. **Breaking** (pre-release, clean break): registry index bumped to `schemaVersion: 2`; every module entry carries a `path` field resolved relative to the main file.
- **Auto-rollback.** `stanza add` (and each module in `stanza init`) now wraps its file writes in a transaction; a mid-apply failure restores touched files and `stanza.json` to their pre-apply state. `reportApplyFailure` updated to say "The change was rolled back" and demotes the `stanza remove` / `git restore` steps to a backstop note.
- **`stanza doctor`.** New read-only command that checks `stanza.json` against the filesystem (tracked files, deps, scripts, env vars, internal packages) and reports drift, exiting non-zero when found.
- Update integration tests to build the first-party registry once into a temp dir and point `STANZA_REGISTRY` at its main file; update the third-party HTTP fixture server to serve `/index.json` as the main file alongside per-module paths.
- Update `AGENTS.md` E2E smoke instructions and `apps/cli/README.md` to reflect the new loader and `doctor` command.
This commit is contained in:
2026-05-30 12:49:11 -04:00
parent 7349b53b82
commit e89fb63c31
23 changed files with 754 additions and 441 deletions
@@ -7,4 +7,6 @@ Add the **`api`** category to the taxonomy (single-choice, `home: package` → `
`stanza add` now reports a mid-apply failure with recovery guidance instead of a raw stack trace: a region conflict states plainly that nothing was written, while a failure after files were touched points at `stanza remove …` (to sweep what Stanza tracked) and `git restore . && git clean -fd` (to reset a clean worktree) — the latter only suggested when a clean baseline was enforced.
A `STANZA_REGISTRY` pointed at a local directory now loads a **built** JSON registry (`index.json` + `modules/<category>-<id>.json`) when one is present, so a self-hosted on-disk mirror, an air-gapped install, or a CI fixture works under the published binary — previously a filesystem path only resolved raw `module.ts` sources, which the bundled CLI can't import.
Both published packages (`stanza-cli`, `create-stanza`) now ship npm READMEs.
@@ -0,0 +1,27 @@
---
"stanza-cli": minor
---
Unify registry loading behind a single **main file**, add transactional apply
rollback, and add `stanza doctor`.
- **Registry main file.** A registry is now addressed by the full URL/path to
its main JSON file (the index), which carries a required `path` on every
module entry; the loader resolves each module relative to the main file over
`file://` and `http(s)://` identically. The `modules/<category>-<id>.json`
naming convention, the `.ts` source-tree loader, the in-repo auto-detect, and
the inline-template disk fallback are all gone — one loader, no conventions.
`STANZA_REGISTRY` must be the full path/URL to a main JSON file (not a
directory). **Breaking** (pre-release, clean break): the registry index is
now `schemaVersion: 2`, and a third-party `registries` object entry is
`{ url, headers?, params? }` where `url` is the full main-file URL —
`indexUrl` and `{category}`/`{id}` URL templating are removed.
- **Auto-rollback.** `stanza add` (and each module in `stanza init`) now wraps
its file writes in a transaction: if any step throws — including mid-codemod —
the touched files and `stanza.json` are restored to their pre-apply state
instead of leaving a partial change.
- **`stanza doctor`.** New read-only command that checks `stanza.json` against
the filesystem (claimed files/deps/scripts/env vars still present, internal
packages wired) and reports drift, exiting non-zero when found.
+1 -1
View File
@@ -22,7 +22,7 @@ The repo runs on the **Vite+ toolchain** (`vp`). All config lives in the root [`
- `vp run -r build` — build everything (`vp pack` per package, wraps tsdown)
- `vp run @stanza/web#dev` — TanStack Start dev server. Its `dev`/`build` scripts first run the `compile-registry` vp task (defined under `run.tasks` in [`apps/web/vite.config.ts`](apps/web/vite.config.ts)) → `jiti packages/registry/src/build.ts apps/web/public`, emitting `apps/web/public/registry/{index,modules/*}.json` + `apps/web/public/schema.json`
- `src/routeTree.gen.ts` is generated by `vp run @stanza/web#build` — run it before the first `vp check` if missing
- E2E smoke: seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `tsx apps/cli/src/bin.ts add <category> <module>`
- E2E smoke: build the registry (`jiti packages/registry/src/build.ts $TMPDIR/reg`), seed `$TMPDIR/x` with `stanza.json` + `apps/web/package.json`, then `STANZA_REGISTRY=$TMPDIR/reg/registry/index.json tsx apps/cli/src/bin.ts add <category> <module>`. The CLI reads a built registry **main file** (full path/URL, any filename) — there is no source-tree loader; `STANZA_REGISTRY` unset hits the production registry
- `pnpm changeset` — drop a markdown file after a substantive PR; the release workflow handles versioning + publish. Only `stanza-cli` + `create-stanza` ship to npm
Use `agent-browser` for web automation (`agent-browser --help`); prefer it over built-in browser tools.
+5 -3
View File
@@ -41,8 +41,9 @@ npx stanza-cli init my-app --yes \
| `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 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` from the project root or any child directory under a `stanza.json`.
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.
@@ -57,14 +58,15 @@ Every module fills exactly one **category**. Single-choice categories (`framewor
Modules ship from the first-party `@stanza` namespace by default. To pull from another publisher, declare it in `stanza.json` and address modules as `@<scope>/<id>`:
```jsonc
{ "registries": { "@acme": "https://reg.acme.dev" } }
// the full URL to the registry's main JSON file (the index)
{ "registries": { "@acme": "https://reg.acme.dev/registry.json" } }
```
```sh
stanza add testing @acme/cosmos
```
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's source (self-hosted mirror, air-gapped install, CI fixture).
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's source — the full URL or filesystem path to a registry's main JSON file (self-hosted mirror, air-gapped install, CI fixture).
## Telemetry
+6 -17
View File
@@ -19,7 +19,7 @@ import { applyModule, RegionConflictError } from "../lib/codemod-runner";
import { ensureCleanWorktree } from "../lib/git";
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
import { regenerateReadmeIfUnmodified } from "../lib/readme";
import { loadRegistries, pickRegistryRoot } from "../lib/registry-loader";
import { loadRegistries } from "../lib/registry-loader";
import * as telemetry from "../lib/telemetry";
import { commonArgs, type CliArgs } from "./_args";
@@ -194,10 +194,6 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
const spinner = p.spinner();
spinner.start(`Adding ${mod.label}`);
// 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({
@@ -206,7 +202,6 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
module: mod,
adapter: resolved.adapter,
targetApps,
registryRoot,
dryRun,
namespace,
});
@@ -353,17 +348,11 @@ function reportApplyFailure(args: {
return;
}
const lines = [
`Failed while adding ${spec}: ${detail}`,
"",
"A partial change may have been written. To recover:",
` ${pc.cyan(`stanza remove ${category} ${moduleId}`)} ${pc.dim("# sweep what Stanza tracked")}`,
];
if (cleanBaseline) {
lines.push(
` ${pc.cyan("git restore . && git clean -fd")} ${pc.dim("# or reset the worktree to its last-clean state")}`,
);
}
const lines = [`Failed while adding ${spec}: ${detail}`, "", "The change was rolled back."];
// Backstops for the rare case rollback couldn't fully restore the worktree.
const escapes = [pc.cyan(`stanza remove ${category} ${moduleId}`)];
if (cleanBaseline) escapes.push(pc.cyan("git restore . && git clean -fd"));
lines.push(`If anything remains, run ${escapes.join(" or ")}.`);
p.log.error(lines.join("\n"));
}
+202 -23
View File
@@ -3,27 +3,44 @@ import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { CATEGORIES } from "@stanza/registry";
import { buildRegistry } from "@stanza/registry/build";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
import { loadRegistries } from "../lib/registry-loader";
import { cmdAdd } from "./add";
import { cmdDoctor } from "./doctor";
import { cmdInit } from "./init";
import { cmdRemove } from "./remove";
const REPO_ROOT = path.resolve(import.meta.dirname, "../../../..");
let tmp: string;
let prevCwd: string;
let prevExitCode: typeof process.exitCode;
// The CLI only reads built registries (no source-tree loader), so build the
// real first-party registry once into a temp dir and point STANZA_REGISTRY at
// its main file. Hermetic + exercises the production loader.
let fixtureRoot: string;
let fixtureMain: string;
beforeAll(async () => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-reg-"));
await buildRegistry({ outBase: fixtureRoot });
fixtureMain = path.join(fixtureRoot, "registry", "index.json");
});
afterAll(() => {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-cmd-"));
prevCwd = process.cwd();
process.chdir(tmp);
prevExitCode = process.exitCode;
process.exitCode = undefined;
// Point the registry loader at this repo's dev-mode registry so tests
// exercise the real first-party modules instead of hitting the network.
process.env.STANZA_REGISTRY = path.join(REPO_ROOT, "registry");
// Full path to the built registry's main file — no directory/base inference.
process.env.STANZA_REGISTRY = fixtureMain;
// Keep the apply path hermetic: skip npm version lookups so deps land at the
// manifest's verbatim ranges and no real network calls happen.
process.env.STANZA_NO_NPM_LOOKUP = "1";
@@ -514,22 +531,34 @@ describe("third-party registries", () => {
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();
const sendJson = (payload: unknown) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
};
// The main file: an index listing every fixture module, each carrying its
// `path`. Adapters are stripped to `key`+`match` (index metadata shape).
if (url === "/index.json" || url.startsWith("/index.json?")) {
const modules = Object.entries(fixture.modules).map(([key, mod]) => {
const m = mod as Record<string, unknown> & {
adapters?: Array<{ key: string; match: unknown }>;
};
return Object.assign({}, m, {
adapters: (m.adapters ?? []).map((a) => ({ key: a.key, match: a.match })),
path: `modules/${key}.json`,
});
});
sendJson({ generatedAt: "t", schemaVersion: 2, categories: [...CATEGORIES], modules });
return;
}
const payload = fixture.modules[match[1]!];
// Per-module full manifests at the `path` advertised by the index.
const match = /^\/modules\/([^?]+)\.json/.exec(url);
const payload = match ? fixture.modules[match[1]!] : undefined;
if (!payload) {
res.statusCode = 404;
res.end();
return;
}
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(payload));
sendJson(payload);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
@@ -543,7 +572,7 @@ describe("third-party registries", () => {
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 });
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
fixture.modules["testing-cosmos"] = cosmosModule();
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
@@ -575,7 +604,7 @@ describe("third-party registries", () => {
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 });
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
fixture.modules["testing-cosmos"] = cosmosModule();
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
@@ -600,7 +629,7 @@ describe("third-party registries", () => {
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), {
"@fixture": {
url: `${baseUrl}/modules/{category}-{id}.json`,
url: `${baseUrl}/index.json`,
headers: {
Authorization: "Bearer ${STANZA_TEST_TOKEN}",
"X-Missing": "Bearer ${STANZA_UNSET_TOKEN}",
@@ -629,7 +658,7 @@ describe("third-party registries", () => {
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 });
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
fixture.modules["testing-cosmos"] = cosmosModule({
adapters: [{ key: "default", match: {}, codemods: [{ id: "not-a-real-codemod" }] }],
});
@@ -644,21 +673,74 @@ describe("third-party registries", () => {
expect(manifest.modules.testing).toBeUndefined();
});
it("surfaces a clear error when the registry has no such module (non-200)", async () => {
it("surfaces a clear error when the registry's main file lists no such module", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": baseUrl });
// fixture.modules is empty → the stub server 404s for testing-ghost.
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
// fixture.modules is empty → the main file lists nothing, so `ghost` isn't
// in the index and resolution fails cleanly.
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/ghost" }));
expect(process.exitCode).toBe(1);
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
});
it("skips a namespace whose main file is unreachable", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
// Points at a path the stub 404s — the namespace fails to initialize and is
// skipped, so the module id resolves to an unknown registry.
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/nope.json` });
fixture.modules["testing-cosmos"] = cosmosModule();
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/cosmos" }));
expect(process.exitCode).toBe(1);
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
});
it("rolls back template writes when a codemod throws mid-apply", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
// A module that writes a template, then runs `append-to-file` against a
// file that doesn't exist — a real catalog codemod that throws at runtime,
// AFTER the template has been flushed to disk.
fixture.modules["testing-probe"] = {
category: "testing",
id: "probe",
label: "Probe",
description: "rollback probe",
version: "1.0.0",
adapters: [
{
key: "default",
match: {},
templates: [
{ src: "p.txt", dest: "rollback-probe.txt", scope: "app", content: "probe\n" },
],
codemods: [
{
id: "append-to-file",
args: { file: "nope.txt", content: "boom", marker: "m", commentStyle: "line" },
},
],
},
],
};
await cmdAdd(args({ slot: "testing", moduleId: "@fixture/probe" }));
expect(process.exitCode).toBe(1);
// Rollback removed the flushed template and restored the manifest.
expect(fs.existsSync("apps/web/rollback-probe.txt")).toBe(false);
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
expect(manifest.modules.testing).toBeUndefined();
});
it("surfaces a region conflict cleanly and writes nothing", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next" }));
process.chdir(path.join(tmp, "app"));
writeStanza(process.cwd(), { "@fixture": baseUrl });
writeStanza(process.cwd(), { "@fixture": `${baseUrl}/index.json` });
// First-party vitest claims `scripts.test` on the app's package.json.
await cmdAdd(args({ slot: "testing", moduleId: "vitest" }));
expect(process.exitCode).toBeFalsy();
@@ -678,6 +760,103 @@ describe("third-party registries", () => {
});
});
describe("filesystem main-file registry", () => {
// STANZA_REGISTRY is the full path to the main JSON file; module `path`s in it
// resolve relative to the file. No directory or filename inference.
it("loads the index and modules from a main-file URI", async () => {
const root = path.join(tmp, "reg");
fs.mkdirSync(path.join(root, "modules"), { recursive: true });
const cosmos = cosmosModule();
const mainFile = path.join(root, "main.json"); // any filename works
fs.writeFileSync(
mainFile,
JSON.stringify({
generatedAt: "2026-01-01T00:00:00.000Z",
schemaVersion: 2,
categories: [...CATEGORIES],
modules: [
{
...cosmos,
adapters: cosmos.adapters.map((a) => ({ key: a.key, match: a.match })),
path: "modules/testing-cosmos.json",
},
],
}),
);
fs.writeFileSync(path.join(root, "modules", "testing-cosmos.json"), JSON.stringify(cosmos));
process.env.STANZA_REGISTRY = mainFile;
const registry = await loadRegistries();
expect(registry.defaultIndex().modules.map((m) => m.id)).toContain("cosmos");
expect(registry.defaultIndex().categories.map((c) => c.id)).toContain("api");
// Full module resolves via the entry's `path`, with install fields intact.
const mod = await registry.loadModule("testing", "cosmos");
expect(mod.id).toBe("cosmos");
expect(mod.devDependencies?.["react-cosmos"]).toBe("^7.0.0");
// A module absent from the main file surfaces a clear error.
await expect(registry.loadModule("testing", "ghost")).rejects.toThrow(/not found in registry/);
});
it("errors clearly when STANZA_REGISTRY points at a directory", async () => {
// The built fixture's `registry/` dir is a directory, not the main file.
process.env.STANZA_REGISTRY = path.dirname(fixtureMain);
await expect(loadRegistries()).rejects.toThrow(
/full path\/URL to the registry's main JSON file/,
);
});
});
describe("cmdDoctor", () => {
beforeEach(async () => {
await cmdInit(
args({ name: "app", yes: true, framework: "next", db: "postgres", orm: "drizzle" }),
);
process.chdir(path.join(tmp, "app"));
});
it("reports no drift for a freshly generated project", async () => {
await cmdDoctor();
expect(process.exitCode).toBeFalsy();
});
it("flags a claimed file that was deleted", async () => {
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
const fileClaim = Object.entries(manifest.regions).find(
([, regions]) => (regions as Record<string, string>).file,
);
expect(fileClaim).toBeTruthy();
fs.rmSync(fileClaim![0]); // delete the claimed file (paths are project-relative)
process.exitCode = undefined;
await cmdDoctor();
expect(process.exitCode).toBe(1);
});
it("flags a claimed dependency that was stripped", async () => {
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
let target: { file: string; kind: string; name: string } | undefined;
for (const [file, regions] of Object.entries(manifest.regions)) {
for (const region of Object.keys(regions as Record<string, string>)) {
const s = region.startsWith("app.") ? region.slice("app.".length) : region;
if (s.startsWith("dependencies.") || s.startsWith("devDependencies.")) {
const [kind, ...rest] = s.split(".");
target = { file, kind: kind!, name: rest.join(".") };
break;
}
}
if (target) break;
}
expect(target).toBeTruthy();
const pkg = JSON.parse(fs.readFileSync(target!.file, "utf8"));
delete pkg[target!.kind][target!.name];
fs.writeFileSync(target!.file, JSON.stringify(pkg, null, 2));
process.exitCode = undefined;
await cmdDoctor();
expect(process.exitCode).toBe(1);
});
});
function writeStanza(projectRoot: string, registries: Record<string, unknown>) {
const file = path.join(projectRoot, "stanza.json");
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
+122
View File
@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
import * as p from "@clack/prompts";
import { PACKAGE_DIRS } from "@stanza/registry";
import { defineCommand } from "citty";
import pc from "picocolors";
import { findProjectRoot, readManifest } from "../lib/manifest";
import { commonArgs } from "./_args";
export const doctor = defineCommand({
meta: {
name: "doctor",
description: "Check stanza.json against the filesystem for drift (read-only).",
},
args: { telemetry: commonArgs.telemetry },
run: () => cmdDoctor(),
});
/**
* Verify that every region claim in `stanza.json` still matches reality on
* disk: claimed files exist, claimed deps/scripts/env vars are still present,
* and each internal package with claims is wired up. Read-only — reports drift
* and exits non-zero, but changes nothing.
*/
export async function cmdDoctor(): Promise<void> {
const projectRoot = findProjectRoot();
if (!projectRoot) {
p.log.error("No stanza.json found in this or any parent directory.");
process.exitCode = 1;
return;
}
const manifest = readManifest(projectRoot);
const issues: string[] = [];
// 1. Region claims ↔ filesystem.
for (const [file, regions] of Object.entries(manifest.regions)) {
const abs = path.join(projectRoot, file);
for (const region of Object.keys(regions)) {
if (region === "file") {
if (!fs.existsSync(abs)) issues.push(`${file} — claimed file is missing`);
continue;
}
if (file === ".env.example") {
if (!envHasVar(abs, region)) issues.push(`${file} — env var "${region}" is missing`);
continue;
}
if (file.endsWith("package.json")) {
// `app.`-prefixed regions come from the app-overlay; the on-disk shape
// is identical (dependencies./devDependencies./scripts.).
const stripped = region.startsWith("app.") ? region.slice("app.".length) : region;
if (stripped.startsWith("dependencies.") || stripped.startsWith("devDependencies.")) {
const [kind, ...rest] = stripped.split(".");
const name = rest.join(".");
if (!pkgHasKey(abs, kind!, name)) issues.push(`${file}${stripped} is missing`);
continue;
}
if (stripped.startsWith("scripts.")) {
const name = stripped.slice("scripts.".length);
if (!pkgHasKey(abs, "scripts", name)) issues.push(`${file} — scripts.${name} is missing`);
continue;
}
}
// Codemod-managed regions (AST edits, marker blocks) can't be verified
// offline — only flag when the host file is gone entirely.
if (!fs.existsSync(abs)) {
issues.push(`${file} — host of codemod region "${region}" is missing`);
}
}
}
// 2. Internal packages: a slot with region claims must have its package.json,
// and every app should carry the workspace dependency on it.
for (const dir of PACKAGE_DIRS) {
const hasClaims = Object.keys(manifest.regions).some((f) => f.startsWith(`packages/${dir}/`));
if (!hasClaims) continue;
const pkgJson = path.join(projectRoot, "packages", dir, "package.json");
if (!fs.existsSync(pkgJson)) {
issues.push(`packages/${dir}/package.json — slot has region claims but no package`);
continue;
}
const depName = `@${manifest.name}/${dir}`;
for (const app of manifest.apps) {
const appPkg = path.join(projectRoot, app.dir, "package.json");
if (!fs.existsSync(appPkg)) continue;
if (!pkgHasKey(appPkg, "dependencies", depName)) {
issues.push(`${app.dir}/package.json — missing workspace dependency "${depName}"`);
}
}
}
if (issues.length === 0) {
p.log.success(`${pc.green("✓")} No drift — stanza.json matches the filesystem.`);
return;
}
p.log.error(`${issues.length} issue(s) found:\n` + issues.map((i) => `${i}`).join("\n"));
process.exitCode = 1;
}
function envHasVar(file: string, name: string): boolean {
if (!fs.existsSync(file)) return false;
const content = fs.readFileSync(file, "utf8");
return new RegExp(`^\\s*${escapeRegExp(name)}=`, "m").test(content);
}
function pkgHasKey(file: string, kind: string, name: string): boolean {
if (!fs.existsSync(file)) return false;
try {
const pkg: Record<string, Record<string, unknown> | undefined> = JSON.parse(
fs.readFileSync(file, "utf8"),
);
return Boolean(pkg[kind]?.[name]);
} catch {
return false;
}
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
+1 -8
View File
@@ -25,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 { loadRegistries, pickRegistryRoot } from "../lib/registry-loader";
import { loadRegistries } from "../lib/registry-loader";
import * as telemetry from "../lib/telemetry";
import { runInitWizard, type WizardOverrides } from "../lib/wizard";
import { commonArgs, type CliArgs } from "./_args";
@@ -104,12 +104,6 @@ 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"));
const spinner = p.spinner();
@@ -168,7 +162,6 @@ export async function cmdInit(args: CliArgs): Promise<void> {
// home: "package" → ship shims into every app (today: just one)
// home: "repo" → seed app for render context (the first app)
targetApps: home.kind === "app" ? appHomeTarget : targetApps,
registryRoot,
dryRun,
});
} catch (err) {
+88 -86
View File
@@ -29,7 +29,8 @@ import {
} from "@stanza/registry";
import semver from "semver";
import { writeManifest } from "./manifest";
import { FileTx } from "./file-tx";
import { manifestPath, writeManifest } from "./manifest";
import { resolveRanges } from "./npm-version";
import { claim, release, RegionConflictError } from "./region-tracker";
@@ -66,13 +67,6 @@ export async function applyModule(args: {
module: Module;
adapter: ModuleAdapter;
targetApps: AppSpec[];
/**
* 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
@@ -81,7 +75,7 @@ export async function applyModule(args: {
namespace?: string;
dryRun: boolean;
}): Promise<RunResult> {
const { projectRoot, module, adapter, targetApps, namespace, registryRoot, dryRun } = args;
const { projectRoot, module, adapter, targetApps, namespace, dryRun } = args;
let manifest = args.manifest;
const touchedFiles = new Set<string>();
@@ -118,15 +112,6 @@ export async function applyModule(args: {
home.kind === "app" && targetApps.length === 1
? `${module.id}@${targetApps[0]!.id}`
: 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
// on conflicts; env merges by `name`.
@@ -203,7 +188,7 @@ export async function applyModule(args: {
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
manifest = claim(manifest, rel, "file", owner);
if (!dryRun) {
const source = readTemplateSource(tpl, moduleDir);
const source = readTemplateSource(tpl);
const rendered = tpl.template ? renderTemplate(source, renderContextFor(app)) : source;
deferredWrites.push(() => {
fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -224,7 +209,7 @@ export async function applyModule(args: {
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
manifest = claim(manifest, rel, "file", owner);
if (!dryRun) {
const source = readTemplateSource(tpl, moduleDir);
const source = readTemplateSource(tpl);
const rendered = tpl.template ? renderTemplate(source, renderContextFor(seedApp)) : source;
deferredWrites.push(() => {
fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -354,61 +339,85 @@ export async function applyModule(args: {
}
if (!dryRun) {
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
// stay capped at ≤ 1 total.
const existing = manifest.modules[module.category] ?? [];
const sameKey = (r: typeof record) => r.id === record.id && sameAppSet(r.apps, record.apps);
manifest = {
...manifest,
modules: {
...manifest.modules,
[module.category]: [...existing.filter((r) => !sameKey(r)), record],
},
};
writeManifest(projectRoot, manifest);
// Snapshot every file we're about to touch so a throw anywhere in the
// mutation phase rolls the worktree back to its pre-apply state instead of
// leaving a partial change. Captured up front (the pre-transaction bytes);
// codemod targets that surface later are snapshotted as they're claimed.
const tx = new FileTx();
tx.snapshot(manifestPath(projectRoot));
for (const rel of touchedFiles) tx.snapshot(path.join(projectRoot, rel));
if (packageRoot) {
tx.snapshot(path.join(packageRoot, "package.json"));
tx.snapshot(path.join(packageRoot, "tsconfig.json"));
}
for (const app of targetApps) tx.snapshot(path.join(projectRoot, app.dir, "package.json"));
for (const write of slotBootstrapWrites) write();
for (const write of deferredWrites) write();
try {
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 stay capped at ≤ 1 total.
const existing = manifest.modules[module.category] ?? [];
const sameKey = (r: typeof record) => r.id === record.id && sameAppSet(r.apps, record.apps);
manifest = {
...manifest,
modules: {
...manifest.modules,
[module.category]: [...existing.filter((r) => !sameKey(r)), record],
},
};
writeManifest(projectRoot, manifest);
// Codemods dispatch once per targeted app (or once with the seed app for
// repo-home). Manifest re-persists after claims are gathered, before
// `project.save()` flushes — same disk-leads-manifest contract as above.
if (adapter.codemods?.length) {
const dispatchApps = home.kind === "repo" ? [seedApp] : targetApps;
const saves: Array<() => Promise<void>> = [];
for (const app of dispatchApps) {
const appRoot = path.join(projectRoot, app.dir);
const project = lazyProject(appRoot);
const ctx = buildContext({
projectRoot,
app,
appRoot,
manifest,
module,
adapter,
project: project.get,
touchedFiles,
dryRun,
onClaim: (file, region) => {
manifest = claim(manifest, file, region, owner);
},
});
for (const invocation of adapter.codemods) {
const fn = CODEMOD_CATALOG[invocation.id]!;
const renderedArgs = renderArgs(invocation.args ?? {}, renderContextFor(app));
const result = await fn.apply(ctx, renderedArgs);
result.touchedFiles.forEach((f) => touchedFiles.add(f));
// Persist after every codemod so a later throw doesn't lose the
// already-claimed regions. Cheap (small JSON), and a partial-apply
// surfaces cleanly to `stanza remove`'s sweep.
writeManifest(projectRoot, manifest);
for (const write of slotBootstrapWrites) write();
for (const write of deferredWrites) write();
// Codemods dispatch once per targeted app (or once with the seed app for
// repo-home). ts-morph edits stay in memory until `project.save()` runs
// last, so a codemod throw never reaches disk; direct-fs codemods are
// snapshotted as they claim / report touched files.
if (adapter.codemods?.length) {
const dispatchApps = home.kind === "repo" ? [seedApp] : targetApps;
const saves: Array<() => Promise<void>> = [];
for (const app of dispatchApps) {
const appRoot = path.join(projectRoot, app.dir);
const project = lazyProject(appRoot);
const ctx = buildContext({
projectRoot,
app,
appRoot,
manifest,
module,
adapter,
project: project.get,
touchedFiles,
dryRun,
onClaim: (file, region) => {
tx.snapshot(path.join(projectRoot, file));
manifest = claim(manifest, file, region, owner);
},
});
for (const invocation of adapter.codemods) {
const fn = CODEMOD_CATALOG[invocation.id]!;
const renderedArgs = renderArgs(invocation.args ?? {}, renderContextFor(app));
const result = await fn.apply(ctx, renderedArgs);
for (const f of result.touchedFiles) {
tx.snapshot(path.isAbsolute(f) ? f : path.join(projectRoot, f));
}
result.touchedFiles.forEach((f) => touchedFiles.add(f));
// Persist after every codemod so a later throw doesn't lose the
// already-claimed regions. Cheap (small JSON), and a partial-apply
// surfaces cleanly to `stanza remove`'s sweep.
writeManifest(projectRoot, manifest);
}
saves.push(() => project.save());
}
saves.push(() => project.save());
for (const save of saves) await save();
}
for (const save of saves) await save();
} catch (err) {
// Restore the worktree to its pre-apply state, then surface the error.
tx.rollback();
throw err;
}
}
@@ -634,25 +643,18 @@ function renderArgs(
}
/**
* Resolve a template's source text. HTTP-loaded modules inline their template
* 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.
* Resolve a template's source text. Every module carries its template bodies
* inlined in `tpl.content` (the registry build bakes them in), so this is a
* straight read. A missing `content` means a malformed module — error early.
*/
function readTemplateSource(tpl: TemplateRef, moduleDir: string | null): string {
if (tpl.content !== undefined) return tpl.content;
if (moduleDir === null) {
function readTemplateSource(tpl: TemplateRef): string {
if (tpl.content === undefined) {
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.`,
`Template "${tpl.src}" has no inlined content. Registry modules must inline ` +
`template content (run the registry build) before publishing.`,
);
}
return fs.readFileSync(path.join(moduleDir, "templates", tpl.src), "utf8");
return tpl.content;
}
/**
+63
View File
@@ -0,0 +1,63 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
import { FileTx } from "./file-tx";
let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-tx-"));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("FileTx", () => {
it("restores a pre-existing file's content on rollback", () => {
const file = path.join(tmp, "a.txt");
fs.writeFileSync(file, "original");
const tx = new FileTx();
tx.snapshot(file);
fs.writeFileSync(file, "modified");
tx.rollback();
expect(fs.readFileSync(file, "utf8")).toBe("original");
});
it("deletes a newly-created file on rollback", () => {
const file = path.join(tmp, "new.txt");
const tx = new FileTx();
tx.snapshot(file); // didn't exist
fs.writeFileSync(file, "created");
tx.rollback();
expect(fs.existsSync(file)).toBe(false);
});
it("captures only the first snapshot of a path (pre-transaction state)", () => {
const file = path.join(tmp, "a.txt");
fs.writeFileSync(file, "v1");
const tx = new FileTx();
tx.snapshot(file);
fs.writeFileSync(file, "v2");
tx.snapshot(file); // ignored — first capture (v1) wins
fs.writeFileSync(file, "v3");
tx.rollback();
expect(fs.readFileSync(file, "utf8")).toBe("v1");
});
it("is idempotent — a second rollback is a no-op", () => {
const file = path.join(tmp, "a.txt");
fs.writeFileSync(file, "original");
const tx = new FileTx();
tx.snapshot(file);
fs.writeFileSync(file, "modified");
tx.rollback();
// Re-dirty the file; a second rollback must NOT touch it.
fs.writeFileSync(file, "later");
tx.rollback();
expect(fs.readFileSync(file, "utf8")).toBe("later");
});
});
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
type Snapshot = { existed: true; content: Buffer } | { existed: false };
/**
* Records the pre-write state of files so a failed multi-file mutation can be
* rolled back to where it started. `snapshot(path)` captures a file's current
* bytes (or its absence) the FIRST time it's seen — later snapshots of the same
* path are ignored, so the recorded state is always the pre-transaction one.
* `rollback()` restores every snapshotted path: rewrite the original bytes, or
* delete files that didn't exist before.
*
* Newly-created directories are intentionally left behind (empty dirs are inert
* and re-creating the package re-bootstraps cleanly); the contract is "no stray
* files", not "no stray dirs".
*/
export class FileTx {
private readonly snapshots = new Map<string, Snapshot>();
/** Capture the current on-disk state of `absPath` (idempotent per path). */
snapshot(absPath: string): void {
if (this.snapshots.has(absPath)) return;
this.snapshots.set(
absPath,
fs.existsSync(absPath)
? { existed: true, content: fs.readFileSync(absPath) }
: { existed: false },
);
}
/** Restore every snapshotted path to its captured state. Idempotent. */
rollback(): void {
for (const [absPath, snap] of this.snapshots) {
if (snap.existed) {
fs.writeFileSync(absPath, snap.content);
} else if (fs.existsSync(absPath)) {
fs.rmSync(absPath, { force: true });
}
}
this.snapshots.clear();
}
}
+83 -231
View File
@@ -3,23 +3,17 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Module, RegistryConfig, RegistryIndex, StanzaManifest } from "@stanza/registry";
import {
CATEGORIES,
DEFAULT_NAMESPACE,
expandEnv,
ModuleSchema,
RegistryIndexSchema,
} from "@stanza/registry";
import { DEFAULT_NAMESPACE, expandEnv, ModuleSchema, RegistryIndexSchema } from "@stanza/registry";
/**
* 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.
* The published Stanza website hosts the canonical first-party registry. A
* registry is addressed by the full URL to its **main JSON file** (the index);
* the bundled CLI defaults to this one when `STANZA_REGISTRY` is unset.
*/
const DEFAULT_REGISTRY_URL = "https://stanza.tools/registry";
const DEFAULT_REGISTRY_URL = "https://stanza.tools/registry/index.json";
// Cap every fetch so a slow/hung third-party registry can't stall the CLI
// (or block CI). Overridable for slow links + testing.
// Cap every fetch so a slow/hung registry can't stall the CLI (or block CI).
// Overridable for slow links + testing.
function defaultTimeoutMs(): number {
const env = process.env.STANZA_HTTP_TIMEOUT_MS;
const parsed = env ? Number.parseInt(env, 10) : NaN;
@@ -30,46 +24,41 @@ function fetchWithTimeout(url: string, init: RequestInit = {}): Promise<Response
return fetch(url, { ...init, signal: AbortSignal.timeout(defaultTimeoutMs()) });
}
/** Per-registry HTTP options (ignored for filesystem URIs). */
type FetchOpts = { headers?: Record<string, string>; params?: Record<string, string> };
/**
* 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.
* `@stanza` namespace is always present (its main file resolved through the
* env-var → 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.
* 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.
* 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.
*/
/** The default `@stanza` namespace's index — always present. */
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.
*/
/** Every namespace's index, for fan-out `search`/`list`. */
searchableIndices(): { namespace: string; index: RegistryIndex }[];
};
type NamespaceLoader = {
index?: RegistryIndex;
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
* 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> {
@@ -83,7 +72,7 @@ export async function loadRegistries(manifest?: StanzaManifest): Promise<Registr
// broken third-party registry doesn't take down the rest of the CLI.
const [defaultLoader, customResults] = await Promise.all([
buildDefaultLoader(),
Promise.allSettled(customEntries.map(([ns, cfg]) => buildCustomLoader(ns, cfg))),
Promise.allSettled(customEntries.map(([, cfg]) => buildCustomLoader(cfg))),
]);
const loaders = new Map<string, NamespaceLoader>();
@@ -109,105 +98,83 @@ export async function loadRegistries(manifest?: StanzaManifest): Promise<Registr
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;
return loaders.get(DEFAULT_NAMESPACE)!.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 [...loaders].map(([namespace, loader]) => ({ namespace, index: loader.index }));
},
};
}
function buildDefaultLoader(): Promise<NamespaceLoader> {
const override = process.env.STANZA_REGISTRY;
return loadRegistry(override && override.length > 0 ? override : DEFAULT_REGISTRY_URL, {});
}
function buildCustomLoader(cfg: RegistryConfig): Promise<NamespaceLoader> {
const { url, headers, params } = typeof cfg === "string" ? { url: cfg } : cfg;
return loadRegistry(url, { headers, params });
}
/**
* 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.
* The one and only registry loader. `mainUri` is the full URL/path to the
* registry's main JSON file (the index) — never a directory or a base. Each
* module's `path` recorded in that file is resolved relative to `mainUri`,
* over `http(s)://` or the filesystem (bare path or `file://`), identically.
*/
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 {
const here = path.dirname(fileURLToPath(import.meta.url));
let dir = here;
for (let i = 0; i < 6; i++) {
const candidate = path.join(dir, "registry", "modules");
if (fs.existsSync(candidate)) return path.join(dir, "registry");
dir = path.dirname(dir);
async function loadRegistry(mainUri: string, opts: FetchOpts): Promise<NamespaceLoader> {
let text: string;
try {
text = await readText(mainUri, opts);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(
`Could not load the registry main file at "${mainUri}". It must be the full ` +
`path/URL to the registry's main JSON file (not a directory). ${detail}`,
{ cause: err },
);
}
return undefined;
}
const index = RegistryIndexSchema.parse(JSON.parse(text));
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 buildCustomLoader(namespace: string, 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(namespace, 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 fetchWithTimeout(url, init);
if (!res.ok) {
throw new Error(`Module fetch failed: ${url} (${res.status} ${res.statusText})`);
const entry = index.modules.find((m) => m.category === category && m.id === id);
if (!entry) {
throw new Error(`Module not found in registry: ${category}/${id}`);
}
return ModuleSchema.parse(await res.json());
const moduleUri = resolveModuleUri(mainUri, entry.path);
const body = await readText(moduleUri, opts);
return ModuleSchema.parse(JSON.parse(body));
},
};
}
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 isHttp(uri: string): boolean {
return uri.startsWith("http://") || uri.startsWith("https://");
}
function renderModuleUrl(cfg: ResolvedConfig, category: string, id: string): string {
return cfg.url.replaceAll("{category}", category).replaceAll("{id}", id);
function toFsPath(uri: string): string {
return uri.startsWith("file://") ? fileURLToPath(uri) : uri;
}
/** Resolve a module's relative `path` against the main file's URI. */
function resolveModuleUri(mainUri: string, relPath: string): string {
if (isHttp(mainUri)) return new URL(relPath, mainUri).toString();
return path.resolve(path.dirname(toFsPath(mainUri)), relPath);
}
/** Read a registry resource as text — HTTP fetch or filesystem read. */
async function readText(uri: string, opts: FetchOpts): Promise<string> {
if (isHttp(uri)) {
const url = appendParams(uri, opts.params);
const res = await fetchWithTimeout(url, { headers: buildHeaders(opts.headers) });
if (!res.ok) {
throw new Error(`fetch failed: ${url} (${res.status} ${res.statusText})`);
}
return res.text();
}
return fs.readFileSync(toFsPath(uri), "utf8");
}
const warnedHeaders = new Set<string>();
@@ -220,8 +187,8 @@ function buildHeaders(headers?: Record<string, string>): Record<string, string>
if (value !== null) {
out[name] = value;
} else if (!warnedHeaders.has(name)) {
// shadcn-style: drop the header rather than ship `Bearer ${TOKEN}` with
// a literal placeholder. Warn once so it's debuggable when the registry
// shadcn-style: drop the header rather than ship `Bearer ${TOKEN}` with a
// literal placeholder. Warn once so it's debuggable when the registry
// then returns 401.
warnedHeaders.add(name);
console.warn(`Registry header "${name}" dropped — env var in "${template}" is unset.`);
@@ -244,118 +211,3 @@ function appendParams(url: string, params?: Record<string, string>): string {
}
return u.toString();
}
async function tryFetchIndex(
namespace: string,
cfg: ResolvedConfig,
): Promise<RegistryIndex | undefined> {
if (!cfg.indexUrl) return undefined;
let url: string;
try {
url = appendParams(cfg.indexUrl, cfg.params);
} catch (err) {
// appendParams throws on unset env vars — surface that since it's a
// config error the user can fix, not a missing-index condition.
console.warn(
`Registry "${namespace}" index skipped: ${err instanceof Error ? err.message : String(err)}`,
);
return undefined;
}
let res: Response;
try {
res = await fetchWithTimeout(url, { headers: buildHeaders(cfg.headers) });
} catch {
// Network failure (DNS, offline, etc.) — the namespace stays
// fetch-by-name-only this run. Silent: typical when offline.
return undefined;
}
// 404 means "no index advertised" — legitimate for fetch-by-name-only
// registries. Other non-OK statuses (401, 403, 5xx) likely indicate a
// misconfiguration the user should see.
if (res.status === 404) return undefined;
if (!res.ok) {
console.warn(`Registry "${namespace}" index returned ${res.status} ${res.statusText}.`);
return undefined;
}
try {
return RegistryIndexSchema.parse(await res.json());
} catch (err) {
// 200 but schema-invalid → config error worth telling the user about.
const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
console.warn(`Registry "${namespace}" index is malformed: ${detail}`);
return undefined;
}
}
async function loadFsRegistry(rootDir: string): Promise<NamespaceLoader> {
const modulesDir = path.join(rootDir, "modules");
const ids = fs
.readdirSync(modulesDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
// Lazily import each module manifest. We don't keep them all in memory;
// we strip the adapter payloads down to `key` + `match` for the index and
// re-import full modules on demand.
const summaries = await Promise.all(
ids.map(async (name) => {
const mod = await importModule(modulesDir, name);
return { ...mod, adapters: mod.adapters.map((a) => ({ key: a.key, match: a.match })) };
}),
);
const index: RegistryIndex = {
generatedAt: new Date().toISOString(),
schemaVersion: 1,
categories: [...CATEGORIES],
modules: summaries,
};
return {
index,
async loadModule(_slot, id) {
// Modules are stored as `<slot>-<id>` directories. We accept either the
// bare id (preferred) or the slot-prefixed dir name.
const dirName = ids.find((d) => d === id || d.endsWith(`-${id}`) || d === `${_slot}-${id}`);
if (!dirName) {
throw new Error(`Module not found in local registry: ${_slot}/${id}`);
}
return importModule(modulesDir, dirName);
},
};
}
async function loadHttpRegistry(baseUrl: string): Promise<NamespaceLoader> {
const indexRes = await fetchWithTimeout(`${baseUrl}/index.json`);
if (!indexRes.ok) {
throw new Error(`Failed to load Stanza registry from ${baseUrl}: ${indexRes.status}`);
}
const index = RegistryIndexSchema.parse(await indexRes.json());
return {
index,
async loadModule(slot, id) {
const url = `${baseUrl}/modules/${slot}-${id}.json`;
const res = await fetchWithTimeout(url);
if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`);
return ModuleSchema.parse(await res.json());
},
};
}
async function importModule(modulesDir: string, dirName: string): Promise<Module> {
const entry = path.join(modulesDir, dirName, "module.ts");
const mod: { default: Module } = await import(entry);
if (!mod.default) {
throw new Error(`Module ${dirName} has no default export at ${entry}`);
}
// Mirror `build.ts`: a sidecar `readme.md` next to `module.ts` is inlined
// onto the manifest so the dev-time FS loader and the inlined-JSON loader
// produce equivalent modules. Authors edit a real markdown file rather than
// a string literal in `module.ts`.
const readmeFile = path.join(modulesDir, dirName, "readme.md");
if (fs.existsSync(readmeFile)) {
return { ...mod.default, readme: fs.readFileSync(readmeFile, "utf8") };
}
return mod.default;
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { defineCommand, runMain } from "citty";
import { version } from "../package.json" with { type: "json" };
import { add } from "./commands/add";
import { doctor } from "./commands/doctor";
import { init } from "./commands/init";
import { list } from "./commands/list";
import { remove } from "./commands/remove";
@@ -10,7 +11,7 @@ import * as telemetry from "./lib/telemetry";
let startedAt = 0;
const KNOWN_COMMANDS = new Set(["init", "add", "remove", "list", "search"]);
const KNOWN_COMMANDS = new Set(["init", "add", "remove", "list", "search", "doctor"]);
const main = defineCommand({
meta: {
@@ -26,7 +27,7 @@ const main = defineCommand({
" stanza remove payments\n\n" +
"Docs: https://stanza.tools",
},
subCommands: { init, add, remove, list, search },
subCommands: { init, add, remove, list, search, doctor },
setup({ rawArgs }) {
const raw = rawArgs.find((arg) => !arg.startsWith("-"));
// No verb (bare `stanza`/help/version): leave telemetry unconfigured so
+1 -1
View File
@@ -37,7 +37,7 @@ async function buildIndex(): Promise<ModuleIndex> {
const loaded = await Promise.all(
index.modules.map(async (meta) => {
try {
const mod = await loadRegistryFile<Module>(`modules/${meta.category}-${meta.id}.json`);
const mod = await loadRegistryFile<Module>(meta.path);
return { meta, mod };
} catch {
return { meta, mod: null as Module | null };
@@ -67,9 +67,9 @@ export type ModuleDetail = {
export const getModuleDetail = createServerFn({ method: "GET" })
.inputValidator((data: ModuleDetailInput) => {
// category and id flow into `loadRegistryFile(modules/${category}-${id}.json)`
// which, in dev, calls path.resolve — unvalidated `..` segments could
// escape the asset root. Constrain to the known shape.
// category + id are used to look the entry up in the index; constrain them
// to the known shape anyway (the module file is then loaded via the entry's
// own `path`, which the index schema already guards against traversal).
if (typeof data.category !== "string" || !isCategoryId(data.category)) {
throw new Error(`Unknown category "${String(data.category)}".`);
}
@@ -84,7 +84,7 @@ export const getModuleDetail = createServerFn({ method: "GET" })
const meta = index.modules.find((m) => m.category === data.category && m.id === data.id);
if (!meta) return null;
const module = await loadRegistryFile<Module>(`modules/${data.category}-${data.id}.json`);
const module = await loadRegistryFile<Module>(meta.path);
const peerOptions = computePeerOptions(module, index);
const resolvedPeers = applyAutoDefaults(module, data.peers, peerOptions);
@@ -20,7 +20,7 @@ async function loadAll(): Promise<Record<string, Module>> {
const settled = await Promise.all(
index.modules.map(async (meta): Promise<readonly [string, Module] | null> => {
try {
const mod = await loadRegistryFile<Module>(`modules/${meta.category}-${meta.id}.json`);
const mod = await loadRegistryFile<Module>(meta.path);
return [`${mod.category}:${mod.id}`, mod] as const;
} catch (cause) {
console.error("[server-error]", cause, {
+1
View File
@@ -8,6 +8,7 @@
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./build": "./src/build.ts",
"./manifest": "./src/manifest.ts",
"./module": "./src/module.ts",
"./resolver": "./src/resolver.ts"
+39 -20
View File
@@ -1,15 +1,17 @@
/**
* Static registry build. Scans `registry/modules/*`, imports each module's
* default export, writes:
* - <out>/registry/index.json — registry index (per-module metadata)
* - <out>/registry/index.json — the main file (per-module
* metadata, each carrying `path`)
* - <out>/registry/modules/<slot>-<id>.json — per-module full manifests
* - <out>/schema.json — JSON Schema for stanza.json (served at the web root)
* - <out>/schema.json — JSON Schema for stanza.json
*
* The output base defaults to `<repoRoot>/dist`. Pass a positional arg to
* redirect — e.g. the web app's prebuild points it at `apps/web/public/` so
* the registry lands directly under `public/registry/`.
*
* Invoked via `jiti packages/registry/src/build.ts [outBase]`.
* Invoked via `jiti packages/registry/src/build.ts [outBase]`. Also exported as
* `buildRegistry()` so the CLI test harness can build a fixture registry.
*/
import fs from "node:fs";
import path from "node:path";
@@ -20,15 +22,21 @@ import { optimize } from "svgo";
import { manifestJsonSchema } from "./manifest";
import { CATEGORIES, type Logo, type Module, type RegistryIndex } from "./module";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = findRepoRoot(here);
const modulesDir = path.join(repoRoot, "registry", "modules");
const outBase = path.resolve(process.argv[2] ?? path.join(repoRoot, "dist"));
const registryDir = path.join(outBase, "registry");
/**
* Build the static registry into `<outBase>/registry/` (+ `<outBase>/schema.json`).
* `modulesDir` defaults to the repo's `registry/modules`; tests can point it
* elsewhere. Returns the module count and resolved output base.
*/
export async function buildRegistry(opts: {
outBase: string;
modulesDir?: string;
}): Promise<{ count: number; outBase: string }> {
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = findRepoRoot(here);
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
const outBase = path.resolve(opts.outBase);
const registryDir = path.join(outBase, "registry");
await main();
async function main() {
// Wipe + recreate so a renamed module's stale JSON doesn't linger and
// ghost-serve from the CDN.
const modulesOut = path.join(registryDir, "modules");
@@ -67,26 +75,27 @@ async function main() {
})),
};
// Dirs/files are keyed by `<category>-<id>` (e.g. testing-vitest). The
// CLI's HTTP loader builds the same filename from the category it's asked for.
fs.writeFileSync(
path.join(registryDir, "modules", `${mod.category}-${mod.id}.json`),
JSON.stringify(inlined, null, 2),
);
// Per-module files live at `modules/<category>-<id>.json`; the index
// records each one's relative `path` so the loader never has to infer a
// filename. (The physical layout is the build's choice — the contract is
// the explicit `path`.)
const modulePath = `modules/${mod.category}-${mod.id}.json`;
fs.writeFileSync(path.join(registryDir, modulePath), JSON.stringify(inlined, null, 2));
// The index keeps lightweight metadata — no template `content`, no
// per-adapter payloads — but it DOES carry top-level fields like `logo`
// so the wizard / web builder can render module cards without fetching
// the full per-module JSON.
// the full per-module JSON, plus the `path` the loader resolves.
metadata.push({
...inlined,
adapters: inlined.adapters.map((a) => ({ key: a.key, match: a.match })),
path: modulePath,
});
}
const index: RegistryIndex = {
generatedAt: new Date().toISOString(),
schemaVersion: 1,
schemaVersion: 2,
categories: [...CATEGORIES],
modules: metadata,
};
@@ -100,7 +109,17 @@ async function main() {
JSON.stringify(manifestJsonSchema(), null, 2),
);
console.log(`Wrote ${metadata.length} modules to ${outBase}`);
return { count: metadata.length, outBase };
}
// Direct invocation: `jiti packages/registry/src/build.ts [outBase]`. Skipped
// when imported (e.g. by the CLI test harness), so importing has no side effect.
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const repoRoot = findRepoRoot(path.dirname(fileURLToPath(import.meta.url)));
const { count, outBase } = await buildRegistry({
outBase: process.argv[2] ?? path.join(repoRoot, "dist"),
});
console.log(`Wrote ${count} modules to ${outBase}`);
}
/**
+4 -4
View File
@@ -185,16 +185,16 @@ describe("third-party registries", () => {
],
},
registries: {
"@thirdparty": "https://reg.thirdparty.dev",
"@thirdparty": "https://reg.thirdparty.example",
"@private": {
url: "https://reg.private.io/{category}/{id}.json",
url: "https://reg.private.example/{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(parsed.registries?.["@thirdparty"]).toBe("https://reg.thirdparty.example");
expect(JSON.parse(JSON.stringify(parsed))).toEqual(manifest);
});
@@ -219,7 +219,7 @@ describe("third-party registries", () => {
it("rejects unknown top-level keys (catches typos like `registies`)", () => {
const result = StanzaManifestSchema.safeParse({
...emptyManifest({ name: "acme" }),
registies: { "@acme": "https://reg.acme.dev" },
registies: { "@acme": "https://reg.acme.example" },
});
expect(result.success).toBe(false);
});
+25 -5
View File
@@ -359,11 +359,22 @@ export type ModuleMetadata = Omit<Module, "adapters"> & {
adapters: Pick<ModuleAdapter, "key" | "match">[];
};
/**
* A module entry in the registry's main file. Extends the display metadata with
* `path` — a relative URI to the module's full JSON, resolved against the main
* file's own location. The loader reads `path` verbatim; there is no
* filename/directory convention.
*/
export type RegistryEntry = ModuleMetadata & {
/** Relative path/URI to this module's full JSON, resolved against the main file. */
path: string;
};
export type RegistryIndex = {
generatedAt: string;
schemaVersion: 1;
schemaVersion: 2;
categories: Category[];
modules: ModuleMetadata[];
modules: RegistryEntry[];
};
/**
@@ -531,10 +542,19 @@ export const ModuleMetadataSchema = z.object({
),
}) satisfies z.ZodType<ModuleMetadata>;
/** Runtime-validatable schema for the registry `index.json` payload. */
/**
* A main-file module entry: index metadata plus the relative `path` to the
* module's full JSON. `path` reuses the safe-relative-path guard so a hostile
* main file can't escape its own directory (`../../etc`) or inject a scheme.
*/
export const RegistryEntrySchema = ModuleMetadataSchema.extend({
path: relativePathSchema,
}) satisfies z.ZodType<RegistryEntry>;
/** Runtime-validatable schema for the registry main-file (index) payload. */
export const RegistryIndexSchema = z.object({
generatedAt: z.string(),
schemaVersion: z.literal(1),
schemaVersion: z.literal(2),
categories: z.array(categorySchema),
modules: z.array(ModuleMetadataSchema),
modules: z.array(RegistryEntrySchema),
}) satisfies z.ZodType<RegistryIndex>;
+13 -11
View File
@@ -119,21 +119,21 @@ describe("expandEnv", () => {
describe("RegistryConfigSchema", () => {
it("accepts a string shorthand", () => {
expect(RegistryConfigSchema.parse("https://reg.acme.com")).toBe("https://reg.acme.com");
expect(RegistryConfigSchema.parse("https://reg.acme.example")).toBe("https://reg.acme.example");
});
it("accepts the full object form with placeholders", () => {
it("accepts the full object form (url = main-file URL + optional auth)", () => {
const cfg = {
url: "https://reg.acme.com/{category}/{id}.json",
url: "https://reg.acme.example/registry.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/,
);
it("rejects legacy/unknown object keys (e.g. indexUrl)", () => {
expect(() =>
RegistryConfigSchema.parse({ url: "https://reg.acme.example/index.json", indexUrl: "x" }),
).toThrow(/indexUrl|[Uu]nrecognized/);
});
});
@@ -141,9 +141,9 @@ describe("RegistriesSchema", () => {
it("accepts well-formed namespaces", () => {
expect(
RegistriesSchema.parse({
"@acme": "https://reg.acme.com",
"@acme": "https://reg.acme.example",
"@private": {
url: "https://reg.private.io/r/{category}/{id}.json",
url: "https://reg.private.example/registry.json",
headers: { Authorization: "Bearer ${TOKEN}" },
},
}),
@@ -151,10 +151,12 @@ describe("RegistriesSchema", () => {
});
it("rejects the reserved @stanza namespace", () => {
expect(() => RegistriesSchema.parse({ [DEFAULT_NAMESPACE]: "https://x" })).toThrow(/reserved/);
expect(() => RegistriesSchema.parse({ [DEFAULT_NAMESPACE]: "https://x.example" })).toThrow(
/reserved/,
);
});
it("rejects malformed namespace keys", () => {
expect(() => RegistriesSchema.parse({ acme: "https://x" })).toThrow(/@scope/);
expect(() => RegistriesSchema.parse({ acme: "https://x.example" })).toThrow(/@scope/);
});
});
+12 -17
View File
@@ -83,22 +83,15 @@ export function expandEnv(input: string, env: NodeJS.ProcessEnv = process.env):
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`).
* Full URL to the registry's main JSON file (the index). Each module's
* `path` recorded in that file is resolved relative to this URL — there is
* no filename convention and no `{category}`/`{id}` templating.
*/
url: z.string().refine((s) => s.includes("{category}") && s.includes("{id}"), {
message: "url must include both {category} and {id} placeholders",
}),
url: z.string(),
/**
* 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 sent with every request to this registry (the main file and each
* module). 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(),
/**
@@ -111,9 +104,11 @@ const registryObjectSchema = z
/**
* 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.
* - a string: the full URL to the registry's main JSON file, or
* - an object: that URL plus optional auth headers / query params.
*
* The main file lists each module's `path`; the CLI resolves modules against
* the main file's URL. No directory or filename is assumed.
*/
export const RegistryConfigSchema = z.union([z.string(), registryObjectSchema]);
+9 -7
View File
@@ -65,8 +65,9 @@ pnpm dev
- `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.
- `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.
Run `add`, `remove`, and `list` from the project root or any child directory containing a parent `stanza.json`.
Run `add`, `remove`, `list`, and `doctor` from the project root or any child directory containing a parent `stanza.json`.
## Categories
@@ -93,10 +94,12 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
Stanza ships modules from the first-party `@stanza` namespace by default. To install modules from another publisher, declare the registry in `stanza.json#registries` and address modules with `@<scope>/<id>` syntax:
A registry is addressed by the **full URL to its main JSON file** — the index, which lists every module and the relative `path` to its full JSON. The string form is that URL:
```json
{
"registries": {
"@acme": "https://reg.acme.dev"
"@acme": "https://reg.acme.dev/registry.json"
}
}
```
@@ -105,14 +108,13 @@ Stanza ships modules from the first-party `@stanza` namespace by default. To ins
stanza add testing @acme/cosmos
```
Editing `stanza.json#registries` is an expected user-driven edit (the rest of the manifest stays runner-managed). For non-canonical layouts, use the object form:
Editing `stanza.json#registries` is an expected user-driven edit (the rest of the manifest stays runner-managed). For auth or query params, use the object form:
```json
{
"registries": {
"@acme": {
"url": "https://api.acme.dev/r/{category}/{id}.json",
"indexUrl": "https://api.acme.dev/r/index.json",
"url": "https://api.acme.dev/r/registry.json",
"headers": { "Authorization": "Bearer ${ACME_TOKEN}" },
"params": { "channel": "stable" }
}
@@ -120,9 +122,9 @@ Editing `stanza.json#registries` is an expected user-driven edit (the rest of th
}
```
`{category}` and `{id}` are required placeholders. `indexUrl` is optional — without it, `stanza search` skips that namespace but install-by-name still works. `${ENV_VAR}` expansion runs against `process.env`: unset vars in `headers` silently drop the header; unset vars in `params` are a hard error. Unknown namespaces fail fast — no implicit fallback to `@stanza`. `@stanza` itself is reserved and rejected if redeclared under `registries`.
`url` is the full URL to the main JSON file (any filename — module paths in it are resolved relative to it; there is no `{category}`/`{id}` templating or directory convention). `${ENV_VAR}` expansion runs against `process.env`: unset vars in `headers` silently drop the header; unset vars in `params` are a hard error. Unknown namespaces fail fast — no implicit fallback to `@stanza`. `@stanza` itself is reserved and rejected if redeclared under `registries`.
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's URL only — for a self-hosted mirror, air-gapped install, or CI fixture. It does NOT enable third-party modules.
`STANZA_REGISTRY=<url-or-path>` overrides the `@stanza` namespace's source only — set it to the **full URL or filesystem path to a registry's main JSON file** (not a directory), for a self-hosted mirror, air-gapped install, or CI fixture. It does NOT enable third-party modules.
## Telemetry