mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
fix: correct batch of CLI correctness bugs found in critical-bug sweep
- `stanza init --dry-run` no longer creates the project directory, monorepo shell, or `stanza.json`; the dry-run manifest is built with `emptyManifest` and package.json existence checks are skipped throughout `applyModule`
- Direct-fs codemods (`append-to-file`, `add-package-dep`, `set-tsconfig-paths`) now claim their region before writing so a failed `add` rolls back to true pre-apply bytes and `RegionConflictError` fires before any disk change
- `stanza remove` reads the `consumesPackages` snapshot persisted on each installed record (falling back to a live registry fetch only for legacy records), so package-dir protection holds offline and after upstream renames
- `stanza remove` only applies the legacy bare-id owner fallback when no sibling install of the same module remains, preventing one app's removal from sweeping another app's files on pre-composite-owner manifests
- `stanza doctor` no longer reports false drift for package-home modules installed with an `--app` restriction; `expectedConsumerApps` now intersects targeted apps from the manifest records
- `wrap-root-layout` resolves the framework per dispatched app, fixing multi-app projects with differing frameworks
- The codemod render context now carries `packageManager` so `{{pm}}`/`{{run …}}` templates render bun/npm/pnpm correctly instead of always defaulting to pnpm
This commit is contained in:
@@ -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/<dir>/` 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.
|
||||||
@@ -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)", () => {
|
describe("add-ons (multi-choice testing slot)", () => {
|
||||||
it("init --yes installs two add-ons in one category without a region conflict", async () => {
|
it("init --yes installs two add-ons in one category without a region conflict", async () => {
|
||||||
await cmdInit(
|
await cmdInit(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import * as p from "@clack/prompts";
|
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 { defineCommand } from "citty";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ export async function cmdDoctor(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const depName = `@${manifest.name}/${dir}`;
|
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");
|
const appPkg = path.join(projectRoot, app.dir, "package.json");
|
||||||
if (!fs.existsSync(appPkg)) continue;
|
if (!fs.existsSync(appPkg)) continue;
|
||||||
if (!pkgHasKey(appPkg, "dependencies", depName)) {
|
if (!pkgHasKey(appPkg, "dependencies", depName)) {
|
||||||
@@ -99,6 +100,27 @@ export async function cmdDoctor(): Promise<void> {
|
|||||||
process.exitCode = 1;
|
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<string>();
|
||||||
|
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 {
|
function envHasVar(file: string, name: string): boolean {
|
||||||
if (!fs.existsSync(file)) return false;
|
if (!fs.existsSync(file)) return false;
|
||||||
const content = fs.readFileSync(file, "utf8");
|
const content = fs.readFileSync(file, "utf8");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { AppSpec, CategoryId, Module, PackageManager } from "@withstanza/sc
|
|||||||
import {
|
import {
|
||||||
categoryHome,
|
categoryHome,
|
||||||
DEFAULT_NAMESPACE,
|
DEFAULT_NAMESPACE,
|
||||||
|
emptyManifest,
|
||||||
KNOWN_CATEGORIES,
|
KNOWN_CATEGORIES,
|
||||||
PEER_CATEGORIES,
|
PEER_CATEGORIES,
|
||||||
} from "@withstanza/schema";
|
} from "@withstanza/schema";
|
||||||
@@ -91,21 +92,29 @@ export async function cmdInit(args: CliArgs): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(projectRoot, { recursive: true });
|
if (!dryRun) {
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
|
||||||
// Bootstrap the empty monorepo shell.
|
// Bootstrap the empty monorepo shell.
|
||||||
await bootstrapShell(projectRoot, {
|
await bootstrapShell(projectRoot, {
|
||||||
name: result.name,
|
name: result.name,
|
||||||
packageManager: result.packageManager,
|
packageManager: result.packageManager,
|
||||||
apps: result.apps,
|
apps: result.apps,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let manifest = initManifest({
|
let manifest = dryRun
|
||||||
projectRoot,
|
? emptyManifest({
|
||||||
name: result.name,
|
name: result.name,
|
||||||
apps: result.apps,
|
apps: result.apps,
|
||||||
packageManager: result.packageManager,
|
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"));
|
if (dryRun) p.log.info(pc.yellow("[dry-run] no files will be written"));
|
||||||
|
|
||||||
|
|||||||
@@ -189,10 +189,18 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
|||||||
// driven by whatever region claims remain after the codemod reverts.
|
// driven by whatever region claims remain after the codemod reverts.
|
||||||
// Owner is composite (`<id>@<app>`) for home:app installs so cross-app
|
// Owner is composite (`<id>@<app>`) for home:app installs so cross-app
|
||||||
// installs of the same module don't collide. Bare `<id>` covers older
|
// installs of the same module don't collide. Bare `<id>` 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 =
|
const ownerKeys =
|
||||||
home.kind === "app" && installed.apps?.length === 1
|
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];
|
: [installed.id];
|
||||||
const owned = regionsOwnedBy(manifest, ownerKeys);
|
const owned = regionsOwnedBy(manifest, ownerKeys);
|
||||||
for (const { file, region } of owned) {
|
for (const { file, region } of owned) {
|
||||||
@@ -349,8 +357,11 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
|||||||
if (dryRun) p.log.info(pc.yellow("[dry-run] no files were written"));
|
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) —
|
// Prefers the `consumesPackages` snapshot persisted on each record so the
|
||||||
// failing open here beats blocking remove on a transient network error.
|
// 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(
|
async function collectConsumesPackagesProtectors(
|
||||||
manifest: StanzaManifest,
|
manifest: StanzaManifest,
|
||||||
registry: Registries,
|
registry: Registries,
|
||||||
@@ -361,11 +372,12 @@ async function collectConsumesPackagesProtectors(
|
|||||||
for (const record of records ?? []) {
|
for (const record of records ?? []) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
const mod = await registry
|
const dirs =
|
||||||
.loadModule(category, record.id, record.namespace)
|
record.consumesPackages ??
|
||||||
.catch(() => null);
|
(await registry.loadModule(category, record.id, record.namespace).catch(() => null))
|
||||||
if (!mod?.consumesPackages?.length) return;
|
?.consumesPackages;
|
||||||
for (const dir of mod.consumesPackages) {
|
if (!dirs?.length) return;
|
||||||
|
for (const dir of dirs) {
|
||||||
const arr = protectors.get(dir) ?? [];
|
const arr = protectors.get(dir) ?? [];
|
||||||
arr.push(`${category}/${record.id}`);
|
arr.push(`${category}/${record.id}`);
|
||||||
protectors.set(dir, arr);
|
protectors.set(dir, arr);
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export async function applyModule(args: {
|
|||||||
projectName: manifest.name,
|
projectName: manifest.name,
|
||||||
app,
|
app,
|
||||||
packageName,
|
packageName,
|
||||||
|
packageManager: manifest.packageManager,
|
||||||
peers: activePeerIds(manifest, app.id),
|
peers: activePeerIds(manifest, app.id),
|
||||||
envNames: declaredEnvNames(manifest),
|
envNames: declaredEnvNames(manifest),
|
||||||
consumesPackages: module.consumesPackages,
|
consumesPackages: module.consumesPackages,
|
||||||
@@ -243,14 +244,18 @@ export async function applyModule(args: {
|
|||||||
// The slot's package.json may be deferred-bootstrapped by planSlotPackageBootstrap;
|
// The slot's package.json may be deferred-bootstrapped by planSlotPackageBootstrap;
|
||||||
// that's fine — the file will exist by the time deferredWrites flush.
|
// that's fine — the file will exist by the time deferredWrites flush.
|
||||||
const slotBootstrapTarget = packageDir !== null ? `packages/${packageDir}/package.json` : null;
|
const slotBootstrapTarget = packageDir !== null ? `packages/${packageDir}/package.json` : null;
|
||||||
for (const target of installTargets) {
|
// Skip on dry-run: nothing is written, and `init --dry-run` plans modules
|
||||||
if (target === slotBootstrapTarget) continue;
|
// before the monorepo shell (and these files) would exist.
|
||||||
const pkgJsonPath = path.join(projectRoot, target);
|
if (!dryRun) {
|
||||||
if (!fs.existsSync(pkgJsonPath)) {
|
for (const target of installTargets) {
|
||||||
throw new Error(
|
if (target === slotBootstrapTarget) continue;
|
||||||
`Cannot apply ${module.id}: ${target} doesn't exist. ` +
|
const pkgJsonPath = path.join(projectRoot, target);
|
||||||
`For \`stanza add\` in an existing project, create it manually first.`,
|
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
|
// 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;
|
Object.keys(appFields.scripts).length > 0;
|
||||||
if (hasAppInstall) {
|
if (hasAppInstall) {
|
||||||
const appTargets = targetApps.map((a) => `${a.dir.replace(/\/+$/, "")}/package.json`);
|
const appTargets = targetApps.map((a) => `${a.dir.replace(/\/+$/, "")}/package.json`);
|
||||||
for (const target of appTargets) {
|
if (!dryRun) {
|
||||||
const pkgJsonPath = path.join(projectRoot, target);
|
for (const target of appTargets) {
|
||||||
if (!fs.existsSync(pkgJsonPath)) {
|
const pkgJsonPath = path.join(projectRoot, target);
|
||||||
throw new Error(`Cannot apply ${module.id} app overlay: ${target} doesn't exist.`);
|
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);
|
const appDeps = dryRun ? appFields.dependencies : await resolveRanges(appFields.dependencies);
|
||||||
@@ -808,6 +815,7 @@ export async function revertCodemods(args: {
|
|||||||
projectName: manifest.name,
|
projectName: manifest.name,
|
||||||
app,
|
app,
|
||||||
packageName,
|
packageName,
|
||||||
|
packageManager: manifest.packageManager,
|
||||||
peers: activePeerIds(manifest, app.id),
|
peers: activePeerIds(manifest, app.id),
|
||||||
envNames: declaredEnvNames(manifest),
|
envNames: declaredEnvNames(manifest),
|
||||||
consumesPackages: args.consumesPackages,
|
consumesPackages: args.consumesPackages,
|
||||||
@@ -948,7 +956,9 @@ function realpathAllowingMissing(p: string, depth = 0): string {
|
|||||||
*/
|
*/
|
||||||
export function assertWithinRoot(projectRoot: string, relativePath: string, label: string): void {
|
export function assertWithinRoot(projectRoot: string, relativePath: string, label: string): void {
|
||||||
const root = path.resolve(projectRoot);
|
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 !== ".");
|
const segments = relativePath.split(/[/\\]+/).filter((s) => s !== "" && s !== ".");
|
||||||
// Reject `..` lexically before any join — a traversal segment must never be
|
// Reject `..` lexically before any join — a traversal segment must never be
|
||||||
// resolved against the root, and isn't something symlink resolution vets.
|
// resolved against the root, and isn't something symlink resolution vets.
|
||||||
|
|||||||
@@ -79,11 +79,14 @@ export const Route = createFileRoute("/api/events")({
|
|||||||
if (!posthog) return new Response(null, { status: 204 });
|
if (!posthog) return new Response(null, { status: 204 });
|
||||||
|
|
||||||
for (const e of payload.events) {
|
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({
|
posthog.capture({
|
||||||
distinctId: payload.distinctId,
|
distinctId: payload.distinctId,
|
||||||
event: e.event,
|
event: e.event,
|
||||||
properties: e.properties ?? {},
|
properties: e.properties ?? {},
|
||||||
timestamp: e.timestamp ? new Date(e.timestamp) : undefined,
|
timestamp: ts && !Number.isNaN(ts.getTime()) ? ts : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { create, insert, search } from "@orama/orama";
|
import { create, insert, search } from "@orama/orama";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
|
import type { Module, ModuleMetadata, RegistryIndex } from "@withstanza/schema";
|
||||||
import { cache } from "react";
|
|
||||||
|
|
||||||
import { loadRegistryFile } from "@/server/registry-base.server";
|
import { loadRegistryFile } from "@/server/registry-base.server";
|
||||||
|
|
||||||
@@ -22,10 +21,13 @@ type ModuleIndex = {
|
|||||||
metadata: Map<string, ModuleMetadata>;
|
metadata: Map<string, ModuleMetadata>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// React `cache()` dedupes the (async) build across any concurrent callers
|
// Process-lifetime singleton: registry data is immutable per deployment, so
|
||||||
// within the same server render. The returned promise is reused on warm
|
// the registry-load + Orama-insert work runs once per server instance.
|
||||||
// calls so the registry-load + Orama-insert work only runs once.
|
let moduleIndexPromise: Promise<ModuleIndex> | undefined;
|
||||||
const getModuleIndex = cache((): Promise<ModuleIndex> => buildIndex());
|
function getModuleIndex(): Promise<ModuleIndex> {
|
||||||
|
if (!moduleIndexPromise) moduleIndexPromise = buildIndex();
|
||||||
|
return moduleIndexPromise;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildIndex(): Promise<ModuleIndex> {
|
async function buildIndex(): Promise<ModuleIndex> {
|
||||||
const index = await loadRegistryFile<RegistryIndex>("index.json");
|
const index = await loadRegistryFile<RegistryIndex>("index.json");
|
||||||
|
|||||||
@@ -34,11 +34,13 @@ const addPackageDep: Codemod<AddPackageDepArgs> = {
|
|||||||
const rel = path.relative(ctx.projectRoot, pkgJsonPath);
|
const rel = path.relative(ctx.projectRoot, pkgJsonPath);
|
||||||
const range = args.range ?? "workspace:*";
|
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";
|
const depKey = args.dev ? "devDependencies" : "dependencies";
|
||||||
ctx.claimRegion(rel, `${depKey}.${args.name}`);
|
ctx.claimRegion(rel, `${depKey}.${args.name}`);
|
||||||
|
|
||||||
|
addPackageDependency(pkgJsonPath, args.name, range, { dev: args.dev });
|
||||||
|
|
||||||
return { touchedFiles: [rel] };
|
return { touchedFiles: [rel] };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -113,10 +113,12 @@ const appendToFile: Codemod<AppendToFileArgs> = {
|
|||||||
position === "start"
|
position === "start"
|
||||||
? prependBlock(current, block, leadingBlank)
|
? prependBlock(current, block, leadingBlank)
|
||||||
: appendBlock(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 });
|
if (!exists) fs.mkdirSync(path.dirname(fileAbs), { recursive: true });
|
||||||
fs.writeFileSync(fileAbs, next, "utf8");
|
fs.writeFileSync(fileAbs, next, "utf8");
|
||||||
|
|
||||||
ctx.claimRegion(fileRel, `append.${args.marker}`);
|
|
||||||
return { touchedFiles: [fileRel] };
|
return { touchedFiles: [fileRel] };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
|||||||
const allPresent = Object.entries(args.paths).every(
|
const allPresent = Object.entries(args.paths).every(
|
||||||
([k, v]) => existing[k] !== undefined && arraysEqual(toArray(existing[k]), v),
|
([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) {
|
if (allPresent && compilerOptions.baseUrl !== undefined) {
|
||||||
ctx.claimRegion(rel, regionKeyFor(args));
|
|
||||||
return { touchedFiles: [] };
|
return { touchedFiles: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,6 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
|||||||
for (const [key, val] of Object.entries(args.paths)) {
|
for (const [key, val] of Object.entries(args.paths)) {
|
||||||
setJsonPathSegments(abs, ["compilerOptions", "paths", key], val);
|
setJsonPathSegments(abs, ["compilerOptions", "paths", key], val);
|
||||||
}
|
}
|
||||||
ctx.claimRegion(rel, regionKeyFor(args));
|
|
||||||
return { touchedFiles: [rel] };
|
return { touchedFiles: [rel] };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ const wrapRootLayout: Codemod<WrapRootLayoutArgs> = {
|
|||||||
description: "Wrap the root layout's children with a provider element.",
|
description: "Wrap the root layout's children with a provider element.",
|
||||||
|
|
||||||
apply(ctx, args) {
|
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) {
|
if (!target) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`wrap-root-layout: framework "${selectedOne(ctx.manifest, "framework")?.id ?? "<unset>"}" not supported. ` +
|
`wrap-root-layout: framework "${frameworkId ?? "<unset>"}" not supported. ` +
|
||||||
`Add a case in frameworkTarget() to enable.`,
|
`Add a case in frameworkTarget() to enable.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,7 +95,7 @@ const wrapRootLayout: Codemod<WrapRootLayoutArgs> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
revert(ctx, args) {
|
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: [] };
|
if (!target) return { touchedFiles: [] };
|
||||||
|
|
||||||
const layoutAbs = path.join(ctx.appRoot, target.relPath);
|
const layoutAbs = path.join(ctx.appRoot, target.relPath);
|
||||||
|
|||||||
@@ -32,26 +32,30 @@ try {
|
|||||||
range = "HEAD~1"; // local fallback
|
range = "HEAD~1"; // local fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const slugs = new Set<string>();
|
const changedDirs = new Set<string>();
|
||||||
for (const f of git(["diff", "--name-only", range, "HEAD"]).split("\n")) {
|
for (const f of git(["diff", "--name-only", range, "HEAD"]).split("\n")) {
|
||||||
const m = /^registry\/modules\/([^/]+)\//.exec(f);
|
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).");
|
console.log("Module version guard passed (no module changes).");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const compiledDir = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-check-"));
|
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 `<dir>.json` would silently skip (and so bypass the
|
||||||
|
// guard for) any module whose dir name doesn't match its `<category>-<id>`.
|
||||||
|
const byDir = new Map(modules.map((m) => [m.dir, m]));
|
||||||
|
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
for (const slug of slugs) {
|
for (const dir of changedDirs) {
|
||||||
const file = path.join(compiledDir, `${slug}.json`);
|
const entry = byDir.get(dir);
|
||||||
if (!fs.existsSync(file)) continue; // module deleted — nothing to pin
|
if (!entry) continue; // module deleted — nothing to pin
|
||||||
const text = fs.readFileSync(file, "utf8");
|
const { slug, version } = entry;
|
||||||
const { version }: { version: string } = JSON.parse(text);
|
const text = fs.readFileSync(path.join(compiledDir, `${slug}.json`), "utf8");
|
||||||
const res = await fetch(`${registryBase}/${slug}@${version}.json`);
|
const res = await fetch(`${registryBase}/${slug}@${version}.json`);
|
||||||
if (res.status === 404) continue; // new version or new module
|
if (res.status === 404) continue; // new version or new module
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -61,7 +65,7 @@ for (const slug of slugs) {
|
|||||||
if ((await res.text()) !== text) {
|
if ((await res.text()) !== text) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Module "${slug}" changed but version ${version} is already published with different ` +
|
`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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,15 +22,21 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { CATEGORIES, type Logo, type Module, type RegistryIndex } from "@withstanza/schema";
|
import { CATEGORIES, type Logo, type Module, type RegistryIndex } from "@withstanza/schema";
|
||||||
import { optimize } from "svgo";
|
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 `<outDir>`. `modulesDir` defaults to the
|
* Build the static registry into `<outDir>`. `modulesDir` defaults to the
|
||||||
* repo's `registry/modules`; tests can point it elsewhere. Returns the final
|
* 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 (`<category>-<id>`), 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: {
|
export async function compileRegistry(opts: {
|
||||||
modulesDir?: string;
|
modulesDir?: string;
|
||||||
outDir: string;
|
outDir: string;
|
||||||
}): Promise<{ count: number }> {
|
}): Promise<{ count: number; modules: CompiledModule[] }> {
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = findRepoRoot(here);
|
const repoRoot = findRepoRoot(here);
|
||||||
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
|
const modulesDir = opts.modulesDir ?? path.join(repoRoot, "registry", "modules");
|
||||||
@@ -49,6 +55,7 @@ export async function compileRegistry(opts: {
|
|||||||
.map((d) => d.name);
|
.map((d) => d.name);
|
||||||
|
|
||||||
const metadata = [];
|
const metadata = [];
|
||||||
|
const compiledModules: CompiledModule[] = [];
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const entry = path.join(modulesDir, dir, "module.ts");
|
const entry = path.join(modulesDir, dir, "module.ts");
|
||||||
const imported: { default: Module } = await import(entry);
|
const imported: { default: Module } = await import(entry);
|
||||||
@@ -87,6 +94,7 @@ export async function compileRegistry(opts: {
|
|||||||
// `path`, resolved relative to the index URL.)
|
// `path`, resolved relative to the index URL.)
|
||||||
const modulePath = `${mod.category}-${mod.id}.json`;
|
const modulePath = `${mod.category}-${mod.id}.json`;
|
||||||
fs.writeFileSync(path.join(opts.outDir, modulePath), JSON.stringify(inlined, null, 2));
|
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
|
// The index keeps lightweight metadata — no template `content`, no
|
||||||
// per-adapter payloads — but it DOES carry top-level fields like `logo`
|
// 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));
|
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
|
// Direct invocation: `jiti scripts/compile-registry.ts [outDir]`. Skipped when
|
||||||
@@ -152,8 +160,11 @@ function optimizeLogo(svg: string, prefix: string): string {
|
|||||||
plugins: [
|
plugins: [
|
||||||
"preset-default",
|
"preset-default",
|
||||||
{ name: "prefixIds", params: { prefix, delim: "-" } },
|
{ name: "prefixIds", params: { prefix, delim: "-" } },
|
||||||
// Strip every `on*` event handler so we can render via dangerouslySetInnerHTML
|
// Logos render via dangerouslySetInnerHTML: `removeScripts` drops
|
||||||
// without smuggling JS through a hostile (third-party) logo.
|
// `<script>` elements, every spec-defined event attribute, and
|
||||||
|
// `javascript:` hrefs; the `removeAttrs` pass additionally catches
|
||||||
|
// nonstandard `on*` attributes the spec groups don't cover.
|
||||||
|
"removeScripts",
|
||||||
{ name: "removeAttrs", params: { attrs: "on[a-zA-Z]+" } },
|
{ name: "removeAttrs", params: { attrs: "on[a-zA-Z]+" } },
|
||||||
],
|
],
|
||||||
}).data;
|
}).data;
|
||||||
|
|||||||
Reference in New Issue
Block a user