@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +351 -0
  2. package/package.json +1 -1
  3. package/src/application/Application.ts +107 -9
  4. package/src/application/DevErrorPage.ts +82 -0
  5. package/src/application/diagnostics.ts +111 -0
  6. package/src/command/CommandRunner.ts +82 -1
  7. package/src/command/builtin/AssetsBuildCommand.ts +102 -0
  8. package/src/command/builtin/DeployCommand.ts +315 -0
  9. package/src/command/builtin/DevCommand.ts +88 -0
  10. package/src/command/builtin/DoctorCommand.ts +97 -0
  11. package/src/command/builtin/MakeCommandCommand.ts +2 -0
  12. package/src/command/builtin/RouteTypesCommand.ts +56 -0
  13. package/src/command/builtin/ServeCommand.ts +232 -44
  14. package/src/command/builtin/index.ts +5 -0
  15. package/src/command/scaffold/zerotal.ts.txt +2 -10
  16. package/src/config/AppConfig.ts +109 -2
  17. package/src/config/DeployConfig.ts +71 -0
  18. package/src/config/index.ts +2 -0
  19. package/src/config/registry.ts +1 -0
  20. package/src/container/Container.ts +3 -3
  21. package/src/container/inject.ts +3 -2
  22. package/src/context/RequestContext.ts +60 -0
  23. package/src/contracts/session.ts +18 -3
  24. package/src/dev/BuildCache.ts +312 -0
  25. package/src/dev/CssPlugins.ts +93 -7
  26. package/src/dev/DevBuildHook.ts +14 -1
  27. package/src/dev/DevDeck.ts +549 -0
  28. package/src/dev/DevOrchestrator.ts +166 -31
  29. package/src/dev/DevProcess.ts +221 -0
  30. package/src/dev/DevReloadMiddleware.ts +1 -1
  31. package/src/dev/DevSupervisor.ts +363 -0
  32. package/src/dev/bootBuild.ts +94 -0
  33. package/src/dev/index.ts +24 -0
  34. package/src/dev/startDevMode.ts +145 -0
  35. package/src/doctor/AppDoctor.ts +399 -0
  36. package/src/doctor/TransportProbe.ts +169 -0
  37. package/src/events/Emitter.ts +4 -3
  38. package/src/facade/facades/App.ts +10 -2
  39. package/src/helpers/index.ts +23 -1
  40. package/src/helpers/response.ts +18 -8
  41. package/src/http/Uri.ts +7 -3
  42. package/src/http/originGuard.ts +1 -1
  43. package/src/http/url.ts +10 -4
  44. package/src/index.ts +43 -0
  45. package/src/lock/LockManager.ts +190 -14
  46. package/src/lock/drivers/LockDriver.ts +11 -0
  47. package/src/lock/drivers/MemoryLockDriver.ts +21 -1
  48. package/src/lock/drivers/RedisLockDriver.ts +64 -8
  49. package/src/lock/drivers/SqliteLockDriver.ts +13 -0
  50. package/src/lock/errors.ts +26 -0
  51. package/src/lock/facades/Lock.ts +30 -5
  52. package/src/lock/index.ts +2 -2
  53. package/src/macros/config.macro.ts +2 -0
  54. package/src/provider/ServiceProvider.ts +40 -0
  55. package/src/router/Router.ts +111 -13
  56. package/src/router/registry.ts +123 -0
  57. package/src/router/routeTypes.ts +132 -0
  58. package/src/support/classRef.ts +27 -0
  59. package/src/support/env.ts +69 -2
  60. 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 is an inspection command — always available regardless of mode.
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
+ }