@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.
- package/CHANGELOG.md +79 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/package.json +72 -0
- package/src/application/Application.ts +1671 -0
- package/src/application/BootDoctor.ts +108 -0
- package/src/application/DevErrorPage.ts +567 -0
- package/src/application/ExceptionHandler.ts +183 -0
- package/src/application/currentApp.ts +73 -0
- package/src/assets/assets.ts +79 -0
- package/src/assets/index.ts +16 -0
- package/src/auth/AuthenticatedUser.ts +18 -0
- package/src/build/PackageLinter.ts +146 -0
- package/src/build/PackageScaffold.ts +127 -0
- package/src/build/codemod.ts +64 -0
- package/src/build/index.ts +12 -0
- package/src/command/Command.ts +254 -0
- package/src/command/CommandRunner.ts +593 -0
- package/src/command/OutputWriter.ts +61 -0
- package/src/command/builtin/CompileCommand.ts +46 -0
- package/src/command/builtin/CssBuildCommand.ts +71 -0
- package/src/command/builtin/KeyGenerateCommand.ts +58 -0
- package/src/command/builtin/LintPackagesCommand.ts +72 -0
- package/src/command/builtin/MakeCommandCommand.ts +85 -0
- package/src/command/builtin/MakeControllerCommand.ts +95 -0
- package/src/command/builtin/MakeEventCommand.ts +85 -0
- package/src/command/builtin/MakeJobCommand.ts +53 -0
- package/src/command/builtin/MakeListenerCommand.ts +35 -0
- package/src/command/builtin/MakeMiddlewareCommand.ts +63 -0
- package/src/command/builtin/MakeNotificationCommand.ts +48 -0
- package/src/command/builtin/MakeObserverCommand.ts +78 -0
- package/src/command/builtin/MakePackageCommand.ts +45 -0
- package/src/command/builtin/MakePolicyCommand.ts +66 -0
- package/src/command/builtin/MakeProviderCommand.ts +75 -0
- package/src/command/builtin/MakeRequestCommand.ts +47 -0
- package/src/command/builtin/MakeResourceCommand.ts +61 -0
- package/src/command/builtin/MakeTestCommand.ts +120 -0
- package/src/command/builtin/ReloadCommand.ts +52 -0
- package/src/command/builtin/ReplCommand.ts +174 -0
- package/src/command/builtin/RouteListCommand.ts +188 -0
- package/src/command/builtin/ServeCommand.ts +321 -0
- package/src/command/builtin/StartCommand.ts +3 -0
- package/src/command/builtin/StatusCommand.ts +71 -0
- package/src/command/builtin/TestCommand.ts +172 -0
- package/src/command/builtin/WorkerCommand.ts +27 -0
- package/src/command/builtin/index.ts +53 -0
- package/src/command/scaffold/worker.ts.txt +12 -0
- package/src/command/scaffold/zerotal.ts.txt +26 -0
- package/src/command/startZerotal.ts +55 -0
- package/src/config/AppConfig.ts +253 -0
- package/src/config/ConfigLoader.ts +117 -0
- package/src/config/ConfigManager.ts +169 -0
- package/src/config/index.ts +46 -0
- package/src/config/registry.ts +59 -0
- package/src/config/validation.ts +117 -0
- package/src/container/Container.ts +606 -0
- package/src/container/ContextualBindingBuilder.ts +57 -0
- package/src/container/ScopedResolver.ts +117 -0
- package/src/container/index.ts +32 -0
- package/src/container/inject.ts +55 -0
- package/src/container/types.ts +71 -0
- package/src/context/RequestContext.ts +91 -0
- package/src/contracts/auth.ts +24 -0
- package/src/contracts/index.ts +23 -0
- package/src/contracts/session.ts +70 -0
- package/src/contracts/transaction.ts +26 -0
- package/src/conventions/ConventionLoader.ts +128 -0
- package/src/conventions/builtinConcerns.ts +131 -0
- package/src/crypt/Crypt.ts +141 -0
- package/src/crypt/URLSigner.ts +96 -0
- package/src/datetime/Carbon.ts +1396 -0
- package/src/datetime/CarbonInterval.ts +421 -0
- package/src/datetime/clock.ts +28 -0
- package/src/datetime/index.ts +23 -0
- package/src/datetime/temporal-shim.ts +1 -0
- package/src/dev/BuildOutput.ts +131 -0
- package/src/dev/CssPlugins.ts +184 -0
- package/src/dev/DevBuildHook.ts +74 -0
- package/src/dev/DevOrchestrator.ts +213 -0
- package/src/dev/DevReloadMiddleware.ts +101 -0
- package/src/dev/DevReloadServer.ts +85 -0
- package/src/dev/DevWsServer.ts +45 -0
- package/src/dev/index.ts +19 -0
- package/src/dev/reloadClient.ts +39 -0
- package/src/env/Def.ts +232 -0
- package/src/env/EnvSchema.ts +105 -0
- package/src/env/index.ts +34 -0
- package/src/env/t.ts +128 -0
- package/src/errors/ConfigError.ts +12 -0
- package/src/errors/ContainerErrors.ts +143 -0
- package/src/errors/HttpError.ts +127 -0
- package/src/errors/ValidationError.ts +19 -0
- package/src/errors/ZerotalError.ts +25 -0
- package/src/errors/index.ts +46 -0
- package/src/events/CallQueuedListener.ts +66 -0
- package/src/events/Emitter.ts +280 -0
- package/src/events/EventFake.ts +160 -0
- package/src/events/FrameworkEvents.ts +252 -0
- package/src/facade/Facade.ts +101 -0
- package/src/facade/facades/App.ts +155 -0
- package/src/facade/facades/Artisan.ts +63 -0
- package/src/facade/facades/Config.ts +21 -0
- package/src/facade/facades/Events.ts +19 -0
- package/src/facade/facades/index.ts +28 -0
- package/src/global.d.ts +9 -0
- package/src/hash/Hash.ts +60 -0
- package/src/health/Health.ts +221 -0
- package/src/health/index.ts +27 -0
- package/src/helpers/Collection.ts +435 -0
- package/src/helpers/config.ts +59 -0
- package/src/helpers/fluent.ts +52 -0
- package/src/helpers/html.ts +11 -0
- package/src/helpers/index.ts +266 -0
- package/src/helpers/make.ts +35 -0
- package/src/helpers/markdown.ts +73 -0
- package/src/helpers/pageElements.ts +27 -0
- package/src/helpers/request.ts +62 -0
- package/src/helpers/response.ts +411 -0
- package/src/helpers/str.ts +208 -0
- package/src/http/Http.ts +298 -0
- package/src/http/HttpClient.ts +289 -0
- package/src/http/Resource.ts +171 -0
- package/src/http/UploadedFile.ts +204 -0
- package/src/http/Uri.ts +490 -0
- package/src/http/index.ts +46 -0
- package/src/http/negotiate.ts +213 -0
- package/src/http/originGuard.ts +76 -0
- package/src/http/sniffContentType.ts +105 -0
- package/src/http/url.ts +204 -0
- package/src/http/withHeaders.ts +24 -0
- package/src/index.ts +250 -0
- package/src/lock/LockManager.ts +228 -0
- package/src/lock/config.ts +49 -0
- package/src/lock/drivers/LockDriver.ts +32 -0
- package/src/lock/drivers/MemoryLockDriver.ts +52 -0
- package/src/lock/drivers/RedisLockDriver.ts +58 -0
- package/src/lock/drivers/SqliteLockDriver.ts +85 -0
- package/src/lock/errors.ts +20 -0
- package/src/lock/facades/Lock.ts +114 -0
- package/src/lock/index.ts +53 -0
- package/src/logger/Log.ts +35 -0
- package/src/logger/LogManager.ts +430 -0
- package/src/logger/LoggerMiddleware.ts +125 -0
- package/src/logger/channels/ConsoleChannel.ts +139 -0
- package/src/logger/channels/DailyChannel.ts +74 -0
- package/src/logger/channels/NullChannel.ts +17 -0
- package/src/logger/channels/SingleChannel.ts +34 -0
- package/src/logger/channels/StackChannel.ts +29 -0
- package/src/logger/config.ts +90 -0
- package/src/logger/format.ts +96 -0
- package/src/logger/frameworkLog.ts +93 -0
- package/src/logger/index.ts +68 -0
- package/src/logger/renderTable.ts +111 -0
- package/src/logger/types.ts +212 -0
- package/src/macros/config.macro.ts +50 -0
- package/src/metrics/HttpMetrics.ts +114 -0
- package/src/metrics/index.ts +18 -0
- package/src/middleware/BaseMiddleware.ts +72 -0
- package/src/middleware/CorsMiddleware.ts +152 -0
- package/src/middleware/RateLimiter.ts +255 -0
- package/src/middleware/SecureHeadersMiddleware.ts +127 -0
- package/src/middleware/ThrottleMiddleware.ts +252 -0
- package/src/middleware/WebhookMiddleware.ts +204 -0
- package/src/pipeline/ContextRegistry.ts +42 -0
- package/src/pipeline/HttpContext.ts +865 -0
- package/src/pipeline/Pipeline.ts +150 -0
- package/src/pipeline/currentPage.ts +46 -0
- package/src/pipeline/types.ts +80 -0
- package/src/provider/LockProvider.ts +64 -0
- package/src/provider/LogProvider.ts +137 -0
- package/src/provider/ServiceProvider.ts +84 -0
- package/src/provider/StorageProvider.ts +45 -0
- package/src/router/FileRouter.ts +526 -0
- package/src/router/Route.ts +76 -0
- package/src/router/RouteHandler.ts +335 -0
- package/src/router/Router.ts +1247 -0
- package/src/router/domain.ts +65 -0
- package/src/security/index.ts +22 -0
- package/src/storage/FakeDisk.ts +233 -0
- package/src/storage/StorageFilesMiddleware.ts +150 -0
- package/src/storage/StorageManager.ts +173 -0
- package/src/storage/config.ts +47 -0
- package/src/storage/drivers/LocalDriver.ts +138 -0
- package/src/storage/drivers/S3Driver.ts +169 -0
- package/src/storage/errors.ts +135 -0
- package/src/storage/facades/Storage.ts +3 -0
- package/src/storage/global.d.ts +7 -0
- package/src/storage/index.ts +22 -0
- package/src/storage/root.ts +59 -0
- package/src/storage/types.ts +104 -0
- package/src/support/appKey.ts +38 -0
- package/src/support/cookie.ts +72 -0
- package/src/support/crypto.ts +52 -0
- package/src/support/deepMerge.ts +117 -0
- package/src/support/env.ts +71 -0
- package/src/support/network.ts +79 -0
- package/src/support/port.ts +197 -0
- package/src/support/str.ts +122 -0
- package/src/view/FileRouteResolver.ts +59 -0
- package/src/view/index.ts +144 -0
- 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
|
+
}
|