@zerotal/core 1.7.5 → 1.8.1
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 +43 -0
- package/api-surface.md +29 -0
- package/package.json +1 -1
- package/src/command/Command.ts +52 -13
- package/src/command/CommandRunner.ts +4 -0
- package/src/command/builtin/AssetsBuildCommand.ts +13 -1
- package/src/command/builtin/ServeCommand.ts +8 -1
- package/src/command/builtin/UpgradeCommand.ts +148 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/dev/BuildOutput.ts +149 -27
- package/src/dev/CssPlugins.ts +9 -2
- package/src/dev/DevDeck.ts +33 -7
- package/src/dev/index.ts +1 -1
- package/src/dev/startDevMode.ts +31 -14
- package/src/doctor/AppDoctor.ts +23 -5
- package/src/helpers/response.ts +8 -0
- package/src/upgrade/codemods/deprecated-aliases.ts +111 -0
- package/src/upgrade/codemods/index.ts +15 -0
- package/src/upgrade/runner.ts +148 -0
- package/src/upgrade/types.ts +78 -0
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
package/src/command/Command.ts
CHANGED
|
@@ -188,10 +188,11 @@ export abstract class Command {
|
|
|
188
188
|
try {
|
|
189
189
|
tty.setRawMode!(true);
|
|
190
190
|
const characters: string[] = [];
|
|
191
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
243
|
-
//
|
|
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
|
|
246
|
-
private
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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";
|
package/src/dev/BuildOutput.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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,
|
|
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(
|
|
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
|
+
}
|
package/src/dev/CssPlugins.ts
CHANGED
|
@@ -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
|
|
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[] };
|
package/src/dev/DevDeck.ts
CHANGED
|
@@ -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}
|
|
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()}
|
|
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";
|
package/src/dev/startDevMode.ts
CHANGED
|
@@ -114,23 +114,40 @@ export async function startDevMode(options: StartDevModeOptions): Promise<void>
|
|
|
114
114
|
},
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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. */
|
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -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
|
-
|
|
114
|
+
const detail =
|
|
105
115
|
`database.synchronize is on and ${migrations.length} migration(s) exist. Boot-time ` +
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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");
|
package/src/helpers/response.ts
CHANGED
|
@@ -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
|
+
}
|