@zerotal/core 1.4.0 → 1.5.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +370 -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 +24 -4
  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 +99 -3
  60. package/src/support/unroutedRoutes.ts +37 -0
@@ -6,6 +6,31 @@ import { watch } from "node:fs";
6
6
  import type { BuildHookFn } from "./DevBuildHook.ts";
7
7
  import { DEV_WORKER_ENV_VAR } from "../support/env.ts";
8
8
 
9
+ /**
10
+ * How the orchestrator reports to whatever is presenting dev mode.
11
+ *
12
+ * All optional, and all defaulting to the console. Without them the orchestrator
13
+ * behaves exactly as it did before the deck existed — which is what keeps the
14
+ * plain `bun --watch` path and the existing tests honest.
15
+ */
16
+ export interface DevOrchestratorHooks {
17
+ /**
18
+ * Take the server's output instead of letting it inherit the terminal.
19
+ *
20
+ * Providing this is what turns the server into a card in the deck rather than
21
+ * a process shouting over the tab bar.
22
+ */
23
+ onServerLine?: (line: string, stream: "stdout" | "stderr") => void;
24
+ /** The server's own lifecycle, so its tab can show state like any other. */
25
+ onServerState?: (state: "starting" | "running" | "restarting" | "parked") => void;
26
+ /** Dev-mode's own narration — rebuilds, restarts, failures. */
27
+ onNotice?: (text: string, level: "info" | "warn" | "error") => void;
28
+ /** Called once the first server has bound, for `after: "server"` processes. */
29
+ onServerReady?: () => void | Promise<void>;
30
+ /** Called on SIGINT/SIGTERM, before the process exits. */
31
+ onCleanup?: () => void | Promise<void>;
32
+ }
33
+
9
34
  /**
10
35
  * Dev Orchestrator — Process 1 of the two-process dev mode.
11
36
  *
@@ -73,33 +98,64 @@ export class DevOrchestrator {
73
98
  private readonly _port: number,
74
99
  private readonly _cwd: string,
75
100
  private readonly _build: BuildHookFn,
101
+ private readonly _hooks: DevOrchestratorHooks = {},
76
102
  ) {}
77
103
 
78
104
  async start(): Promise<void> {
79
- console.log(" [zerotal:dev] ⚙ building assets...");
80
- const buildSucceeded = await this._runBuild();
81
- if (!buildSucceeded) {
82
- console.warn(" [zerotal:dev] ⚠ initial build failed — starting server anyway");
105
+ this._say(" [zerotal:dev] ⚙ building assets...");
106
+ const started = Bun.nanoseconds();
107
+ const build = await this._runBuild();
108
+
109
+ if (!build.ok) {
110
+ this._say(" [zerotal:dev] ⚠ initial build failed — starting server anyway", "warn");
111
+ } else if (build.skipped) {
112
+ // Said out loud on purpose. A boot that prints "building assets…" and then
113
+ // nothing looks like a hang, and a developer who cannot tell a skip from a
114
+ // stall deletes `.zerotal/` and stops trusting the cache.
115
+ this._say(` [zerotal:dev] ✓ assets unchanged — reused the last build (${_ms(started)})`);
83
116
  }
84
117
 
85
118
  // Same retry as a restart: the commonest reason the first bind fails is an orphaned
86
119
  // server from a previous run that has not finished exiting.
87
120
  await this._spawnServerWithRetry();
121
+
122
+ // Only now are `after: "server"` processes started — a process that talks to
123
+ // the server would otherwise spend its first attempts against a closed port
124
+ // and burn its restart budget before the server ever bound.
125
+ await this._hooks.onServerReady?.();
126
+
88
127
  this._watch();
89
128
 
90
129
  // Park the process — cleanup happens in signal handlers registered by _watch()
91
130
  await new Promise<never>(() => {});
92
131
  }
93
132
 
133
+ /** Narrate, through the deck when one is present and the console otherwise. */
134
+ private _say(text: string, level: "info" | "warn" | "error" = "info"): void {
135
+ if (this._hooks.onNotice) {
136
+ this._hooks.onNotice(text, level);
137
+ return;
138
+ }
139
+ if (level === "error") console.error(text);
140
+ else if (level === "warn") console.warn(text);
141
+ else console.log(text);
142
+ }
143
+
94
144
  // ── Server management ──────────────────────────────────────────────────────
95
145
 
96
146
  private _spawnServer(): ReturnType<typeof Bun.spawn> {
147
+ // Piped only when something is listening. Left inherited otherwise, so the
148
+ // server writes straight to the terminal exactly as it always has — routing
149
+ // it through a hook that discards would be how dev mode goes silent.
150
+ const routed = this._hooks.onServerLine !== undefined;
151
+ this._hooks.onServerState?.("starting");
152
+
97
153
  const child = Bun.spawn(
98
154
  ["bun", Bun.main, "serve", "--port", String(this._port), "--dev-worker"],
99
155
  {
100
156
  stdin: "pipe",
101
- stdout: "inherit",
102
- stderr: "inherit",
157
+ stdout: routed ? "pipe" : "inherit",
158
+ stderr: routed ? "pipe" : "inherit",
103
159
  cwd: this._cwd,
104
160
  env: {
105
161
  ...Bun.env,
@@ -116,19 +172,62 @@ export class DevOrchestrator {
116
172
 
117
173
  this._child = child;
118
174
 
175
+ if (routed) {
176
+ void this._pipe(child.stdout as ReadableStream<Uint8Array>, "stdout");
177
+ void this._pipe(child.stderr as ReadableStream<Uint8Array>, "stderr");
178
+ }
179
+
119
180
  void child.exited.then((code) => {
120
181
  // Report only the *current* server dying unexpectedly. Comparing against
121
182
  // `this._child` identity matters: reading the field alone reported an exit that a
122
183
  // restart had deliberately caused, because by then the field held the replacement.
123
184
  // A restart in flight owns its own reporting.
124
185
  if (this._child === child && this._restartInFlight === null && code !== 0) {
125
- console.log(` [zerotal:dev] server exited with code ${code}`);
186
+ this._say(` [zerotal:dev] server exited with code ${code}`);
187
+ this._hooks.onServerState?.("parked");
126
188
  }
127
189
  });
128
190
 
129
191
  return child;
130
192
  }
131
193
 
194
+ /** Split the server child's piped output into lines and hand them to the deck. */
195
+ private async _pipe(
196
+ stream: ReadableStream<Uint8Array> | null,
197
+ kind: "stdout" | "stderr",
198
+ ): Promise<void> {
199
+ if (!stream) return;
200
+ const reader = stream.getReader();
201
+ const decoder = new TextDecoder();
202
+ let buffered = "";
203
+
204
+ try {
205
+ for (;;) {
206
+ const { done, value } = await reader.read();
207
+ if (done) break;
208
+ buffered += decoder.decode(value, { stream: true });
209
+ const lines = buffered.split("\n");
210
+ buffered = lines.pop() ?? "";
211
+ for (const line of lines) this._hooks.onServerLine?.(line.replace(/\r$/, ""), kind);
212
+ }
213
+ } catch {
214
+ // The stream ends when the server is replaced — the normal case on every
215
+ // restart, not something to report.
216
+ }
217
+
218
+ if (buffered) this._hooks.onServerLine?.(buffered, kind);
219
+ }
220
+
221
+ /**
222
+ * Restart the server on request — the deck's `r` key on the server tab.
223
+ *
224
+ * Goes through the same path a file change takes, so it rebuilds assets first
225
+ * and folds into a restart already running rather than racing it for the port.
226
+ */
227
+ restartServer(): Promise<void> {
228
+ return this._requestRestart();
229
+ }
230
+
132
231
  private _scheduleRestart(): void {
133
232
  if (this._restartTimer) clearTimeout(this._restartTimer);
134
233
  this._restartTimer = setTimeout(() => {
@@ -168,7 +267,8 @@ export class DevOrchestrator {
168
267
  }
169
268
 
170
269
  private async _restartOnce(): Promise<void> {
171
- console.log(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
270
+ this._say(" [zerotal:dev] ↻ backend change — rebuilding + restarting server...");
271
+ this._hooks.onServerState?.("restarting");
172
272
 
173
273
  // Rebuild assets before respawning: server-rendered views (Flow pages in `app/`,
174
274
  // controllers returning markup) contain Tailwind classes the stylesheet scans via
@@ -216,7 +316,10 @@ export class DevOrchestrator {
216
316
  Bun.sleep(DevOrchestrator.BIND_SETTLE_MS).then(() => false),
217
317
  ]);
218
318
 
219
- if (!settled) return; // still running → it bound the port
319
+ if (!settled) {
320
+ this._hooks.onServerState?.("running"); // still running → it bound the port
321
+ return;
322
+ }
220
323
  if (this._child !== child) return; // superseded by a newer restart
221
324
 
222
325
  this._child = null;
@@ -225,10 +328,12 @@ export class DevOrchestrator {
225
328
  }
226
329
  }
227
330
 
228
- console.error(
331
+ this._hooks.onServerState?.("parked");
332
+ this._say(
229
333
  ` [zerotal:dev] ✗ server did not start after ${DevOrchestrator.RESPAWN_ATTEMPTS} attempts.\n` +
230
334
  ` Port ${this._port} may be held by another process — the error above says which.\n` +
231
335
  ` Dev mode is still watching; fix the cause and save to retry.`,
336
+ "error",
232
337
  );
233
338
  }
234
339
 
@@ -239,32 +344,43 @@ export class DevOrchestrator {
239
344
  this._buildTimer = setTimeout(async () => {
240
345
  this._buildTimer = null;
241
346
  const label = path.startsWith("resources/pages/") ? "page" : "asset";
242
- console.log(` [zerotal:dev] ⚙ ${label} changed — rebuilding...`);
243
-
244
- const buildSucceeded = await this._runBuild();
245
- if (buildSucceeded) {
246
- console.log(" [zerotal:dev] ✓ ready reloading browser");
347
+ this._say(` [zerotal:dev] ⚙ ${label} changed — rebuilding...`);
348
+
349
+ const build = await this._runBuild();
350
+ if (build.ok) {
351
+ // Still reload on a skip: the change that triggered this may have been
352
+ // to a file the bundles do not contain (a server-rendered template),
353
+ // and the browser has no other way to learn about it.
354
+ this._say(
355
+ build.skipped
356
+ ? " [zerotal:dev] ✓ bundles unchanged — reloading browser"
357
+ : " [zerotal:dev] ✓ ready — reloading browser",
358
+ );
247
359
  this._signalReload();
248
360
  }
249
361
  }, 80);
250
362
  }
251
363
 
252
- private async _runBuild(): Promise<boolean> {
364
+ private async _runBuild(): Promise<{ ok: boolean; skipped: boolean }> {
253
365
  try {
254
366
  const result = await this._build();
255
367
  if (!result.success) {
256
- console.error(" [zerotal:dev] ✗ build failed:");
368
+ this._say(" [zerotal:dev] ✗ build failed:", "error");
257
369
  for (const entry of result.logs ?? []) {
258
- console.error(" ", String(entry));
370
+ this._say(` ${String(entry)}`, "error");
259
371
  }
260
- return false;
372
+ return { ok: false, skipped: false };
261
373
  }
262
- // Fresh token so the next `asset()` URL changes and the browser refetches.
263
- this._assetVersion = Date.now().toString(36);
264
- return true;
374
+
375
+ // A skipped build wrote nothing, so the files the browser already holds
376
+ // are still current — bumping the token would force a pointless refetch
377
+ // of every asset on the page.
378
+ if (result.skipped !== true) this._assetVersion = Date.now().toString(36);
379
+
380
+ return { ok: true, skipped: result.skipped === true };
265
381
  } catch (error) {
266
- console.error(" [zerotal:dev] ✗ build error:", error);
267
- return false;
382
+ this._say(` [zerotal:dev] ✗ build error: ${String(error)}`, "error");
383
+ return { ok: false, skipped: false };
268
384
  }
269
385
  }
270
386
 
@@ -306,13 +422,32 @@ export class DevOrchestrator {
306
422
  }
307
423
  });
308
424
 
309
- const cleanup = () => {
310
- watcher.close();
311
- this._child?.kill("SIGTERM");
312
- process.exit(0);
313
- };
425
+ process.on("SIGTERM", () => void this.shutdown(watcher));
426
+ process.on("SIGINT", () => void this.shutdown(watcher));
427
+ }
314
428
 
315
- process.on("SIGTERM", cleanup);
316
- process.on("SIGINT", cleanup);
429
+ /**
430
+ * Stop everything dev mode owns, then exit.
431
+ *
432
+ * `onCleanup` runs *before* the exit and is awaited, because it is what stops
433
+ * the supervised processes and hands the terminal back. Exiting first would
434
+ * leave orphaned children and a shell in raw mode — the bug this ordering
435
+ * exists to prevent.
436
+ */
437
+ async shutdown(watcher?: { close(): void }): Promise<never> {
438
+ watcher?.close();
439
+ try {
440
+ await this._hooks.onCleanup?.();
441
+ } catch {
442
+ // A failing cleanup must not stop the rest of the shutdown; whatever it
443
+ // could not release, exiting releases anyway.
444
+ }
445
+ this._child?.kill("SIGTERM");
446
+ process.exit(0);
317
447
  }
318
448
  }
449
+
450
+ /** Elapsed time since a `Bun.nanoseconds()` reading, as `"12ms"`. */
451
+ function _ms(since: number): string {
452
+ return `${Math.round((Bun.nanoseconds() - since) / 1e6)}ms`;
453
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The dev-process registry: what `bun zt dev` runs alongside the server.
3
+ *
4
+ * An app with a queue needs a worker running next to its server, and today that
5
+ * is a second terminal the developer has to remember to restart by hand. Every
6
+ * package with a companion process has the same gap — a type-checker, a Stripe
7
+ * listener, a Tailwind watcher — and until now there was no way for a package to
8
+ * close it, because the dev runner only knew about the server.
9
+ *
10
+ * A provider contributes one by overriding `devProcesses()`, the same shape as
11
+ * `replContext()`: the package declares what it needs, the tooling decides what
12
+ * to do with it.
13
+ *
14
+ * ## Why this is not the build hook
15
+ *
16
+ * `registerDevBuildHook` exists for *builds*, and a build is a step that has to
17
+ * finish before the server restarts — a failure there aborts the reload, which is
18
+ * correct, because serving a page against a half-built bundle is worse than not
19
+ * reloading. A process is the opposite: it runs for as long as dev mode does, and
20
+ * its death must never take the server with it. Two different lifetimes, two
21
+ * different failure rules, two different mechanisms.
22
+ */
23
+ import type { ServiceProvider } from "../provider/ServiceProvider.ts";
24
+
25
+ /** What a provider or an app declares when it contributes a dev process. */
26
+ export interface DevProcessDefinition {
27
+ /**
28
+ * Stable identity, and what the user types in `--only` / `--without`.
29
+ *
30
+ * Registering a name twice replaces the earlier definition rather than adding
31
+ * a second entry, so an app can swap out a provider's worker for its own.
32
+ */
33
+ name: string;
34
+ /**
35
+ * What to run, in one of three forms:
36
+ *
37
+ * - `"queue:work"` — a bare string is a `zt` command, resolved against the
38
+ * app's own entrypoint, so it picks up the same bootstrap the CLI does.
39
+ * - `["stripe", "listen"]` — raw argv, for a tool that is not a zt command.
40
+ * - `() => [...]` — the same, computed at startup when the argv depends on
41
+ * config the provider can only read once the app has booted.
42
+ *
43
+ * Mutually exclusive with {@link run}.
44
+ */
45
+ command?: string | string[] | (() => string[]);
46
+ /**
47
+ * Run in-process instead of spawning, for work that has no separate binary.
48
+ * The signal aborts on shutdown and on a restart.
49
+ *
50
+ * Mutually exclusive with {@link command}.
51
+ */
52
+ run?: (signal: AbortSignal) => Promise<void>;
53
+ /**
54
+ * Whether to run at all. A function is resolved **once**, at startup, so a
55
+ * provider can consult its own config — and so a process cannot flicker in
56
+ * and out of the deck while dev mode is running.
57
+ */
58
+ enabled?: boolean | (() => boolean | Promise<boolean>);
59
+ /** Restart policy. Defaults to `"on-failure"`. */
60
+ restart?: "always" | "on-failure" | "never";
61
+ /**
62
+ * `"server"` waits for the server to be up before starting — for a process
63
+ * that talks to it. Defaults to `"none"`, which starts immediately.
64
+ */
65
+ after?: "server" | "none";
66
+ /** Display name for the tab. Defaults to {@link name}. */
67
+ label?: string;
68
+ /** Tab colour. Auto-assigned from the palette when omitted. */
69
+ color?: DevProcessColor;
70
+ }
71
+
72
+ /** The colours a deck tab can take. Named, not ANSI codes, so the deck owns the rendering. */
73
+ export type DevProcessColor = "cyan" | "magenta" | "yellow" | "green" | "blue" | "red";
74
+
75
+ /** Assigned in order to processes that did not pick a colour. */
76
+ const _PALETTE: DevProcessColor[] = ["cyan", "magenta", "yellow", "green", "blue", "red"];
77
+
78
+ /** A definition with every default filled in and its argv settled. */
79
+ export interface ResolvedDevProcess {
80
+ name: string;
81
+ label: string;
82
+ color: DevProcessColor;
83
+ /** Settled argv, when this is a spawned process. */
84
+ argv?: string[];
85
+ /** The in-process body, when this is a `run` process. */
86
+ run?: (signal: AbortSignal) => Promise<void>;
87
+ restart: "always" | "on-failure" | "never";
88
+ after: "server" | "none";
89
+ /**
90
+ * Who registered it — a provider class name, or `"app.dev.processes"`.
91
+ *
92
+ * Kept so `zt dev --list` can answer "why is this running?", which is the
93
+ * question a developer actually has when an unfamiliar tab appears.
94
+ */
95
+ registrant: string;
96
+ }
97
+
98
+ /** The `app.dev` config block. */
99
+ export interface DevConfigShape {
100
+ /** App-level processes, registered after every provider's. */
101
+ processes?: DevProcessDefinition[];
102
+ /** Names to drop, whoever registered them. */
103
+ disable?: string[];
104
+ }
105
+
106
+ /** The bits of `Application` this module needs — kept structural to avoid a cycle. */
107
+ interface ProviderHost {
108
+ _activeProviders: ServiceProvider[];
109
+ }
110
+
111
+ /** The bits of `ConfigManager` this module needs. */
112
+ interface ConfigReader {
113
+ get<T>(key: string, fallback?: T): T | undefined;
114
+ }
115
+
116
+ /**
117
+ * Gather every dev process the booted app has to offer.
118
+ *
119
+ * Collection order is providers in boot order, then `app.dev.processes`, so an
120
+ * app always gets the last word. Name collisions replace in place rather than
121
+ * appending, which keeps the deck's tab order stable when an app overrides one.
122
+ *
123
+ * @param app A booted application — providers must have run, since `enabled`
124
+ * and a `command` thunk are allowed to read config.
125
+ * @param config The config manager, read for `app.dev`.
126
+ */
127
+ export async function collectDevProcesses(
128
+ app: ProviderHost,
129
+ config?: ConfigReader,
130
+ ): Promise<ResolvedDevProcess[]> {
131
+ const devConfig = config?.get<DevConfigShape>("app.dev") ?? {};
132
+ const declared = new Map<string, { definition: DevProcessDefinition; registrant: string }>();
133
+
134
+ for (const provider of app._activeProviders) {
135
+ const contributed = provider.devProcesses?.() ?? [];
136
+ const registrant = provider.constructor.name;
137
+ for (const definition of contributed) {
138
+ _assertShape(definition, registrant);
139
+ declared.set(definition.name, { definition, registrant });
140
+ }
141
+ }
142
+
143
+ for (const definition of devConfig.processes ?? []) {
144
+ _assertShape(definition, "app.dev.processes");
145
+ declared.set(definition.name, { definition, registrant: "app.dev.processes" });
146
+ }
147
+
148
+ const disabled = new Set(devConfig.disable ?? []);
149
+ const resolved: ResolvedDevProcess[] = [];
150
+ let paletteIndex = 0;
151
+
152
+ for (const { definition, registrant } of declared.values()) {
153
+ if (disabled.has(definition.name)) continue;
154
+ if (!(await _isEnabled(definition))) continue;
155
+
156
+ // Consumed only by processes that survived the filters, so removing one
157
+ // does not re-colour the others.
158
+ const color = definition.color ?? _PALETTE[paletteIndex++ % _PALETTE.length]!;
159
+
160
+ resolved.push({
161
+ name: definition.name,
162
+ label: definition.label ?? definition.name,
163
+ color,
164
+ ...(definition.command !== undefined ? { argv: _toArgv(definition.command) } : {}),
165
+ ...(definition.run !== undefined ? { run: definition.run } : {}),
166
+ restart: definition.restart ?? "on-failure",
167
+ after: definition.after ?? "none",
168
+ registrant,
169
+ });
170
+ }
171
+
172
+ return resolved;
173
+ }
174
+
175
+ /**
176
+ * Reject a definition that cannot be run, naming who registered it.
177
+ *
178
+ * Both errors are authoring mistakes in a provider the developer may not own, so
179
+ * the message has to say which package to go and look at.
180
+ */
181
+ function _assertShape(definition: DevProcessDefinition, registrant: string): void {
182
+ if (!definition.name) {
183
+ throw new Error(`[Zerotal] ${registrant} registered a dev process with no name.`);
184
+ }
185
+ const hasCommand = definition.command !== undefined;
186
+ const hasRun = definition.run !== undefined;
187
+ if (hasCommand === hasRun) {
188
+ throw new Error(
189
+ `[Zerotal] Dev process "${definition.name}" (from ${registrant}) must set exactly one of ` +
190
+ `\`command\` or \`run\`, not ${hasCommand ? "both" : "neither"}.`,
191
+ );
192
+ }
193
+ }
194
+
195
+ /** Resolve `enabled` once. Anything that throws is treated as "not enabled". */
196
+ async function _isEnabled(definition: DevProcessDefinition): Promise<boolean> {
197
+ const enabled = definition.enabled;
198
+ if (enabled === undefined) return true;
199
+ if (typeof enabled !== "function") return enabled;
200
+ try {
201
+ return await enabled();
202
+ } catch {
203
+ // A provider probing config that is absent should leave its process out,
204
+ // not fail dev mode for every other process in the deck.
205
+ return false;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Settle a `command` into argv.
211
+ *
212
+ * A bare string is a zt command and runs through `Bun.main` — the app's own
213
+ * entrypoint — so it boots the same providers and reads the same config the
214
+ * developer's `bun zt queue:work` would. Splitting on whitespace is deliberate
215
+ * and shallow: anything needing quoting should use the array form.
216
+ */
217
+ function _toArgv(command: string | string[] | (() => string[])): string[] {
218
+ if (typeof command === "function") return command();
219
+ if (Array.isArray(command)) return command;
220
+ return ["bun", Bun.main, ...command.split(/\s+/).filter(Boolean)];
221
+ }
@@ -61,7 +61,7 @@ export function setDevReloadClientActive(active: boolean): void {
61
61
  }
62
62
 
63
63
  export class DevReloadMiddleware extends BaseMiddleware {
64
- protected options: {} = {};
64
+ protected options: Record<string, never> = {};
65
65
 
66
66
  async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
67
67
  const res = await next();