mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-07-16 18:05:58 -04:00
feat: add --dry-run plan output and post-apply summary to stanza add (#28)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"stanza-cli": minor
|
||||
---
|
||||
|
||||
`stanza add --dry-run` now prints a grouped plan of every file it would create, modify, or skip — including the source files its codemods would edit and the reason for any skip (e.g. a dependency you already pin higher) — instead of just "no files were written". A real `add` prints the same created/modified/skipped tally as a one-line summary when it finishes.
|
||||
|
||||
To enumerate codemod edits accurately, a dry run now reads your source files, so it can surface blockers (like a missing root layout) before a real apply. It still writes nothing.
|
||||
@@ -18,6 +18,7 @@ import pc from "picocolors";
|
||||
import { applyModule, RegionConflictError } from "../lib/codemod-runner";
|
||||
import { ensureCleanWorktree } from "../lib/git";
|
||||
import { findProjectRoot, readManifest, writeManifest } from "../lib/manifest";
|
||||
import { formatPlanLines, summarizePlan } from "../lib/plan-format";
|
||||
import { regenerateReadmeIfUnmodified } from "../lib/readme";
|
||||
import { loadRegistries } from "../lib/registry-loader";
|
||||
import * as telemetry from "../lib/telemetry";
|
||||
@@ -233,8 +234,19 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
module: mod.id,
|
||||
namespace: namespace ?? DEFAULT_NAMESPACE,
|
||||
});
|
||||
spinner.stop(`${pc.green("✓")} ${mod.label} added`);
|
||||
if (result.bootstrappedPackage) {
|
||||
spinner.stop(`${pc.green("✓")} ${mod.label} ${dryRun ? "planned" : "added"}`);
|
||||
|
||||
// Surface exactly what would change (dry-run) or did change (apply). The plan
|
||||
// is built from the same walk that stages the writes, so it can't drift.
|
||||
const appLabel = pickedAppId ? ` → ${pc.cyan(pickedAppId)}` : "";
|
||||
if (dryRun) {
|
||||
// A plain message (not p.note) so long env/codemod details aren't wrapped
|
||||
// by a box border in narrow terminals.
|
||||
const title = pc.bold(`Plan for ${category}/${moduleId}${appLabel}`);
|
||||
p.log.message([title, ...formatPlanLines(result.plan)].join("\n"));
|
||||
}
|
||||
|
||||
if (!dryRun && result.bootstrappedPackage) {
|
||||
const { name } = result.bootstrappedPackage;
|
||||
p.log.info(`Run ${pc.cyan("pnpm install")} to link ${pc.cyan(name)}.`);
|
||||
}
|
||||
@@ -252,7 +264,11 @@ export async function cmdAdd(args: CliArgs): Promise<void> {
|
||||
p.log.warn("Skipped README.md refresh (user-modified). Delete the file to regenerate.");
|
||||
}
|
||||
|
||||
if (dryRun) p.log.info(pc.yellow("[dry-run] no files were written"));
|
||||
if (dryRun) {
|
||||
p.log.info(`${summarizePlan(result.plan)} — ${pc.yellow("no files were written (dry run)")}`);
|
||||
} else {
|
||||
p.log.info(pc.dim(summarizePlan(result.plan)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -149,6 +149,22 @@ describe("cmdAdd", () => {
|
||||
expect(fs.existsSync("packages/db/package.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("dry-run previews a package-home add without writing anything", async () => {
|
||||
await cmdAdd(args({ slot: "db", moduleId: "postgres", "dry-run": true }));
|
||||
expect(process.exitCode).toBeFalsy();
|
||||
|
||||
// The manifest still has no db selection and no package was bootstrapped.
|
||||
const manifest = JSON.parse(fs.readFileSync("stanza.json", "utf8"));
|
||||
expect(manifest.modules.db).toBeUndefined();
|
||||
expect(fs.existsSync("packages/db/package.json")).toBe(false);
|
||||
|
||||
// A real add afterwards still succeeds — dry-run left no partial state.
|
||||
await cmdAdd(args({ slot: "db", moduleId: "postgres" }));
|
||||
expect(process.exitCode).toBeFalsy();
|
||||
expect(JSON.parse(fs.readFileSync("stanza.json", "utf8")).modules.db[0].id).toBe("postgres");
|
||||
expect(fs.existsSync("packages/db/package.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a slot that is already filled", async () => {
|
||||
await cmdAdd(args({ slot: "db", moduleId: "postgres" }));
|
||||
process.exitCode = undefined;
|
||||
|
||||
@@ -2,12 +2,14 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { defineModule, type Module, emptyManifest } from "@withstanza/schema";
|
||||
import { defineModule, type AppSpec, type Module, emptyManifest } from "@withstanza/schema";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import {
|
||||
applyModule,
|
||||
assertWithinRoot,
|
||||
planSlotPackageBootstrap,
|
||||
type PlanAction,
|
||||
recordFor,
|
||||
writeDepKeepingHigher,
|
||||
} from "./codemod-runner";
|
||||
@@ -411,3 +413,78 @@ describe("recordFor", () => {
|
||||
expect(record.consumesPackages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// An app-home `testing` module exercising every plan branch: a fresh template
|
||||
// (create), an over-an-existing template (modify), a new dep (modify), a dep
|
||||
// the user already pins higher (skip), and a new env var (create).
|
||||
function probeModule(): Module {
|
||||
return defineModule({
|
||||
id: "probe",
|
||||
category: "testing",
|
||||
label: "Probe",
|
||||
description: "",
|
||||
version: "0.1.0",
|
||||
devDependencies: { "left-pad": "^1.0.0" },
|
||||
env: [{ name: "PROBE_TOKEN", example: "x", required: false }],
|
||||
adapters: [
|
||||
{
|
||||
key: "default",
|
||||
match: {},
|
||||
dependencies: { react: "^18.0.0" },
|
||||
templates: [
|
||||
{ src: "new.ts", dest: "probe-new.ts", scope: "app" },
|
||||
{ src: "existing.ts", dest: "probe-existing.ts", scope: "app" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function findAction(plan: PlanAction[], pathSuffix: string, detailNeedle: string): PlanAction {
|
||||
const hit = plan.find((a) => a.path.endsWith(pathSuffix) && a.detail.includes(detailNeedle));
|
||||
if (!hit) throw new Error(`no plan action for ${pathSuffix} / ${detailNeedle}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
describe("applyModule dry-run plan", () => {
|
||||
const webApp: AppSpec = { id: "web", dir: "apps/web", kind: "web" };
|
||||
|
||||
it("classifies create/modify/skip and writes nothing", async () => {
|
||||
const projectRoot = tmp;
|
||||
const appDir = path.join(projectRoot, "apps/web");
|
||||
// Target package.json must exist; the user already pins react higher than
|
||||
// the module's declared range, so that dep should be skipped.
|
||||
writePkg(appDir, { name: "@app/web", dependencies: { react: "^19.0.0" } });
|
||||
// An on-disk template target → the template would overwrite it (modify).
|
||||
fs.writeFileSync(path.join(appDir, "probe-existing.ts"), "// user code\n");
|
||||
|
||||
const mod = probeModule();
|
||||
const result = await applyModule({
|
||||
projectRoot,
|
||||
manifest: emptyManifest({ name: "app", apps: [webApp] }),
|
||||
module: mod,
|
||||
adapter: mod.adapters[0]!,
|
||||
targetApps: [webApp],
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(findAction(result.plan, "apps/web/probe-new.ts", "template").op).toBe("create");
|
||||
expect(findAction(result.plan, "apps/web/probe-existing.ts", "template").op).toBe("modify");
|
||||
expect(findAction(result.plan, "apps/web/package.json", "left-pad").op).toBe("modify");
|
||||
const reactSkip = findAction(result.plan, "apps/web/package.json", "react");
|
||||
expect(reactSkip.op).toBe("skip");
|
||||
expect(reactSkip.reason).toMatch(/newer version/i);
|
||||
expect(findAction(result.plan, ".env.example", "PROBE_TOKEN").op).toBe("create");
|
||||
|
||||
// Nothing was written: no manifest, no new template file, no env file, and
|
||||
// the user's package.json is untouched (no left-pad, react still ^19).
|
||||
expect(fs.existsSync(path.join(projectRoot, "stanza.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(appDir, "probe-new.ts"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(projectRoot, ".env.example"))).toBe(false);
|
||||
const pkg = readJson(path.join(appDir, "package.json"));
|
||||
expect(pkg.dependencies?.react).toBe("^19.0.0");
|
||||
expect(pkg.devDependencies?.["left-pad"]).toBeUndefined();
|
||||
expect(fs.readFileSync(path.join(appDir, "probe-existing.ts"), "utf8")).toBe("// user code\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,10 +32,28 @@ import { manifestPath, writeManifest } from "./manifest";
|
||||
import { resolveRanges } from "./npm-version";
|
||||
import { claim, release, RegionConflictError } from "./region-tracker";
|
||||
|
||||
/**
|
||||
* A single entry in the human-facing preview of what an apply would do. Built
|
||||
* during the same walk that stages the real writes, so the preview can't drift
|
||||
* from what apply actually does. Surfaced by `add` on `--dry-run` and as the
|
||||
* post-apply summary.
|
||||
*/
|
||||
export type PlanAction = {
|
||||
op: "create" | "modify" | "skip";
|
||||
/** Repo-relative, forward-slashed (matches region keys). */
|
||||
path: string;
|
||||
/** Human label, e.g. "template", "dependency @clerk/nextjs", "codemod wrap-root-layout". */
|
||||
detail: string;
|
||||
/** Present for op:"skip" (e.g. "newer version already pinned"). */
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type RunResult = {
|
||||
manifest: StanzaManifest;
|
||||
touchedFiles: string[];
|
||||
dryRun: boolean;
|
||||
/** Ordered preview of file actions this apply would take (templates, deps, env, codemods). */
|
||||
plan: PlanAction[];
|
||||
/**
|
||||
* Non-null when this add caused a new `packages/<dir>/` package to be
|
||||
* bootstrapped — used by `add.ts` to print a `pnpm install` hint.
|
||||
@@ -184,6 +202,12 @@ export async function applyModule(args: {
|
||||
// knows how to sweep — the old single-pass ordering didn't.
|
||||
const deferredWrites: Array<() => void> = [];
|
||||
|
||||
// Human-facing preview, accumulated alongside the staged writes so it can't
|
||||
// drift from what apply does. `create` vs `modify` is decided by
|
||||
// `fs.existsSync` at walk time — accurate in both modes since writes are
|
||||
// deferred (nothing on disk has changed yet when we look).
|
||||
const plan: PlanAction[] = [];
|
||||
|
||||
// 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 ?? []) {
|
||||
@@ -197,6 +221,7 @@ export async function applyModule(args: {
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
assertWithinRoot(projectRoot, rel, `${module.id} template dest`);
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
plan.push(templateAction(rel, dest));
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl);
|
||||
const rendered = tpl.template ? renderTemplate(source, renderContextFor(app)) : source;
|
||||
@@ -219,6 +244,7 @@ export async function applyModule(args: {
|
||||
const rel = path.relative(projectRoot, dest).replaceAll(path.sep, "/");
|
||||
assertWithinRoot(projectRoot, rel, `${module.id} template dest`);
|
||||
manifest = claim(manifest, rel, "file", owner);
|
||||
plan.push(templateAction(rel, dest));
|
||||
if (!dryRun) {
|
||||
const source = readTemplateSource(tpl);
|
||||
const rendered = tpl.template ? renderTemplate(source, renderContextFor(seedApp)) : source;
|
||||
@@ -273,16 +299,19 @@ 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);
|
||||
plan.push(depAction(target, pkgJsonPath, name, range, false));
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, false));
|
||||
}
|
||||
for (const [name, range] of Object.entries(devDeps)) {
|
||||
manifest = claim(manifest, target, `devDependencies.${name}`, owner);
|
||||
plan.push(depAction(target, pkgJsonPath, name, range, true));
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, true));
|
||||
}
|
||||
for (const [name, command] of Object.entries(installFields.scripts)) {
|
||||
manifest = claim(manifest, target, `scripts.${name}`, owner);
|
||||
plan.push({ op: "modify", path: target, detail: `script ${name}` });
|
||||
if (!dryRun) deferredWrites.push(() => addPackageScript(pkgJsonPath, name, command));
|
||||
}
|
||||
touchedFiles.add(target);
|
||||
@@ -292,8 +321,10 @@ export async function applyModule(args: {
|
||||
// 3. Env vars in .env.example at repo root.
|
||||
if (installFields.env.length > 0) {
|
||||
const envFile = path.join(projectRoot, ".env.example");
|
||||
const envOp = fs.existsSync(envFile) ? "modify" : "create";
|
||||
for (const v of installFields.env) {
|
||||
manifest = claim(manifest, ".env.example", v.name, owner);
|
||||
plan.push({ op: envOp, path: ".env.example", detail: `env ${v.name}` });
|
||||
if (!dryRun) deferredWrites.push(() => addEnvVar(envFile, v.name, v.example, v.description));
|
||||
}
|
||||
touchedFiles.add(".env.example");
|
||||
@@ -329,16 +360,19 @@ 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);
|
||||
plan.push(depAction(target, pkgJsonPath, name, range, false));
|
||||
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);
|
||||
plan.push(depAction(target, pkgJsonPath, name, range, true));
|
||||
if (!dryRun)
|
||||
deferredWrites.push(() => writeDepKeepingHigher(pkgJsonPath, name, range, true));
|
||||
}
|
||||
for (const [name, command] of Object.entries(appFields.scripts)) {
|
||||
manifest = claim(manifest, target, `app.scripts.${name}`, owner);
|
||||
plan.push({ op: "modify", path: target, detail: `script ${name}` });
|
||||
if (!dryRun) deferredWrites.push(() => addPackageScript(pkgJsonPath, name, command));
|
||||
}
|
||||
touchedFiles.add(target);
|
||||
@@ -348,97 +382,123 @@ export async function applyModule(args: {
|
||||
// App-overlay env vars share the same `.env.example` destination — no
|
||||
// separate per-app env files yet. Treat them like primary env.
|
||||
const envFile = path.join(projectRoot, ".env.example");
|
||||
const envOp = fs.existsSync(envFile) ? "modify" : "create";
|
||||
for (const v of appFields.env) {
|
||||
manifest = claim(manifest, ".env.example", v.name, owner);
|
||||
plan.push({ op: envOp, path: ".env.example", detail: `env ${v.name}` });
|
||||
if (!dryRun) deferredWrites.push(() => addEnvVar(envFile, v.name, v.example, v.description));
|
||||
}
|
||||
touchedFiles.add(".env.example");
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
// Snapshot every file we're about to touch so a throw anywhere in the
|
||||
// mutation phase rolls the worktree back to its pre-apply state instead of
|
||||
// leaving a partial change. Captured up front (the pre-transaction bytes);
|
||||
// codemod targets that surface later are snapshotted as they're claimed.
|
||||
const tx = new FileTx();
|
||||
tx.snapshot(manifestPath(projectRoot));
|
||||
for (const rel of touchedFiles) tx.snapshot(path.join(projectRoot, rel));
|
||||
if (packageRoot) {
|
||||
tx.snapshot(path.join(packageRoot, "package.json"));
|
||||
tx.snapshot(path.join(packageRoot, "tsconfig.json"));
|
||||
}
|
||||
for (const app of targetApps) tx.snapshot(path.join(projectRoot, app.dir, "package.json"));
|
||||
|
||||
try {
|
||||
const record = recordFor(module, adapter, targetApps, namespace);
|
||||
// Push into the category's array, replacing any same-(id, apps-key)
|
||||
// record so re-adds are idempotent. Single-choice categories with
|
||||
// home:"app" are kept to one record per app id by `add`/`init`
|
||||
// validation; other homes stay capped at ≤ 1 total.
|
||||
const existing = manifest.modules[module.category] ?? [];
|
||||
const sameKey = (r: typeof record) => r.id === record.id && sameAppSet(r.apps, record.apps);
|
||||
manifest = {
|
||||
...manifest,
|
||||
modules: {
|
||||
...manifest.modules,
|
||||
[module.category]: [...existing.filter((r) => !sameKey(r)), record],
|
||||
// Codemod execution is shared between apply (mutate + persist) and dry-run
|
||||
// (enumerate the source files they'd touch, persist nothing). ts-morph edits
|
||||
// stay in memory until `project.save()`, which only runs when `persist` is
|
||||
// true — so a dry-run *reads* source but never writes. Codemods read peer
|
||||
// state from the manifest, not their own (not-yet-persisted) record, so
|
||||
// running them against the in-memory manifest is safe in both modes. First-
|
||||
// party codemods only ever target pre-existing user/peer files (never a file
|
||||
// this same module's deferred template would create), so dry-run's
|
||||
// unflushed templates don't break enumeration; a throw is a genuine blocker
|
||||
// (e.g. a missing layout) worth surfacing before a real apply.
|
||||
const runCodemods = async (
|
||||
persist: boolean,
|
||||
onSnapshot?: (abs: string) => void,
|
||||
): Promise<void> => {
|
||||
if (!adapter.codemods?.length) return;
|
||||
const dispatchApps = home.kind === "repo" ? [seedApp] : targetApps;
|
||||
const saves: Array<() => Promise<void>> = [];
|
||||
for (const app of dispatchApps) {
|
||||
const appRoot = path.join(projectRoot, app.dir);
|
||||
const project = lazyProject(appRoot);
|
||||
const ctx = buildContext({
|
||||
projectRoot,
|
||||
app,
|
||||
appRoot,
|
||||
manifest,
|
||||
module,
|
||||
adapter,
|
||||
project: project.get,
|
||||
touchedFiles,
|
||||
dryRun,
|
||||
onClaim: (file, region) => {
|
||||
onSnapshot?.(path.join(projectRoot, file));
|
||||
manifest = claim(manifest, file, region, owner);
|
||||
},
|
||||
};
|
||||
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
|
||||
// repo-home). ts-morph edits stay in memory until `project.save()` runs
|
||||
// last, so a codemod throw never reaches disk; direct-fs codemods are
|
||||
// snapshotted as they claim / report touched files.
|
||||
if (adapter.codemods?.length) {
|
||||
const dispatchApps = home.kind === "repo" ? [seedApp] : targetApps;
|
||||
const saves: Array<() => Promise<void>> = [];
|
||||
for (const app of dispatchApps) {
|
||||
const appRoot = path.join(projectRoot, app.dir);
|
||||
const project = lazyProject(appRoot);
|
||||
const ctx = buildContext({
|
||||
projectRoot,
|
||||
app,
|
||||
appRoot,
|
||||
manifest,
|
||||
module,
|
||||
adapter,
|
||||
project: project.get,
|
||||
touchedFiles,
|
||||
dryRun,
|
||||
onClaim: (file, region) => {
|
||||
tx.snapshot(path.join(projectRoot, file));
|
||||
manifest = claim(manifest, file, region, owner);
|
||||
},
|
||||
});
|
||||
for (const invocation of adapter.codemods) {
|
||||
const fn = CODEMOD_CATALOG[invocation.id]!;
|
||||
const renderedArgs = renderArgs(invocation.args ?? {}, renderContextFor(app));
|
||||
const result = await fn.apply(ctx, renderedArgs);
|
||||
for (const f of result.touchedFiles) {
|
||||
tx.snapshot(path.isAbsolute(f) ? f : path.join(projectRoot, f));
|
||||
}
|
||||
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());
|
||||
});
|
||||
for (const invocation of adapter.codemods) {
|
||||
const fn = CODEMOD_CATALOG[invocation.id]!;
|
||||
const renderedArgs = renderArgs(invocation.args ?? {}, renderContextFor(app));
|
||||
const result = await fn.apply(ctx, renderedArgs);
|
||||
for (const f of result.touchedFiles) {
|
||||
const abs = path.isAbsolute(f) ? f : path.join(projectRoot, f);
|
||||
onSnapshot?.(abs);
|
||||
const rel = path.relative(projectRoot, abs).replaceAll(path.sep, "/");
|
||||
plan.push({ op: "modify", path: rel, detail: `codemod ${invocation.id}` });
|
||||
touchedFiles.add(rel);
|
||||
}
|
||||
for (const save of saves) await save();
|
||||
// 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.
|
||||
if (persist) writeManifest(projectRoot, manifest);
|
||||
}
|
||||
} catch (err) {
|
||||
// Restore the worktree to its pre-apply state, then surface the error.
|
||||
tx.rollback();
|
||||
throw err;
|
||||
saves.push(() => project.save());
|
||||
}
|
||||
if (persist) for (const save of saves) await save();
|
||||
};
|
||||
|
||||
if (dryRun) {
|
||||
// Rehearse codemods in-memory so the preview lists the files they'd edit.
|
||||
// Nothing is saved.
|
||||
await runCodemods(false);
|
||||
return { manifest, touchedFiles: [...touchedFiles], dryRun, plan, bootstrappedPackage };
|
||||
}
|
||||
|
||||
return { manifest, touchedFiles: [...touchedFiles], dryRun, bootstrappedPackage };
|
||||
// Snapshot every file we're about to touch so a throw anywhere in the
|
||||
// mutation phase rolls the worktree back to its pre-apply state instead of
|
||||
// leaving a partial change. Captured up front (the pre-transaction bytes);
|
||||
// codemod targets that surface later are snapshotted as they're claimed.
|
||||
const tx = new FileTx();
|
||||
tx.snapshot(manifestPath(projectRoot));
|
||||
for (const rel of touchedFiles) tx.snapshot(path.join(projectRoot, rel));
|
||||
if (packageRoot) {
|
||||
tx.snapshot(path.join(packageRoot, "package.json"));
|
||||
tx.snapshot(path.join(packageRoot, "tsconfig.json"));
|
||||
}
|
||||
for (const app of targetApps) tx.snapshot(path.join(projectRoot, app.dir, "package.json"));
|
||||
|
||||
try {
|
||||
const record = recordFor(module, adapter, targetApps, namespace);
|
||||
// Push into the category's array, replacing any same-(id, apps-key)
|
||||
// record so re-adds are idempotent. Single-choice categories with
|
||||
// home:"app" are kept to one record per app id by `add`/`init`
|
||||
// validation; other homes stay capped at ≤ 1 total.
|
||||
const existing = manifest.modules[module.category] ?? [];
|
||||
const sameKey = (r: typeof record) => r.id === record.id && sameAppSet(r.apps, record.apps);
|
||||
manifest = {
|
||||
...manifest,
|
||||
modules: {
|
||||
...manifest.modules,
|
||||
[module.category]: [...existing.filter((r) => !sameKey(r)), record],
|
||||
},
|
||||
};
|
||||
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
|
||||
// repo-home). ts-morph edits stay in memory until `project.save()` runs
|
||||
// last, so a codemod throw never reaches disk; direct-fs codemods are
|
||||
// snapshotted as they claim / report touched files.
|
||||
await runCodemods(true, (abs) => tx.snapshot(abs));
|
||||
} catch (err) {
|
||||
// Restore the worktree to its pre-apply state, then surface the error.
|
||||
tx.rollback();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { manifest, touchedFiles: [...touchedFiles], dryRun, plan, bootstrappedPackage };
|
||||
}
|
||||
|
||||
export function recordFor(
|
||||
@@ -903,6 +963,57 @@ function isSemverishRange(v: string): boolean {
|
||||
return semver.validRange(v) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan entry for a template write. `create` vs `modify` is decided by whether
|
||||
* the destination already exists — surfacing that a template would overwrite a
|
||||
* file the user may have authored (Stanza templates overwrite unconditionally).
|
||||
*/
|
||||
function templateAction(rel: string, dest: string): PlanAction {
|
||||
return fs.existsSync(dest)
|
||||
? { op: "modify", path: rel, detail: "template (overwrites)" }
|
||||
: { op: "create", path: rel, detail: "template" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan entry for a dependency write. Mirrors `writeDepKeepingHigher`'s decision
|
||||
* via the shared `shouldKeepExisting` so the preview's `skip` matches what the
|
||||
* writer would actually do — a user already on a newer (or non-semver) pin is
|
||||
* left untouched.
|
||||
*/
|
||||
function depAction(
|
||||
target: string,
|
||||
pkgJsonPath: string,
|
||||
name: string,
|
||||
incoming: string,
|
||||
dev: boolean,
|
||||
): PlanAction {
|
||||
const detail = `${dev ? "dev dependency" : "dependency"} ${name}`;
|
||||
if (wouldKeepExisting(pkgJsonPath, name, incoming, dev)) {
|
||||
return { op: "skip", path: target, detail, reason: "newer version already pinned" };
|
||||
}
|
||||
return { op: "modify", path: target, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only counterpart to `writeDepKeepingHigher`: returns true when the
|
||||
* existing pin would be kept (so the planner can mark a `skip` without writing).
|
||||
* Shares `shouldKeepExisting` so planner and writer can't disagree.
|
||||
*/
|
||||
function wouldKeepExisting(
|
||||
pkgJsonPath: string,
|
||||
name: string,
|
||||
incoming: string,
|
||||
dev: boolean,
|
||||
): boolean {
|
||||
if (!fs.existsSync(pkgJsonPath)) return false;
|
||||
const key = dev ? "devDependencies" : "dependencies";
|
||||
const pkg: Record<string, Record<string, string> | undefined> = JSON.parse(
|
||||
fs.readFileSync(pkgJsonPath, "utf8"),
|
||||
);
|
||||
const existing = pkg[key]?.[name];
|
||||
return existing !== undefined && shouldKeepExisting(existing, incoming);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully resolve symlinks in `p`, tolerating a non-existent tail. Like
|
||||
* `fs.realpathSync`, but it doesn't throw when the path (or a dangling link
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import type { PlanAction } from "./codemod-runner";
|
||||
import { formatPlanLines, summarizePlan } from "./plan-format";
|
||||
|
||||
const sample: PlanAction[] = [
|
||||
{ op: "create", path: "apps/web/proxy.ts", detail: "template" },
|
||||
{ op: "modify", path: "apps/web/src/app/layout.tsx", detail: "codemod wrap-root-layout" },
|
||||
{
|
||||
op: "skip",
|
||||
path: "apps/web/package.json",
|
||||
detail: "dependency react",
|
||||
reason: "newer version already pinned",
|
||||
},
|
||||
];
|
||||
|
||||
describe("summarizePlan", () => {
|
||||
it("tallies each op, omitting empty buckets", () => {
|
||||
expect(summarizePlan(sample)).toBe("1 created, 1 modified, 1 skipped");
|
||||
expect(summarizePlan([{ op: "create", path: "a", detail: "template" }])).toBe("1 created");
|
||||
});
|
||||
|
||||
it("reports no changes for an empty plan", () => {
|
||||
expect(summarizePlan([])).toBe("no changes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPlanLines", () => {
|
||||
it("renders one line per action with op, path, and detail", () => {
|
||||
const lines = formatPlanLines(sample);
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toContain("create");
|
||||
expect(lines[0]).toContain("apps/web/proxy.ts");
|
||||
expect(lines[1]).toContain("codemod wrap-root-layout");
|
||||
});
|
||||
|
||||
it("appends the reason on skip actions", () => {
|
||||
const line = formatPlanLines(sample).find((l) => l.includes("react"));
|
||||
expect(line).toContain("newer version already pinned");
|
||||
});
|
||||
|
||||
it("notes when there are no file changes", () => {
|
||||
expect(formatPlanLines([]).join("\n")).toContain("no file changes");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import pc from "picocolors";
|
||||
|
||||
import type { PlanAction } from "./codemod-runner";
|
||||
|
||||
/**
|
||||
* Render a module's apply plan as aligned, colorized lines for the terminal.
|
||||
* One line per action, columns: op, repo-relative path, detail. `skip` actions
|
||||
* append their reason. Pure except for color codes — used by `add` for the
|
||||
* `--dry-run` preview and reusable by other verbs.
|
||||
*/
|
||||
export function formatPlanLines(plan: PlanAction[]): string[] {
|
||||
if (plan.length === 0) return [pc.dim(" (no file changes)")];
|
||||
const pathWidth = Math.min(Math.max(...plan.map((a) => a.path.length)), 48);
|
||||
return plan.map((a) => {
|
||||
const op = colorOp(a.op).padEnd(6 + colorPad(a.op));
|
||||
const file = a.path.padEnd(pathWidth);
|
||||
const detail = a.reason ? `${a.detail} ${pc.dim(`— ${a.reason}`)}` : pc.dim(a.detail);
|
||||
return ` ${op} ${file} ${detail}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line tally for post-apply / dry-run footers, e.g.
|
||||
* "1 created, 3 modified, 1 skipped". Omits zero buckets; returns
|
||||
* "no changes" when the plan is empty.
|
||||
*/
|
||||
export function summarizePlan(plan: PlanAction[]): string {
|
||||
const counts = { create: 0, modify: 0, skip: 0 };
|
||||
for (const a of plan) counts[a.op] += 1;
|
||||
const parts: string[] = [];
|
||||
if (counts.create) parts.push(`${counts.create} created`);
|
||||
if (counts.modify) parts.push(`${counts.modify} modified`);
|
||||
if (counts.skip) parts.push(`${counts.skip} skipped`);
|
||||
return parts.length ? parts.join(", ") : "no changes";
|
||||
}
|
||||
|
||||
function colorOp(op: PlanAction["op"]): string {
|
||||
if (op === "create") return pc.green("create");
|
||||
if (op === "modify") return pc.yellow("modify");
|
||||
return pc.dim("skip");
|
||||
}
|
||||
|
||||
// picocolors wraps the label in escape codes, so `padEnd` would count those
|
||||
// invisible bytes. Add back the wrapper width so columns still line up.
|
||||
function colorPad(op: PlanAction["op"]): number {
|
||||
const label = op === "skip" ? "skip" : op;
|
||||
return colorOp(op).length - label.length;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ Resolves peers, selects the matching adapter, and writes the module's templates,
|
||||
|
||||
- `--app=<id>` — pick which app the install targets. Required for app-relevant modules when the project has multiple apps and you're not running inside one of them. Single-app projects auto-target; multi-app projects also auto-pick when `cwd` is inside one app's directory. Interactive runs (TTY, no `--yes`) fall back to a prompt; non-interactive runs error out asking for the flag.
|
||||
- The flag is meaningless for `home: "repo"` modules like `tooling`.
|
||||
- `--dry-run` — preview the plan (created/modified/skipped files, including codemod edits) without writing. A real `add` prints the same tally as a one-line summary when it finishes.
|
||||
|
||||
## `remove`
|
||||
|
||||
@@ -87,7 +88,7 @@ Walks every [region](/docs/concepts#regions) claim in the manifest and verifies
|
||||
|
||||
These apply to the mutating verbs (`init`, `add`, `remove`):
|
||||
|
||||
- `--dry-run` — print the actions that would be taken and write nothing.
|
||||
- `--dry-run` — print the actions that would be taken and write nothing. `add` renders a grouped plan of every file it would **create**, **modify**, or **skip** (with the reason — e.g. a dependency you already pin higher), including the source files its codemods would edit. To enumerate those accurately the dry run reads your source, so it surfaces blockers (like a missing root layout) before a real apply.
|
||||
- `--dangerously-allow-dirty` — allow a mutating command to run with a dirty git working tree. By default Stanza refuses, so its edits never mix with uncommitted changes. Commit or stash first when you can.
|
||||
- `--no-telemetry` — disable anonymous usage events for this invocation.
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ For `init --yes`, pass each category's module ids as a comma-separated value; si
|
||||
|
||||
## Safety Flags
|
||||
|
||||
- Use `--dry-run` before a mutating command when the user wants a preview. It writes nothing.
|
||||
- Use `--dry-run` before a mutating command when the user wants a preview. It writes nothing. For `add` it prints a grouped plan of every file it would create, modify, or skip (skips show the reason, e.g. a dependency the user already pins higher), including the source files its codemods would edit. Because it rehearses codemods against your source, a dry run can surface blockers (like a missing root layout) before any real apply. A real `add` prints the same created/modified/skipped tally as a one-line summary.
|
||||
- Mutating commands refuse to run in a dirty git worktree. Ask the user before using `--dangerously-allow-dirty`; it intentionally allows Stanza edits to mix with existing changes.
|
||||
- A failed `add` rolls back automatically — if any step throws (including a codemod), Stanza restores the worktree to its pre-`add` state.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user