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:
2026-05-26 22:25:10 -04:00
parent 1fc133c7dc
commit cf233f8275
51 changed files with 1413 additions and 743 deletions
+1
View File
@@ -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
+9 -7
View File
@@ -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;
+40
View File
@@ -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)", () => {
+102 -8
View File
@@ -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;
+317
View File
@@ -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();
});
});
+190 -79
View File
@@ -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;
}
+2 -1
View File
@@ -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);
+3 -2
View File
@@ -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;
+41 -12
View File
@@ -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());
},
+14 -2
View File
@@ -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
View File
@@ -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);
});
}
+3 -3
View File
@@ -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).
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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}"
+5 -6
View File
@@ -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} />;
+4 -3
View File
@@ -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>;
}
+2 -28
View File
@@ -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;
+2 -20
View File
@@ -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;
+5 -1
View File
@@ -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;
}
+21 -22
View File
@@ -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>
+17 -10
View File
@@ -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 });
+1 -2
View File
@@ -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 },
+1 -2
View File
@@ -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 },
+8 -5
View File
@@ -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&rsquo;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>{" "}
+31 -26
View File
@@ -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,
};
}
-49
View File
@@ -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,
+1
View File
@@ -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 {
+27 -13
View File
@@ -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":
+12 -2
View File
@@ -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;
}
+2
View File
@@ -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";
+8
View File
@@ -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", () => {
+18
View File
@@ -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.
+11 -2
View File
@@ -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);
});
});
+58
View File
@@ -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/);
});
});
+22
View 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)})`);
}
+35 -1
View File
@@ -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");
});
});
+7 -2
View File
@@ -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);
}
};
+46 -279
View File
@@ -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: {}