feat: add --dry-run plan output and post-apply summary to stanza add (#28)

This commit is contained in:
2026-06-13 12:06:25 -04:00
committed by GitHub
parent 6ada5c0a88
commit dc34c92c21
9 changed files with 405 additions and 84 deletions
+7
View File
@@ -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.
+19 -3
View File
@@ -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)));
}
}
/**
+16
View File
@@ -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;
+78 -1
View File
@@ -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");
});
});
+189 -78
View File
@@ -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
+45
View File
@@ -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");
});
});
+48
View File
@@ -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;
}
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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.