@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
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `bun zt deploy:<env>` — run a release, and refuse to finish it when something is
3
+ * wrong.
4
+ *
5
+ * The framework already had the pieces: `zt doctor` finds silent misconfigurations,
6
+ * config validators refuse an insecure production boot, `assets:build` builds a
7
+ * release, `migrate` applies the schema. What it did not have was an order, and an
8
+ * order is the whole value. Every deployment failure this framework has shipped has
9
+ * the same shape — the HTML renders, the transport is dead, the health check passes
10
+ * — and every one of them is cheaper to find before the cutover than after it.
11
+ *
12
+ * So: **everything that can refuse runs before anything that mutates.** A bad origin
13
+ * list stops the deploy while the old release is still serving, rather than after
14
+ * the migration has run and the new process is live and inert.
15
+ *
16
+ * What this deliberately does NOT do is restart the service or reach another
17
+ * machine. It exits non-zero, and the thing that owns process lifecycle — systemd,
18
+ * a container runtime, a deploy script — restarts only on success. The framework
19
+ * has stayed out of that, and this keeps it out.
20
+ */
21
+ import { Command } from "../Command.ts";
22
+ import type { Application } from "../../application/Application.ts";
23
+ import type { ConfigManager } from "../../config/ConfigManager.ts";
24
+ import type { CommandRunner } from "../CommandRunner.ts";
25
+ import { runDoctor } from "../../doctor/AppDoctor.ts";
26
+ import { probeTransport } from "../../doctor/TransportProbe.ts";
27
+ import { runConfigValidators } from "../../config/validation.ts";
28
+ import { deployEnv } from "../../support/env.ts";
29
+ import { DEFAULT_DEPLOY_STEPS, type DeployTarget } from "../../config/DeployConfig.ts";
30
+
31
+ /**
32
+ * The pipeline. Subclassed per target by {@link makeDeployCommand}, which is what
33
+ * gives each environment its own command name.
34
+ *
35
+ * @internal Apps declare targets in `config/deploy.ts` and run `zt deploy:<env>`;
36
+ * the class is how the runner builds those commands, not something to construct.
37
+ */
38
+ export abstract class DeployCommand extends Command {
39
+ /** The deployment this subclass releases to — `production`, `staging`, … */
40
+ static target = "";
41
+ static override needsApp = true;
42
+
43
+ static override flags = [
44
+ {
45
+ name: "dry-run",
46
+ type: "boolean" as const,
47
+ description: "Print the steps that would run, and run none of them",
48
+ default: false,
49
+ },
50
+ {
51
+ name: "skip-migrations",
52
+ type: "boolean" as const,
53
+ description: "Do not run pending migrations as part of this release",
54
+ default: false,
55
+ },
56
+ {
57
+ name: "probe",
58
+ type: "string" as const,
59
+ description:
60
+ "After the release, run a real WebSocket handshake against this URL (defaults to the target's `url`)",
61
+ },
62
+ ];
63
+
64
+ async run(): Promise<void> {
65
+ const app = this.app as Application | undefined;
66
+ if (!app) throw new Error("deploy needs a booted application.");
67
+
68
+ const target = (this.constructor as typeof DeployCommand).target;
69
+ const dryRun = this.flags["dry-run"] === true;
70
+
71
+ this.section(`Deploy → ${target}`);
72
+
73
+ const steps = this._steps(app, target);
74
+ if (dryRun) return this._printPlan(app, target, steps);
75
+
76
+ await this._preflight(app, target);
77
+ await this._runSteps(app, steps);
78
+ await this._verify(app);
79
+ await this._probe(app, target);
80
+
81
+ this.newLine();
82
+ this.info(`${target} release complete. Restart the service to pick it up.`);
83
+ }
84
+
85
+ // ── Phase 1 — preflight, which mutates nothing ──────────────────────────────
86
+
87
+ /**
88
+ * Refuse the deploy before it touches anything. Three questions, in the order
89
+ * that makes a failure cheapest to act on: is this even the right machine, is
90
+ * the configuration safe to deploy, and does the app pass its own checks.
91
+ */
92
+ private async _preflight(app: Application, target: string): Promise<void> {
93
+ this.newLine();
94
+ this.section("Preflight");
95
+
96
+ // Is this the environment it claims to be? `deployEnv()` rather than APP_ENV,
97
+ // which by now holds the runtime mode. Getting this wrong is how a production
98
+ // pipeline migrates a staging database.
99
+ const actual = deployEnv();
100
+ if (actual !== target) {
101
+ throw new Error(
102
+ `This process was started as ${actual || "(unset)"}, not ${target}. ` +
103
+ `Run it with APP_ENV=${target} so the config it loads is the one you are deploying.`,
104
+ );
105
+ }
106
+ this.line(`✓ APP_ENV is ${target}`);
107
+
108
+ // Re-run the config validators with production semantics. Boot already ran
109
+ // them, but the findings only refuse a boot when the deployment is prod-like —
110
+ // so on any other machine they were warnings nobody read.
111
+ const config = this._config(app);
112
+ if (!config) throw new Error("deploy needs a config store to validate.");
113
+ const issues: string[] = [];
114
+ try {
115
+ runConfigValidators(app._configValidators, config, true, (m) => issues.push(m));
116
+ } catch (error) {
117
+ // Thrown when a fatal issue is found: that IS the answer, not a failure.
118
+ for (const issue of (error as { issues?: Array<{ namespace: string; message: string }> })
119
+ .issues ?? []) {
120
+ issues.push(`config(${issue.namespace}): ${issue.message}`);
121
+ }
122
+ }
123
+ if (issues.length > 0) {
124
+ for (const issue of issues) this.error(`✗ ${issue}`);
125
+ throw new Error(
126
+ `${issues.length} config problem(s) would refuse a ${target} boot. Fix them before deploying.`,
127
+ );
128
+ }
129
+ this.line(`✓ config is valid for a ${target} deployment`);
130
+
131
+ await this._doctor(app, "preflight");
132
+ }
133
+
134
+ // ── Phase 2 — build and migrate ─────────────────────────────────────────────
135
+
136
+ /** The `config` store, or undefined when there is none to read. */
137
+ private _config(app: Application): ConfigManager | undefined {
138
+ try {
139
+ return app.container.makeSync("config") as ConfigManager;
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ /** This target's declared settings, if `config/deploy.ts` names it. */
146
+ private _target(app: Application, target: string): DeployTarget | undefined {
147
+ return this._config(app)?.get<DeployTarget | undefined>(`deploy.targets.${target}`, undefined);
148
+ }
149
+
150
+ /** The release steps for this target, minus any whose command is not registered. */
151
+ private _steps(app: Application, target: string): string[] {
152
+ const wanted = this._target(app, target)?.steps ?? DEFAULT_DEPLOY_STEPS;
153
+ let runner: CommandRunner | undefined;
154
+ try {
155
+ runner = app.container.makeSync("commands") as CommandRunner;
156
+ } catch {
157
+ runner = undefined;
158
+ }
159
+ // No registry to ask means nothing can be confirmed present — report the
160
+ // declared list rather than silently claiming the release has no steps.
161
+ if (!runner) return [...wanted];
162
+ return [...wanted].filter((name) => runner.has(name));
163
+ }
164
+
165
+ private async _runSteps(app: Application, steps: string[]): Promise<void> {
166
+ const runner = app.container.makeSync("commands") as CommandRunner;
167
+ const skipMigrations = this.flags["skip-migrations"] === true;
168
+
169
+ for (const name of steps) {
170
+ if (name === "migrate" && skipMigrations) {
171
+ this.newLine();
172
+ this.warn("! migrate skipped (--skip-migrations)");
173
+ continue;
174
+ }
175
+
176
+ this.newLine();
177
+ this.section(name);
178
+ const argv = name === "inertia:build" ? [name, "--production"] : [name];
179
+ const { code, output } = await runner.callInProcess(argv);
180
+ if (output.trim()) this.line(output.trimEnd());
181
+ if (code !== 0) throw new Error(`${name} failed — stopping before the release completes.`);
182
+ }
183
+ }
184
+
185
+ // ── Phase 3 — verify ────────────────────────────────────────────────────────
186
+
187
+ /** The doctor again, now that migrations have run and the schema has one story. */
188
+ private async _verify(app: Application): Promise<void> {
189
+ this.newLine();
190
+ this.section("Verify");
191
+ await this._doctor(app, "verify");
192
+ }
193
+
194
+ /**
195
+ * Run the doctor's checks directly rather than through the command, so a failure
196
+ * is a value this pipeline can report on rather than a process exit.
197
+ */
198
+ private async _doctor(app: Application, phase: string): Promise<void> {
199
+ const report = await runDoctor(app);
200
+ const failures = report.filter((e) => e.result.status === "fail");
201
+ const warnings = report.filter((e) => e.result.status === "warn");
202
+
203
+ for (const { check, result } of [...failures, ...warnings]) {
204
+ const line = `${result.status === "fail" ? "✗" : "!"} ${check.label} — ${result.message}`;
205
+ if (result.status === "fail") this.error(line);
206
+ else this.warn(line);
207
+ if (result.fix) this.line(` fix: ${result.fix}`);
208
+ }
209
+
210
+ if (failures.length > 0) {
211
+ throw new Error(
212
+ `${failures.length} check(s) failed at ${phase}. ` +
213
+ `Nothing further has run — fix these and deploy again.`,
214
+ );
215
+ }
216
+ this.line(
217
+ `✓ ${report.length} checks passed${warnings.length ? `, ${warnings.length} warning(s)` : ""}`,
218
+ );
219
+ }
220
+
221
+ // ── Phase 4 — probe, when asked ─────────────────────────────────────────────
222
+
223
+ /**
224
+ * A real handshake against the deployed site. Off unless asked, because the
225
+ * release this command just built is not live until the service restarts — and
226
+ * the framework does not do that. Useful on a re-deploy, or from a second
227
+ * terminal after the restart.
228
+ */
229
+ private async _probe(app: Application, target: string): Promise<void> {
230
+ const declared = this._target(app, target);
231
+ const url = (this.flags["probe"] as string | undefined) ?? undefined;
232
+ if (!url) return;
233
+ const against = url === "true" ? declared?.url : url;
234
+ if (!against) {
235
+ throw new Error(
236
+ `--probe needs a URL, and config/deploy.ts declares no \`url\` for ${target}.`,
237
+ );
238
+ }
239
+
240
+ this.newLine();
241
+ this.section("Probe");
242
+ const paths = app.webSocketPaths();
243
+ if (paths.length === 0) {
244
+ this.line("✓ no WebSocket paths registered — nothing to probe.");
245
+ return;
246
+ }
247
+
248
+ const results = await probeTransport(against, paths);
249
+ let failures = 0;
250
+ for (const result of results) {
251
+ const line = `${result.ok ? "✓" : "✗"} ${result.url} — ${result.message}`;
252
+ if (result.ok) this.line(line);
253
+ else {
254
+ this.error(line);
255
+ failures++;
256
+ }
257
+ if (result.fix) this.line(` fix: ${result.fix}`);
258
+ }
259
+ if (failures > 0) throw new Error(`${failures} transport path(s) are not reachable.`);
260
+ }
261
+
262
+ // ── --dry-run ───────────────────────────────────────────────────────────────
263
+
264
+ private _printPlan(app: Application, target: string, steps: string[]): void {
265
+ const actual = deployEnv();
266
+ this.line(
267
+ `APP_ENV ${actual || "(unset)"}${actual === target ? "" : ` ✗ expected ${target}`}`,
268
+ );
269
+ this.line(`preflight config validators, then ${"doctor"} checks`);
270
+ for (const name of steps) {
271
+ const skipped = name === "migrate" && this.flags["skip-migrations"] === true;
272
+ this.line(`step ${name}${skipped ? " (skipped: --skip-migrations)" : ""}`);
273
+ }
274
+ this.line(`verify doctor checks again`);
275
+ this.newLine();
276
+ // Worth saying out loud: the app booted to get here, so "nothing ran" is not
277
+ // quite true — providers booted and the database connection was opened.
278
+ this.warn("Dry run — no steps executed. (The app was booted to resolve them.)");
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Build the command for one deploy target.
284
+ *
285
+ * A subclass rather than the closure seam (`runner.registerCommand`), because that
286
+ * one hardcodes `needsApp = false` and this pipeline needs a booted app for the
287
+ * doctor, the config and the registered WebSocket paths.
288
+ *
289
+ * @internal Called by the runner for each declared target.
290
+ */
291
+ export function makeDeployCommand(target: string): DeployCommandClass {
292
+ const Target = class extends DeployCommand {
293
+ static override commandName = `deploy:${target}`;
294
+ static override description = `Run the ${target} release pipeline`;
295
+ static override target = target;
296
+ };
297
+ Object.defineProperty(Target, "name", { value: `DeployCommand<${target}>` });
298
+ return Target;
299
+ }
300
+
301
+ /**
302
+ * A concrete deploy command. `typeof DeployCommand` cannot be used: the base is
303
+ * abstract, and the runner's registry holds constructible classes.
304
+ *
305
+ * @internal
306
+ */
307
+ export interface DeployCommandClass {
308
+ new (): DeployCommand;
309
+ commandName: string;
310
+ description?: string;
311
+ needsApp?: boolean;
312
+ args: typeof DeployCommand.args;
313
+ flags: typeof DeployCommand.flags;
314
+ target: string;
315
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `bun zt dev` — the server plus every process a provider or the app registered,
3
+ * in one terminal, each in its own tab.
4
+ */
5
+ import { ServeCommand } from "./ServeCommand.ts";
6
+ import type { FlagDef } from "../Command.ts";
7
+
8
+ /**
9
+ * `bun zt dev` — start dev mode: the server, the file watcher, and every
10
+ * registered dev process, drawn as a deck of tabs. Aliased as `d`.
11
+ *
12
+ * @remarks
13
+ * This *is* `serve --dev`, with the flags that only make sense when a deck is on
14
+ * screen. It subclasses rather than reimplements so the two cannot drift: an app
15
+ * that runs `serve --dev` in a script gets the same supervisor, the same
16
+ * processes and the same restart behaviour as one that runs `dev`.
17
+ *
18
+ * The flag names deliberately match the conventional ones, so a developer
19
+ * arriving from another framework needs no translation.
20
+ *
21
+ * @example
22
+ * ```bash
23
+ * bun zt dev # server + everything registered
24
+ * bun zt dev --only=server,queue # just these two
25
+ * bun zt dev --without=queue # everything but the worker
26
+ * bun zt dev --list # what would run, and who registered it
27
+ * bun zt dev --stream # no TUI — prefixed lines, pipe-friendly
28
+ * ```
29
+ *
30
+ * @category Serving
31
+ */
32
+ export class DevCommand extends ServeCommand {
33
+ static override commandName = "dev";
34
+ static override aliases = ["d"];
35
+ static override description = "Start dev mode: the server plus every registered dev process";
36
+ static override needsApp = true;
37
+
38
+ static override flags: FlagDef[] = [
39
+ { name: "port", short: "p", type: "number", description: "Port to listen on", default: 3000 },
40
+ {
41
+ name: "force",
42
+ type: "boolean",
43
+ description: "If the port is busy, stop the process holding it",
44
+ default: false,
45
+ },
46
+ {
47
+ name: "auto-port",
48
+ type: "boolean",
49
+ description: "If the port is busy, start on the next free port",
50
+ default: false,
51
+ },
52
+ {
53
+ name: "only",
54
+ type: "string",
55
+ description: "Run only these processes, comma-separated (e.g. server,queue)",
56
+ },
57
+ {
58
+ name: "without",
59
+ type: "string",
60
+ description: "Run everything except these processes, comma-separated",
61
+ },
62
+ {
63
+ name: "list",
64
+ type: "boolean",
65
+ description: "Print what would run, with the provider that registered each",
66
+ default: false,
67
+ },
68
+ {
69
+ name: "force-build",
70
+ type: "boolean",
71
+ description: "Rebuild assets even when the build cache says they are current",
72
+ default: false,
73
+ },
74
+ {
75
+ name: "stream",
76
+ type: "boolean",
77
+ description: "Interleave prefixed output instead of drawing tabs",
78
+ default: false,
79
+ },
80
+ ];
81
+
82
+ override async run(): Promise<void> {
83
+ // `dev` is `serve --dev` — set the flag the parent branches on rather than
84
+ // duplicating its port resolution, asset config and banner.
85
+ this.flags["dev"] = true;
86
+ await super.run();
87
+ }
88
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `bun zt doctor` — run every static sanity check against this app and print
3
+ * the findings with their fixes. Exits 1 when anything is broken outright.
4
+ *
5
+ * With `--url`, it also probes the deployed app's WebSocket transport from the outside,
6
+ * through whatever proxy is in front of it. That is the only way to see the failures that
7
+ * leave the app healthy from the inside and inert in the browser.
8
+ */
9
+ import { Command } from "../Command.ts";
10
+ import type { Application } from "../../application/Application.ts";
11
+ import { runDoctor } from "../../doctor/AppDoctor.ts";
12
+ import type { DoctorReportEntry } from "../../doctor/AppDoctor.ts";
13
+ import { probeTransport } from "../../doctor/TransportProbe.ts";
14
+
15
+ export class DoctorCommand extends Command {
16
+ static override commandName = "doctor";
17
+ static override description = "Check this app for silent misconfigurations";
18
+ static override needsApp = true;
19
+ static override flags = [
20
+ {
21
+ name: "url",
22
+ type: "string" as const,
23
+ description:
24
+ "Also probe the deployed app's WebSocket transport at this public URL, as a browser would",
25
+ },
26
+ ];
27
+
28
+ async run(): Promise<void> {
29
+ const app = this.app as Application | undefined;
30
+ if (!app) throw new Error("doctor needs a booted application.");
31
+
32
+ const report = await runDoctor(app);
33
+ this.section("Doctor");
34
+ for (const entry of report) this._print(entry);
35
+
36
+ const probeFailures = await this._probeTransport(app);
37
+
38
+ const warns = report.filter((e) => e.result.status === "warn").length;
39
+ const fails = report.filter((e) => e.result.status === "fail").length + probeFailures;
40
+ this.newLine();
41
+ // Throw rather than `process.exit(1)` — same exit code from the CLI (the runner
42
+ // converts it), but composable. Exiting here killed any caller running the
43
+ // doctor through `callInProcess` before its buffered report could be flushed,
44
+ // so the failure arrived with no output explaining it.
45
+ if (fails > 0) {
46
+ throw new Error(`${fails} failing, ${warns} warning(s), ${report.length} check(s).`);
47
+ } else if (warns > 0) {
48
+ this.warn(`${warns} warning(s), ${report.length} check(s).`);
49
+ } else {
50
+ this.info(`All ${report.length} checks passed.`);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Probe each registered WebSocket path through the public URL. Returns the number of
56
+ * failures, so they weigh on the exit code the same as a failing check.
57
+ */
58
+ private async _probeTransport(app: Application): Promise<number> {
59
+ const url = this.flags["url"] as string | undefined;
60
+ if (!url) return 0;
61
+
62
+ const paths = app.webSocketPaths();
63
+ this.newLine();
64
+ this.section("Transport");
65
+
66
+ if (paths.length === 0) {
67
+ this.line("✓ No WebSocket paths registered — nothing to probe.");
68
+ return 0;
69
+ }
70
+ if (paths.every((p) => p === "*")) {
71
+ this.warn("! Only catch-all WebSocket handlers are registered; no path to probe.");
72
+ return 0;
73
+ }
74
+
75
+ const results = await probeTransport(url, paths);
76
+ let failures = 0;
77
+ for (const result of results) {
78
+ const line = `${result.ok ? "✓" : "✗"} ${result.url} — ${result.message}`;
79
+ if (result.ok) this.line(line);
80
+ else {
81
+ this.error(line);
82
+ failures++;
83
+ }
84
+ if (result.fix) this.line(` fix: ${result.fix}`);
85
+ }
86
+ return failures;
87
+ }
88
+
89
+ private _print({ check, result }: DoctorReportEntry): void {
90
+ const mark = result.status === "ok" ? "✓" : result.status === "warn" ? "!" : "✗";
91
+ const line = `${mark} ${check.label} — ${result.message}`;
92
+ if (result.status === "ok") this.line(line);
93
+ else if (result.status === "warn") this.warn(line);
94
+ else this.error(line);
95
+ if (result.fix) this.line(` fix: ${result.fix}`);
96
+ }
97
+ }
@@ -79,6 +79,8 @@ export class MakeCommandCommand extends Command {
79
79
  // Bun.write() creates any missing parent directories, so no mkdir is needed.
80
80
  return Bun.write(path, commandStub(name)).then(() => {
81
81
  this.info(`Created: ${path}`);
82
+ // app/commands/ is auto-discovered, so the command is immediately runnable.
83
+ this.info(`Run it with: bun zt.ts ${toKebab(name)}`);
82
84
  });
83
85
  });
84
86
  }
@@ -0,0 +1,56 @@
1
+ import { Command } from "../Command.ts";
2
+ import type { FlagDef } from "../Command.ts";
3
+ import { Router } from "../../router/Router.ts";
4
+ import { writeRouteTypes, ROUTE_TYPES_FILE } from "../../router/routeTypes.ts";
5
+
6
+ /**
7
+ * `bun zt route:types` — write `types/routes.generated.ts`, the name → pattern
8
+ * map that makes `route()` type-checked.
9
+ *
10
+ * Boots the application (`needsApp`) and reads `Router.namedRoutes`, so routes
11
+ * a provider registers programmatically and names set via a route file's
12
+ * `export const meta` are included — not just what the file-router's naming
13
+ * convention would produce.
14
+ *
15
+ * Commit the generated file: editors and CI need it without booting the app.
16
+ * `zt dev` refreshes it on every restart, and `--check` fails when the file on
17
+ * disk no longer matches the routes, which is the CI gate that keeps it honest.
18
+ *
19
+ * @category Diagnostics
20
+ */
21
+ export class RouteTypesCommand extends Command {
22
+ static commandName = "route:types";
23
+ static description = "Generate types/routes.generated.ts for typed route() names";
24
+ static needsApp = true;
25
+ static args = [];
26
+ static flags: FlagDef[] = [
27
+ {
28
+ name: "check",
29
+ type: "boolean",
30
+ description: "Fail instead of writing when the generated file is out of date (for CI)",
31
+ default: false,
32
+ },
33
+ ];
34
+
35
+ async run(): Promise<void> {
36
+ const check = this.flags["check"] as boolean;
37
+ const result = await writeRouteTypes(Router.namedRoutes, { check });
38
+
39
+ if (check) {
40
+ if (result.changed) {
41
+ this.error(`${result.path} is out of date. Run: bun zt route:types`);
42
+ throw new Error("Route types are out of date.");
43
+ }
44
+ this.info(`${result.path} is up to date (${result.count} named routes).`);
45
+ return;
46
+ }
47
+
48
+ if (result.count === 0) {
49
+ this.warn("No named routes found — the generated map is empty.");
50
+ }
51
+
52
+ this.info(
53
+ `${result.changed ? "Wrote" : "Unchanged"}: ${ROUTE_TYPES_FILE} (${result.count} named routes)`,
54
+ );
55
+ }
56
+ }