feat: extract appendEnvVar/synthesizeEnvExample/synthesizeManifest into @stanza/registry and surface .env.example + stanza.json in web preview

- Add `packages/registry/src/synthesize.ts` with three pure (fs-free) exports: `ENV_EXAMPLE_HEADER` (the managed comment stanza writes at the top of `.env.example`), `appendEnvVar` (idempotent env-var upsert — same logic the CLI used inline, now shared), and `synthesizeEnvExample` / `synthesizeManifest` which walk resolved slots + add-ons in apply order to reproduce the files stanza writes at install time
- Refactor `packages/codemods/src/env.ts` — `addEnvVar` now delegates to `appendEnvVar` from `@stanza/registry` and just wraps the fs read/write; the inline split/splice logic is deleted; `ENV_EXAMPLE_HEADER` replaces the hardcoded string in `init.ts`
- Wire `synthesizeManifest` and `synthesizeEnvExample` into `builder-state.functions.ts` so the web builder preview tree now includes a synthesized `stanza.json` and `.env.example` alongside the already-synthesized `package.json` files — the preview matches what `stanza init` actually writes byte-for-byte
- `regions` is intentionally left empty in the synthesized manifest (it's internal per-file codemod bookkeeping that would require duplicating codemod-internal path logic to reproduce faithfully)
This commit is contained in:
2026-05-21 18:11:02 -04:00
parent cca42071ee
commit 0efdeac33a
5 changed files with 151 additions and 32 deletions
+2 -4
View File
@@ -6,6 +6,7 @@ import type { AddonCategoryId, SlotId } from "@stanza/registry";
import {
addonOrder,
appPackageJsonBase,
ENV_EXAMPLE_HEADER,
KNOWN_ADDONS,
KNOWN_SLOTS,
resolveAdapter,
@@ -158,10 +159,7 @@ function bootstrapShell(
"node_modules/\ndist/\n.output/\n.vercel/\n.turbo/\n.env\n.env.local\n.env.*.local\n*.log\n",
);
fs.writeFileSync(
path.join(projectRoot, ".env.example"),
`# Stanza-managed environment variables.\n`,
);
fs.writeFileSync(path.join(projectRoot, ".env.example"), ENV_EXAMPLE_HEADER);
// App shell — empty but layout-correct. The framework module fills it in.
// The package.json must exist before any module runs: the runner appends
+21 -6
View File
@@ -1,5 +1,10 @@
import type { Module, RegistryIndex } from "@stanza/registry";
import { moduleGroup, synthesizePackageJsons } from "@stanza/registry";
import {
moduleGroup,
synthesizeEnvExample,
synthesizeManifest,
synthesizePackageJsons,
} from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start";
import {
@@ -51,11 +56,12 @@ export const getBuilderState = createServerFn({ method: "GET" })
const resolvedAddons = resolveSelectedAddons(modules, selections, addons);
const files = selectedFiles(resolved, resolvedAddons);
// Templates carry their own content; package.json files are synthesized
// (the CLI never ships them as templates it merges deps/scripts into them
// at apply time). Surface the same resolved package.json files in the
// preview so the tree matches what stanza actually writes. Only when
// something is selected, so an empty builder still shows the empty state.
// Templates carry their own content; package.json, stanza.json, and
// .env.example are synthesized — the CLI never ships them as templates, it
// assembles them at apply time (merging deps/scripts/env, pinning the
// manifest). Surface the same resolved files in the preview so the tree
// matches what stanza actually writes. Only when something is selected, so
// an empty builder still shows the empty state.
const hasSelection =
Object.keys(resolved).length > 0 ||
Object.values(resolvedAddons).some((entries) => (entries?.length ?? 0) > 0);
@@ -68,6 +74,15 @@ export const getBuilderState = createServerFn({ method: "GET" })
for (const [path, pkg] of Object.entries(pkgJsons)) {
previewFiles.push({ path, content: JSON.stringify(pkg, null, 2) + "\n" });
}
const manifest = synthesizeManifest(resolved, resolvedAddons, { name });
previewFiles.push({
path: "stanza.json",
content: JSON.stringify(manifest, null, 2) + "\n",
});
previewFiles.push({
path: ".env.example",
content: synthesizeEnvExample(resolved, resolvedAddons),
});
}
const previewEntries = await Promise.all(
+6 -22
View File
@@ -1,9 +1,13 @@
import fs from "node:fs";
import { appendEnvVar } from "@stanza/registry";
/**
* Idempotently append an env var to a .env.example-style file. Preserves
* existing entries; updates the example value in-place if the var already
* exists; adds a leading comment if `description` is supplied.
* exists; adds a leading comment if `description` is supplied. Formatting is
* delegated to `appendEnvVar` (pure, in `@stanza/registry`) so the CLI and the
* web builder's preview produce identical files.
*/
export function addEnvVar(
envFile: string,
@@ -12,27 +16,7 @@ export function addEnvVar(
description?: string,
): void {
const contents = fs.existsSync(envFile) ? fs.readFileSync(envFile, "utf8") : "";
const lines = contents.split("\n");
const existingIdx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
const entry = description ? `# ${description}\n${name}=${example}` : `${name}=${example}`;
if (existingIdx >= 0) {
// Replace existing line (and a preceding comment if present and matches description).
const prev = lines[existingIdx - 1];
if (description && prev?.startsWith("#")) {
lines.splice(existingIdx - 1, 2, ...entry.split("\n"));
} else {
lines.splice(existingIdx, 1, ...entry.split("\n"));
}
} else {
if (contents.length > 0 && !contents.endsWith("\n")) lines.push("");
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
lines.push(...entry.split("\n"));
}
fs.writeFileSync(envFile, lines.join("\n").replace(/\n+$/, "\n"), "utf8");
fs.writeFileSync(envFile, appendEnvVar(contents, name, example, description), "utf8");
}
export function removeEnvVar(envFile: string, name: string): void {
+7
View File
@@ -59,3 +59,10 @@ export {
slotPackageJsonBase,
synthesizePackageJsons,
} from "./package-json";
export {
ENV_EXAMPLE_HEADER,
appendEnvVar,
synthesizeEnvExample,
synthesizeManifest,
} from "./synthesize";
+115
View File
@@ -0,0 +1,115 @@
import { emptyManifest, type StanzaManifest } from "./manifest";
import {
mergeInstallFields,
type PackageManager,
type ResolvedAddons,
type ResolvedEntry,
type ResolvedSlots,
} from "./package-json";
import { addonOrder, slotOrder } from "./resolver";
/** Header `stanza init` writes at the top of `.env.example`. */
export const ENV_EXAMPLE_HEADER = "# Stanza-managed environment variables.\n";
/**
* Idempotently append an env var to `.env.example`-style text, returning the
* new contents. Pure (no fs) so it backs both the CLI's `addEnvVar` and the
* web builder's preview synthesis — the single source of truth for env-file
* formatting. Updates an existing var in place; otherwise appends with a blank
* line separator and an optional leading `# description` comment.
*/
export function appendEnvVar(
contents: string,
name: string,
example: string,
description?: string,
): string {
const lines = contents.split("\n");
const existingIdx = lines.findIndex((line) => line.replace(/^#\s*/, "").startsWith(`${name}=`));
const entry = description ? `# ${description}\n${name}=${example}` : `${name}=${example}`;
if (existingIdx >= 0) {
const prev = lines[existingIdx - 1];
if (description && prev?.startsWith("#")) {
lines.splice(existingIdx - 1, 2, ...entry.split("\n"));
} else {
lines.splice(existingIdx, 1, ...entry.split("\n"));
}
} else {
if (contents.length > 0 && !contents.endsWith("\n")) lines.push("");
if (lines.length > 0 && lines[lines.length - 1] !== "") lines.push("");
lines.push(...entry.split("\n"));
}
return lines.join("\n").replace(/\n+$/, "\n");
}
/**
* Compute the `.env.example` stanza would write for a resolved selection:
* the managed header followed by every module's env vars, in the same order
* the CLI applies modules (slots first, then add-ons). Mirrors the CLI apply
* path, so the preview matches what `stanza init` produces byte-for-byte.
*/
export function synthesizeEnvExample(slots: ResolvedSlots, addons: ResolvedAddons): string {
let out = ENV_EXAMPLE_HEADER;
const apply = (entry: ResolvedEntry) => {
for (const v of mergeInstallFields(entry.module, entry.adapter).env) {
out = appendEnvVar(out, v.name, v.example, v.description);
}
};
for (const slot of slotOrder) {
const entry = slots[slot];
if (entry) apply(entry);
}
for (const category of addonOrder) {
for (const entry of addons[category] ?? []) apply(entry);
}
return out;
}
/**
* Compute the `stanza.json` manifest for a resolved selection — the same shape
* the CLI pins at install time: header (version/projectShape/packageManager/
* name/appDir), one record per filled slot, and per-category add-on records.
*
* `regions` is intentionally left empty: it's internal per-file ownership
* bookkeeping the CLI accretes as it claims templates, deps, env keys, and
* codemod edits. Faithfully reproducing the codemod-derived claims would mean
* duplicating codemod-internal path logic here, so the preview presents the
* manifest as a stack summary rather than the literal on-disk region map.
*/
export function synthesizeManifest(
slots: ResolvedSlots,
addons: ResolvedAddons,
opts: { name: string; appDir?: string; packageManager?: PackageManager },
): StanzaManifest {
const base = emptyManifest({
name: opts.name,
appDir: opts.appDir,
packageManager: opts.packageManager,
});
const modules: StanzaManifest["modules"] = {};
for (const slot of slotOrder) {
const entry = slots[slot];
if (!entry) continue;
modules[slot] = {
id: entry.module.id,
version: entry.module.version,
adapter: entry.adapter.key,
};
}
const addonRecords: StanzaManifest["addons"] = {};
for (const category of addonOrder) {
const entries = addons[category];
if (!entries?.length) continue;
addonRecords[category] = entries.map((entry) => ({
id: entry.module.id,
version: entry.module.version,
adapter: entry.adapter.key,
}));
}
return { ...base, modules, addons: addonRecords };
}