@zerotal/core 1.7.5 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.8.0] — 2026-08-24
12
+
13
+ ### Added
14
+
15
+ - **`zt upgrade`** — the codemod runner. Dry by default: it rewrites source across a whole
16
+ project, so the first run is something you read and disagree with before `--write` applies
17
+ it. Nothing is written until the whole plan is known, so a run that fails halfway leaves no
18
+ half-upgraded tree. The first codemod covers the deprecated aliases — `BaseModel` → `Model`,
19
+ `routes:types` → `route:types`, `serve --dev` → `dev` — renaming the class only in a heritage
20
+ clause and fixing the import to match. What it _could not_ do is reported last and loudest,
21
+ with file, line and reason: a codemod that walks past what it does not understand implies the
22
+ job is finished when it is not.
23
+
24
+ - **`--clean` on `assets:build` and `inertia:build`.** Pruning is conservative by default —
25
+ chunk-shaped filenames plus what the last build on this machine recorded in `.zerotal/` —
26
+ which cannot recognise output some other naming produced. `--clean` needs no record: the
27
+ output directory belongs to the build. It refuses `public/` and the project root, where
28
+ deleting what was not rebuilt takes the app's images and favicon with it.
29
+
30
+ ### Fixed
31
+
32
+ - **Answering the busy-port prompt killed `serve --dev`.** The banner printed, then
33
+ `exited with code 1`, with nothing on screen saying why. Reading a prompt locks Bun's stdin
34
+ stream and the lock is held for the life of the command, so the dev deck's
35
+ `process.stdin.resume()` threw `ReadableStream is locked` — and it threw inside the alternate
36
+ screen buffer, so restoring the terminal erased the error on the way out. The prompt hands
37
+ stdin back where it took it; a deck that still cannot have it degrades to streaming instead
38
+ of dying, and a dev-mode failure stops the deck before it reports.
39
+
40
+ - **Two builds sharing an output directory deleted each other's files.** Nothing forbids
41
+ `inertia:build` and `assets:build` writing to the same place and the defaults invite it, but
42
+ the prune record was one flat list per directory — so each build read the other's files as
43
+ its own previous output and removed them. The release ended up with whichever ran last, and
44
+ neither reported a problem: the build that lost still said "Build complete", and the page it
45
+ served then 404'd its own script. The record is keyed by entry point now, so a file another
46
+ build claimed is not this one's to remove. Unclaimed chunks are still swept.
47
+
48
+ - **`zt doctor` failed a schema configuration that works.** Sync on plus migrations present
49
+ read as "the schema needs exactly one source of truth" — but sync building the schema from
50
+ the models for a fresh clone, with `synchronize` false in production where the deploy runs
51
+ `migrate`, is a documented arrangement in which the two never apply in the same environment.
52
+ It fails in production, where the deploy really does run both, and warns elsewhere.
53
+
11
54
  ## [1.7.3] — 2026-08-20
12
55
 
13
56
  ### Fixed
package/api-surface.md CHANGED
@@ -2248,6 +2248,34 @@ class TestCommand = {
2248
2248
  write: (msg: string) => void
2249
2249
  }
2250
2250
 
2251
+ class UpgradeCommand = {
2252
+ new (): UpgradeCommand
2253
+ static args: ArgDef[]
2254
+ static commandName: string
2255
+ static description: string
2256
+ static flags: ({ name: string; type: 'string'; description: string; default: string; short?: never;} | { name: string; short: string; type: 'boolean'; description: string; default: boolean;} | { name: string; short: string; type: 'string'; description: string; default: string;})[]
2257
+ static needsApp: boolean
2258
+ _readLine: () => Promise<string>
2259
+ _writer: OutputWriter
2260
+ app: unknown
2261
+ args: Record<string, string>
2262
+ ask: (question: string, defaultValue?: string) => Promise<string>
2263
+ choice: (question: string, options: string[]) => Promise<string>
2264
+ confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
2265
+ dim: (msg: string) => void
2266
+ error: (msg: string) => void
2267
+ flags: Record<string, string | number | boolean>
2268
+ info: (msg: string) => void
2269
+ line: (msg: string) => void
2270
+ newLine: () => void
2271
+ run: () => Promise<void>
2272
+ secret: (question: string) => Promise<string>
2273
+ section: (title: string) => void
2274
+ table: (rows: [string, string][], indent?: number) => void
2275
+ warn: (msg: string) => void
2276
+ write: (msg: string) => void
2277
+ }
2278
+
2251
2279
  class WorkerCommand = {
2252
2280
  new (): WorkerCommand
2253
2281
  static aliases: string[]
@@ -2487,6 +2515,7 @@ function registerDevHtmlSnippet = (name: string, fn: DevHtmlSnippet) => void
2487
2515
  function startDevMode = (options: StartDevModeOptions) => Promise<void>
2488
2516
 
2489
2517
  interface AssetBuildConfig = {
2518
+ clean?: boolean
2490
2519
  entrypoint: string | string[]
2491
2520
  loader?: Record<string, string>
2492
2521
  minify: boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -188,10 +188,11 @@ export abstract class Command {
188
188
  try {
189
189
  tty.setRawMode!(true);
190
190
  const characters: string[] = [];
191
- const stdinIterator = this._stdinIter();
191
+ const reader = this._reader();
192
+ if (!reader) return "";
192
193
  const decoder = new TextDecoder();
193
194
  outer: while (true) {
194
- const { value, done } = await stdinIterator.next();
195
+ const { value, done } = await reader.read();
195
196
  if (done) break;
196
197
  for (const character of decoder.decode(value)) {
197
198
  if (character === "\r" || character === "\n") break outer;
@@ -219,11 +220,18 @@ export abstract class Command {
219
220
  return line.replace(/\r$/, "");
220
221
  }
221
222
 
222
- const stdinIterator = this._stdinIter();
223
+ const reader = this._reader();
224
+ if (!reader) {
225
+ // stdin belongs to something else now; answer from the buffer or not at all.
226
+ const rest = this._lineBuf;
227
+ this._lineBuf = "";
228
+ return rest.replace(/\r$/, "");
229
+ }
230
+
223
231
  const decoder = new TextDecoder();
224
232
 
225
233
  while (true) {
226
- const { value, done } = await stdinIterator.next();
234
+ const { value, done } = await reader.read();
227
235
  if (done) break;
228
236
  this._lineBuf += decoder.decode(value);
229
237
  const newlineIndex = this._lineBuf.indexOf("\n");
@@ -239,16 +247,47 @@ export abstract class Command {
239
247
  return remaining.replace(/\r$/, "");
240
248
  }
241
249
 
242
- // Shared stdin async iterator one per Command instance so consecutive prompts
243
- // don't each open a competing stream on the same stdin fd.
250
+ // One reader for the whole command: a second `Bun.stdin.stream()` over the same
251
+ // fd never yields, so consecutive prompts have to share the first one.
244
252
  private _lineBuf = "";
245
- private _stdinIterator: AsyncIterator<Uint8Array> | undefined;
246
- private _stdinIter(): AsyncIterator<Uint8Array> {
247
- if (!this._stdinIterator) {
248
- this._stdinIterator = (Bun.stdin.stream() as unknown as AsyncIterable<Uint8Array>)[
249
- Symbol.asyncIterator
250
- ]();
253
+ private _stdinReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
254
+ private _stdinHandedOver = false;
255
+
256
+ private _reader(): ReadableStreamDefaultReader<Uint8Array> | undefined {
257
+ if (this._stdinHandedOver) return undefined;
258
+ this._stdinReader ??= (Bun.stdin.stream() as ReadableStream<Uint8Array>).getReader();
259
+ return this._stdinReader;
260
+ }
261
+
262
+ /**
263
+ * Give stdin back, for a command that prompts and then hands the terminal to
264
+ * something else.
265
+ *
266
+ * Reading locks the stdin stream, and the lock is held for the life of the
267
+ * command so a second prompt can still read. Anything that takes stdin over
268
+ * afterwards — `process.stdin.resume()`, a raw-mode key listener, the dev deck
269
+ * — then throws `ReadableStream is locked`. That is how answering the busy-port
270
+ * menu used to kill `zt dev` on the spot: the deck died taking the terminal
271
+ * over, and because it dies inside the alternate screen buffer, the restore
272
+ * erased the reason on its way out.
273
+ *
274
+ * Released rather than cancelled. Cancelling closes the underlying stdin, and
275
+ * the next owner would take over a terminal that never delivers a keystroke —
276
+ * a dev deck whose tab keys and `q` silently do nothing.
277
+ *
278
+ * One-way: a prompt after this returns whatever is still buffered, because a
279
+ * fresh reader on Bun's stdin hangs rather than fails, and a hang is the worse
280
+ * of the two.
281
+ */
282
+ protected releaseStdin(): void {
283
+ this._stdinHandedOver = true;
284
+ const reader = this._stdinReader;
285
+ this._stdinReader = undefined;
286
+ try {
287
+ reader?.releaseLock();
288
+ } catch {
289
+ // Only throws with a read still in flight — nobody is mid-keystroke here,
290
+ // and the caller is taking the terminal over either way.
251
291
  }
252
- return this._stdinIterator;
253
292
  }
254
293
  }
@@ -359,6 +359,7 @@ export class CommandRunner {
359
359
  RouteListCommand,
360
360
  RouteTypesCommand,
361
361
  DoctorCommand,
362
+ UpgradeCommand,
362
363
  MakeProviderCommand,
363
364
  CssBuildCommand,
364
365
  AssetsBuildCommand,
@@ -390,6 +391,9 @@ export class CommandRunner {
390
391
  // Non-web commands (console, worker, test).
391
392
  if (this._app._env !== "web") {
392
393
  this.register(ReplCommand);
394
+ // Rewrites source across the project, so it belongs nowhere near a running
395
+ // web process — same reasoning as the release commands below.
396
+ this.register(UpgradeCommand);
393
397
  this.register(CompileCommand, ["build"]);
394
398
  this.registerAll([
395
399
  WorkerCommand,
@@ -34,11 +34,23 @@ export class AssetsBuildCommand extends Command {
34
34
  description: "Minify the output",
35
35
  default: true,
36
36
  },
37
+ {
38
+ name: "clean",
39
+ short: "c",
40
+ type: "boolean" as const,
41
+ description:
42
+ "Delete everything in the output directory this build did not write. " +
43
+ "Refused when that directory holds more than build output",
44
+ default: false,
45
+ },
37
46
  ];
38
47
 
39
48
  async run(): Promise<void> {
40
49
  const cwd = process.cwd();
41
50
  const minify = this.flags["minify"] as boolean;
51
+ // Off by default: the prune below is safe anywhere, and this is not — it is
52
+ // for a directory the build owns, which is a claim only the app can make.
53
+ const clean = this.flags["clean"] as boolean;
42
54
  let built = 0;
43
55
  let failed = 0;
44
56
 
@@ -48,7 +60,7 @@ export class AssetsBuildCommand extends Command {
48
60
  ? assets.entrypoint.join(", ")
49
61
  : assets.entrypoint;
50
62
  this.info(`Building assets: ${entries} → ${assets.outDir}/`);
51
- const result = await buildConfiguredAssets({ ...assets, minify }, cwd);
63
+ const result = await buildConfiguredAssets({ ...assets, minify, clean }, cwd);
52
64
  if (result.success) built++;
53
65
  else {
54
66
  failed++;
@@ -372,7 +372,14 @@ export class ServeCommand extends Command {
372
372
  );
373
373
  }
374
374
 
375
- return this._askAboutPort(requested, owner, held);
375
+ try {
376
+ return await this._askAboutPort(requested, owner, held);
377
+ } finally {
378
+ // Handed back here, where it was taken. What runs next on this path — the
379
+ // dev deck, or a `bun --watch` child with inherited stdio — takes the
380
+ // terminal over, and cannot while the menu still holds the read lock.
381
+ this.releaseStdin();
382
+ }
376
383
  }
377
384
 
378
385
  /**
@@ -0,0 +1,148 @@
1
+ import { Command } from "../Command.ts";
2
+ import { CODEMODS } from "../../upgrade/codemods/index.ts";
3
+ import { planUpgrade, applyPlan } from "../../upgrade/runner.ts";
4
+ import { compareVersions } from "../../upgrade/types.ts";
5
+
6
+ /**
7
+ * `bun zt upgrade` — carry an app across a version boundary.
8
+ *
9
+ * Every breaking change the framework wants to make waits in the 2.0 ledger, and
10
+ * the rule attached to that ledger is that anything which *can* have a codemod
11
+ * has one before 2.0 ships. This is the runner those codemods plug into; without
12
+ * it the ledger is a list of things nobody can afford to pay.
13
+ *
14
+ * ## Dry by default
15
+ *
16
+ * It prints the plan and writes nothing unless asked. That is the opposite of
17
+ * most tools and deliberate: this rewrites source across a whole project, and
18
+ * the first run should be something a person can read and disagree with. `--write`
19
+ * is one word, and by the time somebody types it they have seen the diff summary.
20
+ *
21
+ * ## The report is the product
22
+ *
23
+ * Anyone can rewrite `BaseModel` to `Model` with `sed`. What makes an upgrade
24
+ * tool worth running is the part that says "these eleven places I did not touch,
25
+ * and here is why" — a codemod that silently walks past what it does not
26
+ * understand is worse than none, because the changes it *did* make imply the job
27
+ * is finished.
28
+ *
29
+ * @category Maintenance
30
+ */
31
+ export class UpgradeCommand extends Command {
32
+ static commandName = "upgrade";
33
+ static description = "Apply the codemods for a version upgrade";
34
+ static needsApp = false;
35
+
36
+ static flags = [
37
+ {
38
+ name: "from",
39
+ type: "string" as const,
40
+ description: "Version being upgraded from (defaults to the installed zerotal version)",
41
+ default: "",
42
+ },
43
+ {
44
+ name: "to",
45
+ type: "string" as const,
46
+ description: "Version being upgraded to",
47
+ default: "",
48
+ },
49
+ {
50
+ name: "write",
51
+ short: "w",
52
+ type: "boolean" as const,
53
+ description: "Apply the changes. Without this, the plan is printed and nothing is written",
54
+ default: false,
55
+ },
56
+ {
57
+ name: "path",
58
+ short: "p",
59
+ type: "string" as const,
60
+ description: "Directory to upgrade",
61
+ default: ".",
62
+ },
63
+ ];
64
+
65
+ async run(): Promise<void> {
66
+ const root = (this.flags["path"] as string) || ".";
67
+ const write = this.flags["write"] as boolean;
68
+
69
+ const from = ((this.flags["from"] as string) || (await this.installedVersion(root))).trim();
70
+ const to = (this.flags["to"] as string).trim();
71
+
72
+ if (!/^\d+\.\d+\.\d+$/.test(from)) {
73
+ this.error(
74
+ `Cannot tell which version this app is on. Pass --from <version>, or run this in a ` +
75
+ `project whose package.json depends on zerotal.`,
76
+ );
77
+ return;
78
+ }
79
+ if (!/^\d+\.\d+\.\d+$/.test(to)) {
80
+ this.error("Pass the version you are upgrading to: --to <version>");
81
+ return;
82
+ }
83
+ if (compareVersions(to, from) <= 0) {
84
+ this.error(`--to (${to}) must be newer than --from (${from}).`);
85
+ return;
86
+ }
87
+
88
+ const plan = await planUpgrade(root, CODEMODS, from, to);
89
+
90
+ this.info(`Upgrade ${from} → ${to}`);
91
+ this.dim(` scanned ${plan.scanned} file(s) under ${root}`);
92
+
93
+ if (plan.codemods.length === 0) {
94
+ this.info("No codemods apply to this version range. Nothing to do.");
95
+ return;
96
+ }
97
+
98
+ this.line("");
99
+ this.line("Codemods:");
100
+ for (const codemod of plan.codemods) {
101
+ const ledger = codemod.ledger ? ` (ledger #${codemod.ledger})` : "";
102
+ this.dim(` ${codemod.version} ${codemod.name}${ledger} — ${codemod.description}`);
103
+ }
104
+
105
+ this.line("");
106
+ if (plan.changes.size === 0) {
107
+ this.info("No files need changing.");
108
+ } else {
109
+ this.line(`${plan.changes.size} file(s) to change:`);
110
+ for (const change of plan.changes.values()) this.dim(` ${change.file} — ${change.summary}`);
111
+ }
112
+
113
+ // Last and loudest: the part a person has to act on.
114
+ if (plan.manual.length > 0) {
115
+ this.line("");
116
+ this.warn(`${plan.manual.length} place(s) need a decision this cannot make for you:`);
117
+ for (const item of plan.manual.slice(0, 20)) {
118
+ this.dim(` ${item.file}:${item.line} ${item.text}`);
119
+ this.dim(` ${item.reason}`);
120
+ }
121
+ if (plan.manual.length > 20) this.dim(` … and ${plan.manual.length - 20} more`);
122
+ }
123
+
124
+ this.line("");
125
+ if (!write) {
126
+ this.info("Dry run — nothing written. Re-run with --write to apply.");
127
+ return;
128
+ }
129
+
130
+ const written = await applyPlan(root, plan);
131
+ this.info(`Wrote ${written} file(s).`);
132
+ this.dim(" Run your tests, then run this again — a second run should report no changes.");
133
+ }
134
+
135
+ /** The `zerotal` version this project depends on, if it says. */
136
+ private async installedVersion(root: string): Promise<string> {
137
+ try {
138
+ const pkg = (await Bun.file(`${root}/package.json`).json()) as {
139
+ dependencies?: Record<string, string>;
140
+ devDependencies?: Record<string, string>;
141
+ };
142
+ const spec = pkg.dependencies?.["zerotal"] ?? pkg.devDependencies?.["zerotal"] ?? "";
143
+ return /(\d+\.\d+\.\d+)/.exec(spec)?.[1] ?? "";
144
+ } catch {
145
+ return "";
146
+ }
147
+ }
148
+ }
@@ -50,6 +50,7 @@ export { TestCommand } from "./TestCommand.ts";
50
50
  export { RouteListCommand } from "./RouteListCommand.ts";
51
51
  export { RouteTypesCommand } from "./RouteTypesCommand.ts";
52
52
  export { DoctorCommand } from "./DoctorCommand.ts";
53
+ export { UpgradeCommand } from "./UpgradeCommand.ts";
53
54
  export { DeployCommand, makeDeployCommand } from "./DeployCommand.ts";
54
55
  export { MakeProviderCommand } from "./MakeProviderCommand.ts";
55
56
  export { CssBuildCommand } from "./CssBuildCommand.ts";
@@ -16,6 +16,16 @@ const CHUNK_NAME = /^chunk-[a-z0-9]+\.js(\.map)?$/i;
16
16
  /** Where the per-directory record of "what the last build wrote" is kept. */
17
17
  const MANIFEST_DIR = ".zerotal/build";
18
18
 
19
+ /** What `Bun.build()` reports for one emitted file. */
20
+ interface BuildArtifact {
21
+ path: string;
22
+ /** `"entry-point"` on the files the build was asked for; absent on older shapes. */
23
+ kind?: string;
24
+ }
25
+
26
+ /** One directory's record: which build wrote which files. */
27
+ type Manifest = Record<string, string[]>;
28
+
19
29
  /**
20
30
  * Delete what an earlier build wrote to `outdir` and this one did not.
21
31
  *
@@ -40,40 +50,44 @@ const MANIFEST_DIR = ".zerotal/build";
40
50
  */
41
51
  export async function pruneBuildOutput(
42
52
  outdir: string,
43
- outputs: readonly { path: string }[],
53
+ outputs: readonly BuildArtifact[],
44
54
  ): Promise<string[]> {
45
55
  const root = resolve(outdir);
46
56
  const current = new Set(outputs.map((output) => _relative(root, output.path)));
47
57
 
48
- const previous = await _readManifest(root);
49
- const stale = new Set<string>();
58
+ // Whose output this is. Nothing stops two builds sharing a directory —
59
+ // `inertia:build` and `assets:build` both default near `public/`, and the
60
+ // default release pipeline runs them one after the other — and with a single
61
+ // list per directory each one read the *other's* files as its own previous
62
+ // build and deleted them. The release ended with whichever ran last, and no
63
+ // error anywhere: `assets:build` removed the Inertia entry point, then
64
+ // `inertia:build` removed the other bundle.
65
+ const key = _buildKey(root, outputs);
66
+ const manifest = await _readManifest(root);
67
+ const mine = new Set(manifest[key] ?? []);
68
+ const theirs = new Set(
69
+ Object.entries(manifest)
70
+ .filter(([owner]) => owner !== key)
71
+ .flatMap(([, files]) => files),
72
+ );
50
73
 
51
- for (const path of previous) {
74
+ const stale = new Set<string>();
75
+ for (const path of mine) {
52
76
  if (!current.has(path)) stale.add(path);
53
77
  }
54
78
 
55
79
  // Chunks are swept by name as well as by manifest, so a directory that has
56
80
  // been accumulating them since before any manifest existed still gets cleaned
57
- // on the next build.
81
+ // on the next build. Only the unclaimed ones: a chunk another build has
82
+ // recorded is that build's to remove.
58
83
  for (const path of await _listEntries(root)) {
59
- if (current.has(path)) continue;
84
+ if (current.has(path) || theirs.has(path)) continue;
60
85
  if (CHUNK_NAME.test(path.split("/").at(-1) ?? "")) stale.add(path);
61
86
  }
62
87
 
63
- const removed: string[] = [];
64
- for (const path of stale) {
65
- try {
66
- await unlink(join(root, path));
67
- removed.push(path);
68
- } catch {
69
- // Already gone, or held open by another process — either way the next
70
- // build tries again, and a file we could not delete is not worth failing
71
- // an otherwise good build over.
72
- }
73
- }
74
-
75
- await _writeManifest(root, [...current].sort());
76
- return removed.sort();
88
+ const removed = await _unlinkAll(root, stale);
89
+ await _writeManifest(root, { ...manifest, [key]: [...current].sort() });
90
+ return removed;
77
91
  }
78
92
 
79
93
  // ── Private ──────────────────────────────────────────────────────────────────
@@ -95,28 +109,75 @@ function _manifestPath(root: string): string {
95
109
  return join(process.cwd(), MANIFEST_DIR, `${slug}-${hash}.json`);
96
110
  }
97
111
 
98
- /** What the previous build recorded, or nothing on a first run. */
99
- async function _readManifest(root: string): Promise<string[]> {
112
+ /**
113
+ * Which build these outputs belong to, named by its entry points.
114
+ *
115
+ * Stable across rebuilds of the same build — the entry keeps its name while the
116
+ * chunks around it are rehashed — and different between two builds sharing a
117
+ * directory, which is the whole point of having it.
118
+ */
119
+ function _buildKey(root: string, outputs: readonly BuildArtifact[]): string {
120
+ const entries = outputs
121
+ .filter((output) => output.kind === "entry-point")
122
+ .map((output) => _relative(root, output.path))
123
+ .sort();
124
+ // No entry point reported: an older Bun, or a build of nothing. One shared key
125
+ // is what this did before, and is no worse than it was.
126
+ return entries.join("|") || "default";
127
+ }
128
+
129
+ /** What previous builds recorded, or nothing on a first run. */
130
+ async function _readManifest(root: string): Promise<Manifest> {
100
131
  try {
101
132
  const contents = (await Bun.file(_manifestPath(root)).json()) as unknown;
102
- if (!Array.isArray(contents)) return [];
103
- return contents.filter((entry): entry is string => typeof entry === "string");
133
+
134
+ // Written before this file recorded ownership: one flat list, which belonged
135
+ // to whichever build ran last. Kept under a key no build claims, so those
136
+ // files stay eligible for the build that recognises them and are never
137
+ // treated as another build's property.
138
+ if (Array.isArray(contents)) {
139
+ return { default: contents.filter((entry): entry is string => typeof entry === "string") };
140
+ }
141
+ if (!contents || typeof contents !== "object") return {};
142
+
143
+ const manifest: Manifest = {};
144
+ for (const [key, files] of Object.entries(contents as Record<string, unknown>)) {
145
+ if (!Array.isArray(files)) continue;
146
+ manifest[key] = files.filter((entry): entry is string => typeof entry === "string");
147
+ }
148
+ return manifest;
104
149
  } catch {
105
- return [];
150
+ return {};
106
151
  }
107
152
  }
108
153
 
109
- async function _writeManifest(root: string, files: string[]): Promise<void> {
154
+ async function _writeManifest(root: string, manifest: Manifest): Promise<void> {
110
155
  const path = _manifestPath(root);
111
156
  try {
112
157
  await mkdir(join(process.cwd(), MANIFEST_DIR), { recursive: true });
113
- await writeFile(path, JSON.stringify(files, null, 2));
158
+ await writeFile(path, JSON.stringify(manifest, null, 2));
114
159
  } catch {
115
160
  // A manifest that cannot be written costs precision on the next prune, not
116
161
  // correctness: chunks are still swept by name.
117
162
  }
118
163
  }
119
164
 
165
+ /** Delete each path, reporting what actually went. */
166
+ async function _unlinkAll(root: string, paths: Iterable<string>): Promise<string[]> {
167
+ const removed: string[] = [];
168
+ for (const path of paths) {
169
+ try {
170
+ await unlink(join(root, path));
171
+ removed.push(path);
172
+ } catch {
173
+ // Already gone, or held open by another process — either way the next
174
+ // build tries again, and a file we could not delete is not worth failing
175
+ // an otherwise good build over.
176
+ }
177
+ }
178
+ return removed.sort();
179
+ }
180
+
120
181
  /**
121
182
  * Every entry under `root`, relative and slash-normalised. Empty if unreadable
122
183
  * (a first build has nothing to clean, and neither does a missing directory).
@@ -129,3 +190,64 @@ async function _listEntries(root: string): Promise<string[]> {
129
190
  return [];
130
191
  }
131
192
  }
193
+
194
+ /**
195
+ * Remove everything in `outdir` that this build did not write.
196
+ *
197
+ * The difference from {@link pruneBuildOutput} is what it trusts. Pruning
198
+ * removes only what it recognises: a file the manifest recorded, or one named
199
+ * the way `Bun.build()` names a code-split chunk. That covers what this
200
+ * framework's own builds emit, with or without a manifest — the name rule alone
201
+ * holds a directory steady across releases on a machine that has never seen it
202
+ * before. What it cannot recognise is output some other naming produced: an app
203
+ * that sets its own `naming`, or writes a second bundle into the same directory
204
+ * by other means.
205
+ *
206
+ * This needs no recognition — whatever is not in `outputs` goes. It refuses the
207
+ * two directories where that is certainly wrong: the project root, and `public/`,
208
+ * which holds the app's images and favicon beside its bundles. Point it at a
209
+ * dedicated directory, or prune instead.
210
+ *
211
+ * Neither of these helps a directory nothing runs in. A release unpacked over the
212
+ * top of the previous one — `tar -xzf`, `rsync` without `--delete` — merges into
213
+ * it, and no build happens on that machine to clean anything: the old bundles
214
+ * stay, publicly fetchable at their content-hashed URLs. That one is fixed by
215
+ * replacing the directory on release, not here.
216
+ *
217
+ * @param outdir Absolute path the build wrote to.
218
+ * @param outputs The build's artifacts (`Bun.build()`'s `outputs`).
219
+ * @returns Paths removed, relative to `outdir`.
220
+ * @throws When `outdir` is the project root or its `public/` directory.
221
+ *
222
+ * @internal
223
+ */
224
+ export async function cleanBuildOutput(
225
+ outdir: string,
226
+ outputs: readonly BuildArtifact[],
227
+ ): Promise<string[]> {
228
+ const root = resolve(outdir);
229
+ const cwd = resolve(process.cwd());
230
+
231
+ if (root === cwd || root === join(cwd, "public")) {
232
+ throw new Error(
233
+ `Refusing to clean ${_relative(cwd, root) || "."} — it holds more than this build's output, ` +
234
+ `and everything not rebuilt would be deleted. Point the build at a directory of its own ` +
235
+ `(public/assets, say), or drop --clean and let the prune handle chunks.`,
236
+ );
237
+ }
238
+
239
+ const current = new Set(outputs.map((output) => _relative(root, output.path)));
240
+
241
+ // Directories are left behind: emptied they cost nothing, and removing them
242
+ // races a concurrent read.
243
+ const doomed = (await _listEntries(root)).filter((path) => !current.has(path));
244
+ const removed = await _unlinkAll(root, doomed);
245
+
246
+ // This directory now holds one build's output and nothing else, so the record
247
+ // says exactly that — including dropping any other build that used to claim
248
+ // files here, whose files this has just deleted. Sharing a directory with
249
+ // `--clean` is the one thing it does not support, and the record should not
250
+ // pretend otherwise.
251
+ await _writeManifest(root, { [_buildKey(root, outputs)]: [...current].sort() });
252
+ return removed;
253
+ }
@@ -4,7 +4,7 @@
4
4
  * back to the Tailwind CLI when the plugin is absent).
5
5
  */
6
6
  import type { BunPlugin } from "bun";
7
- import { pruneBuildOutput } from "./BuildOutput.ts";
7
+ import { pruneBuildOutput, cleanBuildOutput } from "./BuildOutput.ts";
8
8
  import { BuildCache } from "./BuildCache.ts";
9
9
 
10
10
  /** Outcome of one bundling helper. `skipped` means the cache answered. */
@@ -201,6 +201,12 @@ export interface AssetBuildConfig {
201
201
  minify: boolean;
202
202
  /** Per-extension loader overrides (e.g. `{ ".woff2": "file" }`). See AppAssetsConfig. */
203
203
  loader?: Record<string, string>;
204
+ /**
205
+ * Delete everything in `outDir` this build did not write, rather than only what
206
+ * the last build on this machine recorded. See {@link cleanBuildOutput} — it
207
+ * refuses a directory that holds more than build output.
208
+ */
209
+ clean?: boolean;
204
210
  }
205
211
 
206
212
  /**
@@ -271,7 +277,8 @@ export async function buildConfiguredAssets(
271
277
  // previous build — which is why the cache check returns early above rather
272
278
  // than falling through to here.
273
279
  if (result.success) {
274
- await pruneBuildOutput(outdir, result.outputs);
280
+ if (assets.clean) await cleanBuildOutput(outdir, result.outputs);
281
+ else await pruneBuildOutput(outdir, result.outputs);
275
282
  await cache.record(result.outputs, { scanRoots: _scanRoots(cwd) });
276
283
  }
277
284
  return { success: result.success, logs: result.logs as unknown[] };
@@ -217,21 +217,47 @@ export class TabsDeck implements Deck {
217
217
  process.on("unhandledRejection", this._onExit);
218
218
 
219
219
  this._write(ALT_SCREEN_ON + CURSOR_HIDE + ALT_SCROLL_ON);
220
- this._stdin.setRawMode?.(true);
221
- this._stdin.resume?.();
222
- this._stdin.on?.("data", this._onData);
223
- this._stdout.on?.("resize", this._onResize);
224
220
 
221
+ try {
222
+ this._stdin.setRawMode?.(true);
223
+ this._stdin.resume?.();
224
+ this._stdin.on?.("data", this._onData);
225
+ } catch (error) {
226
+ // Someone else still owns stdin — a prompt earlier in the same command that
227
+ // never let go, most likely. Tabs with no keys is a deck nobody can drive,
228
+ // but that is no reason to take the dev server down with it, which is
229
+ // exactly what throwing from here used to do.
230
+ this._degrade(statuses, error);
231
+ return;
232
+ }
233
+
234
+ this._stdout.on?.("resize", this._onResize);
225
235
  this._paint();
226
236
  }
227
237
 
238
+ /**
239
+ * Give up the tabs and stream instead, for a terminal that cannot be taken
240
+ * over. Losing the keyboard costs tab switching, restart and `q`; Ctrl-C still
241
+ * works, because that one is the shell's.
242
+ */
243
+ private _degrade(statuses: DevProcessStatus[], error: unknown): void {
244
+ this.stop();
245
+ this._restored = false; // stop() still has to run on quit.
246
+ this._streaming = true;
247
+ this._stream = new StreamDeck(this._options.writer, Boolean(this._stdout.isTTY));
248
+ this._stream.start(statuses);
249
+ this._stream.notice(
250
+ `keyboard controls unavailable (${error instanceof Error ? error.message : String(error)}) — streaming instead`,
251
+ );
252
+ }
253
+
228
254
  line(name: string, text: string, stream: "stdout" | "stderr"): void {
229
255
  const card = this._index.get(name);
230
256
  if (!card) return;
231
257
 
232
258
  const stamp = _clock();
233
259
  const body = stream === "stderr" ? _paint(text, "red") : text;
234
- card.lines.push(`${stamp}${body}`);
260
+ card.lines.push(`${stamp}\0${body}`);
235
261
  if (card.lines.length > SCROLLBACK) card.lines.shift();
236
262
  this._anchor(card, body);
237
263
 
@@ -256,7 +282,7 @@ export class TabsDeck implements Deck {
256
282
  }
257
283
  const card = this._cards[this._focused];
258
284
  if (!card) return;
259
- card.lines.push(`${_clock()}${_paint(text, "yellow")}`);
285
+ card.lines.push(`${_clock()}\0${_paint(text, "yellow")}`);
260
286
  this._schedulePaint();
261
287
  }
262
288
 
@@ -526,7 +552,7 @@ export class TabsDeck implements Deck {
526
552
  const needle = this._search.toLowerCase();
527
553
  const out: string[] = [];
528
554
  for (const entry of card.lines) {
529
- const split = entry.indexOf("");
555
+ const split = entry.indexOf("\0");
530
556
  const stamp = entry.slice(0, split);
531
557
  const body = entry.slice(split + 1);
532
558
  if (needle && !body.toLowerCase().includes(needle)) continue;
package/src/dev/index.ts CHANGED
@@ -33,7 +33,7 @@ export { DEV_RELOAD_CLIENT } from "./reloadClient.ts";
33
33
  export { browserEnvDefines } from "./buildEnv.ts";
34
34
  export { detectCssPlugins, buildCssBundle, buildJsBundle } from "./CssPlugins.ts";
35
35
  export type { AssetBuildConfig } from "./CssPlugins.ts";
36
- export { pruneBuildOutput } from "./BuildOutput.ts";
36
+ export { pruneBuildOutput, cleanBuildOutput } from "./BuildOutput.ts";
37
37
  // Lets `serve` and any view provider agree on whether to build at boot, so a hardened
38
38
  // production unit with a read-only output directory logs a line instead of restart-looping.
39
39
  export { bootBuildDecision, isWritableDir } from "./bootBuild.ts";
@@ -114,23 +114,40 @@ export async function startDevMode(options: StartDevModeOptions): Promise<void>
114
114
  },
115
115
  });
116
116
 
117
- // Cards exist before anything can write to them, so early output has a tab to
118
- // land in rather than being dropped for want of one.
119
- deck.start([...(wantsServer ? [serverStatus] : []), ...supervised.map(_toStatus)]);
117
+ try {
118
+ // Cards exist before anything can write to them, so early output has a tab to
119
+ // land in rather than being dropped for want of one.
120
+ deck.start([...(wantsServer ? [serverStatus] : []), ...supervised.map(_toStatus)]);
120
121
 
121
- // Started before the server rather than after it: these declared they do not
122
- // depend on it, and making them wait for a build they have nothing to do with
123
- // is dead time on every boot.
124
- supervisor.start(supervised.filter((entry) => entry.after === "none"));
122
+ // Started before the server rather than after it: these declared they do not
123
+ // depend on it, and making them wait for a build they have nothing to do with
124
+ // is dead time on every boot.
125
+ supervisor.start(supervised.filter((entry) => entry.after === "none"));
125
126
 
126
- if (!wantsServer) {
127
- // `--only=queue`, say — supervised and drawn, with no server underneath.
128
- // Still parks forever: quitting goes through the deck like everywhere else.
129
- await new Promise<never>(() => {});
130
- return;
131
- }
127
+ if (!wantsServer) {
128
+ // `--only=queue`, say — supervised and drawn, with no server underneath.
129
+ // Still parks forever: quitting goes through the deck like everywhere else.
130
+ await new Promise<never>(() => {});
131
+ return;
132
+ }
132
133
 
133
- await orchestrator.start();
134
+ await orchestrator.start();
135
+ } catch (error) {
136
+ // Give the terminal back *before* saying what went wrong.
137
+ //
138
+ // The deck draws in the alternate screen buffer, and leaving it restores the
139
+ // shell's screen — discarding everything written while it was up. So a dev
140
+ // run that failed printed its reason into a buffer that was then thrown
141
+ // away, and the developer was left with the startup banner and
142
+ // `error: script "zt" exited with code 1`. Nothing about the failure
143
+ // survived, on the one path where knowing the reason matters most.
144
+ //
145
+ // Reported here rather than left to the caller for the same reason: by the
146
+ // time an error reaches the command runner the deck may or may not have been
147
+ // stopped, and "may or may not" decides whether the message is visible.
148
+ deck.stop();
149
+ throw error;
150
+ }
134
151
  }
135
152
 
136
153
  /** The card a process starts life with, before the supervisor has run it. */
@@ -100,13 +100,31 @@ const syncVsMigrationsCheck: DoctorCheck = {
100
100
  run(app) {
101
101
  const synchronize = _config(app, "database.synchronize") === true;
102
102
  const migrations = _sourceFiles(process.cwd(), "database/migrations");
103
+
104
+ // Both present is only *fatal* in production, where `deploy:<env>` runs
105
+ // `migrate` and boot-time sync would have created the tables first.
106
+ //
107
+ // Outside production it is a documented arrangement rather than a mistake:
108
+ // sync builds the schema from the models so a fresh clone runs without a
109
+ // migration step, and `synchronize` is written as an expression that is false
110
+ // in production. This app is one of them. Failing that configuration is how a
111
+ // check stops being trusted — and this is the check the roadmap wants trusted
112
+ // enough to gate a deploy, which it cannot be while it cries wolf locally.
103
113
  if (synchronize && migrations.length > 0) {
104
- return fail(
114
+ const detail =
105
115
  `database.synchronize is on and ${migrations.length} migration(s) exist. Boot-time ` +
106
- `sync creates tables from the models first, so the first \`migrate\` fails with ` +
107
- `"table already exists". The schema needs exactly one source of truth.`,
108
- "Set synchronize: false in config/database.ts (migrations become the source of truth).",
109
- );
116
+ `sync creates tables from the models, so \`migrate\` then fails with ` +
117
+ `"table already exists".`;
118
+ return _isProductionEnv(app)
119
+ ? fail(
120
+ `${detail} In production the deploy runs \`migrate\`, so this will break the release.`,
121
+ "Set synchronize: false in config/database.ts (migrations become the source of truth).",
122
+ )
123
+ : warn(
124
+ `${detail} Fine if sync is deliberately for local only and off in production — ` +
125
+ `just do not run \`migrate\` against this database.`,
126
+ "If you meant migrations to own the schema everywhere, set synchronize: false.",
127
+ );
110
128
  }
111
129
  if (synchronize) return ok("synchronize (no migrations present)");
112
130
  return ok(migrations.length > 0 ? "migrations" : "no schema management configured");
@@ -137,6 +137,14 @@ export class RedirectBuilder {
137
137
  * Redirect to the URL stored in session under `intended_url` (set by RequireAuth when
138
138
  * intercepting an unauthenticated request), then clear it. Falls back to `fallback` when
139
139
  * none is stored, or when the stored URL is cross-origin (open-redirect guard).
140
+ *
141
+ * **Single use.** Reading it spends it, so a second call in the same sign-in gets
142
+ * `fallback` — which is right (a stale destination should not hijack a later
143
+ * navigation) and surprising when the *first* redirect did not take effect. If a
144
+ * form is resubmitted because its redirect went unnoticed, the retry lands on
145
+ * `fallback`, and a customer who was part-way through something arrives somewhere
146
+ * they did not ask for with their selection gone. Nothing here can tell the two
147
+ * cases apart; read the value before authenticating if the flow needs it twice.
140
148
  */
141
149
  intended(fallback = "/", status: 301 | 302 | 303 | 307 | 308 = 302): ResponseBuilder {
142
150
  const session = (
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Ledger #4 — retire the deprecated aliases.
3
+ *
4
+ * Three pairs, each a live alias somebody depends on, which is exactly why they
5
+ * could not be dropped in a minor:
6
+ *
7
+ * - `BaseModel` → `Model`. The same class object under its original name.
8
+ * - `routes:types` → `route:types`. Registered as an alias in `CommandRunner`.
9
+ * - `serve --dev` → `dev`. The flag still works; `dev` is the command it became.
10
+ *
11
+ * ## What this deliberately does not do
12
+ *
13
+ * **It does not touch `BaseModel` in a type position.** `Model` and `BaseModel`
14
+ * are the same runtime class, so a value-position rename is cosmetic and safe.
15
+ * In a generic bound — `<T extends BaseModel>` — the name may be load-bearing
16
+ * for a reader even though it resolves identically, and in framework source it
17
+ * genuinely is. Those are reported for a person instead.
18
+ *
19
+ * That asymmetry is the whole reason this is a codemod rather than a
20
+ * find-and-replace: the mixin-composition script learned it the hard way in
21
+ * 1.3.0, where a blind rename broke import specifiers it had not considered.
22
+ */
23
+ import type { Change, Codemod, CodemodResult, Manual, SourceFile } from "../types.ts";
24
+
25
+ /**
26
+ * `BaseModel` in a class heritage clause — the one place renaming is safe.
27
+ *
28
+ * Anchored on `class Name extends`, not on `extends` alone. A generic bound
29
+ * writes `<T extends BaseModel>` with the same keyword, and matching that was a
30
+ * real bug the tests caught: it rewrote precisely the type positions this file
31
+ * says it will not touch.
32
+ */
33
+ const BASE_MODEL_VALUE = /\bclass\s+(\w+)(\s*<[^>]*>)?\s+extends\s+BaseModel\b/g;
34
+
35
+ /** `BaseModel` inside a generic bound or a type annotation. Reported, not rewritten. */
36
+ const BASE_MODEL_TYPE = /<[^>]*\bextends\s+BaseModel\b[^>]*>|:\s*BaseModel\b/;
37
+
38
+ /** The import specifier, which has to follow the rename or the file stops compiling. */
39
+ const BASE_MODEL_IMPORT = /(\bimport\s*\{[^}]*?)\bBaseModel\b([^}]*?\}\s*from\s*["'][^"']*["'])/g;
40
+
41
+ const COMMAND_ALIASES: { find: RegExp; replace: string; label: string }[] = [
42
+ { find: /\broutes:types\b/g, replace: "route:types", label: "`routes:types` → `route:types`" },
43
+ {
44
+ find: /\b(zt|zerotal)\s+serve\s+--dev\b/g,
45
+ replace: "$1 dev",
46
+ label: "`serve --dev` → `dev`",
47
+ },
48
+ ];
49
+
50
+ export const deprecatedAliases: Codemod = {
51
+ version: "2.0.0",
52
+ name: "deprecated-aliases",
53
+ description: "Retire BaseModel, routes:types and serve --dev in favour of their real names",
54
+ ledger: 4,
55
+
56
+ run(files: SourceFile[]): CodemodResult {
57
+ const changes: Change[] = [];
58
+ const manual: Manual[] = [];
59
+
60
+ for (const { file, contents } of files) {
61
+ let next = contents;
62
+ const notes: string[] = [];
63
+
64
+ // Value positions first, then the import that has to agree with them.
65
+ const valueHits = next.match(BASE_MODEL_VALUE)?.length ?? 0;
66
+ if (valueHits > 0) {
67
+ next = next.replace(
68
+ BASE_MODEL_VALUE,
69
+ (_match, name: string, generics = "") => `class ${name}${generics} extends Model`,
70
+ );
71
+ notes.push(`${valueHits} × \`extends BaseModel\` → \`extends Model\``);
72
+ }
73
+
74
+ if (valueHits > 0 && BASE_MODEL_IMPORT.test(next)) {
75
+ BASE_MODEL_IMPORT.lastIndex = 0;
76
+ // `Model` may already be imported alongside it; collapsing to a single
77
+ // `Model` in that case would produce a duplicate specifier.
78
+ next = next.replace(BASE_MODEL_IMPORT, (whole, before: string, after: string) =>
79
+ /\bModel\b\s*[,}]/.test(before + after)
80
+ ? whole.replace(/\bBaseModel\b\s*,\s*/, "").replace(/,\s*\bBaseModel\b/, "")
81
+ : `${before}Model${after}`,
82
+ );
83
+ notes.push("import specifier updated");
84
+ }
85
+
86
+ for (const { find, replace, label } of COMMAND_ALIASES) {
87
+ const hits = next.match(find)?.length ?? 0;
88
+ if (hits === 0) continue;
89
+ next = next.replace(find, replace);
90
+ notes.push(`${hits} × ${label}`);
91
+ }
92
+
93
+ // Type positions are a handover, not a rewrite.
94
+ contents.split("\n").forEach((line, i) => {
95
+ if (!BASE_MODEL_TYPE.test(line)) return;
96
+ manual.push({
97
+ file,
98
+ line: i + 1,
99
+ text: line.trim(),
100
+ reason:
101
+ "`BaseModel` in a type position. It resolves to the same class as `Model`, so this " +
102
+ "compiles either way — renaming it is a readability call, not a correctness one.",
103
+ });
104
+ });
105
+
106
+ if (next !== contents) changes.push({ file, summary: notes.join(", "), contents: next });
107
+ }
108
+
109
+ return { changes, manual };
110
+ },
111
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Every codemod, in one list.
3
+ *
4
+ * The runner orders by version, so the order here is only what a reader sees.
5
+ * A codemod belongs here the moment its ledger entry is decided, even if the
6
+ * release that needs it is some way off — the roadmap's rule is that every
7
+ * ledger entry which *can* have a codemod has one before 2.0 ships, and the way
8
+ * that rule fails is by everyone assuming there is time.
9
+ */
10
+ import type { Codemod } from "../types.ts";
11
+ import { deprecatedAliases } from "./deprecated-aliases.ts";
12
+
13
+ export const CODEMODS: Codemod[] = [deprecatedAliases];
14
+
15
+ export { deprecatedAliases };
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The upgrade runner: read the tree once, apply the codemods a version gap
3
+ * calls for, and report before writing anything.
4
+ *
5
+ * `scripts/codemod-mixin-composition.ts` proved the shape on a real 1.3.0 break
6
+ * — walk the tree, rewrite call sites, fix the imports a find-and-replace would
7
+ * have broken, offer `--dry`. This generalises that one-off into something a
8
+ * release can hand to users, which is what `zt upgrade` has to be if the 2.0
9
+ * ledger is ever going to be payable.
10
+ *
11
+ * Three properties it keeps:
12
+ *
13
+ * **Nothing is written until the whole plan is known.** Codemods return the new
14
+ * contents rather than writing, so a run that fails halfway leaves no
15
+ * half-upgraded tree, and `--dry` is the same code path as the real thing rather
16
+ * than a separate one that can drift from it.
17
+ *
18
+ * **Running twice is safe.** Every codemod is expected to be idempotent, and the
19
+ * second run is the test: it should report no changes. That matters because the
20
+ * first thing anyone does after an upgrade that printed warnings is run it again.
21
+ *
22
+ * **What it could not do is the headline.** See `Manual` in `./types.ts`.
23
+ */
24
+ import { readdir, readFile, writeFile } from "node:fs/promises";
25
+ import { join, extname, relative } from "node:path";
26
+ import type { Change, Codemod, Manual, SourceFile } from "./types.ts";
27
+ import { compareVersions } from "./types.ts";
28
+
29
+ /** Never walked. Build output and dependencies are not the app's source. */
30
+ const SKIP_DIRS = new Set([
31
+ "node_modules",
32
+ ".git",
33
+ "dist",
34
+ "build",
35
+ "coverage",
36
+ ".next",
37
+ ".zerotal",
38
+ ".release-tarballs",
39
+ ]);
40
+
41
+ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".md", ".mdx"];
42
+
43
+ export interface UpgradePlan {
44
+ /** Codemods selected for this version gap, in the order they will run. */
45
+ codemods: Codemod[];
46
+ /** Every rewrite, keyed by file — later codemods see earlier ones' output. */
47
+ changes: Map<string, Change>;
48
+ manual: Manual[];
49
+ /** Files read, for the "scanned N files" line. */
50
+ scanned: number;
51
+ }
52
+
53
+ /** Every source file under `root`, relative-pathed and forward-slashed. */
54
+ export async function collectFiles(root: string, extensions: string[]): Promise<SourceFile[]> {
55
+ const wanted = new Set(extensions);
56
+ const out: SourceFile[] = [];
57
+
58
+ async function walk(dir: string): Promise<void> {
59
+ let entries;
60
+ try {
61
+ entries = await readdir(dir, { withFileTypes: true });
62
+ } catch {
63
+ return; // unreadable directory is not a reason to abandon an upgrade
64
+ }
65
+ for (const entry of entries) {
66
+ const full = join(dir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ if (SKIP_DIRS.has(entry.name)) continue;
69
+ await walk(full);
70
+ continue;
71
+ }
72
+ if (!wanted.has(extname(entry.name))) continue;
73
+ out.push({
74
+ file: relative(root, full).replace(/\\/g, "/"),
75
+ contents: await readFile(full, "utf8"),
76
+ });
77
+ }
78
+ }
79
+
80
+ await walk(root);
81
+ return out.sort((a, b) => a.file.localeCompare(b.file));
82
+ }
83
+
84
+ /**
85
+ * The codemods that apply to a move from `from` to `to`.
86
+ *
87
+ * Exclusive of `from`, inclusive of `to`: an app already on 1.8.0 has paid
88
+ * 1.8.0's codemods, and an app moving *to* 2.0.0 owes 2.0.0's.
89
+ */
90
+ export function selectCodemods(all: Codemod[], from: string, to: string): Codemod[] {
91
+ return all
92
+ .filter((c) => compareVersions(c.version, from) > 0 && compareVersions(c.version, to) <= 0)
93
+ .sort((a, b) => compareVersions(a.version, b.version) || a.name.localeCompare(b.name));
94
+ }
95
+
96
+ /**
97
+ * Build the plan without touching disk.
98
+ *
99
+ * Codemods run in sequence over the *accumulated* contents, so a later one sees
100
+ * what an earlier one produced. Two codemods rewriting the same line is a real
101
+ * possibility across a version range, and the alternative — each reading the
102
+ * original — silently drops one of them.
103
+ */
104
+ export async function planUpgrade(
105
+ root: string,
106
+ codemods: Codemod[],
107
+ from: string,
108
+ to: string,
109
+ ): Promise<UpgradePlan> {
110
+ const selected = selectCodemods(codemods, from, to);
111
+ const extensions = [...new Set(selected.flatMap((c) => c.extensions ?? DEFAULT_EXTENSIONS))];
112
+ const files = await collectFiles(root, extensions.length ? extensions : DEFAULT_EXTENSIONS);
113
+
114
+ const current = new Map(files.map((f) => [f.file, f.contents]));
115
+ const changes = new Map<string, Change>();
116
+ const manual: Manual[] = [];
117
+
118
+ for (const codemod of selected) {
119
+ const wanted = new Set(codemod.extensions ?? DEFAULT_EXTENSIONS);
120
+ const input: SourceFile[] = [...current]
121
+ .filter(([file]) => wanted.has(extname(file)))
122
+ .map(([file, contents]) => ({ file, contents }));
123
+
124
+ const result = codemod.run(input);
125
+ for (const change of result.changes) {
126
+ current.set(change.file, change.contents);
127
+ const existing = changes.get(change.file);
128
+ changes.set(change.file, {
129
+ file: change.file,
130
+ // One line per file in the report, so a file touched by two codemods
131
+ // reads as one entry naming both rather than appearing twice.
132
+ summary: existing ? `${existing.summary}; ${change.summary}` : change.summary,
133
+ contents: change.contents,
134
+ });
135
+ }
136
+ manual.push(...result.manual);
137
+ }
138
+
139
+ return { codemods: selected, changes, manual, scanned: files.length };
140
+ }
141
+
142
+ /** Write the plan. Separate from building it, so `--dry` shares every other step. */
143
+ export async function applyPlan(root: string, plan: UpgradePlan): Promise<number> {
144
+ for (const change of plan.changes.values()) {
145
+ await writeFile(join(root, change.file), change.contents, "utf8");
146
+ }
147
+ return plan.changes.size;
148
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * What an upgrade codemod is, and what it has to report.
3
+ *
4
+ * The shape is built around one belief: **the interesting output of an upgrade
5
+ * tool is what it could not do.** A codemod that rewrites nine call sites and
6
+ * silently walks past a tenth it did not understand is worse than one that
7
+ * rewrites nothing, because the nine give the impression the job is finished.
8
+ * So a codemod returns two lists, and the runner prints the second one louder.
9
+ */
10
+
11
+ /** A file the codemod would rewrite, or did. */
12
+ export interface Change {
13
+ /** Repo-relative path, forward slashes. */
14
+ file: string;
15
+ /** What changed, in a line a person can scan — "3 × `BaseModel` → `Model`". */
16
+ summary: string;
17
+ /** The rewritten contents. Held rather than written, so `--dry` costs nothing extra. */
18
+ contents: string;
19
+ }
20
+
21
+ /**
22
+ * Something the codemod recognised and deliberately did not touch.
23
+ *
24
+ * Not an error — a handover. Each one is a place the author has to look, with
25
+ * enough detail to find it and a reason that explains why a machine should not
26
+ * decide.
27
+ */
28
+ export interface Manual {
29
+ file: string;
30
+ /** 1-based, so it can be clicked. */
31
+ line: number;
32
+ /** The line as it stands. */
33
+ text: string;
34
+ /** Why this one is a person's call. */
35
+ reason: string;
36
+ }
37
+
38
+ export interface CodemodResult {
39
+ changes: Change[];
40
+ manual: Manual[];
41
+ }
42
+
43
+ /** One file handed to a codemod. */
44
+ export interface SourceFile {
45
+ file: string;
46
+ contents: string;
47
+ }
48
+
49
+ export interface Codemod {
50
+ /**
51
+ * The release that makes this necessary.
52
+ *
53
+ * Codemods run in version order, and only those between the app's current
54
+ * version and the target. A codemod for 2.0.0 does not run on an app moving
55
+ * from 1.6 to 1.7.
56
+ */
57
+ version: string;
58
+ /** Stable identifier, for `--only` and for reporting. */
59
+ name: string;
60
+ /** One line, shown in the plan before anything is written. */
61
+ description: string;
62
+ /** The ledger entry this pays, if it pays one. */
63
+ ledger?: number;
64
+ /** Files this wants to see. Keeps a codemod from scanning what it cannot use. */
65
+ extensions?: string[];
66
+ run(files: SourceFile[]): CodemodResult;
67
+ }
68
+
69
+ /** Compare two `x.y.z` strings numerically. */
70
+ export function compareVersions(a: string, b: string): number {
71
+ const left = a.split(".").map(Number);
72
+ const right = b.split(".").map(Number);
73
+ for (let i = 0; i < 3; i++) {
74
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
75
+ if (diff !== 0) return diff;
76
+ }
77
+ return 0;
78
+ }