diff --git a/.changeset/bug-fixes.md b/.changeset/bug-fixes.md
new file mode 100644
index 0000000..5097170
--- /dev/null
+++ b/.changeset/bug-fixes.md
@@ -0,0 +1,13 @@
+---
+"stanza-cli": patch
+---
+
+Fix a batch of correctness bugs found in a critical-bug sweep:
+
+- `stanza init --dry-run` no longer writes anything (previously it created the project directory, monorepo shell, and `stanza.json` despite saying "no files will be written").
+- Direct-fs codemods (`append-to-file`, `add-package-dep`, `set-tsconfig-paths`) claim their region **before** writing, so a failed `add` rolls files back to their true pre-apply bytes and `RegionConflictError` fires before any disk change.
+- `stanza remove`'s package-sweep guard reads the `consumesPackages` snapshot persisted on each installed record (falling back to a registry fetch only for legacy records), so a `packages/
/` still imported by an installed module is protected even offline or after an upstream rename.
+- `stanza remove` only applies the legacy bare-id owner fallback when no sibling install of the same module remains, so removing one app's install on a pre-composite-owner manifest can no longer sweep another app's files.
+- `stanza doctor` no longer reports false drift for package-home modules installed with an `--app` restriction.
+- `wrap-root-layout` resolves the framework per dispatched app, fixing multi-app projects with differing frameworks.
+- The template/codemod render context carries the project's `packageManager`, so `{{pm}}`/`{{run …}}` render bun/npm commands instead of always pnpm.
diff --git a/apps/cli/src/commands/commands.test.ts b/apps/cli/src/commands/commands.test.ts
index 51bfb3b..5d2ee11 100644
--- a/apps/cli/src/commands/commands.test.ts
+++ b/apps/cli/src/commands/commands.test.ts
@@ -310,6 +310,66 @@ describe("cmdRemove path-traversal hardening", () => {
});
});
+describe("cmdRemove legacy bare-owner claims", () => {
+ it("does not sweep a sibling app's claims when the same module is installed elsewhere", async () => {
+ const projectRoot = path.join(tmp, "proj");
+ fs.mkdirSync(path.join(projectRoot, "apps", "web"), { recursive: true });
+ fs.mkdirSync(path.join(projectRoot, "apps", "native"), { recursive: true });
+
+ const webFile = path.join(projectRoot, "apps/web/ghost.config.ts");
+ const nativeFile = path.join(projectRoot, "apps/native/ghost.config.ts");
+ fs.writeFileSync(webFile, "// web install\n");
+ fs.writeFileSync(nativeFile, "// native install\n");
+
+ // Pre-composite-owner manifest: both installs' claims use the bare module
+ // id, so neither claim can be attributed to a single app. `ghost` isn't in
+ // the registry, mirroring the offline/renamed-upstream scenario.
+ const manifest = {
+ version: CURRENT_MANIFEST_VERSION,
+ projectShape: "monorepo",
+ packageManager: "pnpm",
+ name: "proj",
+ modules: {
+ testing: [
+ { id: "ghost", version: "0.0.0", adapter: "default", apps: ["web"] },
+ { id: "ghost", version: "0.0.0", adapter: "default", apps: ["native"] },
+ ],
+ },
+ apps: [
+ { id: "web", dir: "apps/web", kind: "web" },
+ { id: "native", dir: "apps/native", kind: "native" },
+ ],
+ regions: {
+ "apps/web/ghost.config.ts": { file: "ghost" },
+ "apps/native/ghost.config.ts": { file: "ghost" },
+ },
+ };
+ fs.writeFileSync(path.join(projectRoot, "stanza.json"), JSON.stringify(manifest, null, 2));
+
+ process.chdir(projectRoot);
+ await cmdRemove(args({ slot: "testing", moduleId: "ghost", app: "web" }));
+ process.chdir(tmp);
+
+ // The sibling install's file (and even web's own, since the bare claims
+ // can't be attributed) must survive; only the web record is dropped.
+ expect(fs.existsSync(nativeFile)).toBe(true);
+ const after = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
+ expect(after.modules.testing).toEqual([
+ { id: "ghost", version: "0.0.0", adapter: "default", apps: ["native"] },
+ ]);
+
+ // Removing the last record (no sibling left) applies the bare-id fallback
+ // and sweeps the legacy claims.
+ process.chdir(projectRoot);
+ await cmdRemove(args({ slot: "testing", moduleId: "ghost", app: "native" }));
+ process.chdir(tmp);
+ expect(fs.existsSync(nativeFile)).toBe(false);
+ const final = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
+ expect(final.modules.testing).toBeUndefined();
+ expect(final.regions).toEqual({});
+ });
+});
+
describe("add-ons (multi-choice testing slot)", () => {
it("init --yes installs two add-ons in one category without a region conflict", async () => {
await cmdInit(
diff --git a/apps/cli/src/commands/doctor.ts b/apps/cli/src/commands/doctor.ts
index 4c91015..2014946 100644
--- a/apps/cli/src/commands/doctor.ts
+++ b/apps/cli/src/commands/doctor.ts
@@ -2,7 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import * as p from "@clack/prompts";
-import { PACKAGE_DIRS } from "@withstanza/schema";
+import type { AppSpec, StanzaManifest } from "@withstanza/schema";
+import { categoryHome, isCategoryId, PACKAGE_DIRS } from "@withstanza/schema";
import { defineCommand } from "citty";
import pc from "picocolors";
@@ -82,7 +83,7 @@ export async function cmdDoctor(): Promise {
continue;
}
const depName = `@${manifest.name}/${dir}`;
- for (const app of manifest.apps) {
+ for (const app of expectedConsumerApps(manifest, dir)) {
const appPkg = path.join(projectRoot, app.dir, "package.json");
if (!fs.existsSync(appPkg)) continue;
if (!pkgHasKey(appPkg, "dependencies", depName)) {
@@ -99,6 +100,27 @@ export async function cmdDoctor(): Promise {
process.exitCode = 1;
}
+// The install path wires the workspace dep only into the apps a package-home
+// record targets (`apps` omitted = every app), so only expect it there.
+// Multiple categories can share a dir (db + orm); union their targets.
+function expectedConsumerApps(manifest: StanzaManifest, dir: string): AppSpec[] {
+ const ids = new Set();
+ let any = false;
+ for (const [category, records] of Object.entries(manifest.modules)) {
+ if (!isCategoryId(category)) continue;
+ const home = categoryHome(category);
+ if (home.kind !== "package" || home.dir !== dir) continue;
+ for (const record of records ?? []) {
+ any = true;
+ if (!record.apps?.length) return manifest.apps;
+ for (const id of record.apps) ids.add(id);
+ }
+ }
+ // Claims with no surviving record is itself drift; fall back to every app.
+ if (!any) return manifest.apps;
+ return manifest.apps.filter((a) => ids.has(a.id));
+}
+
function envHasVar(file: string, name: string): boolean {
if (!fs.existsSync(file)) return false;
const content = fs.readFileSync(file, "utf8");
diff --git a/apps/cli/src/commands/init.ts b/apps/cli/src/commands/init.ts
index 6b9e47b..2a358bb 100644
--- a/apps/cli/src/commands/init.ts
+++ b/apps/cli/src/commands/init.ts
@@ -17,6 +17,7 @@ import type { AppSpec, CategoryId, Module, PackageManager } from "@withstanza/sc
import {
categoryHome,
DEFAULT_NAMESPACE,
+ emptyManifest,
KNOWN_CATEGORIES,
PEER_CATEGORIES,
} from "@withstanza/schema";
@@ -91,21 +92,29 @@ export async function cmdInit(args: CliArgs): Promise {
return;
}
- fs.mkdirSync(projectRoot, { recursive: true });
+ if (!dryRun) {
+ fs.mkdirSync(projectRoot, { recursive: true });
- // Bootstrap the empty monorepo shell.
- await bootstrapShell(projectRoot, {
- name: result.name,
- packageManager: result.packageManager,
- apps: result.apps,
- });
+ // Bootstrap the empty monorepo shell.
+ await bootstrapShell(projectRoot, {
+ name: result.name,
+ packageManager: result.packageManager,
+ apps: result.apps,
+ });
+ }
- let manifest = initManifest({
- projectRoot,
- name: result.name,
- apps: result.apps,
- packageManager: result.packageManager,
- });
+ let manifest = dryRun
+ ? emptyManifest({
+ name: result.name,
+ apps: result.apps,
+ packageManager: result.packageManager,
+ })
+ : initManifest({
+ projectRoot,
+ name: result.name,
+ apps: result.apps,
+ packageManager: result.packageManager,
+ });
if (dryRun) p.log.info(pc.yellow("[dry-run] no files will be written"));
diff --git a/apps/cli/src/commands/remove.ts b/apps/cli/src/commands/remove.ts
index c76fe42..5c8579b 100644
--- a/apps/cli/src/commands/remove.ts
+++ b/apps/cli/src/commands/remove.ts
@@ -189,10 +189,18 @@ export async function cmdRemove(args: CliArgs): Promise {
// driven by whatever region claims remain after the codemod reverts.
// Owner is composite (`@`) for home:app installs so cross-app
// installs of the same module don't collide. Bare `` covers older
- // manifests + the non-app homes that never used a composite owner.
+ // manifests + the non-app homes that never used a composite owner — but
+ // only when no sibling record of the same id remains: a legacy bare claim
+ // can't be attributed to one app, so sweeping it while the module is still
+ // installed elsewhere would delete the other app's files.
+ const hasSiblingInstall = (manifest.modules[category] ?? []).some(
+ (r) => r.id === installed.id && !sameAppSet(r.apps, installed.apps),
+ );
const ownerKeys =
home.kind === "app" && installed.apps?.length === 1
- ? [`${installed.id}@${installed.apps[0]}`, installed.id]
+ ? hasSiblingInstall
+ ? [`${installed.id}@${installed.apps[0]}`]
+ : [`${installed.id}@${installed.apps[0]}`, installed.id]
: [installed.id];
const owned = regionsOwnedBy(manifest, ownerKeys);
for (const { file, region } of owned) {
@@ -349,8 +357,11 @@ export async function cmdRemove(args: CliArgs): Promise {
if (dryRun) p.log.info(pc.yellow("[dry-run] no files were written"));
}
-// Skips records whose registry entry can't be re-loaded (offline, rename) —
-// failing open here beats blocking remove on a transient network error.
+// Prefers the `consumesPackages` snapshot persisted on each record so the
+// guard holds offline and after upstream registry changes; the live registry
+// fetch only backfills legacy records that predate the snapshot. Records that
+// can't be resolved either way fail open — better than blocking remove on a
+// transient network error.
async function collectConsumesPackagesProtectors(
manifest: StanzaManifest,
registry: Registries,
@@ -361,11 +372,12 @@ async function collectConsumesPackagesProtectors(
for (const record of records ?? []) {
tasks.push(
(async () => {
- const mod = await registry
- .loadModule(category, record.id, record.namespace)
- .catch(() => null);
- if (!mod?.consumesPackages?.length) return;
- for (const dir of mod.consumesPackages) {
+ const dirs =
+ record.consumesPackages ??
+ (await registry.loadModule(category, record.id, record.namespace).catch(() => null))
+ ?.consumesPackages;
+ if (!dirs?.length) return;
+ for (const dir of dirs) {
const arr = protectors.get(dir) ?? [];
arr.push(`${category}/${record.id}`);
protectors.set(dir, arr);
diff --git a/apps/cli/src/lib/codemod-runner.ts b/apps/cli/src/lib/codemod-runner.ts
index 0f29dbe..2ed3022 100644
--- a/apps/cli/src/lib/codemod-runner.ts
+++ b/apps/cli/src/lib/codemod-runner.ts
@@ -172,6 +172,7 @@ export async function applyModule(args: {
projectName: manifest.name,
app,
packageName,
+ packageManager: manifest.packageManager,
peers: activePeerIds(manifest, app.id),
envNames: declaredEnvNames(manifest),
consumesPackages: module.consumesPackages,
@@ -243,14 +244,18 @@ export async function applyModule(args: {
// The slot's package.json may be deferred-bootstrapped by planSlotPackageBootstrap;
// that's fine — the file will exist by the time deferredWrites flush.
const slotBootstrapTarget = packageDir !== null ? `packages/${packageDir}/package.json` : null;
- for (const target of installTargets) {
- if (target === slotBootstrapTarget) continue;
- const pkgJsonPath = path.join(projectRoot, target);
- if (!fs.existsSync(pkgJsonPath)) {
- throw new Error(
- `Cannot apply ${module.id}: ${target} doesn't exist. ` +
- `For \`stanza add\` in an existing project, create it manually first.`,
- );
+ // Skip on dry-run: nothing is written, and `init --dry-run` plans modules
+ // before the monorepo shell (and these files) would exist.
+ if (!dryRun) {
+ for (const target of installTargets) {
+ if (target === slotBootstrapTarget) continue;
+ const pkgJsonPath = path.join(projectRoot, target);
+ if (!fs.existsSync(pkgJsonPath)) {
+ throw new Error(
+ `Cannot apply ${module.id}: ${target} doesn't exist. ` +
+ `For \`stanza add\` in an existing project, create it manually first.`,
+ );
+ }
}
}
// Re-pin declared `^`/`~` ranges to the latest published version that still
@@ -308,10 +313,12 @@ export async function applyModule(args: {
Object.keys(appFields.scripts).length > 0;
if (hasAppInstall) {
const appTargets = targetApps.map((a) => `${a.dir.replace(/\/+$/, "")}/package.json`);
- for (const target of appTargets) {
- const pkgJsonPath = path.join(projectRoot, target);
- if (!fs.existsSync(pkgJsonPath)) {
- throw new Error(`Cannot apply ${module.id} app overlay: ${target} doesn't exist.`);
+ if (!dryRun) {
+ for (const target of appTargets) {
+ const pkgJsonPath = path.join(projectRoot, target);
+ if (!fs.existsSync(pkgJsonPath)) {
+ throw new Error(`Cannot apply ${module.id} app overlay: ${target} doesn't exist.`);
+ }
}
}
const appDeps = dryRun ? appFields.dependencies : await resolveRanges(appFields.dependencies);
@@ -808,6 +815,7 @@ export async function revertCodemods(args: {
projectName: manifest.name,
app,
packageName,
+ packageManager: manifest.packageManager,
peers: activePeerIds(manifest, app.id),
envNames: declaredEnvNames(manifest),
consumesPackages: args.consumesPackages,
@@ -948,7 +956,9 @@ function realpathAllowingMissing(p: string, depth = 0): string {
*/
export function assertWithinRoot(projectRoot: string, relativePath: string, label: string): void {
const root = path.resolve(projectRoot);
- const realRoot = fs.realpathSync(root);
+ // Missing-tolerant: dry-run init vets paths under a project root that
+ // doesn't exist yet (equivalent to realpathSync when the root exists).
+ const realRoot = realpathAllowingMissing(root);
const segments = relativePath.split(/[/\\]+/).filter((s) => s !== "" && s !== ".");
// Reject `..` lexically before any join — a traversal segment must never be
// resolved against the root, and isn't something symlink resolution vets.
diff --git a/apps/web/src/routes/api.events.ts b/apps/web/src/routes/api.events.ts
index 1cb3234..23c69a1 100644
--- a/apps/web/src/routes/api.events.ts
+++ b/apps/web/src/routes/api.events.ts
@@ -79,11 +79,14 @@ export const Route = createFileRoute("/api/events")({
if (!posthog) return new Response(null, { status: 204 });
for (const e of payload.events) {
+ // Drop unparseable timestamps — an Invalid Date would throw inside
+ // posthog-node's serialization, turning bad input into a 500.
+ const ts = e.timestamp ? new Date(e.timestamp) : undefined;
posthog.capture({
distinctId: payload.distinctId,
event: e.event,
properties: e.properties ?? {},
- timestamp: e.timestamp ? new Date(e.timestamp) : undefined,
+ timestamp: ts && !Number.isNaN(ts.getTime()) ? ts : undefined,
});
}
diff --git a/apps/web/src/routes/api.search.modules.ts b/apps/web/src/routes/api.search.modules.ts
index e1abd6d..7c5de77 100644
--- a/apps/web/src/routes/api.search.modules.ts
+++ b/apps/web/src/routes/api.search.modules.ts
@@ -1,7 +1,6 @@
import { create, insert, search } from "@orama/orama";
import { createFileRoute } from "@tanstack/react-router";
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
-import { cache } from "react";
import { loadRegistryFile } from "@/server/registry-base.server";
@@ -22,10 +21,13 @@ type ModuleIndex = {
metadata: Map;
};
-// React `cache()` dedupes the (async) build across any concurrent callers
-// within the same server render. The returned promise is reused on warm
-// calls so the registry-load + Orama-insert work only runs once.
-const getModuleIndex = cache((): Promise => buildIndex());
+// Process-lifetime singleton: registry data is immutable per deployment, so
+// the registry-load + Orama-insert work runs once per server instance.
+let moduleIndexPromise: Promise | undefined;
+function getModuleIndex(): Promise {
+ if (!moduleIndexPromise) moduleIndexPromise = buildIndex();
+ return moduleIndexPromise;
+}
async function buildIndex(): Promise {
const index = await loadRegistryFile("index.json");
diff --git a/packages/codemods/src/builtins/add-package-dep.ts b/packages/codemods/src/builtins/add-package-dep.ts
index 4114281..8573d17 100644
--- a/packages/codemods/src/builtins/add-package-dep.ts
+++ b/packages/codemods/src/builtins/add-package-dep.ts
@@ -34,11 +34,13 @@ const addPackageDep: Codemod = {
const rel = path.relative(ctx.projectRoot, pkgJsonPath);
const range = args.range ?? "workspace:*";
- addPackageDependency(pkgJsonPath, args.name, range, { dev: args.dev });
-
+ // Claim before writing: the runner snapshots the file on claim, and the
+ // snapshot must capture the pre-write bytes for rollback to restore them.
const depKey = args.dev ? "devDependencies" : "dependencies";
ctx.claimRegion(rel, `${depKey}.${args.name}`);
+ addPackageDependency(pkgJsonPath, args.name, range, { dev: args.dev });
+
return { touchedFiles: [rel] };
},
diff --git a/packages/codemods/src/builtins/append-to-file.ts b/packages/codemods/src/builtins/append-to-file.ts
index f2cd2b1..546a0c1 100644
--- a/packages/codemods/src/builtins/append-to-file.ts
+++ b/packages/codemods/src/builtins/append-to-file.ts
@@ -113,10 +113,12 @@ const appendToFile: Codemod = {
position === "start"
? prependBlock(current, block, leadingBlank)
: appendBlock(current, block, leadingBlank);
+ // Claim before writing: the runner snapshots the file on claim, and the
+ // snapshot must capture the pre-write bytes for rollback to restore them.
+ ctx.claimRegion(fileRel, `append.${args.marker}`);
if (!exists) fs.mkdirSync(path.dirname(fileAbs), { recursive: true });
fs.writeFileSync(fileAbs, next, "utf8");
- ctx.claimRegion(fileRel, `append.${args.marker}`);
return { touchedFiles: [fileRel] };
},
diff --git a/packages/codemods/src/builtins/set-tsconfig-paths.ts b/packages/codemods/src/builtins/set-tsconfig-paths.ts
index b10622f..8c2721d 100644
--- a/packages/codemods/src/builtins/set-tsconfig-paths.ts
+++ b/packages/codemods/src/builtins/set-tsconfig-paths.ts
@@ -90,8 +90,10 @@ const setTsconfigPaths: Codemod = {
const allPresent = Object.entries(args.paths).every(
([k, v]) => existing[k] !== undefined && arraysEqual(toArray(existing[k]), v),
);
+ // Claim before writing: the runner snapshots the file on claim, and the
+ // snapshot must capture the pre-write bytes for rollback to restore them.
+ ctx.claimRegion(rel, regionKeyFor(args));
if (allPresent && compilerOptions.baseUrl !== undefined) {
- ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [] };
}
@@ -101,7 +103,6 @@ const setTsconfigPaths: Codemod = {
for (const [key, val] of Object.entries(args.paths)) {
setJsonPathSegments(abs, ["compilerOptions", "paths", key], val);
}
- ctx.claimRegion(rel, regionKeyFor(args));
return { touchedFiles: [rel] };
},
diff --git a/packages/codemods/src/builtins/wrap-root-layout.ts b/packages/codemods/src/builtins/wrap-root-layout.ts
index 1c90daf..f222f4e 100644
--- a/packages/codemods/src/builtins/wrap-root-layout.ts
+++ b/packages/codemods/src/builtins/wrap-root-layout.ts
@@ -42,10 +42,13 @@ const wrapRootLayout: Codemod = {
description: "Wrap the root layout's children with a provider element.",
apply(ctx, args) {
- const target = frameworkTarget(selectedOne(ctx.manifest, "framework")?.id);
+ // App-scoped read: each dispatch targets one app, and apps can run
+ // different frameworks — the global pick would wrap the wrong layout.
+ const frameworkId = selectedOne(ctx.manifest, "framework", ctx.app.id)?.id;
+ const target = frameworkTarget(frameworkId);
if (!target) {
throw new Error(
- `wrap-root-layout: framework "${selectedOne(ctx.manifest, "framework")?.id ?? ""}" not supported. ` +
+ `wrap-root-layout: framework "${frameworkId ?? ""}" not supported. ` +
`Add a case in frameworkTarget() to enable.`,
);
}
@@ -92,7 +95,7 @@ const wrapRootLayout: Codemod = {
},
revert(ctx, args) {
- const target = frameworkTarget(selectedOne(ctx.manifest, "framework")?.id);
+ const target = frameworkTarget(selectedOne(ctx.manifest, "framework", ctx.app.id)?.id);
if (!target) return { touchedFiles: [] };
const layoutAbs = path.join(ctx.appRoot, target.relPath);
diff --git a/scripts/check-module-versions.ts b/scripts/check-module-versions.ts
index bc0be73..e676813 100644
--- a/scripts/check-module-versions.ts
+++ b/scripts/check-module-versions.ts
@@ -32,26 +32,30 @@ try {
range = "HEAD~1"; // local fallback
}
-const slugs = new Set();
+const changedDirs = new Set();
for (const f of git(["diff", "--name-only", range, "HEAD"]).split("\n")) {
const m = /^registry\/modules\/([^/]+)\//.exec(f);
- if (m) slugs.add(m[1]!);
+ if (m) changedDirs.add(m[1]!);
}
-if (slugs.size === 0) {
+if (changedDirs.size === 0) {
console.log("Module version guard passed (no module changes).");
process.exit(0);
}
const compiledDir = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-check-"));
-await compileRegistry({ outDir: compiledDir });
+const { modules } = await compileRegistry({ outDir: compiledDir });
+// Correlate source dirs with compiled output through the compiler's own
+// mapping — inferring `.json` would silently skip (and so bypass the
+// guard for) any module whose dir name doesn't match its `-`.
+const byDir = new Map(modules.map((m) => [m.dir, m]));
const errors: string[] = [];
-for (const slug of slugs) {
- const file = path.join(compiledDir, `${slug}.json`);
- if (!fs.existsSync(file)) continue; // module deleted — nothing to pin
- const text = fs.readFileSync(file, "utf8");
- const { version }: { version: string } = JSON.parse(text);
+for (const dir of changedDirs) {
+ const entry = byDir.get(dir);
+ if (!entry) continue; // module deleted — nothing to pin
+ const { slug, version } = entry;
+ const text = fs.readFileSync(path.join(compiledDir, `${slug}.json`), "utf8");
const res = await fetch(`${registryBase}/${slug}@${version}.json`);
if (res.status === 404) continue; // new version or new module
if (!res.ok) {
@@ -61,7 +65,7 @@ for (const slug of slugs) {
if ((await res.text()) !== text) {
errors.push(
`Module "${slug}" changed but version ${version} is already published with different ` +
- `content. Bump "version" in registry/modules/${slug}/package.json.`,
+ `content. Bump "version" in registry/modules/${dir}/package.json.`,
);
}
}
diff --git a/scripts/compile-registry.ts b/scripts/compile-registry.ts
index 230b5d6..032133f 100644
--- a/scripts/compile-registry.ts
+++ b/scripts/compile-registry.ts
@@ -22,15 +22,21 @@ import { fileURLToPath } from "node:url";
import { CATEGORIES, type Logo, type Module, type RegistryIndex } from "@withstanza/schema";
import { optimize } from "svgo";
+/** One compiled module: its source dir basename, output slug, and version. */
+export type CompiledModule = { dir: string; slug: string; version: string };
+
/**
* Build the static registry into ``. `modulesDir` defaults to the
* repo's `registry/modules`; tests can point it elsewhere. Returns the final
- * module count.
+ * module count plus a `dir → slug/version` mapping — the source dir name is a
+ * convention (`-`), not a contract, so callers correlating
+ * source dirs with compiled output (e.g. the version-pin guard) must go
+ * through this mapping rather than infer filenames.
*/
export async function compileRegistry(opts: {
modulesDir?: string;
outDir: string;
-}): Promise<{ count: number }> {
+}): Promise<{ count: number; modules: CompiledModule[] }> {
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = findRepoRoot(here);
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
@@ -49,6 +55,7 @@ export async function compileRegistry(opts: {
.map((d) => d.name);
const metadata = [];
+ const compiledModules: CompiledModule[] = [];
for (const dir of dirs) {
const entry = path.join(modulesDir, dir, "module.ts");
const imported: { default: Module } = await import(entry);
@@ -87,6 +94,7 @@ export async function compileRegistry(opts: {
// `path`, resolved relative to the index URL.)
const modulePath = `${mod.category}-${mod.id}.json`;
fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2));
+ compiledModules.push({ dir, slug: `${mod.category}-${mod.id}`, version });
// The index keeps lightweight metadata — no template `content`, no
// per-adapter payloads — but it DOES carry top-level fields like `logo`
@@ -108,7 +116,7 @@ export async function compileRegistry(opts: {
fs.writeFileSync(path.join(opts.outDir, "index.json"), JSON.stringify(index, null, 2));
- return { count: metadata.length };
+ return { count: metadata.length, modules: compiledModules };
}
// Direct invocation: `jiti scripts/compile-registry.ts [outDir]`. Skipped when
@@ -152,8 +160,11 @@ function optimizeLogo(svg: string, prefix: string): string {
plugins: [
"preset-default",
{ name: "prefixIds", params: { prefix, delim: "-" } },
- // Strip every `on*` event handler so we can render via dangerouslySetInnerHTML
- // without smuggling JS through a hostile (third-party) logo.
+ // Logos render via dangerouslySetInnerHTML: `removeScripts` drops
+ // `