@zerotal/core 1.0.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 (201) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +128 -0
  4. package/package.json +72 -0
  5. package/src/application/Application.ts +1671 -0
  6. package/src/application/BootDoctor.ts +108 -0
  7. package/src/application/DevErrorPage.ts +567 -0
  8. package/src/application/ExceptionHandler.ts +183 -0
  9. package/src/application/currentApp.ts +73 -0
  10. package/src/assets/assets.ts +79 -0
  11. package/src/assets/index.ts +16 -0
  12. package/src/auth/AuthenticatedUser.ts +18 -0
  13. package/src/build/PackageLinter.ts +146 -0
  14. package/src/build/PackageScaffold.ts +127 -0
  15. package/src/build/codemod.ts +64 -0
  16. package/src/build/index.ts +12 -0
  17. package/src/command/Command.ts +254 -0
  18. package/src/command/CommandRunner.ts +593 -0
  19. package/src/command/OutputWriter.ts +61 -0
  20. package/src/command/builtin/CompileCommand.ts +46 -0
  21. package/src/command/builtin/CssBuildCommand.ts +71 -0
  22. package/src/command/builtin/KeyGenerateCommand.ts +58 -0
  23. package/src/command/builtin/LintPackagesCommand.ts +72 -0
  24. package/src/command/builtin/MakeCommandCommand.ts +85 -0
  25. package/src/command/builtin/MakeControllerCommand.ts +95 -0
  26. package/src/command/builtin/MakeEventCommand.ts +85 -0
  27. package/src/command/builtin/MakeJobCommand.ts +53 -0
  28. package/src/command/builtin/MakeListenerCommand.ts +35 -0
  29. package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
  30. package/src/command/builtin/MakeNotificationCommand.ts +48 -0
  31. package/src/command/builtin/MakeObserverCommand.ts +78 -0
  32. package/src/command/builtin/MakePackageCommand.ts +45 -0
  33. package/src/command/builtin/MakePolicyCommand.ts +66 -0
  34. package/src/command/builtin/MakeProviderCommand.ts +75 -0
  35. package/src/command/builtin/MakeRequestCommand.ts +47 -0
  36. package/src/command/builtin/MakeResourceCommand.ts +61 -0
  37. package/src/command/builtin/MakeTestCommand.ts +120 -0
  38. package/src/command/builtin/ReloadCommand.ts +52 -0
  39. package/src/command/builtin/ReplCommand.ts +174 -0
  40. package/src/command/builtin/RouteListCommand.ts +188 -0
  41. package/src/command/builtin/ServeCommand.ts +321 -0
  42. package/src/command/builtin/StartCommand.ts +3 -0
  43. package/src/command/builtin/StatusCommand.ts +71 -0
  44. package/src/command/builtin/TestCommand.ts +172 -0
  45. package/src/command/builtin/WorkerCommand.ts +27 -0
  46. package/src/command/builtin/index.ts +53 -0
  47. package/src/command/scaffold/worker.ts.txt +12 -0
  48. package/src/command/scaffold/zerotal.ts.txt +26 -0
  49. package/src/command/startZerotal.ts +55 -0
  50. package/src/config/AppConfig.ts +253 -0
  51. package/src/config/ConfigLoader.ts +117 -0
  52. package/src/config/ConfigManager.ts +169 -0
  53. package/src/config/index.ts +46 -0
  54. package/src/config/registry.ts +59 -0
  55. package/src/config/validation.ts +117 -0
  56. package/src/container/Container.ts +606 -0
  57. package/src/container/ContextualBindingBuilder.ts +57 -0
  58. package/src/container/ScopedResolver.ts +117 -0
  59. package/src/container/index.ts +32 -0
  60. package/src/container/inject.ts +55 -0
  61. package/src/container/types.ts +71 -0
  62. package/src/context/RequestContext.ts +91 -0
  63. package/src/contracts/auth.ts +24 -0
  64. package/src/contracts/index.ts +23 -0
  65. package/src/contracts/session.ts +70 -0
  66. package/src/contracts/transaction.ts +26 -0
  67. package/src/conventions/ConventionLoader.ts +128 -0
  68. package/src/conventions/builtinConcerns.ts +131 -0
  69. package/src/crypt/Crypt.ts +141 -0
  70. package/src/crypt/URLSigner.ts +96 -0
  71. package/src/datetime/Carbon.ts +1396 -0
  72. package/src/datetime/CarbonInterval.ts +421 -0
  73. package/src/datetime/clock.ts +28 -0
  74. package/src/datetime/index.ts +23 -0
  75. package/src/datetime/temporal-shim.ts +1 -0
  76. package/src/dev/BuildOutput.ts +131 -0
  77. package/src/dev/CssPlugins.ts +184 -0
  78. package/src/dev/DevBuildHook.ts +74 -0
  79. package/src/dev/DevOrchestrator.ts +213 -0
  80. package/src/dev/DevReloadMiddleware.ts +101 -0
  81. package/src/dev/DevReloadServer.ts +85 -0
  82. package/src/dev/DevWsServer.ts +45 -0
  83. package/src/dev/index.ts +19 -0
  84. package/src/dev/reloadClient.ts +39 -0
  85. package/src/env/Def.ts +232 -0
  86. package/src/env/EnvSchema.ts +105 -0
  87. package/src/env/index.ts +34 -0
  88. package/src/env/t.ts +128 -0
  89. package/src/errors/ConfigError.ts +12 -0
  90. package/src/errors/ContainerErrors.ts +143 -0
  91. package/src/errors/HttpError.ts +127 -0
  92. package/src/errors/ValidationError.ts +19 -0
  93. package/src/errors/ZerotalError.ts +25 -0
  94. package/src/errors/index.ts +46 -0
  95. package/src/events/CallQueuedListener.ts +66 -0
  96. package/src/events/Emitter.ts +280 -0
  97. package/src/events/EventFake.ts +160 -0
  98. package/src/events/FrameworkEvents.ts +252 -0
  99. package/src/facade/Facade.ts +101 -0
  100. package/src/facade/facades/App.ts +155 -0
  101. package/src/facade/facades/Artisan.ts +63 -0
  102. package/src/facade/facades/Config.ts +21 -0
  103. package/src/facade/facades/Events.ts +19 -0
  104. package/src/facade/facades/index.ts +28 -0
  105. package/src/global.d.ts +9 -0
  106. package/src/hash/Hash.ts +60 -0
  107. package/src/health/Health.ts +221 -0
  108. package/src/health/index.ts +27 -0
  109. package/src/helpers/Collection.ts +435 -0
  110. package/src/helpers/config.ts +59 -0
  111. package/src/helpers/fluent.ts +52 -0
  112. package/src/helpers/html.ts +11 -0
  113. package/src/helpers/index.ts +266 -0
  114. package/src/helpers/make.ts +35 -0
  115. package/src/helpers/markdown.ts +73 -0
  116. package/src/helpers/pageElements.ts +27 -0
  117. package/src/helpers/request.ts +62 -0
  118. package/src/helpers/response.ts +411 -0
  119. package/src/helpers/str.ts +208 -0
  120. package/src/http/Http.ts +298 -0
  121. package/src/http/HttpClient.ts +289 -0
  122. package/src/http/Resource.ts +171 -0
  123. package/src/http/UploadedFile.ts +204 -0
  124. package/src/http/Uri.ts +490 -0
  125. package/src/http/index.ts +46 -0
  126. package/src/http/negotiate.ts +213 -0
  127. package/src/http/originGuard.ts +76 -0
  128. package/src/http/sniffContentType.ts +105 -0
  129. package/src/http/url.ts +204 -0
  130. package/src/http/withHeaders.ts +24 -0
  131. package/src/index.ts +250 -0
  132. package/src/lock/LockManager.ts +228 -0
  133. package/src/lock/config.ts +49 -0
  134. package/src/lock/drivers/LockDriver.ts +32 -0
  135. package/src/lock/drivers/MemoryLockDriver.ts +52 -0
  136. package/src/lock/drivers/RedisLockDriver.ts +58 -0
  137. package/src/lock/drivers/SqliteLockDriver.ts +85 -0
  138. package/src/lock/errors.ts +20 -0
  139. package/src/lock/facades/Lock.ts +114 -0
  140. package/src/lock/index.ts +53 -0
  141. package/src/logger/Log.ts +35 -0
  142. package/src/logger/LogManager.ts +430 -0
  143. package/src/logger/LoggerMiddleware.ts +125 -0
  144. package/src/logger/channels/ConsoleChannel.ts +139 -0
  145. package/src/logger/channels/DailyChannel.ts +74 -0
  146. package/src/logger/channels/NullChannel.ts +17 -0
  147. package/src/logger/channels/SingleChannel.ts +34 -0
  148. package/src/logger/channels/StackChannel.ts +29 -0
  149. package/src/logger/config.ts +90 -0
  150. package/src/logger/format.ts +96 -0
  151. package/src/logger/frameworkLog.ts +93 -0
  152. package/src/logger/index.ts +68 -0
  153. package/src/logger/renderTable.ts +111 -0
  154. package/src/logger/types.ts +212 -0
  155. package/src/macros/config.macro.ts +50 -0
  156. package/src/metrics/HttpMetrics.ts +114 -0
  157. package/src/metrics/index.ts +18 -0
  158. package/src/middleware/BaseMiddleware.ts +72 -0
  159. package/src/middleware/CorsMiddleware.ts +152 -0
  160. package/src/middleware/RateLimiter.ts +255 -0
  161. package/src/middleware/SecureHeadersMiddleware.ts +127 -0
  162. package/src/middleware/ThrottleMiddleware.ts +252 -0
  163. package/src/middleware/WebhookMiddleware.ts +204 -0
  164. package/src/pipeline/ContextRegistry.ts +42 -0
  165. package/src/pipeline/HttpContext.ts +865 -0
  166. package/src/pipeline/Pipeline.ts +150 -0
  167. package/src/pipeline/currentPage.ts +46 -0
  168. package/src/pipeline/types.ts +80 -0
  169. package/src/provider/LockProvider.ts +64 -0
  170. package/src/provider/LogProvider.ts +137 -0
  171. package/src/provider/ServiceProvider.ts +84 -0
  172. package/src/provider/StorageProvider.ts +45 -0
  173. package/src/router/FileRouter.ts +526 -0
  174. package/src/router/Route.ts +76 -0
  175. package/src/router/RouteHandler.ts +335 -0
  176. package/src/router/Router.ts +1247 -0
  177. package/src/router/domain.ts +65 -0
  178. package/src/security/index.ts +22 -0
  179. package/src/storage/FakeDisk.ts +233 -0
  180. package/src/storage/StorageFilesMiddleware.ts +150 -0
  181. package/src/storage/StorageManager.ts +173 -0
  182. package/src/storage/config.ts +47 -0
  183. package/src/storage/drivers/LocalDriver.ts +138 -0
  184. package/src/storage/drivers/S3Driver.ts +169 -0
  185. package/src/storage/errors.ts +135 -0
  186. package/src/storage/facades/Storage.ts +3 -0
  187. package/src/storage/global.d.ts +7 -0
  188. package/src/storage/index.ts +22 -0
  189. package/src/storage/root.ts +59 -0
  190. package/src/storage/types.ts +104 -0
  191. package/src/support/appKey.ts +38 -0
  192. package/src/support/cookie.ts +72 -0
  193. package/src/support/crypto.ts +52 -0
  194. package/src/support/deepMerge.ts +117 -0
  195. package/src/support/env.ts +71 -0
  196. package/src/support/network.ts +79 -0
  197. package/src/support/port.ts +197 -0
  198. package/src/support/str.ts +122 -0
  199. package/src/view/FileRouteResolver.ts +59 -0
  200. package/src/view/index.ts +144 -0
  201. package/src/view/jsx-runtime.ts +233 -0
@@ -0,0 +1,593 @@
1
+ /**
2
+ * Registers console commands, parses their arguments and flags from argv, and
3
+ * dispatches execution — both for the CLI and for in-process calls from HTTP
4
+ * handlers. Also turns closure-style command definitions into command classes.
5
+ */
6
+ import { parseArgs } from "node:util";
7
+ import type { Application } from "../application/Application.ts";
8
+ import { Command } from "./Command.ts";
9
+ import type { ArgDef, FlagDef } from "./Command.ts";
10
+ import { BufferWriter } from "./OutputWriter.ts";
11
+ import { FrameworkEvents, CommandRan } from "../events/FrameworkEvents.ts";
12
+
13
+ // ── Types ─────────────────────────────────────────────────────────────────────
14
+
15
+ interface CommandStatic {
16
+ new (): Command;
17
+ commandName: string;
18
+ description?: string;
19
+ needsApp?: boolean;
20
+ args: ArgDef[];
21
+ flags: FlagDef[];
22
+ }
23
+
24
+ type CommandClass = CommandStatic;
25
+ type CommandThunk = () => Promise<CommandClass>;
26
+
27
+ /** A closure-style command: a signature string plus a handler to run. */
28
+ export interface CommandDefinition {
29
+ signature: string;
30
+ description?: string;
31
+ handle: (
32
+ params: Record<string, string | boolean | number>,
33
+ command: Command,
34
+ ) => void | Promise<void>;
35
+ }
36
+
37
+ /**
38
+ * Parse an Artisan-style signature string (e.g. `make:thing {name} {--force}`)
39
+ * into the command name and its argument and flag definitions.
40
+ *
41
+ * @param signature - The signature: a command name followed by `{arg}`,
42
+ * `{arg?}`, `{arg=default}`, `{--flag}`, or `{--flag=default}` tokens.
43
+ * @returns The parsed command `name` with its `args` and `flags`.
44
+ * @internal
45
+ */
46
+ export function parseSignature(signature: string): {
47
+ name: string;
48
+ args: ArgDef[];
49
+ flags: FlagDef[];
50
+ } {
51
+ const tokens = signature.trim().match(/\S+/g) ?? [];
52
+ const name = tokens[0] ?? "";
53
+ const args: ArgDef[] = [];
54
+ const flags: FlagDef[] = [];
55
+
56
+ for (const token of tokens.slice(1)) {
57
+ const braceMatch = token.match(/^\{(.+)\}$/);
58
+ if (!braceMatch) continue;
59
+ const body = braceMatch[1]!;
60
+ if (body.startsWith("--")) {
61
+ const flagBody = body.slice(2);
62
+ const equalsIndex = flagBody.indexOf("=");
63
+ if (equalsIndex === -1) {
64
+ flags.push({ name: flagBody, type: "boolean", default: false });
65
+ } else {
66
+ const flagName = flagBody.slice(0, equalsIndex);
67
+ const defaultValue = flagBody.slice(equalsIndex + 1);
68
+ flags.push({ name: flagName, type: "string", default: defaultValue || undefined });
69
+ }
70
+ } else {
71
+ const equalsIndex = body.indexOf("=");
72
+ if (equalsIndex !== -1) {
73
+ args.push({
74
+ name: body.slice(0, equalsIndex),
75
+ required: false,
76
+ default: body.slice(equalsIndex + 1),
77
+ });
78
+ } else if (body.endsWith("?")) {
79
+ args.push({ name: body.slice(0, -1), required: false });
80
+ } else {
81
+ args.push({ name: body, required: true });
82
+ }
83
+ }
84
+ }
85
+
86
+ return { name, args, flags };
87
+ }
88
+
89
+ /**
90
+ * Build an anonymous {@link Command} subclass from a closure-style definition.
91
+ *
92
+ * @param definition - The signature, optional description, and `handle` closure.
93
+ * @returns A ready-to-register {@link Command} subclass whose `run()` invokes `handle`.
94
+ * @internal
95
+ */
96
+ export function commandFromDefinition(definition: CommandDefinition): CommandClass {
97
+ const { name, args, flags } = parseSignature(definition.signature);
98
+ const Synthetic = class extends Command {
99
+ static commandName = name;
100
+ static description = definition.description ?? "";
101
+ static needsApp = false;
102
+ static args = args;
103
+ static flags = flags;
104
+ async run(): Promise<void> {
105
+ await definition.handle({ ...this.args, ...this.flags }, this);
106
+ }
107
+ };
108
+ Object.defineProperty(Synthetic, "name", { value: `Closure<${name}>` });
109
+ return Synthetic as unknown as CommandClass;
110
+ }
111
+
112
+ type ParseArgsOption = {
113
+ type: "string" | "boolean";
114
+ short?: string;
115
+ default?: string | boolean;
116
+ };
117
+
118
+ // ── Internal helpers ──────────────────────────────────────────────────────────
119
+
120
+ function buildParseOptions(flags: FlagDef[]): Record<string, ParseArgsOption> {
121
+ const options: Record<string, ParseArgsOption> = {};
122
+ for (const flag of flags) {
123
+ const option: ParseArgsOption = {
124
+ type: flag.type === "number" ? "string" : flag.type,
125
+ };
126
+ if (flag.short !== undefined) option.short = flag.short;
127
+ if (flag.default !== undefined)
128
+ option.default = flag.type === "boolean" ? (flag.default as boolean) : String(flag.default);
129
+ options[flag.name] = option;
130
+ }
131
+ return options;
132
+ }
133
+
134
+ /** `<required>` / `[optional]` signature for a command's positional arguments. */
135
+ function argumentSignature(Cmd: CommandClass): string {
136
+ return (Cmd.args ?? [])
137
+ .map((argument) => (argument.required ? `<${argument.name}>` : `[${argument.name}]`))
138
+ .join(" ");
139
+ }
140
+
141
+ function parseFlagsAndArgs(
142
+ Cmd: CommandClass,
143
+ rawArgv: string[],
144
+ ): {
145
+ parsedArgs: Record<string, string>;
146
+ parsedFlags: Record<string, string | boolean | number>;
147
+ /** Names of `required` arguments the caller omitted. */
148
+ missingArgs: string[];
149
+ } {
150
+ const { values: rawFlags, positionals } = parseArgs({
151
+ args: rawArgv,
152
+ options: buildParseOptions(Cmd.flags ?? []),
153
+ strict: false,
154
+ allowPositionals: true,
155
+ });
156
+
157
+ const parsedFlags: Record<string, string | boolean | number> = {};
158
+ for (const flag of Cmd.flags ?? []) {
159
+ const rawValue = rawFlags[flag.name];
160
+ if (flag.type === "number" && typeof rawValue === "string") {
161
+ parsedFlags[flag.name] = Number(rawValue);
162
+ } else if (rawValue !== undefined) {
163
+ parsedFlags[flag.name] = rawValue as string | boolean;
164
+ } else if (flag.default !== undefined) {
165
+ parsedFlags[flag.name] = flag.default as string | boolean | number;
166
+ }
167
+ }
168
+
169
+ const parsedArgs: Record<string, string> = {};
170
+ const missingArgs: string[] = [];
171
+ for (let index = 0; index < (Cmd.args ?? []).length; index++) {
172
+ const argument = Cmd.args[index]!;
173
+ const value = positionals[index] ?? argument.default;
174
+ // `required` used to be decorative — it shaped the help text and nothing
175
+ // else, so an omitted argument arrived as "" and each command improvised.
176
+ // `make:migration` with no name wrote `001_.ts` containing `class {`.
177
+ if (value === undefined && argument.required) missingArgs.push(argument.name);
178
+ parsedArgs[argument.name] = value ?? "";
179
+ }
180
+
181
+ return { parsedArgs, parsedFlags, missingArgs };
182
+ }
183
+
184
+ // ── CommandRunner ─────────────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Registry and dispatcher for console commands.
188
+ *
189
+ * Holds the map of command name (and aliases) to command class, parses argv into
190
+ * the arguments and flags each command declares, and runs the matched command —
191
+ * either as a CLI process via {@link CommandRunner.run} (which calls
192
+ * `process.exit`) or in-process via {@link CommandRunner.callInProcess} (which
193
+ * captures output and returns a status code). Commands can be registered as
194
+ * classes ({@link CommandRunner.register}), from closure-style definitions
195
+ * ({@link CommandRunner.command}), as lazy thunks ({@link CommandRunner.registerLazy}),
196
+ * or discovered from a directory ({@link CommandRunner.discover}).
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * const runner = new CommandRunner(app);
201
+ *
202
+ * // Register a command class…
203
+ * runner.register(GreetCommand, ["hello"]);
204
+ *
205
+ * // …or define one inline with an Artisan-style signature.
206
+ * runner.command({
207
+ * signature: "cache:clear {--tag=}",
208
+ * description: "Clear the application cache",
209
+ * handle: async (params, command) => {
210
+ * command.info(`Clearing ${params["tag"] ?? "all"} cache…`);
211
+ * },
212
+ * });
213
+ *
214
+ * // Dispatch the argv the CLI entry point received.
215
+ * await runner.run(process.argv.slice(2)); // e.g. `bun zt greet Ada`
216
+ * ```
217
+ */
218
+ export class CommandRunner {
219
+ private _registry = new Map<string, CommandClass | CommandThunk>();
220
+
221
+ constructor(private readonly _app: Application) {}
222
+
223
+ // ── Registration ──────────────────────────────────────────────────────────
224
+
225
+ /** Register a command class under its name and any aliases. */
226
+ register(Cmd: CommandClass, aliases: string[] = []): void {
227
+ this._registry.set(Cmd.commandName, Cmd);
228
+ for (const alias of aliases) this._registry.set(alias, Cmd);
229
+ }
230
+
231
+ /** Register several command classes at once. */
232
+ registerAll(commandClasses: CommandClass[]): void {
233
+ for (const commandClass of commandClasses) this.register(commandClass);
234
+ }
235
+
236
+ /** Build and register a command from a closure-style definition. */
237
+ command(definition: CommandDefinition, aliases: string[] = []): CommandClass {
238
+ const Cmd = commandFromDefinition(definition);
239
+ this.register(Cmd, aliases);
240
+ return Cmd;
241
+ }
242
+
243
+ /** Alias for {@link CommandRunner.command}. */
244
+ registerCommand(definition: CommandDefinition, aliases: string[] = []): CommandClass {
245
+ return this.command(definition, aliases);
246
+ }
247
+
248
+ /**
249
+ * Import every non-test module under a directory and register any exported
250
+ * command classes it finds. Returns the names that were registered.
251
+ */
252
+ async discover(dir: string): Promise<string[]> {
253
+ const registered: string[] = [];
254
+ let glob: { scan(opts: { cwd: string; onlyFiles: boolean }): AsyncIterable<string> };
255
+ try {
256
+ glob = new Bun.Glob("**/*.{ts,js}");
257
+ } catch {
258
+ return registered;
259
+ }
260
+ try {
261
+ for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
262
+ if (file.endsWith(".test.ts") || file.endsWith(".test.js")) continue;
263
+ let module: Record<string, unknown>;
264
+ try {
265
+ module = (await import(`${dir}/${file}`)) as Record<string, unknown>;
266
+ } catch {
267
+ continue;
268
+ }
269
+ for (const exported of Object.values(module)) {
270
+ if (CommandRunner._isCommandClass(exported)) {
271
+ this.register(exported);
272
+ registered.push(exported.commandName);
273
+ }
274
+ }
275
+ }
276
+ } catch {
277
+ /* discovery is best-effort — ignore load failures */
278
+ }
279
+ return registered;
280
+ }
281
+
282
+ private static _isCommandClass(value: unknown): value is CommandClass {
283
+ return (
284
+ typeof value === "function" &&
285
+ value !== Command &&
286
+ value.prototype instanceof Command &&
287
+ typeof (value as { commandName?: unknown }).commandName === "string" &&
288
+ (value as unknown as { commandName: string }).commandName.length > 0
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Register a command as a lazy thunk.
294
+ * The module is only imported if/when the command is called.
295
+ * Use this in web mode to avoid parsing CLI files during HTTP boot.
296
+ *
297
+ * @example
298
+ * runner.registerLazy('cache:clear',
299
+ * () => import('@zerotal/cache/commands').then(m => m.CacheClearCommand)
300
+ * );
301
+ */
302
+ registerLazy(name: string, thunk: CommandThunk, aliases: string[] = []): void {
303
+ this._registry.set(name, thunk);
304
+ for (const alias of aliases) this._registry.set(alias, thunk);
305
+ }
306
+
307
+ // ── Boot ──────────────────────────────────────────────────────────────────
308
+
309
+ /**
310
+ * Register self in the container, boot the application, and register the
311
+ * built-in commands appropriate to the current environment.
312
+ *
313
+ * Called by the CLI entry point (`zt.ts`) before {@link CommandRunner.run}.
314
+ *
315
+ * Environment is set by `zt.ts` via Bun.env['APP_ENV'] BEFORE bootstrap/app.ts
316
+ * is dynamically imported. Application.create() reads it, so _env is already
317
+ * correct by the time boot() runs — no argv inspection needed here.
318
+ */
319
+ async boot(): Promise<void> {
320
+ this._app.container.value("commands", this);
321
+ await this._app.boot();
322
+
323
+ const {
324
+ ServeCommand,
325
+ ReplCommand,
326
+ WorkerCommand,
327
+ CompileCommand,
328
+ KeyGenerateCommand,
329
+ ReloadCommand,
330
+ StatusCommand,
331
+ MakeControllerCommand,
332
+ MakeMiddlewareCommand,
333
+ MakeCommandCommand,
334
+ MakeRequestCommand,
335
+ MakeEventCommand,
336
+ MakeListenerCommand,
337
+ MakeJobCommand,
338
+ MakePolicyCommand,
339
+ MakeNotificationCommand,
340
+ MakeObserverCommand,
341
+ MakeResourceCommand,
342
+ MakeTestCommand,
343
+ TestCommand,
344
+ RouteListCommand,
345
+ MakeProviderCommand,
346
+ CssBuildCommand,
347
+ LintPackagesCommand,
348
+ MakePackageCommand,
349
+ } = await import("./builtin/index.ts");
350
+
351
+ // ServeCommand is always registered — usable from any environment.
352
+ this.register(ServeCommand);
353
+
354
+ // reload + status are always available — they talk to the running server,
355
+ // not the app itself, so they don't need an application instance.
356
+ this.register(ReloadCommand);
357
+ this.register(StatusCommand);
358
+
359
+ // route:list is an inspection command — always available regardless of mode.
360
+ this.register(RouteListCommand);
361
+
362
+ // Non-web commands (console, worker, test).
363
+ if (this._app._env !== "web") {
364
+ this.register(ReplCommand);
365
+ this.register(CompileCommand, ["build"]);
366
+ this.registerAll([
367
+ WorkerCommand,
368
+ KeyGenerateCommand,
369
+ MakeProviderCommand,
370
+ MakeControllerCommand,
371
+ MakeMiddlewareCommand,
372
+ MakeCommandCommand,
373
+ MakeRequestCommand,
374
+ MakeEventCommand,
375
+ MakeListenerCommand,
376
+ MakeJobCommand,
377
+ MakePolicyCommand,
378
+ MakeNotificationCommand,
379
+ MakeObserverCommand,
380
+ MakeResourceCommand,
381
+ MakeTestCommand,
382
+ TestCommand,
383
+ CssBuildCommand,
384
+ LintPackagesCommand,
385
+ MakePackageCommand,
386
+ ]);
387
+ }
388
+ }
389
+
390
+ // ── Private resolution ────────────────────────────────────────────────────
391
+
392
+ /** Resolve a command name to its class — handles both eager and lazy entries. */
393
+ private async _resolve(name: string): Promise<CommandClass | undefined> {
394
+ const entry = this._registry.get(name);
395
+ if (!entry) return undefined;
396
+
397
+ // A CommandClass has a 'commandName' static property; a thunk does not.
398
+ if (typeof entry === "function" && !("commandName" in entry)) {
399
+ const Cmd = await (entry as CommandThunk)();
400
+ // Cache the resolved class — subsequent calls are instant
401
+ this._registry.set(name, Cmd);
402
+ return Cmd;
403
+ }
404
+ return entry as CommandClass;
405
+ }
406
+
407
+ // ── Execution ─────────────────────────────────────────────────────────────
408
+
409
+ /**
410
+ * Parse argv, run the matched command, and exit the process with its status.
411
+ * Handles the built-in `list` and `help` commands. Intended for CLI use.
412
+ */
413
+ async run(argv: string[]): Promise<void> {
414
+ const commandName = argv[0] ?? "";
415
+ if (commandName === "list" || commandName === "") {
416
+ this._printList();
417
+ return;
418
+ }
419
+ if (commandName === "help") {
420
+ await this._printHelp(argv[1] ?? "");
421
+ return;
422
+ }
423
+ const Cmd = await this._resolve(commandName);
424
+
425
+ if (!Cmd) {
426
+ console.error(`\x1b[31mUnknown command: "${commandName}"\x1b[0m`);
427
+ process.exit(1);
428
+ }
429
+
430
+ const { parsedArgs, parsedFlags, missingArgs } = parseFlagsAndArgs(Cmd, argv.slice(1));
431
+
432
+ if (missingArgs.length > 0) {
433
+ console.error(
434
+ `\x1b[31mMissing required argument${missingArgs.length > 1 ? "s" : ""}: ${missingArgs.join(", ")}\x1b[0m`,
435
+ );
436
+ console.error(`\x1b[1mUsage:\x1b[0m ${commandName} ${argumentSignature(Cmd)}`);
437
+ process.exit(1);
438
+ }
439
+
440
+ const instance = new Cmd();
441
+ (instance as unknown as Record<string, unknown>).args = parsedArgs;
442
+ (instance as unknown as Record<string, unknown>).flags = parsedFlags;
443
+ (instance as unknown as Record<string, unknown>).app = this._app;
444
+
445
+ const name = (Cmd as { commandName?: string }).commandName ?? commandName;
446
+ const startedAt = performance.now();
447
+ try {
448
+ await instance.run();
449
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
450
+ process.exit(0);
451
+ } catch (error) {
452
+ FrameworkEvents.emit(
453
+ new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
454
+ );
455
+ console.error(`\x1b[31m✖ ${(error as Error).message}\x1b[0m`);
456
+ process.exit(1);
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Run a command in-process and return { code, output }.
462
+ * Does NOT call process.exit(). Safe to call from HTTP handlers.
463
+ * Output is captured via BufferWriter — nothing is written to stdout.
464
+ *
465
+ * See: plans/boot-modes.md §6
466
+ */
467
+ async callInProcess(
468
+ argv: string[],
469
+ parameters: Record<string, string | boolean | number> = {},
470
+ ): Promise<{ code: number; output: string }> {
471
+ const commandName = argv[0] ?? "";
472
+ const Cmd = await this._resolve(commandName);
473
+
474
+ if (!Cmd) {
475
+ return { code: 1, output: `Unknown command: "${commandName}"\n` };
476
+ }
477
+
478
+ // Build argv array from parameters object if provided separately
479
+ const fullArgv = argv.length > 1 ? [...argv] : [commandName];
480
+ for (const [key, value] of Object.entries(parameters)) {
481
+ if (typeof value === "boolean") {
482
+ if (value) fullArgv.push(key);
483
+ } else {
484
+ fullArgv.push(`${key}=${String(value)}`);
485
+ }
486
+ }
487
+
488
+ const { parsedArgs, parsedFlags, missingArgs } = parseFlagsAndArgs(Cmd, fullArgv.slice(1));
489
+
490
+ if (missingArgs.length > 0) {
491
+ const plural = missingArgs.length > 1 ? "s" : "";
492
+ return {
493
+ code: 1,
494
+ output:
495
+ `\x1b[31m✖ Missing required argument${plural}: ${missingArgs.join(", ")}\x1b[0m\n` +
496
+ `Usage: ${commandName} ${argumentSignature(Cmd)}\n`,
497
+ };
498
+ }
499
+
500
+ // Instantiate with BufferWriter — output is captured, not printed
501
+ const instance = new Cmd();
502
+ const writer = new BufferWriter();
503
+ (instance as any)._writer = writer;
504
+ (instance as any).args = parsedArgs;
505
+ (instance as any).flags = parsedFlags;
506
+ (instance as any).app = this._app;
507
+
508
+ const name = (Cmd as { commandName?: string }).commandName ?? commandName;
509
+ const startedAt = performance.now();
510
+ try {
511
+ await instance.run();
512
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
513
+ return { code: 0, output: writer.flush() };
514
+ } catch (error) {
515
+ FrameworkEvents.emit(
516
+ new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
517
+ );
518
+ const errorLine = `\x1b[31m✖ ${(error as Error).message}\x1b[0m\n`;
519
+ return { code: 1, output: writer.flush() + errorLine };
520
+ }
521
+ }
522
+
523
+ private _printList(): void {
524
+ const groups = new Map<CommandClass | CommandThunk, string[]>();
525
+ for (const [name, entry] of this._registry) {
526
+ const list = groups.get(entry) ?? [];
527
+ list.push(name);
528
+ groups.set(entry, list);
529
+ }
530
+
531
+ type Row = { name: string; description: string; aliases: string[] };
532
+ const rows: Row[] = [];
533
+ for (const [entry, names] of groups) {
534
+ const isClass = typeof entry === "function" && "commandName" in entry;
535
+ const primary = isClass ? (entry as CommandClass).commandName : names[0]!;
536
+ const description = isClass
537
+ ? ((entry as CommandClass).description ?? "")
538
+ : "(lazy — run to load)";
539
+ const aliases = names.filter((registeredName) => registeredName !== primary);
540
+ rows.push({ name: primary, description, aliases });
541
+ }
542
+
543
+ rows.sort((first, second) => first.name.localeCompare(second.name));
544
+ const columnWidth = Math.max(0, ...rows.map((row) => row.name.length)) + 4;
545
+
546
+ console.log("\x1b[1mAvailable commands:\x1b[0m");
547
+ for (const row of rows) {
548
+ const alias = row.aliases.length ? ` \x1b[2m(alias: ${row.aliases.join(", ")})\x1b[0m` : "";
549
+ console.log(` ${row.name.padEnd(columnWidth)}\x1b[2m${row.description}\x1b[0m${alias}`);
550
+ }
551
+ }
552
+
553
+ private async _printHelp(name: string): Promise<void> {
554
+ if (!name) {
555
+ this._printList();
556
+ return;
557
+ }
558
+ const Cmd = await this._resolve(name);
559
+ if (!Cmd) {
560
+ console.error(`\x1b[31mUnknown command: "${name}"\x1b[0m`);
561
+ return;
562
+ }
563
+
564
+ const signature = argumentSignature(Cmd);
565
+ console.log(
566
+ `\x1b[1mUsage:\x1b[0m ${Cmd.commandName}${signature ? " " + signature : ""}${(Cmd.flags ?? []).length ? " [options]" : ""}`,
567
+ );
568
+ if (Cmd.description) console.log(`\n${Cmd.description}`);
569
+
570
+ if ((Cmd.args ?? []).length) {
571
+ console.log("\n\x1b[1mArguments:\x1b[0m");
572
+ for (const argument of Cmd.args) {
573
+ const detail = argument.required
574
+ ? "required"
575
+ : argument.default !== undefined
576
+ ? `default: ${argument.default}`
577
+ : "optional";
578
+ console.log(` ${argument.name.padEnd(20)}\x1b[2m${detail}\x1b[0m`);
579
+ }
580
+ }
581
+
582
+ if ((Cmd.flags ?? []).length) {
583
+ console.log("\n\x1b[1mOptions:\x1b[0m");
584
+ for (const flag of Cmd.flags) {
585
+ const flagName = `--${flag.name}${flag.short ? `, -${flag.short}` : ""}`;
586
+ const description =
587
+ flag.description ??
588
+ `${flag.type}${flag.default !== undefined ? ` (default: ${String(flag.default)})` : ""}`;
589
+ console.log(` ${flagName.padEnd(20)}\x1b[2m${description}\x1b[0m`);
590
+ }
591
+ }
592
+ }
593
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Abstraction over where command output goes.
3
+ * Injected into Command instances before run() is called.
4
+ *
5
+ * - TerminalWriter: default for CLI — writes to process.stdout/stderr
6
+ * - BufferWriter: used by Artisan.call() — captures output as a string
7
+ *
8
+ * See: plans/boot-modes.md §6
9
+ */
10
+ export interface OutputWriter {
11
+ write(message: string): void;
12
+ writeLine(message: string): void;
13
+ writeError(message: string): void;
14
+ }
15
+
16
+ /** Default writer — output goes to the terminal */
17
+ export class TerminalWriter implements OutputWriter {
18
+ // Explicit constructor so JSC's function-coverage counter attributes
19
+ // the new TerminalWriter() call to this entry.
20
+ constructor() {}
21
+ write(message: string): void {
22
+ process.stdout.write(message);
23
+ }
24
+ writeLine(message: string): void {
25
+ console.log(message);
26
+ }
27
+ writeError(message: string): void {
28
+ console.error(message);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Capturing writer — output is stored in memory.
34
+ * Used by Artisan.call() so the caller receives the command's output
35
+ * as a string rather than it being written to the server terminal.
36
+ */
37
+ export class BufferWriter implements OutputWriter {
38
+ // `declare` is a TypeScript-only annotation that emits no runtime code,
39
+ // avoiding a JSC function-coverage entry for the bare field declaration.
40
+ declare private _buf: string[];
41
+ constructor() {
42
+ this._buf = [];
43
+ }
44
+
45
+ write(message: string): void {
46
+ this._buf.push(message);
47
+ }
48
+ writeLine(message: string): void {
49
+ this._buf.push(message + "\n");
50
+ }
51
+ writeError(message: string): void {
52
+ this._buf.push(message + "\n");
53
+ }
54
+
55
+ /** Return all captured output as a single string and clear the buffer. */
56
+ flush(): string {
57
+ const out = this._buf.join("");
58
+ this._buf = [];
59
+ return out;
60
+ }
61
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The `compile` command, which builds the app into a self-contained binary.
3
+ */
4
+ import { Command } from "../Command.ts";
5
+
6
+ /**
7
+ * `bun zt compile` — compiles the application's CLI entry point into a
8
+ * self-contained Bun binary (default output `zerotal-app`). Aliased as `build`.
9
+ *
10
+ * @category Build & assets
11
+ */
12
+ export class CompileCommand extends Command {
13
+ static commandName = "compile";
14
+ static description = "Compile to a self-contained binary";
15
+ static needsApp = false;
16
+
17
+ static override flags = [
18
+ {
19
+ name: "outfile",
20
+ type: "string" as const,
21
+ description: "Output binary filename",
22
+ default: "zerotal-app",
23
+ },
24
+ {
25
+ name: "entry",
26
+ type: "string" as const,
27
+ description: "Entrypoint to compile",
28
+ default: "zerotal.ts",
29
+ },
30
+ ];
31
+
32
+ async run(): Promise<void> {
33
+ const outfile = (this.flags["outfile"] as string | undefined) ?? "zerotal-app";
34
+ const entry = (this.flags["entry"] as string | undefined) ?? "zerotal.ts";
35
+ this.info(`Compiling ${entry} → ${outfile} ...`);
36
+ const subprocess = Bun.spawn(
37
+ ["bun", "build", "--compile", "--target=bun", entry, `--outfile=${outfile}`],
38
+ { stdout: "inherit", stderr: "inherit" },
39
+ );
40
+ const code = await subprocess.exited;
41
+ if (code !== 0) {
42
+ throw new Error(`Compile failed (exit code ${code})`);
43
+ }
44
+ this.info(`Binary written to ./${outfile}`);
45
+ }
46
+ }