@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.
- package/CHANGELOG.md +43 -0
- package/api-surface.md +29 -57
- package/package.json +1 -1
- package/src/application/Application.ts +18 -2
- package/src/assets/assets.ts +56 -4
- package/src/assets/index.ts +9 -4
- package/src/build/PackageLinter.ts +56 -0
- 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/events/FrameworkEvents.ts +2 -1
- 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
|
@@ -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");
|
|
@@ -35,7 +35,8 @@ function _kindOf(ctor: EventCtor): string {
|
|
|
35
35
|
*
|
|
36
36
|
* @example
|
|
37
37
|
* ```ts
|
|
38
|
-
* import { FrameworkEvents
|
|
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) => {
|
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 = (
|