@zerotal/core 1.4.0 → 1.5.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 +351 -0
- package/package.json +1 -1
- package/src/application/Application.ts +107 -9
- package/src/application/DevErrorPage.ts +82 -0
- package/src/application/diagnostics.ts +111 -0
- package/src/command/CommandRunner.ts +82 -1
- package/src/command/builtin/AssetsBuildCommand.ts +102 -0
- package/src/command/builtin/DeployCommand.ts +315 -0
- package/src/command/builtin/DevCommand.ts +88 -0
- package/src/command/builtin/DoctorCommand.ts +97 -0
- package/src/command/builtin/MakeCommandCommand.ts +2 -0
- package/src/command/builtin/RouteTypesCommand.ts +56 -0
- package/src/command/builtin/ServeCommand.ts +232 -44
- package/src/command/builtin/index.ts +5 -0
- package/src/command/scaffold/zerotal.ts.txt +2 -10
- package/src/config/AppConfig.ts +109 -2
- package/src/config/DeployConfig.ts +71 -0
- package/src/config/index.ts +2 -0
- package/src/config/registry.ts +1 -0
- package/src/container/Container.ts +3 -3
- package/src/container/inject.ts +3 -2
- package/src/context/RequestContext.ts +60 -0
- package/src/contracts/session.ts +18 -3
- package/src/dev/BuildCache.ts +312 -0
- package/src/dev/CssPlugins.ts +93 -7
- package/src/dev/DevBuildHook.ts +14 -1
- package/src/dev/DevDeck.ts +549 -0
- package/src/dev/DevOrchestrator.ts +166 -31
- package/src/dev/DevProcess.ts +221 -0
- package/src/dev/DevReloadMiddleware.ts +1 -1
- package/src/dev/DevSupervisor.ts +363 -0
- package/src/dev/bootBuild.ts +94 -0
- package/src/dev/index.ts +24 -0
- package/src/dev/startDevMode.ts +145 -0
- package/src/doctor/AppDoctor.ts +399 -0
- package/src/doctor/TransportProbe.ts +169 -0
- package/src/events/Emitter.ts +4 -3
- package/src/facade/facades/App.ts +10 -2
- package/src/helpers/index.ts +23 -1
- package/src/helpers/response.ts +18 -8
- package/src/http/Uri.ts +7 -3
- package/src/http/originGuard.ts +1 -1
- package/src/http/url.ts +10 -4
- package/src/index.ts +43 -0
- package/src/lock/LockManager.ts +190 -14
- package/src/lock/drivers/LockDriver.ts +11 -0
- package/src/lock/drivers/MemoryLockDriver.ts +21 -1
- package/src/lock/drivers/RedisLockDriver.ts +64 -8
- package/src/lock/drivers/SqliteLockDriver.ts +13 -0
- package/src/lock/errors.ts +26 -0
- package/src/lock/facades/Lock.ts +30 -5
- package/src/lock/index.ts +2 -2
- package/src/macros/config.macro.ts +2 -0
- package/src/provider/ServiceProvider.ts +40 -0
- package/src/router/Router.ts +111 -13
- package/src/router/registry.ts +123 -0
- package/src/router/routeTypes.ts +132 -0
- package/src/support/classRef.ts +27 -0
- package/src/support/env.ts +69 -2
- package/src/support/unroutedRoutes.ts +37 -0
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* stack trace, source context, and request details.
|
|
5
5
|
*/
|
|
6
6
|
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
7
|
+
import { _diagnoseError } from "./diagnostics.ts";
|
|
8
|
+
import type { ErrorDiagnosis } from "./diagnostics.ts";
|
|
7
9
|
import { devSurfacesEnabled } from "../support/env.ts";
|
|
8
10
|
|
|
9
11
|
// ── HTTP error page (4xx / production 5xx) ────────────────────────────────────
|
|
@@ -236,6 +238,34 @@ function shortPath(full: string): string {
|
|
|
236
238
|
* with its parsed stack trace, source context, and request details. Never
|
|
237
239
|
* served in production.
|
|
238
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* The diagnosis panel, above the stack because that is where the answer is.
|
|
243
|
+
*
|
|
244
|
+
* The button posts the token it was given and replaces its own label with the
|
|
245
|
+
* outcome. On success the page reloads, because the point is to get back to what
|
|
246
|
+
* the developer was actually doing — and a failure says so in place rather than
|
|
247
|
+
* navigating away from the error that prompted it.
|
|
248
|
+
*/
|
|
249
|
+
function renderDiagnosis(d: ErrorDiagnosis): string {
|
|
250
|
+
const items = d.items?.length
|
|
251
|
+
? `<ul class="dx-items">${d.items.map((i) => `<li>${esc(i)}</li>`).join("")}</ul>`
|
|
252
|
+
: "";
|
|
253
|
+
const action = d.action
|
|
254
|
+
? `<div class="dx-actions">
|
|
255
|
+
<button class="dx-btn" id="dxRun"
|
|
256
|
+
data-url="${esc(d.action.url)}" data-token="${esc(d.action.token)}"
|
|
257
|
+
data-pending="${esc(d.action.pendingLabel ?? "Working…")}">${esc(d.action.label)}</button>
|
|
258
|
+
<span class="dx-status" id="dxStatus"></span>
|
|
259
|
+
</div>`
|
|
260
|
+
: "";
|
|
261
|
+
return `<div class="dx">
|
|
262
|
+
<div class="dx-title">${esc(d.title)}</div>
|
|
263
|
+
<div class="dx-detail">${esc(d.detail)}</div>
|
|
264
|
+
${items}
|
|
265
|
+
${action}
|
|
266
|
+
</div>`;
|
|
267
|
+
}
|
|
268
|
+
|
|
239
269
|
export async function renderDevErrorPage(err: unknown, ctx?: HttpContext): Promise<Response> {
|
|
240
270
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
241
271
|
const errClass = error.constructor?.name || "Error";
|
|
@@ -259,6 +289,11 @@ export async function renderDevErrorPage(err: unknown, ctx?: HttpContext): Promi
|
|
|
259
289
|
.map(([k, v]) => ({ k, v }))
|
|
260
290
|
: [];
|
|
261
291
|
|
|
292
|
+
// Runs before the page is assembled so a diagnosis can lead, above the stack.
|
|
293
|
+
// The stack for this error class is usually all framework frames — the answer
|
|
294
|
+
// is never in it, which is the whole reason this panel exists.
|
|
295
|
+
const diagnosis = await _diagnoseError(error, ctx);
|
|
296
|
+
|
|
262
297
|
const appFrames = ctxFrames.filter((f) => f.isApp);
|
|
263
298
|
const vendorFrames = ctxFrames.filter((f) => !f.isApp);
|
|
264
299
|
const firstApp = appFrames[0] ?? ctxFrames[0];
|
|
@@ -327,6 +362,19 @@ table.info td{padding:5px 12px;vertical-align:top;border-bottom:1px solid #f1f5f
|
|
|
327
362
|
table.info td:first-child{width:200px;color:#64748b;font-weight:500;white-space:nowrap}
|
|
328
363
|
table.info td:last-child{font-family:monospace;color:#1e293b;word-break:break-all}
|
|
329
364
|
|
|
365
|
+
/* ── Diagnosis ── */
|
|
366
|
+
.dx{background:#fffbeb;border-bottom:1px solid #fde68a;padding:20px 32px}
|
|
367
|
+
.dx-title{font-size:15px;font-weight:700;color:#92400e;margin-bottom:6px}
|
|
368
|
+
.dx-detail{font-size:13px;color:#78350f;line-height:1.65;max-width:900px}
|
|
369
|
+
.dx-items{margin:12px 0 0;padding-left:18px;font-family:monospace;font-size:12px;color:#78350f}
|
|
370
|
+
.dx-items li{margin:3px 0}
|
|
371
|
+
.dx-actions{margin-top:16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
|
372
|
+
.dx-btn{padding:8px 16px;background:#92400e;border:0;border-radius:6px;color:#fff;font-size:13px;font-weight:600;cursor:pointer;transition:opacity .15s}
|
|
373
|
+
.dx-btn:hover{opacity:.9}
|
|
374
|
+
.dx-btn:disabled{opacity:.55;cursor:default}
|
|
375
|
+
.dx-status{font-size:12px;color:#78350f}
|
|
376
|
+
.dx-status.bad{color:#b91c1c;font-family:monospace}
|
|
377
|
+
|
|
330
378
|
/* ── Footer ── */
|
|
331
379
|
.footer{padding:12px 20px;background:#f8fafc;border-top:1px solid #e2e8f0;font-size:11px;color:#94a3b8;display:flex;gap:16px}
|
|
332
380
|
</style>
|
|
@@ -352,6 +400,8 @@ table.info td:last-child{font-family:monospace;color:#1e293b;word-break:break-al
|
|
|
352
400
|
</div>
|
|
353
401
|
</div>
|
|
354
402
|
|
|
403
|
+
${diagnosis ? renderDiagnosis(diagnosis) : ""}
|
|
404
|
+
|
|
355
405
|
<!-- Body: frames + code -->
|
|
356
406
|
<div class="body">
|
|
357
407
|
|
|
@@ -459,6 +509,38 @@ table.info td:last-child{font-family:monospace;color:#1e293b;word-break:break-al
|
|
|
459
509
|
<script>
|
|
460
510
|
const ctxData = ${JSON.stringify(ctxFrames.map((f) => f.file + ":" + f.line))};
|
|
461
511
|
|
|
512
|
+
// The diagnosis button, when one was offered. Attached by id rather than inline
|
|
513
|
+
// so the panel's markup carries no script, and disabled while in flight so a
|
|
514
|
+
// double click cannot run the action twice.
|
|
515
|
+
(function () {
|
|
516
|
+
const btn = document.getElementById('dxRun');
|
|
517
|
+
if (!btn) return;
|
|
518
|
+
const status = document.getElementById('dxStatus');
|
|
519
|
+
btn.addEventListener('click', async () => {
|
|
520
|
+
btn.disabled = true;
|
|
521
|
+
const original = btn.textContent;
|
|
522
|
+
btn.textContent = btn.dataset.pending;
|
|
523
|
+
status.className = 'dx-status';
|
|
524
|
+
status.textContent = '';
|
|
525
|
+
try {
|
|
526
|
+
const res = await fetch(btn.dataset.url, {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
headers: { 'X-Zerotal-Diagnosis-Token': btn.dataset.token },
|
|
529
|
+
});
|
|
530
|
+
const body = await res.text();
|
|
531
|
+
if (!res.ok) throw new Error(body || ('HTTP ' + res.status));
|
|
532
|
+
status.textContent = body || 'Done. Reloading…';
|
|
533
|
+
// Back to what the developer was actually doing.
|
|
534
|
+
location.reload();
|
|
535
|
+
} catch (e) {
|
|
536
|
+
btn.disabled = false;
|
|
537
|
+
btn.textContent = original;
|
|
538
|
+
status.className = 'dx-status bad';
|
|
539
|
+
status.textContent = String(e && e.message ? e.message : e);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
})();
|
|
543
|
+
|
|
462
544
|
const _errorMd = ${JSON.stringify({
|
|
463
545
|
errClass,
|
|
464
546
|
message,
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnoses for the development error page.
|
|
3
|
+
*
|
|
4
|
+
* The overlay is good at showing *what* threw and bad at saying what to do about
|
|
5
|
+
* it. `no such table: assets` is the canonical case: the message is exact, the
|
|
6
|
+
* stack is entirely framework frames, and the answer — "you have three
|
|
7
|
+
* migrations you have not run" — lives in a package `@zerotal/core` cannot
|
|
8
|
+
* import, because `@zerotal/orm` depends on it and not the other way round.
|
|
9
|
+
*
|
|
10
|
+
* So the overlay asks instead. A package registers a diagnoser, the error page
|
|
11
|
+
* runs them in order, and the first one that recognises the error contributes a
|
|
12
|
+
* panel. A diagnoser that recognises nothing returns `null` and costs a function
|
|
13
|
+
* call.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* registerErrorDiagnoser((error) => {
|
|
18
|
+
* const missing = detectMissingTable(error);
|
|
19
|
+
* if (!missing) return null;
|
|
20
|
+
* return { title: `The ${missing} table does not exist.`, detail: "…" };
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
import type { HttpContext } from "../pipeline/HttpContext.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A button the overlay may offer.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately narrow: a `POST` to a same-origin path, carrying a token the page
|
|
30
|
+
* was given. It exists so a diagnosis can *fix* the thing it diagnosed, and the
|
|
31
|
+
* shape is constrained because that means a page rendered by a GET can change
|
|
32
|
+
* server state — see the safety note on {@link ErrorDiagnosis}.
|
|
33
|
+
*/
|
|
34
|
+
export interface DiagnosisAction {
|
|
35
|
+
/** Button text, e.g. `"Run 3 migrations"`. */
|
|
36
|
+
label: string;
|
|
37
|
+
/** Same-origin path the button posts to. */
|
|
38
|
+
url: string;
|
|
39
|
+
/** Single-use token minted for this render, required by the endpoint. */
|
|
40
|
+
token: string;
|
|
41
|
+
/** Text shown while the request is in flight. */
|
|
42
|
+
pendingLabel?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a diagnoser concluded.
|
|
47
|
+
*
|
|
48
|
+
* > ⚠️ **An `action` mutates server state from a page rendered by a GET.** A dev
|
|
49
|
+
* > server on `localhost:3000` is reachable by any site the developer has open in
|
|
50
|
+
* > another tab, so the endpoint behind one must: refuse outside development on
|
|
51
|
+
* > its own terms rather than trusting that the overlay is dev-only, require the
|
|
52
|
+
* > `token`, and check the origin. Whoever registers the endpoint owns all three
|
|
53
|
+
* > — this type only carries them to the page.
|
|
54
|
+
*/
|
|
55
|
+
export interface ErrorDiagnosis {
|
|
56
|
+
/** One line: what is actually wrong. */
|
|
57
|
+
title: string;
|
|
58
|
+
/** A short paragraph: why, and what to do. */
|
|
59
|
+
detail: string;
|
|
60
|
+
/** Supporting specifics — migration names, candidate files. Rendered as a list. */
|
|
61
|
+
items?: string[];
|
|
62
|
+
/** Offered only when the diagnosis is confident enough to be actionable. */
|
|
63
|
+
action?: DiagnosisAction;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ErrorDiagnoser = (
|
|
67
|
+
error: Error,
|
|
68
|
+
ctx?: HttpContext,
|
|
69
|
+
) => ErrorDiagnosis | null | Promise<ErrorDiagnosis | null>;
|
|
70
|
+
|
|
71
|
+
const _diagnosers: ErrorDiagnoser[] = [];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Contribute a diagnosis to the development error page.
|
|
75
|
+
*
|
|
76
|
+
* Registered from a provider's `onRegister()`. Diagnosers run in registration
|
|
77
|
+
* order and the first non-null result wins, so a package should recognise only
|
|
78
|
+
* errors it genuinely owns.
|
|
79
|
+
*/
|
|
80
|
+
export function registerErrorDiagnoser(diagnoser: ErrorDiagnoser): void {
|
|
81
|
+
_diagnosers.push(diagnoser);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run the registered diagnosers and return the first result.
|
|
86
|
+
*
|
|
87
|
+
* A diagnoser that throws is skipped: it runs while an error page is already
|
|
88
|
+
* being rendered, and failing there would replace a useful stack trace with a
|
|
89
|
+
* useless one.
|
|
90
|
+
*
|
|
91
|
+
* @internal
|
|
92
|
+
*/
|
|
93
|
+
export async function _diagnoseError(
|
|
94
|
+
error: Error,
|
|
95
|
+
ctx?: HttpContext,
|
|
96
|
+
): Promise<ErrorDiagnosis | null> {
|
|
97
|
+
for (const diagnoser of _diagnosers) {
|
|
98
|
+
try {
|
|
99
|
+
const result = await diagnoser(error, ctx);
|
|
100
|
+
if (result) return result;
|
|
101
|
+
} catch {
|
|
102
|
+
// See above — a broken diagnoser must not take the error page with it.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Drop every registered diagnoser. Tests. @internal */
|
|
109
|
+
export function _resetErrorDiagnosers(): void {
|
|
110
|
+
_diagnosers.length = 0;
|
|
111
|
+
}
|
|
@@ -9,6 +9,8 @@ import { Command } from "./Command.ts";
|
|
|
9
9
|
import type { ArgDef, FlagDef } from "./Command.ts";
|
|
10
10
|
import { BufferWriter } from "./OutputWriter.ts";
|
|
11
11
|
import { FrameworkEvents, CommandRan } from "../events/FrameworkEvents.ts";
|
|
12
|
+
import { makeDeployCommand } from "./builtin/DeployCommand.ts";
|
|
13
|
+
import { DEFAULT_DEPLOY_TARGETS } from "../config/DeployConfig.ts";
|
|
12
14
|
|
|
13
15
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
14
16
|
|
|
@@ -304,6 +306,18 @@ export class CommandRunner {
|
|
|
304
306
|
for (const alias of aliases) this._registry.set(alias, thunk);
|
|
305
307
|
}
|
|
306
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Whether a command is registered, without resolving it.
|
|
311
|
+
*
|
|
312
|
+
* Lets a caller compose a pipeline out of whatever this app actually has —
|
|
313
|
+
* `zt deploy:<env>` skips `inertia:build` in an app with no Inertia rather than
|
|
314
|
+
* failing on it. Deliberately does not resolve the lazy thunk: asking whether a
|
|
315
|
+
* step exists should not import the package that provides it.
|
|
316
|
+
*/
|
|
317
|
+
has(name: string): boolean {
|
|
318
|
+
return this._registry.has(name);
|
|
319
|
+
}
|
|
320
|
+
|
|
307
321
|
// ── Boot ──────────────────────────────────────────────────────────────────
|
|
308
322
|
|
|
309
323
|
/**
|
|
@@ -322,6 +336,7 @@ export class CommandRunner {
|
|
|
322
336
|
|
|
323
337
|
const {
|
|
324
338
|
ServeCommand,
|
|
339
|
+
DevCommand,
|
|
325
340
|
ReplCommand,
|
|
326
341
|
WorkerCommand,
|
|
327
342
|
CompileCommand,
|
|
@@ -342,8 +357,11 @@ export class CommandRunner {
|
|
|
342
357
|
MakeTestCommand,
|
|
343
358
|
TestCommand,
|
|
344
359
|
RouteListCommand,
|
|
360
|
+
RouteTypesCommand,
|
|
361
|
+
DoctorCommand,
|
|
345
362
|
MakeProviderCommand,
|
|
346
363
|
CssBuildCommand,
|
|
364
|
+
AssetsBuildCommand,
|
|
347
365
|
LintPackagesCommand,
|
|
348
366
|
MakePackageCommand,
|
|
349
367
|
} = await import("./builtin/index.ts");
|
|
@@ -351,13 +369,23 @@ export class CommandRunner {
|
|
|
351
369
|
// ServeCommand is always registered — usable from any environment.
|
|
352
370
|
this.register(ServeCommand);
|
|
353
371
|
|
|
372
|
+
// So is `dev`, and it has to be: `setAppEnv("dev")` boots process 1 as `web`
|
|
373
|
+
// so that web-only providers are asked for their dev processes, which puts
|
|
374
|
+
// the command itself on the wrong side of the `!== "web"` gate below.
|
|
375
|
+
this.register(DevCommand);
|
|
376
|
+
|
|
354
377
|
// reload + status are always available — they talk to the running server,
|
|
355
378
|
// not the app itself, so they don't need an application instance.
|
|
356
379
|
this.register(ReloadCommand);
|
|
357
380
|
this.register(StatusCommand);
|
|
358
381
|
|
|
359
|
-
// route:list
|
|
382
|
+
// route:list and doctor are inspection commands — always available regardless of mode.
|
|
360
383
|
this.register(RouteListCommand);
|
|
384
|
+
this.register(DoctorCommand);
|
|
385
|
+
|
|
386
|
+
// route:types reads the same booted router, so it is available wherever
|
|
387
|
+
// route:list is — including in CI containers that only ever run `--check`.
|
|
388
|
+
this.register(RouteTypesCommand, ["routes:types"]);
|
|
361
389
|
|
|
362
390
|
// Non-web commands (console, worker, test).
|
|
363
391
|
if (this._app._env !== "web") {
|
|
@@ -381,10 +409,63 @@ export class CommandRunner {
|
|
|
381
409
|
MakeTestCommand,
|
|
382
410
|
TestCommand,
|
|
383
411
|
CssBuildCommand,
|
|
412
|
+
AssetsBuildCommand,
|
|
384
413
|
LintPackagesCommand,
|
|
385
414
|
MakePackageCommand,
|
|
386
415
|
]);
|
|
416
|
+
|
|
417
|
+
// One `deploy:<target>` per environment this app releases to. Console-only,
|
|
418
|
+
// like the other release commands — a running web server has no business
|
|
419
|
+
// migrating a database.
|
|
420
|
+
this._registerDeployTargets();
|
|
421
|
+
|
|
422
|
+
// `make:command` generates into `app/commands/`; the runner reads the same
|
|
423
|
+
// directory, so a generated command is runnable without registering a provider.
|
|
424
|
+
// App commands land last, after the built-ins, so a name collision resolves in
|
|
425
|
+
// the app's favour.
|
|
426
|
+
await this._discoverAppCommands();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Register `deploy:<target>` for each target in `config/deploy.ts`, or for
|
|
432
|
+
* `production` and `staging` when the app declares none.
|
|
433
|
+
*
|
|
434
|
+
* Runs after `boot()` has loaded config, so the targets are readable here for the
|
|
435
|
+
* same reason `_discoverAppCommands` can read the conventions block.
|
|
436
|
+
*/
|
|
437
|
+
private _registerDeployTargets(): void {
|
|
438
|
+
let targets = DEFAULT_DEPLOY_TARGETS;
|
|
439
|
+
try {
|
|
440
|
+
const config = this._app.container.makeSync("config") as {
|
|
441
|
+
get(key: string, fallback?: unknown): unknown;
|
|
442
|
+
};
|
|
443
|
+
const declared = config.get("deploy.targets", undefined) as
|
|
444
|
+
Record<string, unknown> | undefined;
|
|
445
|
+
if (declared && Object.keys(declared).length > 0) targets = declared as typeof targets;
|
|
446
|
+
} catch {
|
|
447
|
+
/* no config store — the defaults are the answer */
|
|
448
|
+
}
|
|
449
|
+
for (const target of Object.keys(targets)) this.register(makeDeployCommand(target));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Auto-discover commands from the conventional directory (`app/commands/`, overridable
|
|
454
|
+
* via `app.conventions.paths.commands`). Gated by `app.conventions.enabled`, like every
|
|
455
|
+
* other convention. Best-effort: a missing directory registers nothing.
|
|
456
|
+
*/
|
|
457
|
+
private async _discoverAppCommands(root: string = process.cwd()): Promise<string[]> {
|
|
458
|
+
let raw: { enabled?: boolean; paths?: Record<string, string> } = {};
|
|
459
|
+
try {
|
|
460
|
+
const config = this._app.container.makeSync("config") as {
|
|
461
|
+
get(key: string): unknown;
|
|
462
|
+
};
|
|
463
|
+
raw = (config.get("app.conventions") ?? {}) as typeof raw;
|
|
464
|
+
} catch {
|
|
465
|
+
/* config not resolvable — use the defaults */
|
|
387
466
|
}
|
|
467
|
+
if (raw.enabled === false) return [];
|
|
468
|
+
return this.discover(`${root}/${raw.paths?.["commands"] ?? "app/commands"}`);
|
|
388
469
|
}
|
|
389
470
|
|
|
390
471
|
// ── Private resolution ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Command } from "../Command.ts";
|
|
2
|
+
import type { Application } from "../../application/Application.ts";
|
|
3
|
+
import type { ConfigManager } from "../../config/ConfigManager.ts";
|
|
4
|
+
import { buildConfiguredAssets, type AssetBuildConfig } from "../../dev/CssPlugins.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `bun zt assets:build` — build every frontend bundle this app declares, in one step,
|
|
8
|
+
* as part of a release rather than as a side effect of starting the server.
|
|
9
|
+
*
|
|
10
|
+
* `serve` builds at boot, which is right in development and awkward in production: it
|
|
11
|
+
* makes the server process need write access to its own output directory, so a properly
|
|
12
|
+
* hardened unit (`ProtectSystem=strict`) restart-loops on a read-only filesystem. Running
|
|
13
|
+
* this at deploy time means `serve` finds the output already there, skips the build, and
|
|
14
|
+
* needs no write access at all.
|
|
15
|
+
*
|
|
16
|
+
* Covers both sources of bundles:
|
|
17
|
+
* - `app.assets` from `config/app.ts` (whatever entrypoints it names)
|
|
18
|
+
* - Flow's conventional entry points, `resources/css/app.css` and `resources/js/app.js`
|
|
19
|
+
*
|
|
20
|
+
* Inertia apps build with `bun zt inertia:build` instead — this does not duplicate it.
|
|
21
|
+
*
|
|
22
|
+
* @category Build & assets
|
|
23
|
+
*/
|
|
24
|
+
export class AssetsBuildCommand extends Command {
|
|
25
|
+
static override commandName = "assets:build";
|
|
26
|
+
static override description = "Build all configured frontend bundles for a release";
|
|
27
|
+
static override needsApp = true;
|
|
28
|
+
|
|
29
|
+
static override flags = [
|
|
30
|
+
{
|
|
31
|
+
name: "minify",
|
|
32
|
+
short: "m",
|
|
33
|
+
type: "boolean" as const,
|
|
34
|
+
description: "Minify the output",
|
|
35
|
+
default: true,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
async run(): Promise<void> {
|
|
40
|
+
const cwd = process.cwd();
|
|
41
|
+
const minify = this.flags["minify"] as boolean;
|
|
42
|
+
let built = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
|
|
45
|
+
const assets = this._assetsConfig();
|
|
46
|
+
if (assets) {
|
|
47
|
+
const entries = Array.isArray(assets.entrypoint)
|
|
48
|
+
? assets.entrypoint.join(", ")
|
|
49
|
+
: assets.entrypoint;
|
|
50
|
+
this.info(`Building assets: ${entries} → ${assets.outDir}/`);
|
|
51
|
+
const result = await buildConfiguredAssets({ ...assets, minify }, cwd);
|
|
52
|
+
if (result.success) built++;
|
|
53
|
+
else {
|
|
54
|
+
failed++;
|
|
55
|
+
this.error("Asset build failed:");
|
|
56
|
+
for (const log of result.logs ?? []) console.error(log);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Flow's own bundles. Built by entry-point convention rather than config, which is
|
|
61
|
+
// why they are easy to forget in a release script and easy to discover here.
|
|
62
|
+
const { buildCssBundle, buildJsBundle } = await import("../../dev/CssPlugins.ts");
|
|
63
|
+
const conventional = [
|
|
64
|
+
{ entry: "resources/css/app.css", outDir: "public/css", build: buildCssBundle },
|
|
65
|
+
{ entry: "resources/js/app.js", outDir: "public/js", build: buildJsBundle },
|
|
66
|
+
];
|
|
67
|
+
for (const { entry, outDir, build } of conventional) {
|
|
68
|
+
if (!(await Bun.file(`${cwd}/${entry}`).exists())) continue;
|
|
69
|
+
this.info(`Building ${entry} → ${outDir}/`);
|
|
70
|
+
const result = await build(`${cwd}/${entry}`, `${cwd}/${outDir}`, minify);
|
|
71
|
+
if (result.success) built++;
|
|
72
|
+
else {
|
|
73
|
+
failed++;
|
|
74
|
+
this.error(`${entry} build failed:`);
|
|
75
|
+
for (const log of result.logs ?? []) console.error(log);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (built === 0 && failed === 0) {
|
|
80
|
+
this.info("No frontend bundles configured — nothing to build.");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Throw rather than `process.exit(1)`. The runner turns a throw into exit 1, so
|
|
84
|
+
// the CLI behaves identically — but a caller composing this through
|
|
85
|
+
// `callInProcess` gets a failed step it can report on, instead of having the
|
|
86
|
+
// whole process killed mid-pipeline with its buffered output never flushed.
|
|
87
|
+
if (failed > 0) {
|
|
88
|
+
throw new Error(`${failed} bundle(s) failed, ${built} succeeded.`);
|
|
89
|
+
}
|
|
90
|
+
this.info(`Built ${built} bundle(s).`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Read the resolved `app.assets` config block, or undefined when not configured. */
|
|
94
|
+
private _assetsConfig(): AssetBuildConfig | undefined {
|
|
95
|
+
try {
|
|
96
|
+
const config = (this.app as Application).container.makeSync("config") as ConfigManager;
|
|
97
|
+
return config.get("app.assets") as AssetBuildConfig | undefined;
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|