@zerotal/core 1.7.4 → 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.
@@ -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");
@@ -35,7 +35,8 @@ function _kindOf(ctor: EventCtor): string {
35
35
  *
36
36
  * @example
37
37
  * ```ts
38
- * import { FrameworkEvents, QueryExecuted } from "@zerotal/core";
38
+ * import { FrameworkEvents } from "@zerotal/core";
39
+ * import { QueryExecuted } from "@zerotal/orm";
39
40
  *
40
41
  * // In a provider's onBooting(): watch every SQL query.
41
42
  * const off = FrameworkEvents.on(QueryExecuted, (e) => {
@@ -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 = (