mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
fix: harden remove sweep safety, snapshot codemods on records, and fix package-home cardinality scope
- Refuse to sweep `packages/<dir>/` in two new cases: (1) stray non-Stanza files exist under the dir (user-authored secrets, scratch scripts, etc.), and (2) a still-installed module lists the dir in `consumesPackages` — both append to `manualCleanup` with targeted hints instead of silently destroying user work; `findStrayFiles` and `collectConsumesPackagesProtectors` (parallel `Promise.all` over all installed records) implement the checks - Snapshot `codemods` onto each `StanzaModuleRecord` at install time; `remove` drives `revertCodemods` from that snapshot so reversal is correct even when the upstream registry has renamed the adapter or reshuffled codemod ids since install — falls back to the live registry entry only when no snapshot is present - Fix cardinality scope in `add` for package-home and repo-home categories: pass `undefined` (not `pickedAppId`) to `selectedAll` so the per-project uniqueness check covers all apps; previously a second install targeting a different app could slip past the check only to fail on the next read - Use composite owner keys (`<id>@<app>`) in `regionsOwnedBy` for app-home installs so two apps installing the same module don't share a region owner and accidentally release each other's claims during remove; bare `<id>` fallback covers pre-snapshot manifests and non-app homes - Refactor `applyCodemods` / `revertCodemods` to accept `codemods`, `adapterKey`, and `consumesPackages` directly rather than full `Module` + adapter objects; add a comprehensive `codemod-runner.test.ts` covering apply, revert, dry-run, idempotency, and error paths for all built-in codemod types - Add `packages/registry/src/safe-path.ts` with `safePath` / `isSafePath` guards and tests; use in the codemod runner to reject path-traversal write targets before any disk touch - Add `set-tsconfig-paths` builtin codemod with tests; harden `add-array-entry-in-call`, `add-jsx-child`, `wrap-root-layout`, and `replace-import` for idempotency and edge cases - Validate unknown `--app` flags early in `remove` with a targeted error before any registry fetch; replace the `posthog-provider` React component and `posthog.server.ts` in the web app with a unified `analytics.ts` / `api.events.ts` pattern
This commit is contained in:
@@ -83,6 +83,7 @@ Core workflow:
|
||||
- **Reserved manifest fields**: `modules[category][].version` and `regions` are written today but only fully consumed by the upcoming `swap`/`update` verbs — do not drop them
|
||||
- **Web app hosts the canonical registry**: `pnpm --filter @stanza/web build` runs `prebuild` which builds the registry (if missing) and copies it into `apps/web/public/registry/`. The deployed Vercel output therefore ships `index.json` + `modules/*.json` at the same origin. The published CLI's `DEFAULT_REGISTRY_URL` points at this same URL, so users get a working CLI with zero configuration. Self-hosters and CI override via `STANZA_REGISTRY` (URL or filesystem path) — this overrides the bundled `@stanza` default namespace's URL only
|
||||
- **Third-party registries**: namespaces declared in `stanza.json` under `registries` (string URL prefix or `{ url, indexUrl?, headers?, params? }` object). Modules from them are addressed as `<category> @<ns>/<id>` on the CLI. Resolved by [packages/registry/src/registry-config.ts](packages/registry/src/registry-config.ts) (Zod schema, `parseModuleSpec`, `expandEnv`) and the [Registries](apps/cli/src/lib/registry-loader.ts) loader. Codemods still come from the first-party catalog only — third-party modules can invoke catalog ids but can't ship new ones
|
||||
- **Third-party id privacy**: `telemetry.captureModule` redacts the `module` field for non-`@stanza` namespaces (`"<redacted>"`) so per-namespace install counts aggregate without leaking private ids to first-party analytics. Stderr and progress messages still print the full `@ns/id` — CI pipelines that forward stderr to external log aggregators will see those ids
|
||||
|
||||
## Module authoring
|
||||
|
||||
|
||||
@@ -139,22 +139,24 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
targetApps = [manifest.apps[0]!];
|
||||
}
|
||||
|
||||
// Per-app cardinality check for home:"app" categories; per-project for the
|
||||
// rest. `selectedAll(manifest, category, appId)` filters to records that
|
||||
// target the given app (or are global).
|
||||
const existing = selectedAll(manifest, category, pickedAppId);
|
||||
// Per-app cardinality check for home:"app" categories; per-project for
|
||||
// home:"package" and "repo" — the manifest schema enforces ≤1 record total
|
||||
// for non-app homes regardless of `apps`. App-filtering here would let a
|
||||
// second package-home pick slip past, only to fail on the next read.
|
||||
const cardinalityScopeApp = home.kind === "app" ? pickedAppId : undefined;
|
||||
const existing = selectedAll(manifest, category, cardinalityScopeApp);
|
||||
if (isMulti(category)) {
|
||||
if (existing.some((r) => r.id === moduleId)) {
|
||||
const where = pickedAppId ? ` in app "${pickedAppId}"` : "";
|
||||
const where = cardinalityScopeApp ? ` in app "${cardinalityScopeApp}"` : "";
|
||||
p.log.error(`"${category}/${moduleId}" is already added${where}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else if (existing.length > 0) {
|
||||
const where = pickedAppId ? ` (app "${pickedAppId}")` : "";
|
||||
const where = cardinalityScopeApp ? ` (app "${cardinalityScopeApp}")` : "";
|
||||
p.log.error(
|
||||
`Category "${category}"${where} is already filled by "${existing[0]!.id}". ` +
|
||||
`Run \`stanza remove ${category}${pickedAppId ? ` --app=${pickedAppId}` : ""}\` first.`,
|
||||
`Run \`stanza remove ${category}${cardinalityScopeApp ? ` --app=${cardinalityScopeApp}` : ""}\` first.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
|
||||
@@ -182,6 +182,46 @@ describe("cmdRemove", () => {
|
||||
await cmdRemove(args({ slot: "nonsense" }));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses to sweep a slot package that's still consumed via consumesPackages", async () => {
|
||||
// better-auth declares `consumesPackages: ["db"]`.
|
||||
await cmdAdd(args({ slot: "auth", moduleId: "better-auth" }));
|
||||
expect(process.exitCode).toBeFalsy();
|
||||
|
||||
await cmdRemove(args({ slot: "orm" }));
|
||||
await cmdRemove(args({ slot: "db" }));
|
||||
expect(process.exitCode).toBeFalsy();
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
|
||||
expect(manifest.modules.db).toBeUndefined();
|
||||
expect(manifest.modules.orm).toBeUndefined();
|
||||
expect(manifest.modules.auth[0].id).toBe("better-auth");
|
||||
|
||||
expect(fs.existsSync("packages/db/package.json")).toBe(true);
|
||||
const appPkg = JSON.parse(fs.readFileSync("apps/web/package.json", "utf8"));
|
||||
expect(appPkg.dependencies?.["@app/db"]).toBe("workspace:*");
|
||||
|
||||
// Removing auth releases the protection — next sweep runs clean.
|
||||
await cmdRemove(args({ slot: "auth" }));
|
||||
expect(fs.existsSync("packages/db")).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to sweep a slot package that contains user-authored files", async () => {
|
||||
fs.writeFileSync("packages/db/.env.local", "USER_SECRET=hunter2\n");
|
||||
fs.mkdirSync("packages/db/scratch", { recursive: true });
|
||||
fs.writeFileSync("packages/db/scratch/notes.md", "# my notes\n");
|
||||
|
||||
await cmdRemove(args({ slot: "orm" }));
|
||||
await cmdRemove(args({ slot: "db" }));
|
||||
expect(process.exitCode).toBeFalsy();
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
|
||||
expect(manifest.modules.db).toBeUndefined();
|
||||
expect(manifest.modules.orm).toBeUndefined();
|
||||
expect(fs.existsSync("packages/db")).toBe(true);
|
||||
expect(fs.readFileSync("packages/db/.env.local", "utf8")).toContain("USER_SECRET=hunter2");
|
||||
expect(fs.existsSync("packages/db/scratch/notes.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-ons (multi-choice testing slot)", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { removePackageDependency, removeEnvVar } from "@stanza/codemods";
|
||||
import type { AppSpec, StanzaModuleRecord } from "@stanza/registry";
|
||||
import type { AppSpec, StanzaManifest, StanzaModuleRecord } from "@stanza/registry";
|
||||
import {
|
||||
appsForRecord,
|
||||
categoryHome,
|
||||
@@ -24,7 +24,7 @@ import { ensureCleanWorktree } from "../lib/git";
|
||||
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
|
||||
import { regenerateReadmeIfUnmodified } from "../lib/readme";
|
||||
import { regionsOwnedBy } from "../lib/region-tracker";
|
||||
import { loadRegistries } from "../lib/registry-loader";
|
||||
import { loadRegistries, type Registries } from "../lib/registry-loader";
|
||||
import * as telemetry from "../lib/telemetry";
|
||||
import { commonArgs, type CliArgs } from "./_args";
|
||||
|
||||
@@ -102,6 +102,14 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
let manifest = readManifest(projectRoot);
|
||||
const home = categoryHome(category);
|
||||
|
||||
if (appFlag && !manifest.apps.some((a) => a.id === appFlag)) {
|
||||
p.log.error(
|
||||
`Unknown app "${appFlag}". Available: ${manifest.apps.map((a) => a.id).join(", ")}.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// For app-home categories, --app scopes to records in that app. For other
|
||||
// homes the flag is mostly meaningless (records aren't app-keyed), but we
|
||||
// honor it to filter package-home records that explicitly target one app.
|
||||
@@ -146,15 +154,21 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
// Step 1: revert imperative codemods first. They modify framework- or
|
||||
// peer-owned files (root layout, schema barrels, vite config) — reverts
|
||||
// need to see those files intact, before any template deletions below.
|
||||
// Drive from the snapshot persisted on the record so this works even if
|
||||
// the upstream registry has renamed the adapter or shuffled codemod ids.
|
||||
const registry = await loadRegistries(manifest);
|
||||
const mod = await registry.loadModule(group, installed.id, installed.namespace).catch(() => null);
|
||||
const adapter = mod?.adapters.find((a) => a.key === installed.adapter);
|
||||
if (mod && adapter) {
|
||||
const codemodSnapshot = installed.codemods ?? adapter?.codemods ?? [];
|
||||
if (codemodSnapshot.length > 0) {
|
||||
const revertResult = await revertCodemods({
|
||||
projectRoot,
|
||||
manifest,
|
||||
module: mod,
|
||||
adapter,
|
||||
category,
|
||||
moduleId: installed.id,
|
||||
adapterKey: installed.adapter,
|
||||
codemods: codemodSnapshot,
|
||||
consumesPackages: installed.consumesPackages ?? mod?.consumesPackages,
|
||||
targetApps: installedApps,
|
||||
dryRun,
|
||||
});
|
||||
@@ -162,7 +176,9 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
for (const id of revertResult.manualCleanup) {
|
||||
manualCleanup.push(`codemod:${id} (no revert or revert threw)`);
|
||||
}
|
||||
} else {
|
||||
} else if (!mod || !adapter) {
|
||||
// Nothing to revert from the registry AND nothing snapshotted — surface
|
||||
// so the user knows reverts were skipped if they expected some.
|
||||
p.log.warn(
|
||||
`Couldn't re-load ${installed.id}@${installed.adapter} from the registry — skipping imperative codemod reversal.`,
|
||||
);
|
||||
@@ -170,7 +186,14 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
|
||||
// Step 2: reverse the declarative side — files, deps, scripts, env vars —
|
||||
// driven by whatever region claims remain after the codemod reverts.
|
||||
const owned = regionsOwnedBy(manifest, installed.id);
|
||||
// Owner is composite (`<id>@<app>`) for home:app installs so cross-app
|
||||
// installs of the same module don't collide. Bare `<id>` covers older
|
||||
// manifests + the non-app homes that never used a composite owner.
|
||||
const ownerKeys =
|
||||
home.kind === "app" && installed.apps?.length === 1
|
||||
? [`${installed.id}@${installed.apps[0]}`, installed.id]
|
||||
: [installed.id];
|
||||
const owned = regionsOwnedBy(manifest, ownerKeys);
|
||||
for (const { file, region } of owned) {
|
||||
const abs = path.join(projectRoot, file);
|
||||
if (file === ".env.example") {
|
||||
@@ -231,7 +254,12 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
// Step 3: sweep any internal package whose claims have all been released.
|
||||
// The bootstrap files (package.json, tsconfig.json, the workspace dep on
|
||||
// every consuming app) are system-owned — not tracked in regions, so they'd
|
||||
// otherwise linger forever.
|
||||
// otherwise linger forever. Anything *else* under `packages/<dir>/` after
|
||||
// Step 2 is user-authored (a gitignored secrets file, scratch script, etc.)
|
||||
// — refuse to sweep so we don't silently destroy user work. Also refuse
|
||||
// when a still-installed module declares the dir in `consumesPackages` —
|
||||
// its source code imports from `@<name>/<dir>` and would break otherwise.
|
||||
const protectors = await collectConsumesPackagesProtectors(manifest, registry);
|
||||
const sweptPackages: string[] = [];
|
||||
for (const dir of PACKAGE_DIRS) {
|
||||
const stillUsed = Object.keys(manifest.regions).some((file) =>
|
||||
@@ -240,6 +268,23 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
if (stillUsed) continue;
|
||||
const pkgRoot = path.join(projectRoot, "packages", dir);
|
||||
if (!fs.existsSync(pkgRoot)) continue;
|
||||
const consumers = protectors.get(dir);
|
||||
if (consumers && consumers.length > 0) {
|
||||
manualCleanup.push(
|
||||
`packages/${dir}/ (refusing to sweep — still consumed by: ` +
|
||||
`${consumers.join(", ")}. Remove those first.)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const stray = findStrayFiles(pkgRoot);
|
||||
if (stray.length > 0) {
|
||||
manualCleanup.push(
|
||||
`packages/${dir}/ (refusing to sweep — found ${stray.length} non-Stanza file(s):\n` +
|
||||
stray.map((f) => ` • packages/${dir}/${f}`).join("\n") +
|
||||
`)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!dryRun) {
|
||||
fs.rmSync(pkgRoot, { recursive: true, force: true });
|
||||
// Strip the workspace dep from every app's package.json.
|
||||
@@ -293,6 +338,55 @@ export async function cmdRemove(args: CliArgs): Promise<void> {
|
||||
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.
|
||||
async function collectConsumesPackagesProtectors(
|
||||
manifest: StanzaManifest,
|
||||
registry: Registries,
|
||||
): Promise<Map<string, string[]>> {
|
||||
const protectors = new Map<string, string[]>();
|
||||
const tasks: Array<Promise<void>> = [];
|
||||
for (const [category, records] of Object.entries(manifest.modules)) {
|
||||
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 arr = protectors.get(dir) ?? [];
|
||||
arr.push(`${category}/${record.id}`);
|
||||
protectors.set(dir, arr);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return protectors;
|
||||
}
|
||||
|
||||
// Anything else under `packages/<dir>/` after Step 2 is user-authored.
|
||||
const SYSTEM_BOOTSTRAP_FILES = new Set(["package.json", "tsconfig.json"]);
|
||||
|
||||
function findStrayFiles(pkgRoot: string): string[] {
|
||||
const stray: string[] = [];
|
||||
const walk = (dir: string, relParts: string[]): void => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(abs, [...relParts, entry.name]);
|
||||
continue;
|
||||
}
|
||||
if (relParts.length === 0 && SYSTEM_BOOTSTRAP_FILES.has(entry.name)) continue;
|
||||
stray.push([...relParts, entry.name].join("/"));
|
||||
}
|
||||
};
|
||||
walk(pkgRoot, []);
|
||||
return stray;
|
||||
}
|
||||
|
||||
function sameAppSet(a: string[] | undefined, b: string[] | undefined): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { defineModule, emptyManifest, type Module } from "@stanza/registry";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { planSlotPackageBootstrap, recordFor, writeDepKeepingHigher } from "./codemod-runner";
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "stanza-runner-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writePkg(dir: string, pkg: Record<string, unknown>): string {
|
||||
const abs = path.join(dir, "package.json");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(abs, JSON.stringify(pkg, null, 2) + "\n");
|
||||
return abs;
|
||||
}
|
||||
|
||||
function readJson(p: string): Record<string, Record<string, string>> {
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
}
|
||||
|
||||
describe("writeDepKeepingHigher", () => {
|
||||
function setup(initial: Record<string, unknown>): string {
|
||||
return writePkg(tmp, initial);
|
||||
}
|
||||
|
||||
it("writes a new dep when none exists", () => {
|
||||
const pkg = setup({ dependencies: {} });
|
||||
writeDepKeepingHigher(pkg, "react", "^19.0.0", false);
|
||||
expect(readJson(pkg).dependencies?.react).toBe("^19.0.0");
|
||||
});
|
||||
|
||||
it("keeps the user's higher pin instead of downgrading", () => {
|
||||
const pkg = setup({ dependencies: { "better-auth": "^1.9.0" } });
|
||||
writeDepKeepingHigher(pkg, "better-auth", "^1.6.11", false);
|
||||
expect(readJson(pkg).dependencies?.["better-auth"]).toBe("^1.9.0");
|
||||
});
|
||||
|
||||
it("upgrades when the incoming range is higher", () => {
|
||||
const pkg = setup({ dependencies: { "better-auth": "^1.6.11" } });
|
||||
writeDepKeepingHigher(pkg, "better-auth", "^1.9.0", false);
|
||||
expect(readJson(pkg).dependencies?.["better-auth"]).toBe("^1.9.0");
|
||||
});
|
||||
|
||||
it("preserves workspace:* over a semver incoming", () => {
|
||||
const pkg = setup({ dependencies: { "@app/db": "workspace:*" } });
|
||||
writeDepKeepingHigher(pkg, "@app/db", "^1.0.0", false);
|
||||
expect(readJson(pkg).dependencies?.["@app/db"]).toBe("workspace:*");
|
||||
});
|
||||
|
||||
it("writes workspace:* even over an existing semver range", () => {
|
||||
const pkg = setup({ dependencies: { "@app/db": "^1.0.0" } });
|
||||
writeDepKeepingHigher(pkg, "@app/db", "workspace:*", false);
|
||||
expect(readJson(pkg).dependencies?.["@app/db"]).toBe("workspace:*");
|
||||
});
|
||||
|
||||
it("routes through devDependencies when `dev: true`", () => {
|
||||
const pkg = setup({ dependencies: {}, devDependencies: {} });
|
||||
writeDepKeepingHigher(pkg, "vitest", "^4.0.0", true);
|
||||
expect(readJson(pkg).devDependencies?.vitest).toBe("^4.0.0");
|
||||
expect(readJson(pkg).dependencies?.vitest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSlotPackageBootstrap", () => {
|
||||
function setupProject(opts: { withSlotPkg?: boolean; appPkgs?: string[] } = {}): {
|
||||
manifest: ReturnType<typeof emptyManifest>;
|
||||
packageRoot: string;
|
||||
appPkgPath: (appId: string) => string;
|
||||
} {
|
||||
const manifest = emptyManifest({
|
||||
name: "acme",
|
||||
apps: (opts.appPkgs ?? ["web"]).map((id) => ({
|
||||
id,
|
||||
dir: `apps/${id}`,
|
||||
kind: "web" as const,
|
||||
})),
|
||||
});
|
||||
for (const app of manifest.apps) {
|
||||
writePkg(path.join(tmp, app.dir), { name: `@acme/${app.id}`, dependencies: {} });
|
||||
}
|
||||
const packageRoot = path.join(tmp, "packages", "db");
|
||||
if (opts.withSlotPkg) {
|
||||
writePkg(packageRoot, { name: "@acme/db", dependencies: {} });
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
packageRoot,
|
||||
appPkgPath: (id) => path.join(tmp, "apps", id, "package.json"),
|
||||
};
|
||||
}
|
||||
|
||||
it("schedules package.json + tsconfig.json writes when the slot is fresh", () => {
|
||||
const { manifest, packageRoot } = setupProject();
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "db",
|
||||
packageName: "@acme/db",
|
||||
packageRoot,
|
||||
consumesPackages: [],
|
||||
});
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.writes.length).toBeGreaterThan(0);
|
||||
// Disk is untouched until thunks flush.
|
||||
expect(fs.existsSync(path.join(packageRoot, "package.json"))).toBe(false);
|
||||
|
||||
for (const w of result.writes) w();
|
||||
expect(fs.existsSync(path.join(packageRoot, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(packageRoot, "tsconfig.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("wires the consuming app's workspace dep when fresh", () => {
|
||||
const { manifest, packageRoot, appPkgPath } = setupProject();
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "db",
|
||||
packageName: "@acme/db",
|
||||
packageRoot,
|
||||
consumesPackages: [],
|
||||
});
|
||||
for (const w of result.writes) w();
|
||||
expect(readJson(appPkgPath("web")).dependencies?.["@acme/db"]).toBe("workspace:*");
|
||||
});
|
||||
|
||||
it("bakes consumesPackages into the freshly-written slot package.json", () => {
|
||||
const { manifest } = setupProject();
|
||||
// Pre-create the orm slot so `db` declaring it as a consumed peer can resolve.
|
||||
writePkg(path.join(tmp, "packages", "orm"), { name: "@acme/orm", dependencies: {} });
|
||||
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "auth",
|
||||
packageName: "@acme/auth",
|
||||
packageRoot: path.join(tmp, "packages", "auth"),
|
||||
consumesPackages: ["db"],
|
||||
});
|
||||
for (const w of result.writes) w();
|
||||
const slotPkg = readJson(path.join(tmp, "packages", "auth", "package.json"));
|
||||
expect(slotPkg.dependencies?.["@acme/db"]).toBe("workspace:*");
|
||||
});
|
||||
|
||||
it("layers consumesPackages onto an existing slot via addPackageDependency", () => {
|
||||
const { manifest, packageRoot } = setupProject({ withSlotPkg: true });
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "db",
|
||||
packageName: "@acme/db",
|
||||
packageRoot,
|
||||
consumesPackages: ["ui"],
|
||||
});
|
||||
expect(result.created).toBe(true);
|
||||
for (const w of result.writes) w();
|
||||
expect(readJson(path.join(packageRoot, "package.json")).dependencies?.["@acme/ui"]).toBe(
|
||||
"workspace:*",
|
||||
);
|
||||
});
|
||||
|
||||
it("is a no-op when the slot is fully wired and consumesPackages is empty", () => {
|
||||
const { manifest, packageRoot } = setupProject({ withSlotPkg: true });
|
||||
// Pre-wire the app dep so nothing's pending.
|
||||
const appPkgAbs = path.join(tmp, "apps/web/package.json");
|
||||
fs.writeFileSync(
|
||||
appPkgAbs,
|
||||
JSON.stringify({ name: "@acme/web", dependencies: { "@acme/db": "workspace:*" } }, null, 2),
|
||||
);
|
||||
// tsconfig already there.
|
||||
fs.writeFileSync(path.join(packageRoot, "tsconfig.json"), "{}\n");
|
||||
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "db",
|
||||
packageName: "@acme/db",
|
||||
packageRoot,
|
||||
consumesPackages: [],
|
||||
});
|
||||
expect(result.created).toBe(false);
|
||||
expect(result.writes).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a consumesPackages peer that isn't a known package dir", () => {
|
||||
const { manifest, packageRoot } = setupProject();
|
||||
const result = planSlotPackageBootstrap({
|
||||
projectRoot: tmp,
|
||||
consumingApps: manifest.apps,
|
||||
manifest,
|
||||
packageDir: "db",
|
||||
packageName: "@acme/db",
|
||||
packageRoot,
|
||||
// "ghost" isn't in PACKAGE_DIRS — should be silently ignored.
|
||||
consumesPackages: ["ghost"],
|
||||
});
|
||||
for (const w of result.writes) w();
|
||||
const slotPkg = readJson(path.join(packageRoot, "package.json"));
|
||||
expect(slotPkg.dependencies?.["@acme/ghost"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordFor", () => {
|
||||
const baseAdapter = { key: "default", match: {} };
|
||||
const seedApp = { id: "web", dir: "apps/web", kind: "web" as const };
|
||||
|
||||
it("emits the minimal shape for a home:repo module", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "biome",
|
||||
category: "tooling",
|
||||
label: "Biome",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
adapters: [baseAdapter],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], undefined);
|
||||
expect(record).toEqual({ id: "biome", version: "1.0.0", adapter: "default" });
|
||||
expect(record.apps).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tags home:app records with targeted apps", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "vitest",
|
||||
category: "testing",
|
||||
label: "Vitest",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
adapters: [baseAdapter],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], undefined);
|
||||
expect(record.apps).toEqual(["web"]);
|
||||
});
|
||||
|
||||
it("includes the namespace when not the default", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "cosmos",
|
||||
category: "testing",
|
||||
label: "Cosmos",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
adapters: [baseAdapter],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], "@fixture");
|
||||
expect(record.namespace).toBe("@fixture");
|
||||
});
|
||||
|
||||
it("snapshots codemods so future reverts work even if the registry drifts", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "clerk",
|
||||
category: "auth",
|
||||
label: "Clerk",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
adapters: [
|
||||
{
|
||||
key: "next",
|
||||
match: { framework: "next" },
|
||||
codemods: [
|
||||
{
|
||||
id: "wrap-root-layout",
|
||||
args: { providerName: "ClerkProvider", providerImport: "@clerk/nextjs" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], undefined);
|
||||
expect(record.codemods).toEqual([
|
||||
{
|
||||
id: "wrap-root-layout",
|
||||
args: { providerName: "ClerkProvider", providerImport: "@clerk/nextjs" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("snapshots consumesPackages so render context can rebuild offline", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "better-auth",
|
||||
category: "auth",
|
||||
label: "Better Auth",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
consumesPackages: ["db"],
|
||||
adapters: [baseAdapter],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], undefined);
|
||||
expect(record.consumesPackages).toEqual(["db"]);
|
||||
});
|
||||
|
||||
it("omits codemods/consumesPackages when empty (keep manifests lean)", () => {
|
||||
const mod: Module = defineModule({
|
||||
id: "drizzle",
|
||||
category: "orm",
|
||||
label: "Drizzle",
|
||||
description: "",
|
||||
version: "1.0.0",
|
||||
adapters: [baseAdapter],
|
||||
});
|
||||
const record = recordFor(mod, mod.adapters[0]!, [seedApp], undefined);
|
||||
expect(record.codemods).toBeUndefined();
|
||||
expect(record.consumesPackages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import type { CodemodContext, Project } from "@stanza/codemods";
|
||||
import { CODEMOD_CATALOG } from "@stanza/codemods/builtins";
|
||||
import type {
|
||||
AppSpec,
|
||||
CategoryId,
|
||||
JsonValue,
|
||||
Module,
|
||||
ModuleAdapter,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
} from "@stanza/registry";
|
||||
import {
|
||||
activePeerIds,
|
||||
assertSafeRelativePath,
|
||||
buildRenderContext,
|
||||
categoryHome,
|
||||
declaredEnvNames,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
renderTemplate,
|
||||
slotPackageJsonBase,
|
||||
} from "@stanza/registry";
|
||||
import semver from "semver";
|
||||
|
||||
import { writeManifest } from "./manifest";
|
||||
import { resolveRanges } from "./npm-version";
|
||||
@@ -107,7 +110,14 @@ export async function applyModule(args: {
|
||||
);
|
||||
}
|
||||
|
||||
const owner = module.id;
|
||||
// `home: app` records can coexist across apps for multi-cardinality
|
||||
// categories (e.g. testing). Composite owner disambiguates so a remove on
|
||||
// one app doesn't sweep another app's claims. Non-app homes stay on bare
|
||||
// module id (their regions are project-wide).
|
||||
const owner =
|
||||
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
|
||||
@@ -137,8 +147,11 @@ export async function applyModule(args: {
|
||||
Object.keys(installFields.scripts).length > 0);
|
||||
|
||||
let bootstrappedPackage: { dir: string; name: string } | undefined;
|
||||
// Slot-package bootstrap writes get deferred alongside everything else so a
|
||||
// mid-flush throw leaves no on-disk state the manifest can't sweep.
|
||||
const slotBootstrapWrites: Array<() => void> = [];
|
||||
if (needsPackage && packageDir && packageRoot) {
|
||||
const created = ensureSlotPackage({
|
||||
const { created, writes } = planSlotPackageBootstrap({
|
||||
projectRoot,
|
||||
consumingApps: targetApps,
|
||||
manifest,
|
||||
@@ -146,9 +159,9 @@ export async function applyModule(args: {
|
||||
packageName,
|
||||
packageRoot,
|
||||
consumesPackages: module.consumesPackages ?? [],
|
||||
dryRun,
|
||||
});
|
||||
if (created) bootstrappedPackage = { dir: packageDir, name: packageName };
|
||||
if (!dryRun) slotBootstrapWrites.push(...writes);
|
||||
}
|
||||
|
||||
// Pre-build a render context per targeted app. Each context binds `app.*` to
|
||||
@@ -180,10 +193,14 @@ export async function applyModule(args: {
|
||||
// 1. Templates (claim regions per-template-file). App-scoped templates loop
|
||||
// over `targetApps`; package/repo-scoped templates emit once.
|
||||
for (const tpl of adapter.templates ?? []) {
|
||||
// Defense in depth — Zod rejects bad dest at parse; this catches anything
|
||||
// that bypassed parse (in-memory mutation, stale manifest).
|
||||
assertSafeRelativePath(tpl.dest, `${module.id} template dest`);
|
||||
if (tpl.scope === "app") {
|
||||
for (const app of targetApps) {
|
||||
const dest = path.join(projectRoot, app.dir, tpl.dest);
|
||||
const rel = path.relative(projectRoot, dest);
|
||||
// Forward-slash so the manifest stays portable across Windows + Unix.
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl, moduleDir);
|
||||
@@ -204,7 +221,7 @@ export async function applyModule(args: {
|
||||
packageRoot,
|
||||
category: module.category,
|
||||
});
|
||||
const rel = path.relative(projectRoot, dest);
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl, moduleDir);
|
||||
@@ -228,7 +245,11 @@ export async function applyModule(args: {
|
||||
Object.keys(installFields.scripts).length > 0;
|
||||
if (hasInstall) {
|
||||
const installTargets = installPackageJsonTargets(module, targetApps);
|
||||
// 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(
|
||||
@@ -252,12 +273,13 @@ export async function applyModule(args: {
|
||||
const pkgJsonPath = path.join(projectRoot, target);
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
manifest = claim(manifest, target, `dependencies.${name}`, owner);
|
||||
if (!dryRun) deferredWrites.push(() => addPackageDependency(pkgJsonPath, name, range));
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, false));
|
||||
}
|
||||
for (const [name, range] of Object.entries(devDeps)) {
|
||||
manifest = claim(manifest, target, `devDependencies.${name}`, owner);
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => addPackageDependency(pkgJsonPath, name, range, { dev: true }));
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, true));
|
||||
}
|
||||
for (const [name, command] of Object.entries(installFields.scripts)) {
|
||||
manifest = claim(manifest, target, `scripts.${name}`, owner);
|
||||
@@ -305,12 +327,13 @@ export async function applyModule(args: {
|
||||
const pkgJsonPath = path.join(projectRoot, target);
|
||||
for (const [name, range] of Object.entries(appDeps)) {
|
||||
manifest = claim(manifest, target, `app.dependencies.${name}`, owner);
|
||||
if (!dryRun) deferredWrites.push(() => addPackageDependency(pkgJsonPath, name, range));
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, false));
|
||||
}
|
||||
for (const [name, range] of Object.entries(appDevDeps)) {
|
||||
manifest = claim(manifest, target, `app.devDependencies.${name}`, owner);
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => addPackageDependency(pkgJsonPath, name, range, { dev: true }));
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, true));
|
||||
}
|
||||
for (const [name, command] of Object.entries(appFields.scripts)) {
|
||||
manifest = claim(manifest, target, `app.scripts.${name}`, owner);
|
||||
@@ -347,6 +370,7 @@ export async function applyModule(args: {
|
||||
};
|
||||
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
|
||||
@@ -377,10 +401,13 @@ export async function applyModule(args: {
|
||||
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);
|
||||
}
|
||||
saves.push(() => project.save());
|
||||
}
|
||||
writeManifest(projectRoot, manifest);
|
||||
for (const save of saves) await save();
|
||||
}
|
||||
}
|
||||
@@ -388,12 +415,20 @@ export async function applyModule(args: {
|
||||
return { manifest, touchedFiles: [...touchedFiles], dryRun, bootstrappedPackage };
|
||||
}
|
||||
|
||||
function recordFor(
|
||||
export function recordFor(
|
||||
module: Module,
|
||||
adapter: ModuleAdapter,
|
||||
targetApps: AppSpec[],
|
||||
namespace: string | undefined,
|
||||
): { id: string; version: string; adapter: string; apps?: string[]; namespace?: string } {
|
||||
): {
|
||||
id: string;
|
||||
version: string;
|
||||
adapter: string;
|
||||
apps?: string[];
|
||||
namespace?: string;
|
||||
codemods?: Array<{ id: string; args?: Record<string, unknown> }>;
|
||||
consumesPackages?: string[];
|
||||
} {
|
||||
const home = categoryHome(module.category);
|
||||
const base: {
|
||||
id: string;
|
||||
@@ -401,14 +436,20 @@ function recordFor(
|
||||
adapter: string;
|
||||
apps?: string[];
|
||||
namespace?: string;
|
||||
codemods?: Array<{ id: string; args?: Record<string, unknown> }>;
|
||||
consumesPackages?: string[];
|
||||
} = { id: module.id, version: module.version, adapter: adapter.key };
|
||||
// Record the origin namespace so `remove` / `update` know which registry
|
||||
// to refetch from. Omit when it's the default `@stanza` to keep manifests
|
||||
// for first-party-only projects clean.
|
||||
if (namespace) base.namespace = namespace;
|
||||
// Snapshot enough state for revert to run offline / after the upstream
|
||||
// registry has renamed adapters or shuffled codemod ids.
|
||||
if (adapter.codemods?.length) {
|
||||
base.codemods = adapter.codemods.map((c) => ({
|
||||
id: c.id,
|
||||
...(c.args ? { args: c.args } : {}),
|
||||
}));
|
||||
}
|
||||
if (module.consumesPackages?.length) base.consumesPackages = [...module.consumesPackages];
|
||||
if (home.kind === "repo") return base;
|
||||
// Both app-home and package-home tag with the consuming apps so `remove`
|
||||
// can find them and so the schema stays well-formed.
|
||||
return { ...base, apps: targetApps.map((a) => a.id) };
|
||||
}
|
||||
|
||||
@@ -443,10 +484,10 @@ function resolveNonAppTemplateDest(args: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the slot's workspace package on first need and wire the workspace dep
|
||||
* into every *consuming* app's package.json. Idempotent: re-applying the same
|
||||
* module against the same apps is a no-op. Returns true when at least one file
|
||||
* was newly created (the signal `add.ts` uses to print a `pnpm install` hint).
|
||||
* Plan the slot-package bootstrap as a list of write thunks. Returning thunks
|
||||
* (vs writing inline) keeps the manifest-leads-disk contract: the caller
|
||||
* queues these alongside template/dep writes, so a mid-apply throw never
|
||||
* leaves orphan disk state the manifest can't sweep.
|
||||
*
|
||||
* The package's own `package.json` and `tsconfig.json` are NOT claimed as
|
||||
* regions: they're shared by every module that lives in the package (e.g. db
|
||||
@@ -454,7 +495,7 @@ function resolveNonAppTemplateDest(args: {
|
||||
* deletes them only after every module has released its claims under
|
||||
* `packages/<dir>/`.
|
||||
*/
|
||||
function ensureSlotPackage(args: {
|
||||
export function planSlotPackageBootstrap(args: {
|
||||
projectRoot: string;
|
||||
consumingApps: AppSpec[];
|
||||
manifest: StanzaManifest;
|
||||
@@ -462,46 +503,27 @@ function ensureSlotPackage(args: {
|
||||
packageName: string;
|
||||
packageRoot: string;
|
||||
consumesPackages: string[];
|
||||
dryRun: boolean;
|
||||
}): boolean {
|
||||
const {
|
||||
projectRoot,
|
||||
consumingApps,
|
||||
packageDir,
|
||||
packageName,
|
||||
packageRoot,
|
||||
consumesPackages,
|
||||
dryRun,
|
||||
} = args;
|
||||
}): { created: boolean; writes: Array<() => void> } {
|
||||
const { projectRoot, consumingApps, packageDir, packageName, packageRoot, consumesPackages } =
|
||||
args;
|
||||
const pkgPath = path.join(packageRoot, "package.json");
|
||||
const tsconfigPath = path.join(packageRoot, "tsconfig.json");
|
||||
|
||||
const writes: Array<() => void> = [];
|
||||
let created = false;
|
||||
|
||||
if (!fs.existsSync(pkgPath)) {
|
||||
created = true;
|
||||
if (!dryRun) {
|
||||
fs.mkdirSync(packageRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
pkgPath,
|
||||
JSON.stringify(
|
||||
slotPackageJsonBase({ name: args.manifest.name, dir: packageDir }),
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Build the slot's package.json in memory so consumesPackages wiring can
|
||||
// mutate it alongside bootstrap. If it already exists on disk, we layer
|
||||
// the cross-package deps via addPackageDependency at flush time.
|
||||
const slotExists = fs.existsSync(pkgPath);
|
||||
const slotPkg: { dependencies?: Record<string, string> } = slotExists
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: slotPackageJsonBase({ name: args.manifest.name, dir: packageDir });
|
||||
if (!slotExists) created = true;
|
||||
|
||||
if (!fs.existsSync(tsconfigPath)) {
|
||||
created = true;
|
||||
if (!dryRun) {
|
||||
// Self-contained tsconfig — generated projects don't share a base, so
|
||||
// each app and package stands on its own. Mirrors the shape framework
|
||||
// modules ship for app `tsconfig.json`. The `ui` package adds a path
|
||||
// alias to its own `./src/*` so internal subpath self-imports
|
||||
// (e.g. button.tsx → `@<name>/ui/lib/utils`) resolve at type-check time.
|
||||
writes.push(() => {
|
||||
const compilerOptions: Record<string, unknown> = {
|
||||
target: "ES2022",
|
||||
lib: ["dom", "dom.iterable", "esnext"],
|
||||
@@ -521,17 +543,17 @@ function ensureSlotPackage(args: {
|
||||
compilerOptions.baseUrl = ".";
|
||||
compilerOptions.paths = { [`${packageName}/*`]: ["./src/*"] };
|
||||
}
|
||||
fs.mkdirSync(packageRoot, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
tsconfigPath,
|
||||
JSON.stringify({ compilerOptions, include: ["src"], exclude: ["node_modules"] }, null, 2) +
|
||||
"\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire the workspace dep into *every consuming* app's package.json. Not
|
||||
// region-tracked — the sweep cleans it up.
|
||||
// Consuming apps: workspace dep wiring.
|
||||
for (const app of consumingApps) {
|
||||
const appPkgPath = path.join(projectRoot, app.dir, "package.json");
|
||||
if (!fs.existsSync(appPkgPath)) continue;
|
||||
@@ -540,31 +562,43 @@ function ensureSlotPackage(args: {
|
||||
);
|
||||
if (appPkg.dependencies?.[packageName] !== "workspace:*") {
|
||||
created = true;
|
||||
if (!dryRun) addPackageDependency(appPkgPath, packageName, "workspace:*");
|
||||
writes.push(() => addPackageDependency(appPkgPath, packageName, "workspace:*"));
|
||||
}
|
||||
}
|
||||
|
||||
// Wire cross-package workspace deps so this package can import from its
|
||||
// peers (e.g. better-auth's auth.ts importing `db` from `@<project>/db`).
|
||||
// Skip self-references and unknown package dirs.
|
||||
// consumesPackages: mutate slotPkg in memory; pending deps go through
|
||||
// addPackageDependency at flush time when the slot already exists.
|
||||
const pendingPeers: string[] = [];
|
||||
if (consumesPackages.length > 0) {
|
||||
const ownPkgJson = path.join(packageRoot, "package.json");
|
||||
slotPkg.dependencies = slotPkg.dependencies ?? {};
|
||||
for (const peer of consumesPackages) {
|
||||
if (peer === packageDir) continue;
|
||||
if (!PACKAGE_DIRS.has(peer)) continue;
|
||||
const peerName = `@${args.manifest.name}/${peer}`;
|
||||
if (!fs.existsSync(ownPkgJson)) continue;
|
||||
const pkg: { dependencies?: Record<string, string> } = JSON.parse(
|
||||
fs.readFileSync(ownPkgJson, "utf8"),
|
||||
);
|
||||
if (pkg.dependencies?.[peerName] !== "workspace:*") {
|
||||
if (slotPkg.dependencies[peerName] !== "workspace:*") {
|
||||
slotPkg.dependencies[peerName] = "workspace:*";
|
||||
created = true;
|
||||
if (!dryRun) addPackageDependency(ownPkgJson, peerName, "workspace:*");
|
||||
if (slotExists) pendingPeers.push(peerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
// Write the slot's package.json: either a fresh bootstrap (carrying any
|
||||
// consumesPackages deps we baked in) or layered updates onto an existing file.
|
||||
if (!slotExists) {
|
||||
writes.push(() => {
|
||||
fs.mkdirSync(packageRoot, { recursive: true });
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(slotPkg, null, 2) + "\n", "utf8");
|
||||
});
|
||||
} else if (pendingPeers.length > 0) {
|
||||
writes.push(() => {
|
||||
for (const peerName of pendingPeers) {
|
||||
addPackageDependency(pkgPath, peerName, "workspace:*");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { created, writes };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -669,6 +703,34 @@ function buildContext(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRevertContext(args: {
|
||||
projectRoot: string;
|
||||
app: AppSpec;
|
||||
appRoot: string;
|
||||
manifest: StanzaManifest;
|
||||
category: CategoryId;
|
||||
moduleId: string;
|
||||
adapterKey: string;
|
||||
project: () => Project;
|
||||
touchedFiles: Set<string>;
|
||||
dryRun: boolean;
|
||||
onRelease?: (file: string, region: string) => void;
|
||||
}): CodemodContext {
|
||||
return {
|
||||
projectRoot: args.projectRoot,
|
||||
app: args.app,
|
||||
appRoot: args.appRoot,
|
||||
project: args.project,
|
||||
manifest: args.manifest,
|
||||
owner: { category: args.category, module: args.moduleId },
|
||||
adapter: args.adapterKey,
|
||||
claimRegion() {},
|
||||
releaseRegion(file, region) {
|
||||
args.onRelease?.(file, region);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RevertResult = {
|
||||
manifest: StanzaManifest;
|
||||
touchedFiles: string[];
|
||||
@@ -698,23 +760,33 @@ export type RevertResult = {
|
||||
export async function revertCodemods(args: {
|
||||
projectRoot: string;
|
||||
manifest: StanzaManifest;
|
||||
module: Module;
|
||||
adapter: ModuleAdapter;
|
||||
/**
|
||||
* Category + module id of the install being reverted. Used to bind the
|
||||
* codemod context (`owner`) and to derive the `packages/<dir>` home for
|
||||
* package-home categories.
|
||||
*/
|
||||
category: CategoryId;
|
||||
moduleId: string;
|
||||
/** Adapter key recorded at install time — passed through for context only. */
|
||||
adapterKey: string;
|
||||
/** Codemod invocations to revert (snapshotted on the manifest record). */
|
||||
codemods: Array<{ id: string; args?: Record<string, unknown> }>;
|
||||
/** Persisted consumesPackages so the render context resolves correctly offline. */
|
||||
consumesPackages?: string[];
|
||||
targetApps: AppSpec[];
|
||||
dryRun: boolean;
|
||||
}): Promise<RevertResult> {
|
||||
const { projectRoot, module, adapter, targetApps, dryRun } = args;
|
||||
const { projectRoot, category, moduleId, adapterKey, codemods, targetApps, dryRun } = args;
|
||||
let manifest = args.manifest;
|
||||
const touchedFiles = new Set<string>();
|
||||
const manualCleanup: string[] = [];
|
||||
|
||||
const codemods = adapter.codemods ?? [];
|
||||
if (codemods.length === 0) return { manifest, touchedFiles: [], dryRun, manualCleanup };
|
||||
if (targetApps.length === 0) {
|
||||
return { manifest, touchedFiles: [], dryRun, manualCleanup };
|
||||
}
|
||||
|
||||
const home = categoryHome(module.category);
|
||||
const home = categoryHome(category);
|
||||
const packageName = home.kind === "package" ? `@${manifest.name}/${home.dir}` : "";
|
||||
const dispatchApps = home.kind === "repo" ? [targetApps[0]!] : targetApps;
|
||||
|
||||
@@ -726,16 +798,17 @@ export async function revertCodemods(args: {
|
||||
packageName,
|
||||
peers: activePeerIds(manifest, app.id),
|
||||
envNames: declaredEnvNames(manifest),
|
||||
consumesPackages: module.consumesPackages,
|
||||
consumesPackages: args.consumesPackages,
|
||||
});
|
||||
const project = lazyProject(appRoot);
|
||||
const ctx = buildContext({
|
||||
const ctx = buildRevertContext({
|
||||
projectRoot,
|
||||
app,
|
||||
appRoot,
|
||||
manifest,
|
||||
module,
|
||||
adapter,
|
||||
category,
|
||||
moduleId,
|
||||
adapterKey,
|
||||
project: project.get,
|
||||
touchedFiles,
|
||||
dryRun,
|
||||
@@ -753,7 +826,12 @@ export async function revertCodemods(args: {
|
||||
}
|
||||
if (dryRun) continue;
|
||||
try {
|
||||
const renderedArgs = renderArgs(invocation.args ?? {}, renderContext);
|
||||
// Persisted args' static type is `Record<string, unknown>` (Zod
|
||||
// accepts hand-edited manifests), but at runtime they're always
|
||||
// JSON-shaped because they came from the registry. Pass through.
|
||||
// oxlint-disable-next-line typescript/no-unsafe-type-assertion
|
||||
const rawArgs = (invocation.args ?? {}) as Record<string, JsonValue>;
|
||||
const renderedArgs = renderArgs(rawArgs, renderContext);
|
||||
const result = await fn.revert(ctx, renderedArgs);
|
||||
result.touchedFiles.forEach((f) => touchedFiles.add(f));
|
||||
} catch {
|
||||
@@ -771,3 +849,36 @@ export async function revertCodemods(args: {
|
||||
}
|
||||
|
||||
export { RegionConflictError };
|
||||
|
||||
// Avoid clobbering a user-pinned range with the module's declared one when
|
||||
// the user is already on a newer version. Both ranges go through
|
||||
// `semver.minVersion` and the higher pin wins; non-semver values
|
||||
// (workspace:*, link:, etc.) are preserved when present.
|
||||
export function writeDepKeepingHigher(
|
||||
pkgJsonPath: string,
|
||||
name: string,
|
||||
incoming: string,
|
||||
dev: boolean,
|
||||
): void {
|
||||
const key = dev ? "devDependencies" : "dependencies";
|
||||
const raw = fs.readFileSync(pkgJsonPath, "utf8");
|
||||
const pkg: Record<string, Record<string, string> | undefined> = JSON.parse(raw);
|
||||
const existing = pkg[key]?.[name];
|
||||
if (existing && shouldKeepExisting(existing, incoming)) return;
|
||||
addPackageDependency(pkgJsonPath, name, incoming, { dev });
|
||||
}
|
||||
|
||||
function shouldKeepExisting(existing: string, incoming: string): boolean {
|
||||
if (existing === incoming) return true;
|
||||
// Preserve any non-semver pin (workspace:*, link:, git+…, file:, etc.).
|
||||
if (!isSemverishRange(existing)) return true;
|
||||
if (!isSemverishRange(incoming)) return false;
|
||||
const a = semver.minVersion(existing);
|
||||
const b = semver.minVersion(incoming);
|
||||
if (!a || !b) return false;
|
||||
return semver.gte(a, b);
|
||||
}
|
||||
|
||||
function isSemverishRange(v: string): boolean {
|
||||
return semver.validRange(v) !== null;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,9 @@ async function fetchVersions(name: string): Promise<string[] | null> {
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const res = await fetch(`${NPM_REGISTRY}/${encodeName(name)}`, {
|
||||
// Abbreviated packument — smaller payload, version keys are all we need.
|
||||
headers: { accept: "application/vnd.npm.install-v1+json" },
|
||||
// Bound the slow path (offline, captive portal) so init/add doesn't hang.
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
cache.set(name, null);
|
||||
|
||||
@@ -57,12 +57,13 @@ export function release(manifest: StanzaManifest, file: string, region: string):
|
||||
|
||||
export function regionsOwnedBy(
|
||||
manifest: StanzaManifest,
|
||||
owner: string,
|
||||
owner: string | string[],
|
||||
): { file: string; region: string }[] {
|
||||
const owners = new Set(Array.isArray(owner) ? owner : [owner]);
|
||||
const out: { file: string; region: string }[] = [];
|
||||
for (const [file, regions] of Object.entries(manifest.regions)) {
|
||||
for (const [region, value] of Object.entries(regions)) {
|
||||
if (value === owner) out.push({ file, region });
|
||||
if (owners.has(value)) out.push({ file, region });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -18,6 +18,18 @@ import {
|
||||
*/
|
||||
const DEFAULT_REGISTRY_URL = "https://stanza.tools/registry";
|
||||
|
||||
// Cap every fetch so a slow/hung third-party 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;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 15_000;
|
||||
}
|
||||
|
||||
function fetchWithTimeout(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
return fetch(url, { ...init, signal: AbortSignal.timeout(defaultTimeoutMs()) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-namespace registry surface used by every CLI command. The default
|
||||
* `@stanza` namespace is always present (resolved through the env-var →
|
||||
@@ -66,16 +78,25 @@ export async function loadRegistries(manifest?: StanzaManifest): Promise<Registr
|
||||
// shadow the default.
|
||||
([ns]) => ns !== DEFAULT_NAMESPACE,
|
||||
);
|
||||
// Build default + every custom loader in parallel — each one issues an
|
||||
// independent index fetch and there's no ordering dependency between them.
|
||||
const [defaultLoader, ...customLoaders] = await Promise.all([
|
||||
// Default + every custom loader in parallel. The default is critical (most
|
||||
// commands need it); custom loaders go through `Promise.allSettled` so one
|
||||
// broken third-party registry doesn't take down the rest of the CLI.
|
||||
const [defaultLoader, customResults] = await Promise.all([
|
||||
buildDefaultLoader(),
|
||||
...customEntries.map(([ns, cfg]) => buildCustomLoader(ns, cfg)),
|
||||
Promise.allSettled(customEntries.map(([ns, cfg]) => buildCustomLoader(ns, cfg))),
|
||||
]);
|
||||
|
||||
const loaders = new Map<string, NamespaceLoader>();
|
||||
loaders.set(DEFAULT_NAMESPACE, defaultLoader);
|
||||
customEntries.forEach(([ns], i) => loaders.set(ns, customLoaders[i]!));
|
||||
customEntries.forEach(([ns], i) => {
|
||||
const r = customResults[i]!;
|
||||
if (r.status === "fulfilled") {
|
||||
loaders.set(ns, r.value);
|
||||
} else {
|
||||
const detail = r.reason instanceof Error ? r.reason.message : String(r.reason);
|
||||
console.warn(`Registry "${ns}" failed to initialize — skipping: ${detail}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
namespaces: () => [...loaders.keys()],
|
||||
@@ -158,7 +179,7 @@ async function buildCustomLoader(namespace: string, cfg: RegistryConfig): Promis
|
||||
async loadModule(category, id) {
|
||||
const url = appendParams(renderModuleUrl(resolved, category, id), resolved.params);
|
||||
const init: RequestInit = { headers: buildHeaders(resolved.headers) };
|
||||
const res = await fetch(url, init);
|
||||
const res = await fetchWithTimeout(url, init);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Module fetch failed: ${url} (${res.status} ${res.statusText})`);
|
||||
}
|
||||
@@ -189,14 +210,22 @@ function renderModuleUrl(cfg: ResolvedConfig, category: string, id: string): str
|
||||
return cfg.url.replaceAll("{category}", category).replaceAll("{id}", id);
|
||||
}
|
||||
|
||||
const warnedHeaders = new Set<string>();
|
||||
|
||||
function buildHeaders(headers?: Record<string, string>): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [name, template] of Object.entries(headers)) {
|
||||
const value = expandEnv(template);
|
||||
// Match shadcn: a header whose template references an unset env var is
|
||||
// silently dropped (rather than sent literally as `Bearer ${TOKEN}`).
|
||||
if (value !== null) out[name] = value;
|
||||
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
|
||||
// then returns 401.
|
||||
warnedHeaders.add(name);
|
||||
console.warn(`Registry header "${name}" dropped — env var in "${template}" is unset.`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -234,7 +263,7 @@ async function tryFetchIndex(
|
||||
}
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, { headers: buildHeaders(cfg.headers) });
|
||||
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.
|
||||
@@ -297,7 +326,7 @@ async function loadFsRegistry(rootDir: string): Promise<NamespaceLoader> {
|
||||
}
|
||||
|
||||
async function loadHttpRegistry(baseUrl: string): Promise<NamespaceLoader> {
|
||||
const indexRes = await fetch(`${baseUrl}/index.json`);
|
||||
const indexRes = await fetchWithTimeout(`${baseUrl}/index.json`);
|
||||
if (!indexRes.ok) {
|
||||
throw new Error(`Failed to load Stanza registry from ${baseUrl}: ${indexRes.status}`);
|
||||
}
|
||||
@@ -307,7 +336,7 @@ async function loadHttpRegistry(baseUrl: string): Promise<NamespaceLoader> {
|
||||
index,
|
||||
async loadModule(slot, id) {
|
||||
const url = `${baseUrl}/modules/${slot}-${id}.json`;
|
||||
const res = await fetch(url);
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (!res.ok) throw new Error(`Module fetch failed: ${url} (${res.status})`);
|
||||
return ModuleSchema.parse(await res.json());
|
||||
},
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import type { AppSpec, CategoryId, Module, PackageManager, RegistryIndex } from "@stanza/registry";
|
||||
import {
|
||||
@@ -63,8 +66,13 @@ export async function runInitWizard(args: {
|
||||
message: "Project name",
|
||||
initialValue: defaultName,
|
||||
validate: (v) => {
|
||||
const result = validateProjectName(v ?? "");
|
||||
return result.ok ? undefined : result.message;
|
||||
const candidate = v ?? "";
|
||||
const result = validateProjectName(candidate);
|
||||
if (!result.ok) return result.message;
|
||||
if (fs.existsSync(path.resolve(process.cwd(), candidate))) {
|
||||
return `Directory "${candidate}" already exists in ${process.cwd()}.`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (p.isCancel(name)) {
|
||||
@@ -191,6 +199,10 @@ async function runNonInteractive(args: {
|
||||
p.log.error(`Invalid project name: "${name}". ${validation.message}`);
|
||||
return null;
|
||||
}
|
||||
if (fs.existsSync(path.resolve(process.cwd(), name))) {
|
||||
p.log.error(`Directory "${name}" already exists in ${process.cwd()}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifest = emptyManifest({ name: "tmp" });
|
||||
const selections: Partial<Record<CategoryId, Module[]>> = {};
|
||||
|
||||
+32
-3
@@ -10,6 +10,8 @@ import * as telemetry from "./lib/telemetry";
|
||||
|
||||
let startedAt = 0;
|
||||
|
||||
const KNOWN_COMMANDS = new Set(["init", "add", "remove", "list", "search"]);
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "stanza",
|
||||
@@ -26,16 +28,19 @@ const main = defineCommand({
|
||||
},
|
||||
subCommands: { init, add, remove, list, search },
|
||||
setup({ rawArgs }) {
|
||||
const command = rawArgs.find((arg) => !arg.startsWith("-"));
|
||||
const raw = rawArgs.find((arg) => !arg.startsWith("-"));
|
||||
// No verb (bare `stanza`/help/version): leave telemetry unconfigured so
|
||||
// cleanup's capture/flush no-op.
|
||||
if (!command) return;
|
||||
// cleanup's capture/flush no-op. Bucket against the known subcommand
|
||||
// set so a typo like `stanza ohno` doesn't pollute the `command` tag.
|
||||
if (!raw) return;
|
||||
const command = KNOWN_COMMANDS.has(raw) ? raw : "unknown";
|
||||
startedAt = Date.now();
|
||||
telemetry.configure({
|
||||
command,
|
||||
version,
|
||||
disabled: telemetry.isTelemetryDisabled(rawArgs),
|
||||
});
|
||||
installInterruptHandler(command);
|
||||
},
|
||||
// citty awaits cleanup before runMain's process.exit, so the flush lands. It
|
||||
// can't see the run error, so only handled errors (process.exitCode) read as
|
||||
@@ -50,3 +55,27 @@ const main = defineCommand({
|
||||
export function run(rawArgs: string[] = process.argv.slice(2)): Promise<void> {
|
||||
return runMain(main, { rawArgs });
|
||||
}
|
||||
|
||||
let interruptHandlerInstalled = false;
|
||||
|
||||
// Print a recovery hint on Ctrl-C so users aren't left wondering why
|
||||
// re-running init dies with "Directory already exists" — without this,
|
||||
// the process tears down silently mid-applyModule and leaves a half-
|
||||
// bootstrapped tree behind.
|
||||
function installInterruptHandler(command: string): void {
|
||||
if (interruptHandlerInstalled) return;
|
||||
interruptHandlerInstalled = true;
|
||||
process.on("SIGINT", () => {
|
||||
process.stderr.write("\nInterrupted.\n");
|
||||
if (command === "init") {
|
||||
process.stderr.write(
|
||||
"If a project directory was created, delete it manually before retrying.\n",
|
||||
);
|
||||
} else if (command === "add" || command === "remove") {
|
||||
process.stderr.write(
|
||||
`\`stanza ${command}\` may have left partial changes — review \`git status\` before continuing.\n`,
|
||||
);
|
||||
}
|
||||
process.exit(130);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ templates: [
|
||||
| `"repo"` | The monorepo root. |
|
||||
| `"package"` | `packages/<dir>/` where `<dir>` is the category's `home.dir` (package-home only). |
|
||||
|
||||
Set `template: true` to run the file through Handlebars before writing. The render context is:
|
||||
Set `template: true` to run the file through [Handlebars](https://handlebarsjs.com/) before writing. The render context is:
|
||||
|
||||
| Token | Value |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
@@ -141,7 +141,7 @@ Set `template: true` to run the file through Handlebars before writing. The rend
|
||||
| `{{pm}}` | The project's package manager (`pnpm` \| `bun` \| `npm`). |
|
||||
| `{{env}}` | Sorted list of every env var name declared so far. |
|
||||
|
||||
Conditional blocks use Handlebars' built-ins plus an `eq` helper:
|
||||
Conditional blocks use Handlebars' [built-ins](https://handlebarsjs.com/guide/builtin-helpers.html) plus an `eq` helper:
|
||||
|
||||
```hbs
|
||||
{{#if (eq peers.framework "next")}}
|
||||
@@ -257,7 +257,7 @@ Drop these next to `module.ts`:
|
||||
| File | What it does |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `logo.svg` | Theme-agnostic SVG, inlined onto `mod.logo`. Used by the web builder and module cards. |
|
||||
| `logo-light.svg` + `logo-dark.svg` | Theme pair — inlined as `mod.logo = { light, dark }`. Take precedence over `logo.svg`. |
|
||||
| `logo-light.svg` + `logo-dark.svg` | Theme pair — inlined as `mod.logo = { light, dark }`. Takes precedence over a single `logo.svg`. |
|
||||
| `readme.md` | Markdown contribution to the generated project's README. Renders with the same Handlebars context as templates. |
|
||||
|
||||
The build runs SVGO on logos and prefixes every `id` so multiple module logos on the same page can't collide. First-party logos generally come from [svgl.app](https://svgl.app).
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/react-start": "^1.168.14",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/functions": "^3.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -36,7 +37,6 @@
|
||||
"lru-cache": "^11.5.0",
|
||||
"motion": "^12.40.0",
|
||||
"nitro": "^3.0.260522-beta",
|
||||
"posthog-js": "^1.376.2",
|
||||
"posthog-node": "^5.35.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
|
||||
@@ -13,5 +13,5 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
app_dir="$(cd "$here/.." && pwd)"
|
||||
repo_root="$(cd "$app_dir/../.." && pwd)"
|
||||
|
||||
(cd "$repo_root" && npx jiti packages/registry/src/build.ts "$app_dir/public")
|
||||
(cd "$repo_root" && pnpm exec jiti packages/registry/src/build.ts "$app_dir/public")
|
||||
echo "[prepare-registry] → ${app_dir#"$repo_root/"}/public/{registry/,schema.json}"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { CategoryId } from "@stanza/registry";
|
||||
import { isMulti } from "@stanza/registry";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { track } from "@vercel/analytics";
|
||||
import { startTransition, useCallback, useMemo, useOptimistic, useRef } from "react";
|
||||
|
||||
import { FilePreview } from "@/components/builder/file-preview";
|
||||
import { ModuleCards } from "@/components/builder/module-cards";
|
||||
import { ProjectSetup } from "@/components/builder/project-setup";
|
||||
import { CommandPreview } from "@/components/command-preview";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import type { PackageManager } from "@/lib/package-manager";
|
||||
import {
|
||||
type BuilderSearch,
|
||||
@@ -21,7 +21,6 @@ import type { BuilderState } from "@/server/builder-state.functions";
|
||||
|
||||
export function Builder({ state, search }: { state: BuilderState; search: BuilderSearch }) {
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const capture = useAnalytics();
|
||||
// Same-pathname gate so navigations *away* from this route don't flash the
|
||||
// overlay before the page unmounts. `useRouterState` is the documented
|
||||
// primitive for router-wide pending state; FilePreview applies its own
|
||||
@@ -90,14 +89,14 @@ export function Builder({ state, search }: { state: BuilderState; search: Builde
|
||||
const nextIds = enabled ? [...current, id] : current.filter((x) => x !== id);
|
||||
if (nextIds.length > 0) draft[category] = nextIds;
|
||||
else delete draft[category];
|
||||
capture("builder_addon_toggled", { category, addon: id, enabled });
|
||||
track("builder_addon_toggled", { category, addon: id, enabled });
|
||||
} else if (current[0] === id) {
|
||||
// Clicking the selected single-choice module clears it.
|
||||
delete draft[category];
|
||||
capture("builder_module_deselected", { category });
|
||||
track("builder_module_deselected", { category });
|
||||
} else {
|
||||
draft[category] = [id];
|
||||
capture("builder_module_selected", { category, module: id });
|
||||
track("builder_module_selected", { category, module: id });
|
||||
}
|
||||
// Dropping a peer can strand its dependents (e.g. `db` → `orm`); prune.
|
||||
const next = pruneUnresolved(snapshot.modules, draft);
|
||||
@@ -111,7 +110,7 @@ export function Builder({ state, search }: { state: BuilderState; search: Builde
|
||||
});
|
||||
});
|
||||
},
|
||||
[capture, navigate, setOptimistic],
|
||||
[navigate, setOptimistic],
|
||||
);
|
||||
|
||||
const commandBar = <ProjectSetup name={name} defaultName={DEFAULT_NAME} onNameChange={setName} />;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { track } from "@vercel/analytics";
|
||||
|
||||
import { PackageManagerSelect } from "@/components/package-manager-select";
|
||||
import { CopyableField } from "@/components/ui/copyable-field";
|
||||
import { selectionProperties, useAnalytics } from "@/lib/analytics";
|
||||
import { selectionProperties } from "@/lib/analytics";
|
||||
import type { PackageManager } from "@/lib/package-manager";
|
||||
import { buildCommand, DEFAULT_NAME, type Selections } from "@/lib/selection";
|
||||
|
||||
@@ -22,10 +24,9 @@ export function CommandPreview({
|
||||
onPmChange: (pm: PackageManager) => void;
|
||||
}) {
|
||||
const command = buildCommand({ name, selections, pm });
|
||||
const capture = useAnalytics();
|
||||
|
||||
const onCopy = () => {
|
||||
capture("builder_command_copied", {
|
||||
track("builder_command_copied", {
|
||||
package_manager: pm,
|
||||
command,
|
||||
name_customized: name !== DEFAULT_NAME,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { posthog } from "posthog-js";
|
||||
import { PostHogProvider as PostHogReactProvider } from "posthog-js/react";
|
||||
|
||||
/**
|
||||
* Client-side PostHog. Initializes the browser SDK once, but only when a key is
|
||||
* configured (`VITE_PUBLIC_POSTHOG_KEY`) — local dev and self-hosters without
|
||||
* analytics get an uninitialized singleton that no-ops on capture, mirroring the
|
||||
* `/api/events` proxy's "no key → do nothing" behavior.
|
||||
*
|
||||
* Auto-captures pageviews + pageleaves (history-based, for the SPA router) and
|
||||
* web vitals; DOM click autocapture and session replay are off.
|
||||
*/
|
||||
|
||||
const key = import.meta.env.VITE_PUBLIC_POSTHOG_KEY;
|
||||
|
||||
if (typeof window !== "undefined" && key && !posthog.__loaded) {
|
||||
posthog.init(key, {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
|
||||
defaults: "2026-01-30",
|
||||
capture_exceptions: true,
|
||||
capture_pageview: "history_change",
|
||||
autocapture: false,
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
disable_external_dependency_loading: true,
|
||||
person_profiles: "never",
|
||||
});
|
||||
}
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
return <PostHogReactProvider client={posthog}>{children}</PostHogReactProvider>;
|
||||
}
|
||||
@@ -1,24 +1,8 @@
|
||||
import { IconHome, IconRefresh } from "@tabler/icons-react";
|
||||
import {
|
||||
ErrorComponent,
|
||||
type ErrorComponentProps,
|
||||
Link,
|
||||
useRouter,
|
||||
isNotFound,
|
||||
isRedirect,
|
||||
} from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { ErrorComponent, type ErrorComponentProps, Link, useRouter } from "@tanstack/react-router";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* `throw notFound()` and `throw redirect()` flow through the same error path
|
||||
* as real failures, but they're routing primitives — not bugs to report.
|
||||
*/
|
||||
function isRoutingError(error: unknown): boolean {
|
||||
return isNotFound(error) || isRedirect(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared layout for every route-boundary state (error, not-found, pending).
|
||||
* Mirrors the centered-message pattern that lived inline in `__root.tsx`.
|
||||
@@ -47,21 +31,11 @@ const GENERIC_ERROR_MESSAGE =
|
||||
/**
|
||||
* Default error component for the router. Re-runs the loader on Try again via
|
||||
* `router.invalidate()` (the TanStack-recommended retry path — `reset()` only
|
||||
* clears UI without re-fetching). Reports client-side errors to PostHog when
|
||||
* it's loaded.
|
||||
* clears UI without re-fetching).
|
||||
*/
|
||||
export function RouteErrorBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isRoutingError(error)) return;
|
||||
// Dynamic import so SSR never pulls the browser bundle into the server.
|
||||
void (async () => {
|
||||
const { posthog } = await import("posthog-js");
|
||||
if (posthog.__loaded) posthog.captureException(error);
|
||||
})();
|
||||
}, [error]);
|
||||
|
||||
const description = import.meta.env.DEV
|
||||
? (error.message ?? GENERIC_ERROR_MESSAGE)
|
||||
: GENERIC_ERROR_MESSAGE;
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import { KNOWN_CATEGORIES } from "@stanza/registry";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { Selections } from "@/lib/selection";
|
||||
|
||||
/**
|
||||
* Stable `capture(event, properties)` bound to the client-side PostHog instance.
|
||||
* Safe to call unconditionally: when no key is configured the singleton is
|
||||
* uninitialized and `capture` is a no-op.
|
||||
*/
|
||||
export function useAnalytics(): (event: string, properties?: Record<string, unknown>) => void {
|
||||
const posthog = usePostHog();
|
||||
return useCallback(
|
||||
(event, properties) => {
|
||||
if (!posthog.__loaded) return;
|
||||
posthog.capture(event, properties);
|
||||
},
|
||||
[posthog],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the current builder selection into event properties: one key per
|
||||
* category, holding the comma-joined selected ids (empty → null). Shared across
|
||||
* the builder events so they all carry the same selection shape.
|
||||
*/
|
||||
export function selectionProperties(selections: Selections): Record<string, unknown> {
|
||||
const props: Record<string, unknown> = {};
|
||||
export function selectionProperties(selections: Selections): Record<string, string | null> {
|
||||
const props: Record<string, string | null> = {};
|
||||
for (const category of KNOWN_CATEGORIES) {
|
||||
const ids = selections[category];
|
||||
props[category] = ids && ids.length > 0 ? ids.join(",") : null;
|
||||
|
||||
@@ -29,11 +29,15 @@ const SEARCH_KEYS = ["name", "pm", ...KNOWN_CATEGORIES] as const;
|
||||
* Shared by the route's `validateSearch` and the server function's
|
||||
* `inputValidator` so direct HTTP calls can't bypass the allow-list.
|
||||
*/
|
||||
// Cap to keep a hostile URL from cramming thousands of comma-joined ids into
|
||||
// any single search param. Well above any legitimate selection.
|
||||
const MAX_SEARCH_PARAM_LEN = 512;
|
||||
|
||||
export function validateBuilderSearch(input: Record<string, unknown>): BuilderSearch {
|
||||
const out: BuilderSearch = {};
|
||||
for (const key of SEARCH_KEYS) {
|
||||
const v = input[key];
|
||||
if (typeof v === "string" && v.length > 0) out[key] = v;
|
||||
if (typeof v === "string" && v.length > 0 && v.length <= MAX_SEARCH_PARAM_LEN) out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
|
||||
import { Footer } from "@/components/footer";
|
||||
import { Header } from "@/components/header";
|
||||
import { NavigationProgress } from "@/components/navigation-progress";
|
||||
import { PostHogProvider } from "@/components/posthog-provider";
|
||||
import { RouteErrorBoundary, RouteNotFoundBoundary } from "@/components/route-boundaries";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@@ -44,31 +44,30 @@ function RootComponent() {
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body className="min-h-svh bg-background font-sans text-foreground tabular-nums antialiased">
|
||||
<PostHogProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
||||
<TooltipProvider>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<NavigationProgress />
|
||||
<div className="flex min-h-svh flex-col">
|
||||
<Header />
|
||||
<main id="main" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</PostHogProvider>
|
||||
<ThemeProvider defaultTheme="system" storageKey="stanza-theme">
|
||||
<TooltipProvider>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:top-4 focus-visible:left-4 focus-visible:z-50 focus-visible:rounded-none focus-visible:border focus-visible:border-border focus-visible:bg-background focus-visible:px-3 focus-visible:py-2 focus-visible:text-sm focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<NavigationProgress />
|
||||
<div className="flex min-h-svh flex-col">
|
||||
<Header />
|
||||
<main id="main" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-right" }}
|
||||
plugins={[{ name: "TanStack Router", render: <TanStackRouterDevtoolsPanel /> }]}
|
||||
/>
|
||||
<Analytics />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { waitUntil } from "@vercel/functions";
|
||||
|
||||
import { getPostHogServerClient } from "@/server/posthog.server";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
/**
|
||||
* `POST /api/events` — analytics ingest for the `stanza-cli`. The CLI sends
|
||||
* plain `fetch` batches here so it never has to bundle `posthog-node`; this
|
||||
* route holds the PostHog project key server-side and forwards each event via
|
||||
* the `posthog-node` client.
|
||||
*
|
||||
* Configure via env (set these in the Vercel project for prod):
|
||||
* VITE_PUBLIC_POSTHOG_KEY — the PostHog project key. When unset the route
|
||||
* no-ops (returns 204) so local dev / self-hosters
|
||||
* never error.
|
||||
* VITE_PUBLIC_POSTHOG_HOST — PostHog ingest host. Defaults to
|
||||
* https://us.i.posthog.com.
|
||||
*/
|
||||
|
||||
const MAX_EVENTS = 20;
|
||||
|
||||
// Module-level singleton so successive cold-start invocations reuse the client.
|
||||
let client: PostHog | null = null;
|
||||
function getClient(): PostHog | null {
|
||||
const apiKey = process.env.VITE_PUBLIC_POSTHOG_KEY;
|
||||
if (!apiKey) return null;
|
||||
if (!client) {
|
||||
client = new PostHog(apiKey, {
|
||||
host: process.env.VITE_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
type IncomingEvent = {
|
||||
event: string;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -66,7 +73,7 @@ export const Route = createFileRoute("/api/events")({
|
||||
const payload = parsePayload(body);
|
||||
if (!payload) return new Response("Invalid payload", { status: 400 });
|
||||
|
||||
const posthog = getPostHogServerClient();
|
||||
const posthog = getClient();
|
||||
// No key configured (local dev / self-host without analytics): accept
|
||||
// and discard so the CLI never sees an error.
|
||||
if (!posthog) return new Response(null, { status: 204 });
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createFromSource } from "fumadocs-core/search/server";
|
||||
import { cache } from "react";
|
||||
|
||||
import { source } from "@/lib/source";
|
||||
import { reportServerError } from "@/server/posthog.server";
|
||||
|
||||
// React `cache()` memoizes the SearchAPI per server request so any
|
||||
// concurrent callers in the same render tree share one instance. The
|
||||
@@ -23,7 +22,7 @@ export const Route = createFileRoute("/api/search/docs")({
|
||||
try {
|
||||
return await getSearchServer().GET(request);
|
||||
} catch (error) {
|
||||
reportServerError(error, { source: "/api/search/docs" });
|
||||
console.error("[server-error]", error, { source: "/api/search/docs" });
|
||||
return Response.json(
|
||||
{ error: "Search failed", code: "DOCS_SEARCH_FAILED" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Module, ModuleSummary, RegistryIndex } from "@stanza/registry";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { cache } from "react";
|
||||
|
||||
import { reportServerError } from "@/server/posthog.server";
|
||||
import { loadRegistryFile } from "@/server/registry-base.server";
|
||||
|
||||
const moduleSchema = {
|
||||
@@ -116,7 +115,7 @@ export const Route = createFileRoute("/api/search/modules")({
|
||||
}
|
||||
return Response.json({ results });
|
||||
} catch (error) {
|
||||
reportServerError(error, { source: "/api/search/modules" });
|
||||
console.error("[server-error]", error, { source: "/api/search/modules" });
|
||||
return Response.json(
|
||||
{ error: "Search failed", code: "MODULES_SEARCH_FAILED" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -59,9 +59,9 @@ function LastRefreshed({ iso }: { iso: string }) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
Last refreshed {ago}
|
||||
Last refreshed {ago}.
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formatGeneratedAt(iso)} UTC</TooltipContent>
|
||||
<TooltipContent sideOffset={8}>{formatGeneratedAt(iso)} UTC</TooltipContent>
|
||||
</Tooltip>
|
||||
</p>
|
||||
);
|
||||
@@ -102,7 +102,10 @@ function StatsPage() {
|
||||
<h1 className="mb-2 text-3xl font-semibold tracking-tight">Stats</h1>
|
||||
<p className="mb-4 text-pretty text-muted-foreground">
|
||||
What modules developers actually pick, aggregated from anonymous CLI telemetry —{" "}
|
||||
<a href="#telemetry" className="text-primary underline underline-offset-3">
|
||||
<a
|
||||
href="#telemetry"
|
||||
className="text-primary underline underline-offset-3 hover:text-primary/80"
|
||||
>
|
||||
see exactly what’s collected
|
||||
</a>
|
||||
.
|
||||
@@ -248,7 +251,7 @@ function StatsPage() {
|
||||
to="/docs/$"
|
||||
params={{ _splat: "cli" }}
|
||||
hash="telemetry"
|
||||
className="text-primary underline underline-offset-1"
|
||||
className="text-primary underline underline-offset-1 hover:text-primary/80"
|
||||
>
|
||||
CLI docs
|
||||
</Link>{" "}
|
||||
@@ -257,7 +260,7 @@ function StatsPage() {
|
||||
href="https://github.com/jakejarvis/stanza/blob/main/apps/cli/src/lib/telemetry.ts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-1"
|
||||
className="text-primary underline underline-offset-1 hover:text-primary/80"
|
||||
>
|
||||
audit the code
|
||||
</a>{" "}
|
||||
|
||||
@@ -7,7 +7,15 @@ import type {
|
||||
PeerRequirement,
|
||||
RegistryIndex,
|
||||
} from "@stanza/registry";
|
||||
import { emptyManifest, KNOWN_CATEGORIES, PEER_CATEGORIES, resolveAdapter } from "@stanza/registry";
|
||||
import {
|
||||
emptyManifest,
|
||||
isCategoryId,
|
||||
isValidModuleId,
|
||||
KNOWN_CATEGORIES,
|
||||
mergeInstallFields,
|
||||
PEER_CATEGORIES,
|
||||
resolveAdapter,
|
||||
} from "@stanza/registry";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
|
||||
import type { Preview } from "@/server/highlighter";
|
||||
@@ -58,7 +66,18 @@ export type ModuleDetail = {
|
||||
};
|
||||
|
||||
export const getModuleDetail = createServerFn({ method: "GET" })
|
||||
.inputValidator((data: ModuleDetailInput) => data)
|
||||
.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.
|
||||
if (typeof data.category !== "string" || !isCategoryId(data.category)) {
|
||||
throw new Error(`Unknown category "${String(data.category)}".`);
|
||||
}
|
||||
if (typeof data.id !== "string" || !isValidModuleId(data.id)) {
|
||||
throw new Error(`Invalid module id "${data.id}".`);
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.handler(async ({ data }): Promise<ModuleDetail | null> => {
|
||||
const index = await loadRegistryFile<RegistryIndex>("index.json");
|
||||
|
||||
@@ -70,7 +89,7 @@ export const getModuleDetail = createServerFn({ method: "GET" })
|
||||
const peerOptions = computePeerOptions(module, index);
|
||||
const resolvedPeers = applyAutoDefaults(module, data.peers, peerOptions);
|
||||
const adapter = pickAdapter(module, resolvedPeers);
|
||||
const effective = mergeInstallFields(module, adapter);
|
||||
const effective = effectiveInstallFields(module, adapter);
|
||||
|
||||
const previewEntries = await Promise.all(
|
||||
(adapter.templates ?? []).map(async (tpl) => {
|
||||
@@ -202,32 +221,18 @@ function pickAdapter(module: Module, peers: Partial<Record<CategoryId, string>>)
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter-wins merge of install fields. Matches the runner's behavior:
|
||||
* - `dependencies` / `devDependencies` / `scripts` merge per-key
|
||||
* - `env` merges by `name` (adapter overrides module)
|
||||
*/
|
||||
function mergeInstallFields(module: Module, adapter: ModuleAdapter): EffectiveInstallFields {
|
||||
const dependencies: Record<string, string> = {
|
||||
...module.dependencies,
|
||||
...adapter.dependencies,
|
||||
};
|
||||
const devDependencies: Record<string, string> = {
|
||||
...module.devDependencies,
|
||||
...adapter.devDependencies,
|
||||
};
|
||||
const scripts: Record<string, string> = {
|
||||
...module.scripts,
|
||||
...adapter.scripts,
|
||||
};
|
||||
// Union of primary + `app` overlay — the CLI routes them to different
|
||||
// package.jsons, but the detail page shows what the module installs total.
|
||||
function effectiveInstallFields(module: Module, adapter: ModuleAdapter): EffectiveInstallFields {
|
||||
const merged = mergeInstallFields(module, adapter);
|
||||
const envByName = new Map<string, EnvVar>();
|
||||
for (const e of module.env ?? []) envByName.set(e.name, e);
|
||||
for (const e of adapter.env ?? []) envByName.set(e.name, e);
|
||||
for (const v of merged.env) envByName.set(v.name, v);
|
||||
for (const v of merged.app.env) envByName.set(v.name, v);
|
||||
return {
|
||||
dependencies,
|
||||
devDependencies,
|
||||
dependencies: { ...merged.dependencies, ...merged.app.dependencies },
|
||||
devDependencies: { ...merged.devDependencies, ...merged.app.devDependencies },
|
||||
scripts: { ...merged.scripts, ...merged.app.scripts },
|
||||
env: [...envByName.values()],
|
||||
scripts,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
/**
|
||||
* Server-only PostHog client. Holds the project key (read from env) and talks
|
||||
* to PostHog's HTTP API so the `/api/events` proxy never has to hand-roll the
|
||||
* capture protocol. Returns `null` when no key is configured (local dev /
|
||||
* self-host without analytics) so callers can no-op.
|
||||
*
|
||||
* `flushAt: 1` + `flushInterval: 0` make capture send eagerly; serverless
|
||||
* handlers should still `await client.flush()` before returning since the
|
||||
* function may freeze before an in-flight request settles.
|
||||
*/
|
||||
|
||||
let client: PostHog | null = null;
|
||||
|
||||
export function getPostHogServerClient(): PostHog | null {
|
||||
const apiKey = process.env.VITE_PUBLIC_POSTHOG_KEY;
|
||||
if (!apiKey) return null;
|
||||
|
||||
if (!client) {
|
||||
client = new PostHog(apiKey, {
|
||||
host: process.env.VITE_PUBLIC_POSTHOG_HOST ?? "https://us.i.posthog.com",
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a server-side error to both the function console (always — Vercel
|
||||
* captures it) and PostHog (when a project key is configured). Never throws —
|
||||
* reporting failures must not cascade into the request itself.
|
||||
*/
|
||||
export function reportServerError(error: unknown, context?: Record<string, unknown>): void {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
console.error("[server-error]", err, context);
|
||||
|
||||
const posthog = getPostHogServerClient();
|
||||
if (!posthog) return;
|
||||
try {
|
||||
posthog.captureException(err, "server", {
|
||||
...context,
|
||||
$exception_source: "server",
|
||||
});
|
||||
} catch {
|
||||
// Reporting is best-effort.
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Module, RegistryIndex } from "@stanza/registry";
|
||||
|
||||
import { reportServerError } from "@/server/posthog.server";
|
||||
import { loadRegistryFile } from "@/server/registry-base.server";
|
||||
|
||||
let modulesPromise: Promise<Record<string, Module>> | undefined;
|
||||
@@ -26,7 +25,7 @@ async function loadAll(): Promise<Record<string, Module>> {
|
||||
);
|
||||
return [`${mod.category}:${mod.id}`, mod] as const;
|
||||
} catch (cause) {
|
||||
reportServerError(cause, {
|
||||
console.error("[server-error]", cause, {
|
||||
source: "getAllModules/loadModule",
|
||||
category: summary.category,
|
||||
moduleId: summary.id,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@stanza/registry": "workspace:*",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"ts-morph": "^28.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -137,6 +137,39 @@ export default defineConfig({
|
||||
expect(text).toContain('deps: ["foo"]');
|
||||
});
|
||||
|
||||
it("targets the right call when a file has two createRootRoute() calls", () => {
|
||||
const TWO_CALLS = `import { createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export const RouteA = createRootRoute({
|
||||
head: () => ({
|
||||
links: [{ rel: "icon", href: "/a.ico" }],
|
||||
}),
|
||||
});
|
||||
|
||||
export const RouteB = createRootRoute({
|
||||
head: () => ({
|
||||
links: [{ rel: "icon", href: "/b.ico" }],
|
||||
}),
|
||||
});
|
||||
`;
|
||||
const { ctx, abs, project } = setup("src/routes/__root.tsx", TWO_CALLS);
|
||||
addArrayEntryInCall.apply(ctx, {
|
||||
file: "src/routes/__root.tsx",
|
||||
callee: "createRootRoute",
|
||||
property: "head().links",
|
||||
entry: '{ rel: "stylesheet", href: appCss }',
|
||||
imports: [{ from: "@acme/ui/globals.css?url", default: "appCss" }],
|
||||
});
|
||||
project.saveSync();
|
||||
const text = fs.readFileSync(abs, "utf8");
|
||||
// The inserted entry lands in RouteA's links (the first call), and RouteB
|
||||
// stays intact — confirmation we're not crossing into the wrong call.
|
||||
const routeA = text.slice(text.indexOf("RouteA"), text.indexOf("RouteB"));
|
||||
const routeB = text.slice(text.indexOf("RouteB"));
|
||||
expect(routeA).toContain("appCss");
|
||||
expect(routeB).not.toContain("appCss");
|
||||
});
|
||||
|
||||
it("revert removes the entry and import", () => {
|
||||
const { ctx, abs, project } = setup("src/routes/__root.tsx", ROOT_WITH_HEAD);
|
||||
addArrayEntryInCall.apply(ctx, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import type { ArrayLiteralExpression, CallExpression, ObjectLiteralExpression } from "ts-morph";
|
||||
|
||||
import {
|
||||
@@ -142,9 +143,12 @@ function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: AddArrayEntryInCallArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "add-array-entry-in-call: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "add-array-entry-in-call: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
|
||||
}
|
||||
@@ -204,29 +208,48 @@ function findArray(sf: SourceFile, args: AddArrayEntryInCallArgs): ArrayLiteral
|
||||
|
||||
/** Same as `findArray` but creates missing intermediates and the terminal array. */
|
||||
function getOrCreateArray(sf: SourceFile, args: AddArrayEntryInCallArgs): ArrayLiteral {
|
||||
const call = findCalleeCall(sf, args.callee);
|
||||
if (!call) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: ${sf.getBaseName()} has no \`${args.callee}(...)\` call.`,
|
||||
);
|
||||
}
|
||||
const segments = parsePath(args.property);
|
||||
const argIndex = args.argIndex ?? 0;
|
||||
const arg = call.getArguments()[argIndex]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
||||
if (!arg) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: ${sf.getBaseName()}'s \`${args.callee}\` call needs an ` +
|
||||
`object-literal at argument ${argIndex}.`,
|
||||
);
|
||||
|
||||
// Re-walk from the call expression each time we need a fresh handle.
|
||||
// ts-morph forgets nodes adjacent to text inserts, so we can't cache obj
|
||||
// across mutations — but re-walking by callee + segment path stays scoped
|
||||
// to the right call expression (vs a file-wide property search).
|
||||
const walkTo = (depth: number): ObjectLiteral => {
|
||||
const call = findCalleeCall(sf, args.callee);
|
||||
if (!call) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: ${sf.getBaseName()} has no \`${args.callee}(...)\` call.`,
|
||||
);
|
||||
}
|
||||
const arg = call.getArguments()[argIndex]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
||||
if (!arg) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: ${sf.getBaseName()}'s \`${args.callee}\` call needs an ` +
|
||||
`object-literal at argument ${argIndex}.`,
|
||||
);
|
||||
}
|
||||
let obj: ObjectLiteral = arg;
|
||||
for (let i = 0; i < depth; i += 1) {
|
||||
const next = descend(obj, segments[i]!);
|
||||
if (!next) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: failed to materialize segment "${segments[i]!.name}".`,
|
||||
);
|
||||
}
|
||||
obj = next;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
const obj = walkTo(i);
|
||||
const seg = segments[i]!;
|
||||
if (descend(obj, seg)) continue;
|
||||
addObjectProperty(sf, obj, seg.name, seg.call ? "() => ({})" : "{}");
|
||||
}
|
||||
|
||||
const segments = parsePath(args.property);
|
||||
// Walk/create intermediates.
|
||||
let obj: ObjectLiteral = arg;
|
||||
for (let i = 0; i < segments.length - 1; i += 1) {
|
||||
obj = ensureIntermediate(sf, obj, segments[i]!);
|
||||
}
|
||||
// Terminal segment must resolve (or be created) as an array.
|
||||
return ensureTerminalArray(sf, obj, segments[segments.length - 1]!);
|
||||
return ensureTerminalArray(walkTo(segments.length - 1), segments[segments.length - 1]!);
|
||||
}
|
||||
|
||||
/** Descend a single segment; returns `null` if the property doesn't match. */
|
||||
@@ -258,20 +281,8 @@ function descend(obj: ObjectLiteral, seg: PathSegment): ObjectLiteral | null {
|
||||
return body.asKind(SyntaxKind.ObjectLiteralExpression) ?? null;
|
||||
}
|
||||
|
||||
/** Ensure `obj.<seg>` (or `obj.<seg>()`) is an object literal we can descend into. */
|
||||
function ensureIntermediate(sf: SourceFile, obj: ObjectLiteral, seg: PathSegment): ObjectLiteral {
|
||||
const existing = descend(obj, seg);
|
||||
if (existing) return existing;
|
||||
|
||||
// Create the property in the form the segment demands.
|
||||
const init = seg.call ? "() => ({})" : "{}";
|
||||
addObjectProperty(sf, obj, seg.name, init);
|
||||
// Re-traverse — the inserted text invalidates the previous handle.
|
||||
return findOrThrowAfterInsert(sf, seg);
|
||||
}
|
||||
|
||||
/** Ensure the terminal segment resolves to an array literal; create if missing. */
|
||||
function ensureTerminalArray(sf: SourceFile, obj: ObjectLiteral, seg: PathSegment): ArrayLiteral {
|
||||
function ensureTerminalArray(obj: ObjectLiteral, seg: PathSegment): ArrayLiteral {
|
||||
// Reject `()` on the terminal — arrays don't return from a call.
|
||||
if (seg.call) {
|
||||
throw new Error(
|
||||
@@ -279,9 +290,9 @@ function ensureTerminalArray(sf: SourceFile, obj: ObjectLiteral, seg: PathSegmen
|
||||
`drop the parentheses (the leaf must be an array property).`,
|
||||
);
|
||||
}
|
||||
const prop = obj.getProperty(seg.name)?.asKind(SyntaxKind.PropertyAssignment);
|
||||
if (prop) {
|
||||
const arr = prop.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
|
||||
const existing = obj.getProperty(seg.name)?.asKind(SyntaxKind.PropertyAssignment);
|
||||
if (existing) {
|
||||
const arr = existing.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
|
||||
if (!arr) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: \`${seg.name}\` already exists but isn't an array literal — ` +
|
||||
@@ -290,12 +301,10 @@ function ensureTerminalArray(sf: SourceFile, obj: ObjectLiteral, seg: PathSegmen
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
addObjectProperty(sf, obj, seg.name, "[]");
|
||||
// Re-find — the cached `obj` handle is now stale.
|
||||
const refreshed = sf
|
||||
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
||||
.findLast((p) => p.getName() === seg.name);
|
||||
const arr = refreshed?.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
|
||||
// Use AST mutation (not text-insert) so the parent stays valid and we can
|
||||
// pull the new property out via `addPropertyAssignment`'s return value.
|
||||
const inserted = obj.addPropertyAssignment({ name: seg.name, initializer: "[]" });
|
||||
const arr = inserted.getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
|
||||
if (!arr) {
|
||||
throw new Error(`add-array-entry-in-call: failed to create \`${seg.name}: []\`.`);
|
||||
}
|
||||
@@ -315,39 +324,6 @@ function addObjectProperty(sf: SourceFile, obj: ObjectLiteral, name: string, ini
|
||||
}
|
||||
}
|
||||
|
||||
/** After a text insert, re-find the segment's object/arrow value or throw. */
|
||||
function findOrThrowAfterInsert(sf: SourceFile, seg: PathSegment): ObjectLiteral {
|
||||
const prop = sf
|
||||
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
|
||||
.findLast((p) => p.getName() === seg.name);
|
||||
if (!prop) {
|
||||
throw new Error(`add-array-entry-in-call: failed to materialize property \`${seg.name}\`.`);
|
||||
}
|
||||
const init = prop.getInitializer();
|
||||
if (!init) {
|
||||
throw new Error(`add-array-entry-in-call: \`${seg.name}\` has no initializer after insert.`);
|
||||
}
|
||||
if (!seg.call) {
|
||||
const obj = init.asKind(SyntaxKind.ObjectLiteralExpression);
|
||||
if (!obj) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: \`${seg.name}\` initializer isn't an object after insert.`,
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
const arrow = init.asKind(SyntaxKind.ArrowFunction);
|
||||
const body = arrow?.getBody();
|
||||
const paren = body?.asKind(SyntaxKind.ParenthesizedExpression);
|
||||
const obj = paren?.getExpression().asKind(SyntaxKind.ObjectLiteralExpression);
|
||||
if (!obj) {
|
||||
throw new Error(
|
||||
`add-array-entry-in-call: \`${seg.name}\` arrow-returned object missing after insert.`,
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/** Text-edit insertion so we keep the array's existing indent (ts-morph
|
||||
* `insertElement` would reformat to 4-space). Mirrors add-plugin-to-call. */
|
||||
function insertEntry(sf: SourceFile, arr: ArrayLiteral, index: number, entry: string): void {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
|
||||
import {
|
||||
addDefaultImport,
|
||||
addNamedImport,
|
||||
@@ -118,23 +120,20 @@ const addJsxChild: Codemod<AddJsxChildArgs> = {
|
||||
const sf = ctx.project().addSourceFileAtPath(abs);
|
||||
const target = normalize(args.element);
|
||||
|
||||
// Only remove an inserted element that's still a child of the parent we
|
||||
// inserted into — a user-added clone elsewhere isn't our responsibility.
|
||||
const parent = findFirstJsxElement(sf, args.parent);
|
||||
let changed = false;
|
||||
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) {
|
||||
if (normalize(node.getText()) === target) {
|
||||
node.replaceWithText("");
|
||||
if (parent) {
|
||||
for (const child of parent.getJsxChildren()) {
|
||||
const kind = child.getKind();
|
||||
if (kind !== SyntaxKind.JsxSelfClosingElement && kind !== SyntaxKind.JsxElement) continue;
|
||||
if (normalize(child.getText()) !== target) continue;
|
||||
removeWithSurroundingWhitespace(sf, child);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
||||
if (normalize(node.getText()) === target) {
|
||||
node.replaceWithText("");
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const imp of args.imports ?? []) {
|
||||
const decl = sf.getImportDeclaration(
|
||||
@@ -148,13 +147,28 @@ const addJsxChild: Codemod<AddJsxChildArgs> = {
|
||||
},
|
||||
};
|
||||
|
||||
// Replace a JSX child with empty text, also consuming the preceding newline
|
||||
// + indent so we don't leave a blank gap behind.
|
||||
function removeWithSurroundingWhitespace(sf: SourceFile, node: Node): void {
|
||||
const full = sf.getFullText();
|
||||
let start = node.getStart();
|
||||
const end = node.getEnd();
|
||||
// Walk back through inline whitespace, and at most one preceding newline.
|
||||
while (start > 0 && (full[start - 1] === " " || full[start - 1] === "\t")) start -= 1;
|
||||
if (start > 0 && full[start - 1] === "\n") start -= 1;
|
||||
sf.replaceText([start, end], "");
|
||||
}
|
||||
|
||||
function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: AddJsxChildArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "add-jsx-child: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "add-jsx-child: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import type { ArrayLiteralExpression } from "ts-morph";
|
||||
|
||||
import {
|
||||
@@ -79,10 +80,12 @@ const addPluginToCall: Codemod<AddPluginToCallArgs> = {
|
||||
const sf = ctx.project().addSourceFileAtPath(abs);
|
||||
const plugins = getOrCreatePluginsArray(sf, args);
|
||||
|
||||
// Idempotency: same call already present → no-op.
|
||||
// Idempotency: same call already present → no-op. Still claim so revert
|
||||
// works on installs where the user pre-populated the array manually.
|
||||
const target = normalize(args.call);
|
||||
const elements = plugins.getElements();
|
||||
if (elements.some((el: Node) => normalize(el.getText()) === target)) {
|
||||
ctx.claimRegion(rel, regionKeyFor(args));
|
||||
return { touchedFiles: [] };
|
||||
}
|
||||
|
||||
@@ -125,9 +128,12 @@ function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: AddPluginToCallArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "add-plugin-to-call: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "add-plugin-to-call: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
|
||||
import type { Codemod } from "../index";
|
||||
|
||||
/** Comment-line shape used to wrap inserted blocks for idempotency + revert. */
|
||||
@@ -148,6 +150,7 @@ function prependBlock(current: string, block: string, trailingBlank: boolean): s
|
||||
}
|
||||
|
||||
function resolve(ctx: Parameters<Codemod<AppendToFileArgs>["apply"]>[0], args: AppendToFileArgs) {
|
||||
assertSafeRelativePath(args.file, "append-to-file: args.file");
|
||||
const baseAbs = baseDir(ctx, args);
|
||||
const fileAbs = path.join(baseAbs, args.file);
|
||||
const fileRel = path.relative(ctx.projectRoot, fileAbs);
|
||||
@@ -160,7 +163,9 @@ function baseDir(
|
||||
args: AppendToFileArgs,
|
||||
): string {
|
||||
if (args.base && args.base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", args.base.slice("package:".length));
|
||||
const dir = args.base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "append-to-file: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir);
|
||||
}
|
||||
const scope = args.scope ?? "app";
|
||||
return scope === "repo" ? ctx.projectRoot : ctx.appRoot;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
|
||||
import {
|
||||
type Codemod,
|
||||
type ExportDeclaration,
|
||||
@@ -114,8 +116,10 @@ function resolveBase(
|
||||
ctx: Parameters<Codemod<ReExportArgs>["apply"]>[0],
|
||||
args: ReExportArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "re-export: args.file");
|
||||
if (args.base && args.base.startsWith("package:")) {
|
||||
const dir = args.base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "re-export: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(ctx.appRoot, args.file);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
|
||||
import { type Codemod, type ImportDeclaration } from "../index";
|
||||
|
||||
/**
|
||||
@@ -89,9 +91,12 @@ function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: ReplaceImportArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "replace-import: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "replace-import: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
|
||||
import {
|
||||
type Codemod,
|
||||
type JsxAttribute,
|
||||
@@ -95,9 +97,12 @@ function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: SetHtmlAttributesArgs,
|
||||
): string {
|
||||
assertSafeRelativePath(args.file, "set-html-attributes: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), args.file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "set-html-attributes: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, args.file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, args.file);
|
||||
}
|
||||
@@ -138,9 +143,18 @@ function applyOne(
|
||||
|
||||
if ("value" in attr) {
|
||||
if (attr.name === "className" && existing) {
|
||||
// Merge tokens into the existing string literal (only — bail on expr form).
|
||||
// Merge tokens into the existing string literal. If the initializer is
|
||||
// an expression (e.g. `className={cn(…)}`), we can't safely splice
|
||||
// tokens — surface a warning so the user knows their intended classes
|
||||
// didn't land, then leave the file alone.
|
||||
const init = existing.getInitializer();
|
||||
if (!init || init.getKind() !== SyntaxKind.StringLiteral) return false;
|
||||
if (!init || init.getKind() !== SyntaxKind.StringLiteral) {
|
||||
console.warn(
|
||||
`set-html-attributes: <html> className is an expression; can't merge ` +
|
||||
`tokens [${attr.value}]. Add them to the existing expression manually.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const current: string[] = init.getText().slice(1, -1).split(/\s+/).filter(Boolean);
|
||||
const incoming: string[] = attr.value.split(/\s+/).filter(Boolean);
|
||||
const additions = incoming.filter((t: string) => !current.includes(t));
|
||||
|
||||
@@ -92,6 +92,52 @@ describe("set-tsconfig-paths", () => {
|
||||
).toThrow(/already maps "@acme\/ui\/\*"/);
|
||||
});
|
||||
|
||||
it("handles a JSONC tsconfig (comments + trailing comma) without crashing", () => {
|
||||
const abs = path.join(tmp, "apps/web/tsconfig.json");
|
||||
const jsonc = `{
|
||||
// Editor hints for the IDE
|
||||
"compilerOptions": {
|
||||
"strict": true, // important
|
||||
},
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(abs, jsonc);
|
||||
const project = openProject(path.join(tmp, "apps/web"));
|
||||
const manifest = emptyManifest({ name: "acme" });
|
||||
const ctx: CodemodContext = {
|
||||
projectRoot: tmp,
|
||||
app: manifest.apps[0]!,
|
||||
appRoot: path.join(tmp, "apps/web"),
|
||||
project: () => project,
|
||||
manifest,
|
||||
owner: { category: "ui", module: "shadcn-radix" },
|
||||
adapter: "next",
|
||||
claimRegion() {},
|
||||
releaseRegion() {},
|
||||
};
|
||||
setTsconfigPaths.apply(ctx, {
|
||||
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
|
||||
});
|
||||
const next = fs.readFileSync(abs, "utf8");
|
||||
// Comments should survive the rewrite.
|
||||
expect(next).toContain("// Editor hints for the IDE");
|
||||
expect(next).toContain("// important");
|
||||
expect(next).toContain('"@acme/ui/*"');
|
||||
expect(next).toContain('"baseUrl"');
|
||||
});
|
||||
|
||||
it("refuses to add paths to a tsconfig that uses `extends`", () => {
|
||||
const { ctx } = setup({
|
||||
extends: "./tsconfig.base.json",
|
||||
compilerOptions: { strict: true },
|
||||
});
|
||||
expect(() =>
|
||||
setTsconfigPaths.apply(ctx, {
|
||||
paths: { "@acme/ui/*": ["../../packages/ui/src/*"] },
|
||||
}),
|
||||
).toThrow(/extends/);
|
||||
});
|
||||
|
||||
it("revert removes only the keys we added", () => {
|
||||
const { ctx, abs } = setup({
|
||||
compilerOptions: {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { type Codemod, mergeJson, readJson, writeJson } from "../index";
|
||||
import { assertSafeRelativePath } from "@stanza/registry";
|
||||
import { applyEdits, modify, parse, type ParseError, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
import type { Codemod } from "../index";
|
||||
|
||||
/**
|
||||
* Merge entries into a `tsconfig.json`'s `compilerOptions.paths` map.
|
||||
@@ -52,10 +55,22 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
||||
);
|
||||
}
|
||||
|
||||
const root = readObject(abs);
|
||||
let text = fs.readFileSync(abs, "utf8");
|
||||
const root = parseJsonc(text, rel);
|
||||
const compilerOptions = isRecord(root.compilerOptions) ? root.compilerOptions : {};
|
||||
const existing = isRecord(compilerOptions.paths) ? compilerOptions.paths : {};
|
||||
|
||||
// `extends` chains a parent tsconfig — adding `paths` at the child level
|
||||
// *replaces* the inherited map, silently dropping any aliases the parent
|
||||
// declared. Refuse and ask the user to add the alias to the right level.
|
||||
if (root.extends !== undefined) {
|
||||
throw new Error(
|
||||
`set-tsconfig-paths: ${rel} has an \`extends\` clause; adding paths here ` +
|
||||
`would override the inherited map. Add ${Object.keys(args.paths).join(", ")} ` +
|
||||
`to the extended tsconfig manually, or remove \`extends\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Conflict check: same key, different value → throw so the user resolves.
|
||||
for (const [key, val] of Object.entries(args.paths)) {
|
||||
const prior = existing[key];
|
||||
@@ -67,7 +82,6 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
||||
}
|
||||
}
|
||||
|
||||
// Already satisfied → no-op.
|
||||
const allPresent = Object.entries(args.paths).every(
|
||||
([k, v]) => existing[k] !== undefined && arraysEqual(toArray(existing[k]), v),
|
||||
);
|
||||
@@ -76,13 +90,13 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
||||
return { touchedFiles: [] };
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
compilerOptions: {
|
||||
...(compilerOptions.baseUrl === undefined ? { baseUrl: "." } : {}),
|
||||
paths: { ...existing, ...args.paths },
|
||||
},
|
||||
};
|
||||
mergeJson(abs, patch);
|
||||
if (compilerOptions.baseUrl === undefined) {
|
||||
text = applyEdits(text, modify(text, ["compilerOptions", "baseUrl"], ".", FORMAT));
|
||||
}
|
||||
for (const [key, val] of Object.entries(args.paths)) {
|
||||
text = applyEdits(text, modify(text, ["compilerOptions", "paths", key], val, FORMAT));
|
||||
}
|
||||
fs.writeFileSync(abs, text, "utf8");
|
||||
ctx.claimRegion(rel, regionKeyFor(args));
|
||||
return { touchedFiles: [rel] };
|
||||
},
|
||||
@@ -95,37 +109,56 @@ const setTsconfigPaths: Codemod<SetTsconfigPathsArgs> = {
|
||||
return { touchedFiles: [] };
|
||||
}
|
||||
|
||||
const root = readObject(abs);
|
||||
const compilerOptions = isRecord(root.compilerOptions) ? { ...root.compilerOptions } : {};
|
||||
const paths = isRecord(compilerOptions.paths) ? { ...compilerOptions.paths } : {};
|
||||
let text = fs.readFileSync(abs, "utf8");
|
||||
const root = parseJsonc(text, rel);
|
||||
const compilerOptions = isRecord(root.compilerOptions) ? root.compilerOptions : {};
|
||||
const paths = isRecord(compilerOptions.paths) ? compilerOptions.paths : {};
|
||||
let changed = false;
|
||||
for (const [key, val] of Object.entries(args.paths)) {
|
||||
if (paths[key] !== undefined && arraysEqual(toArray(paths[key]), val)) {
|
||||
delete paths[key];
|
||||
text = applyEdits(text, modify(text, ["compilerOptions", "paths", key], undefined, FORMAT));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
if (Object.keys(paths).length === 0) {
|
||||
delete compilerOptions.paths;
|
||||
} else {
|
||||
compilerOptions.paths = paths;
|
||||
// If the paths map ends up empty after removal, drop it entirely.
|
||||
const after = parseJsonc(text, rel);
|
||||
const afterCo = isRecord(after.compilerOptions) ? after.compilerOptions : {};
|
||||
if (isRecord(afterCo.paths) && Object.keys(afterCo.paths).length === 0) {
|
||||
text = applyEdits(text, modify(text, ["compilerOptions", "paths"], undefined, FORMAT));
|
||||
}
|
||||
writeJson(abs, { ...root, compilerOptions });
|
||||
fs.writeFileSync(abs, text, "utf8");
|
||||
}
|
||||
ctx.releaseRegion(rel, regionKeyFor(args));
|
||||
return { touchedFiles: changed ? [rel] : [] };
|
||||
},
|
||||
};
|
||||
|
||||
const FORMAT = { formattingOptions: { tabSize: 2, insertSpaces: true } } as const;
|
||||
|
||||
function parseJsonc(text: string, rel: string): Record<string, unknown> {
|
||||
const errors: ParseError[] = [];
|
||||
const value = parse(text, errors, { allowTrailingComma: true });
|
||||
if (errors.length > 0) {
|
||||
const first = errors[0]!;
|
||||
throw new Error(
|
||||
`set-tsconfig-paths: ${rel} is not valid JSON/JSONC (${printParseErrorCode(first.error)} at offset ${first.offset}).`,
|
||||
);
|
||||
}
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function resolveFilePath(
|
||||
ctx: { projectRoot: string; appRoot: string },
|
||||
args: SetTsconfigPathsArgs,
|
||||
): string {
|
||||
const file = args.file ?? "tsconfig.json";
|
||||
assertSafeRelativePath(file, "set-tsconfig-paths: args.file");
|
||||
const base = args.base ?? "app";
|
||||
if (base.startsWith("package:")) {
|
||||
return path.join(ctx.projectRoot, "packages", base.slice("package:".length), file);
|
||||
const dir = base.slice("package:".length);
|
||||
assertSafeRelativePath(dir, "set-tsconfig-paths: args.base package dir");
|
||||
return path.join(ctx.projectRoot, "packages", dir, file);
|
||||
}
|
||||
return path.join(base === "repo" ? ctx.projectRoot : ctx.appRoot, file);
|
||||
}
|
||||
@@ -139,11 +172,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readObject(file: string): Record<string, unknown> {
|
||||
const v = readJson(file);
|
||||
return isRecord(v) ? v : {};
|
||||
}
|
||||
|
||||
function toArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return [];
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
addNamedImport,
|
||||
type Codemod,
|
||||
type ImportDeclaration,
|
||||
type SourceFile,
|
||||
SyntaxKind,
|
||||
} from "../index";
|
||||
|
||||
@@ -53,8 +54,13 @@ const wrapRootLayout: Codemod<WrapRootLayoutArgs> = {
|
||||
const layoutRel = path.relative(ctx.projectRoot, layoutAbs);
|
||||
const sf = ctx.project().addSourceFileAtPath(layoutAbs);
|
||||
|
||||
// Idempotency: if the provider is already present, treat as no-op.
|
||||
if (sf.getText().includes(args.providerName)) return { touchedFiles: [] };
|
||||
// Idempotency: structural match on the provider's JSX tag, not raw text —
|
||||
// a mention in a comment or unrelated identifier shouldn't skip the wrap.
|
||||
if (hasJsxTag(sf, args.providerName)) {
|
||||
ctx.claimRegion(layoutRel, `imports.${args.providerName}`);
|
||||
ctx.claimRegion(layoutRel, `providers.${args.providerName}`);
|
||||
return { touchedFiles: [] };
|
||||
}
|
||||
|
||||
if (args.importKind === "default") {
|
||||
addDefaultImport(sf, args.providerImport, args.providerName);
|
||||
@@ -98,12 +104,25 @@ const wrapRootLayout: Codemod<WrapRootLayoutArgs> = {
|
||||
);
|
||||
importDecl?.remove();
|
||||
|
||||
const wrapped = `<${args.providerName}>${target.childrenMarker}</${args.providerName}>`;
|
||||
for (const node of sf.getDescendantsOfKind(SyntaxKind.JsxElement)) {
|
||||
if (normalize(node.getText()) === normalize(wrapped)) {
|
||||
node.replaceWithText(target.childrenMarker);
|
||||
break;
|
||||
// Find the provider element structurally. Only unwrap if it has no
|
||||
// attributes and its body is exactly the original marker — otherwise the
|
||||
// user customized inside the provider and we shouldn't touch it.
|
||||
const wrapper = sf
|
||||
.getDescendantsOfKind(SyntaxKind.JsxElement)
|
||||
.find((el) => el.getOpeningElement().getTagNameNode().getText() === args.providerName);
|
||||
if (wrapper) {
|
||||
const hasAttrs = wrapper.getOpeningElement().getAttributes().length > 0;
|
||||
const innerText = wrapper
|
||||
.getJsxChildren()
|
||||
.map((c) => c.getText())
|
||||
.join("")
|
||||
.trim();
|
||||
if (hasAttrs || innerText !== target.childrenMarker) {
|
||||
throw new Error(
|
||||
`wrap-root-layout: <${args.providerName}> in ${layoutRel} has user edits — unwrap manually.`,
|
||||
);
|
||||
}
|
||||
wrapper.replaceWithText(target.childrenMarker);
|
||||
}
|
||||
|
||||
ctx.releaseRegion(layoutRel, `imports.${args.providerName}`);
|
||||
@@ -113,6 +132,16 @@ const wrapRootLayout: Codemod<WrapRootLayoutArgs> = {
|
||||
},
|
||||
};
|
||||
|
||||
function hasJsxTag(sf: SourceFile, tag: string): boolean {
|
||||
for (const el of sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)) {
|
||||
if (el.getTagNameNode().getText() === tag) return true;
|
||||
}
|
||||
for (const el of sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)) {
|
||||
if (el.getTagNameNode().getText() === tag) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function frameworkTarget(frameworkId: string | undefined): FrameworkTarget | undefined {
|
||||
switch (frameworkId) {
|
||||
case "next":
|
||||
|
||||
@@ -29,7 +29,11 @@ const registryDir = path.join(outBase, "registry");
|
||||
await main();
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(path.join(registryDir, "modules"), { recursive: true });
|
||||
// Wipe + recreate so a renamed module's stale JSON doesn't linger and
|
||||
// ghost-serve from the CDN.
|
||||
const modulesOut = path.join(registryDir, "modules");
|
||||
fs.rmSync(modulesOut, { recursive: true, force: true });
|
||||
fs.mkdirSync(modulesOut, { recursive: true });
|
||||
|
||||
const dirs = fs
|
||||
.readdirSync(modulesDir, { withFileTypes: true })
|
||||
@@ -128,7 +132,13 @@ function readLogo(moduleDir: string, slug: string): Logo | undefined {
|
||||
function optimizeLogo(svg: string, prefix: string): string {
|
||||
return optimize(svg, {
|
||||
multipass: true,
|
||||
plugins: ["preset-default", { name: "prefixIds", params: { prefix, delim: "-" } }],
|
||||
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.
|
||||
{ name: "removeAttrs", params: { attrs: "on[a-zA-Z]+" } },
|
||||
],
|
||||
}).data;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +102,5 @@ export { renderTemplate, buildRenderContext, pmRun, pmRecursive } from "./templa
|
||||
|
||||
export type { ProjectNameValidation } from "./project-name";
|
||||
export { validateProjectName } from "./project-name";
|
||||
|
||||
export { safeRelativePath, assertSafeRelativePath } from "./safe-path";
|
||||
|
||||
@@ -215,6 +215,14 @@ describe("third-party registries", () => {
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown top-level keys (catches typos like `registies`)", () => {
|
||||
const result = StanzaManifestSchema.safeParse({
|
||||
...emptyManifest({ name: "acme" }),
|
||||
registies: { "@acme": "https://reg.acme.dev" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("app-aware selectors", () => {
|
||||
|
||||
@@ -66,6 +66,14 @@ export type StanzaModuleRecord = {
|
||||
* registry.
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* Snapshot of the codemod invocations the adapter ran. `stanza remove`
|
||||
* reads these to drive reverts so the operation works even if the upstream
|
||||
* registry has since renamed the adapter or its codemod list.
|
||||
*/
|
||||
codemods?: Array<{ id: string; args?: Record<string, unknown> }>;
|
||||
/** Module-level `consumesPackages`, snapshotted so revert can rebuild a render context offline. */
|
||||
consumesPackages?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -150,6 +158,15 @@ export const StanzaManifestSchema = z
|
||||
adapter: z.string(),
|
||||
apps: z.array(z.string()).optional(),
|
||||
namespace: z.string().optional(),
|
||||
codemods: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
consumesPackages: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -157,6 +174,7 @@ export const StanzaManifestSchema = z
|
||||
registries: RegistriesSchema.optional(),
|
||||
readmeChecksum: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((m, ctx) => {
|
||||
// The CLI enforces cardinality at apply-time; this catches hand-edited or
|
||||
// third-party-synthesized manifests so `selectedOne` callers can rely on it.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { safeRelativePath } from "./safe-path";
|
||||
|
||||
/**
|
||||
* Kind of app a framework module targets. The closed enum lets the runtime
|
||||
* validate "you can't install Next.js into a `kind: \"native\"` app" — add new
|
||||
@@ -408,6 +410,13 @@ const appInstallFieldsSchema = {
|
||||
scripts: z.record(z.string(), z.string()).optional(),
|
||||
};
|
||||
|
||||
// Path traversal defense — template src/dest values come from third-party
|
||||
// registry JSON, which can't be trusted to stay inside the project root.
|
||||
const relativePathSchema = z.string().superRefine((s, ctx) => {
|
||||
const err = safeRelativePath(s);
|
||||
if (err) ctx.addIssue({ code: "custom", message: err });
|
||||
});
|
||||
|
||||
const installFieldsSchema = {
|
||||
...appInstallFieldsSchema,
|
||||
// The `app` overlay routes to consuming app(s) instead of the module's
|
||||
@@ -423,8 +432,8 @@ const adapterSchema = z.object({
|
||||
templates: z
|
||||
.array(
|
||||
z.object({
|
||||
src: z.string(),
|
||||
dest: z.string(),
|
||||
src: relativePathSchema,
|
||||
dest: relativePathSchema,
|
||||
scope: z.enum(["repo", "app", "package"]).optional(),
|
||||
template: z.boolean().optional(),
|
||||
content: z.string().optional(),
|
||||
|
||||
@@ -356,4 +356,55 @@ describe("ModuleSchema (Zod) app-overlay validation", () => {
|
||||
};
|
||||
expect(ModuleSchema.safeParse(bad).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects HTTP-loaded manifests with a path-traversal `dest`", () => {
|
||||
// A hostile third-party manifest could ship a template that writes to
|
||||
// ~/.ssh/authorized_keys via `dest: "../../../../home/<user>/.ssh/..."`.
|
||||
// The relativePathSchema must reject at parse time, before any disk write.
|
||||
const bad = {
|
||||
id: "evil",
|
||||
category: "ui" as const,
|
||||
label: "evil",
|
||||
description: "",
|
||||
version: "0.1.0",
|
||||
adapters: [
|
||||
{
|
||||
key: "default",
|
||||
match: {},
|
||||
templates: [
|
||||
{
|
||||
src: "globals.css",
|
||||
dest: "../../../../home/victim/.ssh/authorized_keys",
|
||||
content: "pwned",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = ModuleSchema.safeParse(bad);
|
||||
expect(result.success).toBe(false);
|
||||
const issues = result.success ? [] : result.error.issues;
|
||||
expect(issues.some((i) => /escape with `\.\.`/.test(i.message))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects HTTP-loaded manifests with an absolute `dest`", () => {
|
||||
const bad = {
|
||||
id: "evil",
|
||||
category: "ui" as const,
|
||||
label: "evil",
|
||||
description: "",
|
||||
version: "0.1.0",
|
||||
adapters: [
|
||||
{
|
||||
key: "default",
|
||||
match: {},
|
||||
templates: [{ src: "globals.css", dest: "/etc/passwd", content: "pwned" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = ModuleSchema.safeParse(bad);
|
||||
expect(result.success).toBe(false);
|
||||
const issues = result.success ? [] : result.error.issues;
|
||||
expect(issues.some((i) => /must be relative/.test(i.message))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { assertSafeRelativePath, safeRelativePath } from "./safe-path";
|
||||
|
||||
describe("safeRelativePath", () => {
|
||||
it("accepts simple relative paths", () => {
|
||||
expect(safeRelativePath("src/foo.ts")).toBeNull();
|
||||
expect(safeRelativePath("file.ts")).toBeNull();
|
||||
expect(safeRelativePath("a/b/c.tsx")).toBeNull();
|
||||
expect(safeRelativePath("./local.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(safeRelativePath("")).toMatch(/empty/);
|
||||
});
|
||||
|
||||
it("rejects POSIX-absolute paths", () => {
|
||||
expect(safeRelativePath("/etc/passwd")).toMatch(/relative/);
|
||||
expect(safeRelativePath("/")).toMatch(/relative/);
|
||||
});
|
||||
|
||||
it("rejects Windows-style absolute paths", () => {
|
||||
expect(safeRelativePath("C:\\Windows\\System32")).toMatch(/relative/);
|
||||
expect(safeRelativePath("c:/windows")).toMatch(/relative/);
|
||||
expect(safeRelativePath("\\foo\\bar")).toMatch(/relative/);
|
||||
});
|
||||
|
||||
it("rejects `..` segments anywhere in the path", () => {
|
||||
expect(safeRelativePath("..")).toMatch(/escape/);
|
||||
expect(safeRelativePath("../etc/passwd")).toMatch(/escape/);
|
||||
expect(safeRelativePath("a/../b")).toMatch(/escape/);
|
||||
expect(safeRelativePath("a/b/..")).toMatch(/escape/);
|
||||
// Windows separator variant.
|
||||
expect(safeRelativePath("..\\etc\\passwd")).toMatch(/escape/);
|
||||
expect(safeRelativePath("a\\..\\b")).toMatch(/escape/);
|
||||
});
|
||||
|
||||
it("rejects null bytes", () => {
|
||||
expect(safeRelativePath("foo\0bar")).toMatch(/null/);
|
||||
});
|
||||
|
||||
it("accepts paths with `..` as a substring of a segment (not as the segment)", () => {
|
||||
// `..tsx` is a legitimate (if unusual) filename, not a parent reference.
|
||||
expect(safeRelativePath("foo..bar")).toBeNull();
|
||||
expect(safeRelativePath("..foo")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertSafeRelativePath", () => {
|
||||
it("returns void for safe paths", () => {
|
||||
expect(() => assertSafeRelativePath("src/foo.ts", "test")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws with the label for unsafe paths", () => {
|
||||
expect(() => assertSafeRelativePath("../../etc", "template dest")).toThrow(/template dest/);
|
||||
expect(() => assertSafeRelativePath("/abs/path", "args.file")).toThrow(/args\.file/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// Reject anything that could escape the intended root: absolute paths,
|
||||
// `..` segments, null bytes. Registry-supplied paths flow into `fs.writeFile`
|
||||
// and `addSourceFileAtPath`; a hostile third-party manifest with
|
||||
// `dest: "../../../home/<user>/.ssh/..."` would otherwise be honored.
|
||||
// Returns an error message, or `null` on acceptance.
|
||||
export function safeRelativePath(input: string): string | null {
|
||||
if (typeof input !== "string") return "path must be a string";
|
||||
if (input.length === 0) return "path cannot be empty";
|
||||
if (input.includes("\0")) return "path cannot contain null bytes";
|
||||
if (input.startsWith("/") || input.startsWith("\\")) return "path must be relative";
|
||||
if (/^[a-zA-Z]:[/\\]/.test(input)) return "path must be relative";
|
||||
const segments = input.replaceAll("\\", "/").split("/");
|
||||
for (const seg of segments) {
|
||||
if (seg === "..") return "path cannot escape with `..`";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function assertSafeRelativePath(input: string, label: string): void {
|
||||
const err = safeRelativePath(input);
|
||||
if (err) throw new Error(`${label}: ${err} (got ${JSON.stringify(input)})`);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { defineModule, type Module } from "./module";
|
||||
import type { Resolved } from "./package-json";
|
||||
import { synthesizeReadme } from "./synthesize";
|
||||
import { ENV_EXAMPLE_HEADER, synthesizeEnvExample, synthesizeReadme } from "./synthesize";
|
||||
|
||||
const nextMod: Module = defineModule({
|
||||
id: "next",
|
||||
@@ -129,3 +129,37 @@ describe("synthesizeReadme", () => {
|
||||
expect(out).toContain("## Getting started");
|
||||
});
|
||||
});
|
||||
|
||||
describe("synthesizeEnvExample", () => {
|
||||
it("emits just the header when no module declares env", () => {
|
||||
const resolved: Resolved = {
|
||||
framework: [{ module: nextMod, adapter: nextMod.adapters[0]! }],
|
||||
};
|
||||
expect(synthesizeEnvExample(resolved)).toBe(ENV_EXAMPLE_HEADER);
|
||||
});
|
||||
|
||||
it("includes module-level env entries", () => {
|
||||
const resolved: Resolved = {
|
||||
db: [{ module: postgresMod, adapter: postgresMod.adapters[0]! }],
|
||||
};
|
||||
const out = synthesizeEnvExample(resolved);
|
||||
expect(out).toContain("DATABASE_URL=");
|
||||
});
|
||||
|
||||
it("includes app-overlay env entries (CLI writes both into .env.example)", () => {
|
||||
const uiWithAppEnv: Module = defineModule({
|
||||
id: "shadcn-base",
|
||||
category: "ui",
|
||||
label: "Shadcn",
|
||||
description: "",
|
||||
version: "0.1.0",
|
||||
app: { env: [{ name: "POSTHOG_API_KEY", example: "phc_xxx", required: false }] },
|
||||
adapters: [{ key: "default", match: {} }],
|
||||
});
|
||||
const resolved: Resolved = {
|
||||
ui: [{ module: uiWithAppEnv, adapter: uiWithAppEnv.adapters[0]! }],
|
||||
};
|
||||
const out = synthesizeEnvExample(resolved);
|
||||
expect(out).toContain("POSTHOG_API_KEY=phc_xxx");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,12 +64,17 @@ export function appendEnvVar(
|
||||
* Compute the `.env.example` stanza would write for a resolved selection:
|
||||
* the managed header followed by every module's env vars, in `categoryOrder`.
|
||||
* Mirrors the CLI apply path, so the preview matches what `stanza init`
|
||||
* produces byte-for-byte.
|
||||
* produces byte-for-byte. Includes both the module-level `env` and the
|
||||
* `app.env` overlay (the CLI runner writes both into `.env.example`).
|
||||
*/
|
||||
export function synthesizeEnvExample(resolved: Resolved): string {
|
||||
let out = ENV_EXAMPLE_HEADER;
|
||||
const apply = (entry: ResolvedEntry) => {
|
||||
for (const v of mergeInstallFields(entry.module, entry.adapter).env) {
|
||||
const merged = mergeInstallFields(entry.module, entry.adapter);
|
||||
for (const v of merged.env) {
|
||||
out = appendEnvVar(out, v.name, v.example, v.description);
|
||||
}
|
||||
for (const v of merged.app.env) {
|
||||
out = appendEnvVar(out, v.name, v.example, v.description);
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+46
-279
@@ -131,6 +131,9 @@ importers:
|
||||
'@tanstack/react-start':
|
||||
specifier: ^1.168.14
|
||||
version: 1.168.14(@vitejs/plugin-rsc@0.5.26(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(crossws@0.4.5(srvx@0.11.16))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react@19.2.6)
|
||||
'@vercel/functions':
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
@@ -158,9 +161,6 @@ importers:
|
||||
nitro:
|
||||
specifier: ^3.0.260522-beta
|
||||
version: 3.0.260522-beta(@vercel/functions@3.6.0)(@voidzero-dev/vite-plus-core@0.1.22(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(typescript@6.0.3))(chokidar@5.0.0)(dotenv@17.4.2)(jiti@2.7.0)(lru-cache@11.5.0)
|
||||
posthog-js:
|
||||
specifier: ^1.376.2
|
||||
version: 1.376.2
|
||||
posthog-node:
|
||||
specifier: ^5.35.4
|
||||
version: 5.35.4
|
||||
@@ -249,6 +249,9 @@ importers:
|
||||
'@stanza/registry':
|
||||
specifier: workspace:*
|
||||
version: link:../registry
|
||||
jsonc-parser:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
ts-morph:
|
||||
specifier: ^28.0.0
|
||||
version: 28.0.0
|
||||
@@ -1176,78 +1179,10 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/core@2.2.0':
|
||||
resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/core@2.7.1':
|
||||
resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0':
|
||||
resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0':
|
||||
resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0':
|
||||
resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/resources@2.2.0':
|
||||
resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/resources@2.7.1':
|
||||
resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0':
|
||||
resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0':
|
||||
resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.9.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0':
|
||||
resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1':
|
||||
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@orama/orama@3.1.18':
|
||||
resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==}
|
||||
engines: {node: '>= 20.0.0'}
|
||||
@@ -1685,36 +1620,6 @@ packages:
|
||||
'@posthog/types@1.376.2':
|
||||
resolution: {integrity: sha512-Y3ROpAxNqgcy2G0w6JoG5Gt+P6WNY2lkHTPMPzWqexRwemYbFegDi5AifDyD9/tstKTlOYKTTExtaJ5EBcghyQ==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1':
|
||||
resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.2':
|
||||
resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0':
|
||||
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
|
||||
peerDependencies:
|
||||
@@ -2417,9 +2322,6 @@ packages:
|
||||
'@types/statuses@2.0.6':
|
||||
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -2435,6 +2337,35 @@ packages:
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
'@vercel/analytics@2.0.1':
|
||||
resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==}
|
||||
peerDependencies:
|
||||
'@remix-run/react': ^2
|
||||
'@sveltejs/kit': ^1 || ^2
|
||||
next: '>= 13'
|
||||
nuxt: '>= 3'
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
svelte: '>= 4'
|
||||
vue: ^3
|
||||
vue-router: ^4
|
||||
peerDependenciesMeta:
|
||||
'@remix-run/react':
|
||||
optional: true
|
||||
'@sveltejs/kit':
|
||||
optional: true
|
||||
next:
|
||||
optional: true
|
||||
nuxt:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
svelte:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
vue-router:
|
||||
optional: true
|
||||
|
||||
'@vercel/functions@3.6.0':
|
||||
resolution: {integrity: sha512-Xj68JYqqJwtqFWJN7EWmFN7MOiUjv5whjw48UEKqQbg8r7hRkhuJhncTU0ba3jJCh/wxEuLWckrtsKcrHQraxw==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -2874,9 +2805,6 @@ packages:
|
||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
cors@2.8.6:
|
||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -3095,9 +3023,6 @@ packages:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.6:
|
||||
resolution: {integrity: sha512-+7gzEI8trIIQkVCvQ3ucGtNfH3nOmDgVTzc62rAAOlMxLth78pwpPoZCPc7CyRzAQF89MqcfPdEWkDwnjgqktg==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
@@ -3327,9 +3252,6 @@ packages:
|
||||
fetchdts@0.1.7:
|
||||
resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==}
|
||||
|
||||
fflate@0.4.8:
|
||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||
|
||||
figures@6.1.0:
|
||||
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3842,6 +3764,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
@@ -3947,9 +3872,6 @@ packages:
|
||||
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
@@ -4511,9 +4433,6 @@ packages:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
posthog-js@1.376.2:
|
||||
resolution: {integrity: sha512-Anz2pCp7dcNbammTExiZpcKC08dxfrHYaJgaXH6rq5x3Zcfj/4FkcMJF2cGCrdQXel5Y4vftiVSseZda0HAQTQ==}
|
||||
|
||||
posthog-node@5.35.4:
|
||||
resolution: {integrity: sha512-X4OCh3Lr4tfyUc/67ssDhe5cD3fwFVq4QBdNpQwpjtuOCGWZ4wDONc6zSkE1i6FVUkTc884GvwphorKC6To/BQ==}
|
||||
engines: {node: ^20.20.0 || >=22.22.0}
|
||||
@@ -4532,9 +4451,6 @@ packages:
|
||||
peerDependencies:
|
||||
preact: '>=10 || >= 11.0.0-0'
|
||||
|
||||
preact@10.29.2:
|
||||
resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==}
|
||||
|
||||
preact@11.0.0-beta.0:
|
||||
resolution: {integrity: sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==}
|
||||
|
||||
@@ -4563,10 +4479,6 @@ packages:
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
protobufjs@7.6.1:
|
||||
resolution: {integrity: sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -4582,9 +4494,6 @@ packages:
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -5359,9 +5268,6 @@ packages:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-vitals@5.2.0:
|
||||
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -6263,81 +6169,8 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
|
||||
'@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
protobufjs: 7.6.1
|
||||
|
||||
'@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
|
||||
'@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.41.1
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.41.1': {}
|
||||
'@opentelemetry/api@1.9.1':
|
||||
optional: true
|
||||
|
||||
'@orama/orama@3.1.18': {}
|
||||
|
||||
@@ -6563,28 +6396,6 @@ snapshots:
|
||||
|
||||
'@posthog/types@1.376.2': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.1': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.1':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.2': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
|
||||
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
@@ -7307,9 +7118,6 @@ snapshots:
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -7320,6 +7128,10 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
'@vercel/analytics@2.0.1(react@19.2.6)':
|
||||
optionalDependencies:
|
||||
react: 19.2.6
|
||||
|
||||
'@vercel/functions@3.6.0':
|
||||
dependencies:
|
||||
'@vercel/oidc': 3.4.1
|
||||
@@ -7675,8 +7487,6 @@ snapshots:
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
cors@2.8.6:
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
@@ -7841,10 +7651,6 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.6:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
@@ -8127,8 +7933,6 @@ snapshots:
|
||||
|
||||
fetchdts@0.1.7: {}
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
figures@6.1.0:
|
||||
dependencies:
|
||||
is-unicode-supported: 2.1.0
|
||||
@@ -8641,6 +8445,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -8722,8 +8528,6 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
is-unicode-supported: 1.3.0
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
lru-cache@11.5.0: {}
|
||||
@@ -9596,22 +9400,6 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
posthog-js@1.376.2:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@posthog/core': 1.29.11
|
||||
'@posthog/types': 1.376.2
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.6
|
||||
fflate: 0.4.8
|
||||
preact: 10.29.2
|
||||
query-selector-shadow-dom: 1.0.1
|
||||
web-vitals: 5.2.0
|
||||
|
||||
posthog-node@5.35.4:
|
||||
dependencies:
|
||||
'@posthog/core': 1.29.11
|
||||
@@ -9622,8 +9410,6 @@ snapshots:
|
||||
dependencies:
|
||||
preact: 11.0.0-beta.0
|
||||
|
||||
preact@10.29.2: {}
|
||||
|
||||
preact@11.0.0-beta.0: {}
|
||||
|
||||
prettier@2.8.8: {}
|
||||
@@ -9647,21 +9433,6 @@ snapshots:
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
protobufjs@7.6.1:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.1
|
||||
'@protobufjs/fetch': 1.1.1
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.2
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.1
|
||||
'@types/node': 25.9.1
|
||||
long: 5.3.2
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
@@ -9675,8 +9446,6 @@ snapshots:
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
query-selector-shadow-dom@1.0.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
@@ -10559,8 +10328,6 @@ snapshots:
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-vitals@5.2.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
Reference in New Issue
Block a user