mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-08-30 23:05:33 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,3 +59,10 @@ export {
|
||||
slotPackageJsonBase,
|
||||
synthesizePackageJsons,
|
||||
} from "./package-json";
|
||||
|
||||
export {
|
||||
ENV_EXAMPLE_HEADER,
|
||||
appendEnvVar,
|
||||
synthesizeEnvExample,
|
||||
synthesizeManifest,
|
||||
} from "./synthesize";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user