mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
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:
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user