@crustjs/core 0.0.2

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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @crustjs/core
2
+
3
+ The core library for the [Crust](https://crust.cyanlabs.co) CLI framework.
4
+
5
+ Provides command definition, argument/flag parsing, subcommand routing, lifecycle hooks, and a plugin system — with **zero runtime dependencies**.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ bun add @crustjs/core
11
+ ```
12
+
13
+ ## Quick Example
14
+
15
+ ```ts
16
+ import { defineCommand, runMain } from "@crustjs/core";
17
+
18
+ const main = defineCommand({
19
+ meta: { name: "greet", description: "Say hello" },
20
+ args: [{ name: "name", type: String, default: "world" }],
21
+ flags: {
22
+ loud: { type: Boolean, description: "Shout it", alias: "l" },
23
+ },
24
+ run({ args, flags }) {
25
+ const msg = `Hello, ${args.name}!`;
26
+ console.log(flags.loud ? msg.toUpperCase() : msg);
27
+ },
28
+ });
29
+
30
+ runMain(main);
31
+ ```
32
+
33
+ ## Documentation
34
+
35
+ See the full docs at [crust.cyanlabs.co](https://crust.cyanlabs.co).
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,460 @@
1
+ /** Maps a constructor function (String, Number, Boolean) to its primitive type */
2
+ type TypeConstructor = StringConstructor | NumberConstructor | BooleanConstructor;
3
+ /**
4
+ * Resolves a constructor function to its corresponding TypeScript primitive type.
5
+ *
6
+ * - `StringConstructor` → `string`
7
+ * - `NumberConstructor` → `number`
8
+ * - `BooleanConstructor` → `boolean`
9
+ */
10
+ type ResolvePrimitive<T extends TypeConstructor> = T extends StringConstructor ? string : T extends NumberConstructor ? number : T extends BooleanConstructor ? boolean : never;
11
+ /**
12
+ * Defines a single positional argument for a CLI command.
13
+ *
14
+ * Args are defined as an ordered tuple so that positional ordering is explicit
15
+ * and the type system can enforce that only the last arg may be variadic.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const args = [
20
+ * { name: "port", type: Number, description: "Port number", default: 3000 },
21
+ * { name: "name", type: String, required: true },
22
+ * { name: "files", type: String, variadic: true },
23
+ * ] as const satisfies ArgsDef;
24
+ * ```
25
+ */
26
+ interface ArgDef<
27
+ N extends string = string,
28
+ T extends TypeConstructor = TypeConstructor,
29
+ D extends ResolvePrimitive<T> | undefined = ResolvePrimitive<T> | undefined,
30
+ R extends boolean = boolean,
31
+ V extends boolean = boolean
32
+ > {
33
+ /** The argument name (used as the key in the parsed result and in help text) */
34
+ name: N;
35
+ /** Constructor function indicating the argument's type: `String`, `Number`, or `Boolean` */
36
+ type: T;
37
+ /** Human-readable description for help text */
38
+ description?: string;
39
+ /** Default value when the argument is not provided */
40
+ default?: D;
41
+ /** Whether this argument must be provided (errors if missing) */
42
+ required?: R;
43
+ /** Whether this argument collects all remaining positionals into an array */
44
+ variadic?: V;
45
+ }
46
+ /** Ordered tuple of positional argument definitions */
47
+ type ArgsDef = readonly ArgDef[];
48
+ /**
49
+ * Defines a single named flag for a CLI command.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const flags = {
54
+ * verbose: { type: Boolean, description: "Enable verbose logging", alias: "v" },
55
+ * port: { type: Number, description: "Port number", default: 3000 },
56
+ * } satisfies FlagsDef;
57
+ * ```
58
+ */
59
+ interface FlagDef<
60
+ T extends TypeConstructor = TypeConstructor,
61
+ R extends boolean = boolean,
62
+ M extends boolean = boolean
63
+ > {
64
+ /** Constructor function indicating the flag's type: `String`, `Number`, or `Boolean` */
65
+ type: T;
66
+ /** Human-readable description for help text */
67
+ description?: string;
68
+ /** Default value when the flag is not provided. Must be an array when `multiple: true`. */
69
+ default?: M extends true ? ResolvePrimitive<T>[] : ResolvePrimitive<T>;
70
+ /** Whether this flag must be provided (errors if missing) */
71
+ required?: R;
72
+ /** Short alias or array of aliases (e.g. `"v"` or `["v", "V"]`) */
73
+ alias?: string | string[];
74
+ /** Whether this flag can be provided multiple times, collecting values into an array */
75
+ multiple?: M;
76
+ }
77
+ /** Record mapping flag names to their definitions */
78
+ type FlagsDef = Record<string, FlagDef>;
79
+ /** Extract alias string literals from a single FlagDef */
80
+ type ExtractAliases<F extends FlagDef> = F extends {
81
+ alias: infer A;
82
+ } ? A extends string ? A : A extends readonly string[] ? A[number] : never : never;
83
+ /**
84
+ * Collects all alias literals across a FlagsDef, then intersects with `keyof F`.
85
+ * Resolves to `never` when no alias matches a flag name, or the colliding
86
+ * name(s) when a match exists.
87
+ */
88
+ type FlagAliasNameCollision<F extends FlagsDef> = { [K in keyof F & string] : ExtractAliases<F[K]> }[keyof F & string] & keyof F;
89
+ /**
90
+ * Collects aliases from every flag *except* flag K.
91
+ * Used to detect alias→alias duplicates across different flags.
92
+ */
93
+ type AliasesExcluding<
94
+ F extends FlagsDef,
95
+ K extends keyof F & string
96
+ > = { [J in Exclude<keyof F & string, K>] : ExtractAliases<F[J]> }[Exclude<keyof F & string, K>];
97
+ /**
98
+ * Resolves to the alias literal(s) that appear in more than one flag's
99
+ * alias list, or `never` when no duplicate aliases exist.
100
+ */
101
+ type FlagAliasAliasCollision<F extends FlagsDef> = { [K in keyof F & string] : ExtractAliases<F[K]> & AliasesExcluding<F, K> }[keyof F & string];
102
+ /**
103
+ * Compile-time check that no flag alias collides with another flag's name
104
+ * or another flag's alias.
105
+ *
106
+ * - Resolves to `unknown` (no-op intersection) when no collision exists.
107
+ * - Resolves to a descriptive error tuple when a collision is found,
108
+ * causing a type error on the `flags` property.
109
+ */
110
+ type CheckFlagAliasCollisions<F extends FlagsDef> = FlagAliasNameCollision<F> extends never ? FlagAliasAliasCollision<F> extends never ? unknown : ["ERROR: Duplicate flag alias across different flags. Colliding alias(es):", FlagAliasAliasCollision<F>] : ["ERROR: Flag alias collides with a flag name. Colliding name(s):", FlagAliasNameCollision<F>];
111
+ /**
112
+ * Checks whether any element in `Init` (all elements except the last) has
113
+ * `variadic: true`. Used by {@link CheckVariadicArgs} to ensure only the
114
+ * last positional arg can be variadic.
115
+ */
116
+ type InitHasVariadic<T extends readonly ArgDef[]> = T extends readonly [infer Head, ...infer Tail extends readonly ArgDef[]] ? Head extends {
117
+ variadic: true;
118
+ } ? true : InitHasVariadic<Tail> : false;
119
+ /**
120
+ * Compile-time check that only the last positional arg has `variadic: true`.
121
+ *
122
+ * Since args are now an ordered tuple, we can split it into `[...Init, Last]`
123
+ * and verify that no element in `Init` is variadic.
124
+ *
125
+ * - Resolves to `unknown` (no-op intersection) when the constraint is satisfied.
126
+ * - Resolves to a descriptive error string when a non-last arg is variadic,
127
+ * causing a type error on the `args` property.
128
+ */
129
+ type CheckVariadicArgs<A extends ArgsDef> = A extends readonly [...infer Init extends readonly ArgDef[], ArgDef] ? InitHasVariadic<Init> extends true ? "ERROR: Only the last positional argument can be variadic" : unknown : unknown;
130
+ /**
131
+ * Infer the resolved type for a single ArgDef:
132
+ *
133
+ * - **variadic** → `primitive[]`
134
+ * - **required** or **has default** → `primitive` (non-optional)
135
+ * - otherwise → `primitive | undefined`
136
+ */
137
+ type InferArgValue<A extends ArgDef> = A extends {
138
+ variadic: true;
139
+ } ? ResolvePrimitive<A["type"]>[] : A extends {
140
+ required: true;
141
+ } ? ResolvePrimitive<A["type"]> : A extends {
142
+ default: ResolvePrimitive<A["type"]>;
143
+ } ? ResolvePrimitive<A["type"]> : ResolvePrimitive<A["type"]> | undefined;
144
+ /**
145
+ * Recursively converts an ArgsDef tuple into a named object type.
146
+ *
147
+ * Each element's `name` literal becomes a key, and its value is resolved
148
+ * via {@link InferArgValue}. Uses intersection + `Simplify` to flatten.
149
+ */
150
+ type InferArgsTuple<A extends readonly ArgDef[]> = A extends readonly [infer Head extends ArgDef, ...infer Tail extends readonly ArgDef[]] ? { [K in Head["name"]] : InferArgValue<Head> } & InferArgsTuple<Tail> : {};
151
+ /** Flattens an intersection of objects into a single object type for readability */
152
+ type Simplify<T> = { [K in keyof T] : T[K] };
153
+ /**
154
+ * Maps an ArgsDef tuple to resolved arg types keyed by each arg's `name`.
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * type Result = InferArgs<readonly [
159
+ * { name: "port"; type: NumberConstructor; default: 3000 },
160
+ * { name: "name"; type: StringConstructor; required: true },
161
+ * { name: "files"; type: StringConstructor; variadic: true },
162
+ * ]>;
163
+ * // Result = { port: number; name: string; files: string[] }
164
+ * ```
165
+ */
166
+ type InferArgs<A> = A extends ArgsDef ? Simplify<InferArgsTuple<A>> : Record<string, never>;
167
+ /**
168
+ * Infer the resolved type for a single FlagDef:
169
+ *
170
+ * - **multiple** → wraps the resolved type in an array
171
+ * - **required** or **has default** → `primitive` (non-optional)
172
+ * - otherwise → `primitive | undefined`
173
+ */
174
+ type InferFlagValue<F extends FlagDef> = F extends {
175
+ multiple: true;
176
+ } ? F extends {
177
+ required: true;
178
+ } ? ResolvePrimitive<F["type"]>[] : F extends {
179
+ default: ResolvePrimitive<F["type"]>[];
180
+ } ? ResolvePrimitive<F["type"]>[] : ResolvePrimitive<F["type"]>[] | undefined : F extends {
181
+ required: true;
182
+ } ? ResolvePrimitive<F["type"]> : F extends {
183
+ default: ResolvePrimitive<F["type"]>;
184
+ } ? ResolvePrimitive<F["type"]> : ResolvePrimitive<F["type"]> | undefined;
185
+ /**
186
+ * Maps a full FlagsDef record to resolved flag types.
187
+ *
188
+ * @example
189
+ * ```ts
190
+ * type Result = InferFlags<{
191
+ * verbose: { type: BooleanConstructor };
192
+ * port: { type: NumberConstructor, default: 3000 };
193
+ * }>;
194
+ * // Result = { verbose: boolean | undefined; port: number }
195
+ * ```
196
+ */
197
+ type InferFlags<F> = F extends FlagsDef ? { [K in keyof F] : InferFlagValue<F[K]> } : Record<string, never>;
198
+ /** Metadata describing a CLI command */
199
+ interface CommandMeta {
200
+ /** The command name (used in help text and routing) */
201
+ name: string;
202
+ /** Human-readable description for help text */
203
+ description?: string;
204
+ /** Custom usage string (overrides auto-generated usage) */
205
+ usage?: string;
206
+ }
207
+ /**
208
+ * The result of parsing argv against a command's arg/flag definitions.
209
+ *
210
+ * Generic parameters flow from the command definition to provide
211
+ * strongly-typed `args` and `flags` objects.
212
+ */
213
+ interface ParseResult<
214
+ A extends ArgsDef = ArgsDef,
215
+ F extends FlagsDef = FlagsDef
216
+ > {
217
+ /** Resolved positional arguments, keyed by arg name */
218
+ args: InferArgs<A>;
219
+ /** Resolved flags, keyed by flag name */
220
+ flags: InferFlags<F>;
221
+ /** Raw arguments that appeared after the `--` separator */
222
+ rawArgs: string[];
223
+ }
224
+ /**
225
+ * The runtime context object passed to `preRun()`, `run()`, and `postRun()` hooks.
226
+ *
227
+ * Extends {@link ParseResult} with a back-reference to the resolved command.
228
+ */
229
+ interface CommandContext<
230
+ A extends ArgsDef = ArgsDef,
231
+ F extends FlagsDef = FlagsDef
232
+ > extends ParseResult<A, F> {
233
+ /** The resolved command that is being executed */
234
+ command: AnyCommand;
235
+ }
236
+ /** Unified command shape used for both command definitions and resolved commands. */
237
+ interface Command<
238
+ A extends ArgsDef = ArgsDef,
239
+ F extends FlagsDef = FlagsDef
240
+ > {
241
+ /** Command metadata (name, description, usage) */
242
+ readonly meta: CommandMeta;
243
+ /** Positional argument definitions */
244
+ readonly args?: A;
245
+ /** Flag definitions */
246
+ readonly flags?: F;
247
+ /** Named subcommands */
248
+ readonly subCommands?: Record<string, AnyCommand>;
249
+ /** Called before `run()` — useful for initialization */
250
+ preRun?(context: CommandContext<A, F>): void | Promise<void>;
251
+ /** The main command handler */
252
+ run?(context: CommandContext<A, F>): void | Promise<void>;
253
+ /** Called after `run()` (even if it throws) — useful for teardown */
254
+ postRun?(context: CommandContext<A, F>): void | Promise<void>;
255
+ }
256
+ type AnyCommand = Command<any, any>;
257
+ /**
258
+ * Define a CLI command with full type inference.
259
+ *
260
+ * The returned command object is frozen (immutable) and typed so that
261
+ * `run()`, `preRun()`, and `postRun()` callbacks receive correctly-typed
262
+ * `args` and `flags` based on the definitions provided.
263
+ *
264
+ * @param config - The command definition config object
265
+ * @returns A frozen, readonly Command object
266
+ * @throws {CrustError} `DEFINITION` if `meta.name` is missing or empty
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * const cmd = defineCommand({
271
+ * meta: { name: "serve", description: "Start dev server" },
272
+ * args: [
273
+ * { name: "port", type: Number, description: "Port number", default: 3000 },
274
+ * ],
275
+ * flags: {
276
+ * verbose: { type: Boolean, description: "Enable verbose logging", alias: "v" },
277
+ * },
278
+ * run({ args, flags }) {
279
+ * // args.port is typed as number, flags.verbose is typed as boolean | undefined
280
+ * console.log(`Starting server on port ${args.port}`);
281
+ * },
282
+ * });
283
+ * ```
284
+ */
285
+ declare function defineCommand<
286
+ const A extends ArgsDef = ArgsDef,
287
+ const F extends FlagsDef = FlagsDef
288
+ >(config: Command<A, F> & {
289
+ args?: A & CheckVariadicArgs<A>;
290
+ flags?: F & CheckFlagAliasCollisions<F>;
291
+ }): Command<A, F>;
292
+ interface CommandNotFoundErrorDetails {
293
+ input: string;
294
+ available: string[];
295
+ commandPath: string[];
296
+ parentCommand: AnyCommand;
297
+ }
298
+ interface CrustErrorDetailsMap {
299
+ DEFINITION: undefined;
300
+ VALIDATION: undefined;
301
+ PARSE: undefined;
302
+ EXECUTION: undefined;
303
+ COMMAND_NOT_FOUND: CommandNotFoundErrorDetails;
304
+ }
305
+ /**
306
+ * All possible error codes emitted by Crust.
307
+ *
308
+ * - `DEFINITION` — Invalid command configuration (empty name, alias collision, bad variadic position)
309
+ * - `VALIDATION` — Missing required arguments or flags
310
+ * - `PARSE` — Argv parsing failures (unknown flags, type coercion)
311
+ * - `EXECUTION` — Runtime command/middleware failures
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * try {
316
+ * parseArgs(cmd, argv);
317
+ * } catch (err) {
318
+ * if (err instanceof CrustError) {
319
+ * switch (err.code) {
320
+ * case "VALIDATION":
321
+ * console.error(err.message);
322
+ * showHelp(cmd);
323
+ * break;
324
+ * case "PARSE":
325
+ * console.error(err.message);
326
+ * break;
327
+ * }
328
+ * }
329
+ * }
330
+ * ```
331
+ */
332
+ type CrustErrorCode = keyof CrustErrorDetailsMap;
333
+ type CrustErrorDetails<C extends CrustErrorCode> = CrustErrorDetailsMap[C];
334
+ /**
335
+ * A typed error thrown by Crust when command definition or argument parsing fails.
336
+ *
337
+ * Every `CrustError` carries a {@link CrustErrorCode} that identifies the specific
338
+ * failure, enabling programmatic error handling without fragile message parsing.
339
+ *
340
+ * @example
341
+ * ```ts
342
+ * import { CrustError, parseArgs } from "@crustjs/core";
343
+ *
344
+ * try {
345
+ * const result = parseArgs(cmd, process.argv.slice(2));
346
+ * } catch (err) {
347
+ * if (err instanceof CrustError) {
348
+ * console.error(`[${err.code}] ${err.message}`);
349
+ * }
350
+ * }
351
+ * ```
352
+ */
353
+ declare class CrustError<C extends CrustErrorCode = CrustErrorCode> extends Error {
354
+ /** Machine-readable error code for programmatic handling */
355
+ readonly code: C;
356
+ /** Structured payload for programmatic handling */
357
+ readonly details: CrustErrorDetails<C>;
358
+ /** Optional wrapped original error/value */
359
+ cause?: unknown;
360
+ constructor(code: C, message: string, ...details: CrustErrorDetails<C> extends undefined ? [] | [undefined] : [CrustErrorDetails<C>]);
361
+ is<T extends CrustErrorCode>(code: T): this is CrustError<T>;
362
+ withCause(cause: unknown): this;
363
+ }
364
+ /**
365
+ * Parse argv against a command's arg/flag definitions.
366
+ *
367
+ * Wraps Node's `util.parseArgs` with Crust's enhanced semantics:
368
+ * positional arg mapping, type coercion, alias expansion, default values,
369
+ * required validation, variadic args, and strict mode.
370
+ *
371
+ * @param command - The command whose arg/flag definitions drive the parsing
372
+ * @param argv - The argv array to parse (typically `process.argv.slice(2)`)
373
+ * @returns Parsed args, flags, and rawArgs (everything after `--`)
374
+ * @throws {CrustError} On unknown flags, missing required args/flags, type coercion failure, or alias collisions
375
+ */
376
+ declare function parseArgs<
377
+ A extends ArgsDef = ArgsDef,
378
+ F extends FlagsDef = FlagsDef
379
+ >(command: Command<A, F>, argv: string[]): ParseResult<A, F>;
380
+ /**
381
+ * The result of resolving a command from an argv array.
382
+ *
383
+ * Contains the resolved (sub)command and argv after subcommand
384
+ * resolution, and the full command path for help text rendering.
385
+ */
386
+ interface CommandRoute {
387
+ /** The routed command (may be a subcommand of the original) */
388
+ command: AnyCommand;
389
+ /** The argv after subcommand names have been consumed */
390
+ argv: string[];
391
+ /** The command path for help text (e.g. ["crust", "generate", "command"]) */
392
+ commandPath: string[];
393
+ }
394
+ /**
395
+ * Resolve a command from an argv array by walking the subcommand tree.
396
+ *
397
+ * Subcommand matching happens BEFORE flag parsing, so:
398
+ * `crust build --entry src/cli.ts` first resolves "build" as a subcommand,
399
+ * then passes `["--entry", "src/cli.ts"]` to the build command's parser.
400
+ *
401
+ * Resolution rules:
402
+ * 1. If `argv[0]` matches a subcommand key, recurse into that subcommand
403
+ * 2. If no match and the current command has `run()`, return it (args passed to parser)
404
+ * 3. If no match and the current command has NO `run()`, it signals the caller
405
+ * should show help (the `showHelp` flag is set in the result)
406
+ * 4. Unknown subcommands produce a structured COMMAND_NOT_FOUND error
407
+ *
408
+ * @param command - The root command to resolve from
409
+ * @param argv - The argv array to resolve against
410
+ * @returns The resolved command, argv, and the command path
411
+ * @throws {CrustError} COMMAND_NOT_FOUND when an unknown subcommand is given and the parent has no run()
412
+ */
413
+ declare function resolveCommand(command: AnyCommand, argv: string[]): CommandRoute;
414
+ interface PluginState {
415
+ get<T = unknown>(key: string): T | undefined;
416
+ has(key: string): boolean;
417
+ set(key: string, value: unknown): void;
418
+ delete(key: string): boolean;
419
+ }
420
+ interface SetupActions {
421
+ /**
422
+ * Inject a flag definition into a command's flags object.
423
+ *
424
+ * Use this in plugin `setup()` hooks to register plugin-specific flags
425
+ * (e.g. `--version`, `--help`) so they are recognized by the parser
426
+ * and rendered in help text.
427
+ *
428
+ * @param command - The command to add the flag to
429
+ * @param name - The flag name (e.g. "version")
430
+ * @param def - The flag definition
431
+ */
432
+ addFlag(command: AnyCommand, name: string, def: FlagDef): void;
433
+ }
434
+ /** Shared context fields available in both setup and middleware phases. */
435
+ interface BaseContext {
436
+ readonly argv: readonly string[];
437
+ readonly rootCommand: AnyCommand;
438
+ readonly state: PluginState;
439
+ }
440
+ /** Context passed to plugin `setup()` hooks. */
441
+ interface SetupContext extends BaseContext {}
442
+ /** Context passed to plugin `middleware()` hooks. */
443
+ interface MiddlewareContext extends BaseContext {
444
+ route: Readonly<CommandRoute> | null;
445
+ input: ParseResult | null;
446
+ }
447
+ type Next = () => Promise<void>;
448
+ type PluginMiddleware = (context: MiddlewareContext, next: Next) => void | Promise<void>;
449
+ interface CrustPlugin {
450
+ name?: string;
451
+ setup?: (context: SetupContext, actions: SetupActions) => void | Promise<void>;
452
+ middleware?: PluginMiddleware;
453
+ }
454
+ interface RunOptions {
455
+ argv?: string[];
456
+ plugins?: CrustPlugin[];
457
+ }
458
+ declare function runCommand(command: AnyCommand, options?: RunOptions): Promise<void>;
459
+ declare function runMain(command: AnyCommand, options?: RunOptions): Promise<void>;
460
+ export { runMain, runCommand, resolveCommand, parseArgs, defineCommand, SetupContext, RunOptions, PluginMiddleware, ParseResult, MiddlewareContext, InferFlags, InferArgs, FlagsDef, FlagDef, CrustPlugin, CrustErrorCode, CrustError, CommandRoute, CommandNotFoundErrorDetails, CommandMeta, CommandContext, Command, ArgsDef, ArgDef, AnyCommand };
package/dist/index.js ADDED
@@ -0,0 +1,403 @@
1
+ // @bun
2
+ // src/errors.ts
3
+ class CrustError extends Error {
4
+ code;
5
+ details;
6
+ cause;
7
+ constructor(code, message, ...details) {
8
+ super(message);
9
+ this.name = "CrustError";
10
+ this.code = code;
11
+ this.details = details[0];
12
+ }
13
+ is(code) {
14
+ return this.code === code;
15
+ }
16
+ withCause(cause) {
17
+ this.cause = cause;
18
+ return this;
19
+ }
20
+ }
21
+
22
+ // src/command.ts
23
+ function defineCommand(config) {
24
+ if (!config.meta.name.trim()) {
25
+ throw new CrustError("DEFINITION", "defineCommand: meta.name is required and must be a non-empty string");
26
+ }
27
+ const copy = {
28
+ ...config,
29
+ meta: { ...config.meta },
30
+ ...config.args && {
31
+ args: config.args.map((def) => ({ ...def }))
32
+ },
33
+ flags: config.flags ? Object.fromEntries(Object.entries(config.flags).map(([k, v]) => [k, { ...v }])) : {},
34
+ ...config.subCommands && {
35
+ subCommands: { ...config.subCommands }
36
+ }
37
+ };
38
+ return Object.freeze(copy);
39
+ }
40
+ // src/parser.ts
41
+ import {
42
+ parseArgs as nodeParseArgs
43
+ } from "util";
44
+ function buildParseArgsOptionDescriptor(flagsDef) {
45
+ const options = {};
46
+ const aliasToName = {};
47
+ if (!flagsDef)
48
+ return { options, aliasToName };
49
+ const aliasRegistry = new Map;
50
+ for (const name of Object.keys(flagsDef)) {
51
+ aliasRegistry.set(name, name);
52
+ }
53
+ for (const [name, def] of Object.entries(flagsDef)) {
54
+ const parseType = def.type === Boolean ? "boolean" : "string";
55
+ const opt = { type: parseType };
56
+ if (def.multiple) {
57
+ opt.multiple = true;
58
+ }
59
+ if (def.alias) {
60
+ const aliases = Array.isArray(def.alias) ? def.alias : [def.alias];
61
+ for (const alias of aliases) {
62
+ const existing = aliasRegistry.get(alias);
63
+ if (existing) {
64
+ throw new CrustError("DEFINITION", `Alias collision: "-${alias}" is used by both "--${existing}" and "--${name}"`);
65
+ }
66
+ aliasRegistry.set(alias, name);
67
+ aliasToName[alias] = name;
68
+ if (alias.length === 1 && !opt.short) {
69
+ opt.short = alias;
70
+ } else {
71
+ const aliasOpt = { type: parseType };
72
+ if (def.multiple) {
73
+ aliasOpt.multiple = true;
74
+ }
75
+ options[alias] = aliasOpt;
76
+ }
77
+ }
78
+ }
79
+ options[name] = opt;
80
+ }
81
+ return { options, aliasToName };
82
+ }
83
+ function coerceValue(value, type, label) {
84
+ if (type === Number) {
85
+ const num = Number(value);
86
+ if (Number.isNaN(num)) {
87
+ throw new CrustError("PARSE", `Expected number for ${label}, got "${value}"`);
88
+ }
89
+ return num;
90
+ }
91
+ if (type === Boolean) {
92
+ return value === "true" || value === "1";
93
+ }
94
+ return value;
95
+ }
96
+ function applyDefaultOrThrow(def, label) {
97
+ if (def.default !== undefined)
98
+ return def.default;
99
+ if (def.required === true) {
100
+ throw new CrustError("VALIDATION", `Missing required ${label}`);
101
+ }
102
+ return;
103
+ }
104
+ function coerceFlagValue(name, def, parsedValue) {
105
+ const label = `--${name}`;
106
+ if (def.multiple && Array.isArray(parsedValue)) {
107
+ return def.type === Boolean ? parsedValue.map((v) => typeof v === "boolean" ? v : Boolean(v)) : parsedValue.map((v) => coerceValue(v, def.type, label));
108
+ }
109
+ if (def.type === Boolean) {
110
+ return typeof parsedValue === "boolean" ? parsedValue : Boolean(parsedValue);
111
+ }
112
+ if (typeof parsedValue === "string") {
113
+ return coerceValue(parsedValue, def.type, label);
114
+ }
115
+ if (parsedValue === true) {
116
+ return def.default ?? undefined;
117
+ }
118
+ return parsedValue;
119
+ }
120
+ function resolveAliases(parsedValues, aliasToName, flagsDef) {
121
+ const canonical = {};
122
+ for (const key in parsedValues) {
123
+ const canonicalName = aliasToName[key] ?? key;
124
+ if (!(canonicalName in flagsDef))
125
+ continue;
126
+ const value = parsedValues[key];
127
+ const existing = canonical[canonicalName];
128
+ if (existing !== undefined && Array.isArray(existing) && Array.isArray(value)) {
129
+ existing.push(...value);
130
+ } else {
131
+ canonical[canonicalName] = value;
132
+ }
133
+ }
134
+ return canonical;
135
+ }
136
+ function resolveFlags(flagsDef, parsedValues, aliasToName) {
137
+ if (!flagsDef)
138
+ return {};
139
+ const canonical = resolveAliases(parsedValues, aliasToName, flagsDef);
140
+ const resolved = {};
141
+ for (const [name, def] of Object.entries(flagsDef)) {
142
+ const parsedValue = canonical[name];
143
+ if (parsedValue !== undefined) {
144
+ resolved[name] = coerceFlagValue(name, def, parsedValue);
145
+ continue;
146
+ }
147
+ resolved[name] = def.default ?? undefined;
148
+ }
149
+ return resolved;
150
+ }
151
+ function validateRequiredFlags(flagsDef, resolvedFlags) {
152
+ if (!flagsDef)
153
+ return;
154
+ for (const [name, def] of Object.entries(flagsDef)) {
155
+ if (def.required === true && def.default === undefined) {
156
+ if (resolvedFlags[name] === undefined) {
157
+ throw new CrustError("VALIDATION", `Missing required flag "--${name}"`);
158
+ }
159
+ }
160
+ }
161
+ }
162
+ function resolveArgs(argsDef, positionals) {
163
+ if (!argsDef)
164
+ return {};
165
+ const resolved = {};
166
+ let index = 0;
167
+ for (const def of argsDef) {
168
+ const { name } = def;
169
+ const label = `argument "<${name}>"`;
170
+ if (def.variadic) {
171
+ const remaining = positionals.slice(index);
172
+ if (def.required === true && remaining.length === 0) {
173
+ throw new CrustError("VALIDATION", `Missing required ${label}`);
174
+ }
175
+ resolved[name] = def.type === Number ? remaining.map((v) => coerceValue(v, Number, `<${name}>`)) : remaining;
176
+ index = positionals.length;
177
+ } else if (index < positionals.length) {
178
+ resolved[name] = coerceValue(positionals[index], def.type, `<${name}>`);
179
+ index++;
180
+ } else {
181
+ resolved[name] = applyDefaultOrThrow(def, label);
182
+ }
183
+ }
184
+ return resolved;
185
+ }
186
+ function parseArgs(command, argv) {
187
+ const argsDef = command.args;
188
+ const flagsDef = command.flags;
189
+ const { options: parseOptions, aliasToName } = buildParseArgsOptionDescriptor(flagsDef);
190
+ let parsed;
191
+ try {
192
+ parsed = nodeParseArgs({
193
+ args: argv,
194
+ options: parseOptions,
195
+ strict: true,
196
+ allowPositionals: true,
197
+ allowNegative: true,
198
+ tokens: true
199
+ });
200
+ } catch (error) {
201
+ if (error instanceof Error) {
202
+ const unknownMatch = error.message.match(/Unknown option '(.+?)'/);
203
+ if (unknownMatch) {
204
+ throw new CrustError("PARSE", `Unknown flag "${unknownMatch[1]}"`);
205
+ }
206
+ }
207
+ throw error;
208
+ }
209
+ const rawArgs = [];
210
+ const preSeparatorPositionals = [];
211
+ if (parsed.tokens) {
212
+ let afterSeparator = false;
213
+ for (const token of parsed.tokens) {
214
+ if (token.kind === "option-terminator") {
215
+ afterSeparator = true;
216
+ continue;
217
+ }
218
+ if (token.kind === "positional") {
219
+ (afterSeparator ? rawArgs : preSeparatorPositionals).push(token.value ?? "");
220
+ }
221
+ }
222
+ } else {
223
+ preSeparatorPositionals.push(...parsed.positionals);
224
+ }
225
+ const resolvedFlags = resolveFlags(flagsDef, parsed.values, aliasToName);
226
+ const resolvedArgs = resolveArgs(argsDef, preSeparatorPositionals);
227
+ validateRequiredFlags(flagsDef, resolvedFlags);
228
+ return {
229
+ args: resolvedArgs,
230
+ flags: resolvedFlags,
231
+ rawArgs
232
+ };
233
+ }
234
+ // src/router.ts
235
+ function resolveCommand(command, argv) {
236
+ const path = [command.meta.name];
237
+ let current = command;
238
+ let routedArgv = argv;
239
+ while (routedArgv.length > 0) {
240
+ const subCommands = current.subCommands;
241
+ if (!subCommands || Object.keys(subCommands).length === 0) {
242
+ break;
243
+ }
244
+ const candidate = routedArgv[0];
245
+ if (!candidate || candidate.startsWith("-")) {
246
+ break;
247
+ }
248
+ if (candidate in subCommands) {
249
+ current = subCommands[candidate];
250
+ path.push(candidate);
251
+ routedArgv = routedArgv.slice(1);
252
+ continue;
253
+ }
254
+ if (current.run) {
255
+ break;
256
+ }
257
+ const available = Object.keys(subCommands);
258
+ throw new CrustError("COMMAND_NOT_FOUND", `Unknown command "${candidate}".`, {
259
+ input: candidate,
260
+ available,
261
+ commandPath: [...path],
262
+ parentCommand: current
263
+ });
264
+ }
265
+ return {
266
+ command: current,
267
+ argv: routedArgv,
268
+ commandPath: path
269
+ };
270
+ }
271
+ // src/run.ts
272
+ function createPluginState() {
273
+ const map = new Map;
274
+ return {
275
+ get(key) {
276
+ return map.get(key);
277
+ },
278
+ has(key) {
279
+ return map.has(key);
280
+ },
281
+ set(key, value) {
282
+ map.set(key, value);
283
+ },
284
+ delete(key) {
285
+ return map.delete(key);
286
+ }
287
+ };
288
+ }
289
+ async function runSetupHooks(plugins, context, actions) {
290
+ for (const plugin of plugins) {
291
+ if (!plugin.setup)
292
+ continue;
293
+ await plugin.setup(context, actions);
294
+ }
295
+ }
296
+ async function executeCommand(command, parsed) {
297
+ if (!command.run)
298
+ return;
299
+ const context = {
300
+ args: parsed.args,
301
+ flags: parsed.flags,
302
+ rawArgs: parsed.rawArgs,
303
+ command
304
+ };
305
+ try {
306
+ if (command.preRun) {
307
+ await command.preRun(context);
308
+ }
309
+ await command.run(context);
310
+ } finally {
311
+ if (command.postRun) {
312
+ await command.postRun(context);
313
+ }
314
+ }
315
+ }
316
+ async function runMiddlewareChain(plugins, context, terminal) {
317
+ const stack = plugins.map((plugin) => plugin.middleware).filter((middleware) => Boolean(middleware));
318
+ let index = -1;
319
+ const dispatch = async (i) => {
320
+ if (i <= index) {
321
+ throw new CrustError("DEFINITION", "Plugin middleware called next() multiple times");
322
+ }
323
+ index = i;
324
+ if (i === stack.length) {
325
+ await terminal();
326
+ return;
327
+ }
328
+ const middleware = stack[i];
329
+ if (!middleware) {
330
+ throw new CrustError("DEFINITION", "Plugin middleware stack is invalid");
331
+ }
332
+ await middleware(context, () => dispatch(i + 1));
333
+ };
334
+ await dispatch(0);
335
+ }
336
+ async function runCommand(command, options) {
337
+ const argv = options?.argv ?? process.argv.slice(2);
338
+ const plugins = options?.plugins ?? [];
339
+ const actions = {
340
+ addFlag(target, name, def) {
341
+ if (!target.flags) {
342
+ throw new CrustError("DEFINITION", `Cannot add flag "${name}": command "${target.meta.name}" has no flags object.`);
343
+ }
344
+ target.flags[name] = def;
345
+ }
346
+ };
347
+ const middlewareContext = {
348
+ argv: [...argv],
349
+ rootCommand: command,
350
+ state: createPluginState(),
351
+ route: null,
352
+ input: null
353
+ };
354
+ try {
355
+ await runSetupHooks(plugins, middlewareContext, actions);
356
+ let parsed;
357
+ let resolvedCommand;
358
+ try {
359
+ const resolved = resolveCommand(command, [...argv]);
360
+ middlewareContext.route = resolved;
361
+ parsed = parseArgs(resolved.command, resolved.argv);
362
+ middlewareContext.input = {
363
+ args: parsed.args,
364
+ flags: parsed.flags,
365
+ rawArgs: parsed.rawArgs
366
+ };
367
+ resolvedCommand = resolved.command;
368
+ } catch (error) {
369
+ await runMiddlewareChain(plugins, middlewareContext, async () => {
370
+ throw error;
371
+ });
372
+ return;
373
+ }
374
+ await runMiddlewareChain(plugins, middlewareContext, async () => {
375
+ await executeCommand(resolvedCommand, parsed);
376
+ });
377
+ } catch (error) {
378
+ if (error instanceof CrustError) {
379
+ throw error;
380
+ }
381
+ if (error instanceof Error) {
382
+ throw new CrustError("EXECUTION", error.message).withCause(error);
383
+ }
384
+ throw new CrustError("EXECUTION", String(error)).withCause(error);
385
+ }
386
+ }
387
+ async function runMain(command, options) {
388
+ try {
389
+ await runCommand(command, options);
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ console.error(`Error: ${message}`);
393
+ process.exitCode = 1;
394
+ }
395
+ }
396
+ export {
397
+ runMain,
398
+ runCommand,
399
+ resolveCommand,
400
+ parseArgs,
401
+ defineCommand,
402
+ CrustError
403
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@crustjs/core",
3
+ "version": "0.0.2",
4
+ "description": "Core library for the Crust CLI framework",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "chenxin-yan",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/chenxin-yan/crust.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "homepage": "https://crust.cyanlabs.co",
14
+ "bugs": {
15
+ "url": "https://github.com/chenxin-yan/crust/issues"
16
+ },
17
+ "keywords": [
18
+ "cli",
19
+ "command",
20
+ "framework",
21
+ "parser",
22
+ "bun",
23
+ "typescript"
24
+ ],
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "exports": {
29
+ ".": {
30
+ "import": "./dist/index.js",
31
+ "types": "./dist/index.d.ts"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "build": "bunup",
39
+ "dev": "bunup --watch",
40
+ "check:types": "tsc --noEmit",
41
+ "test": "bun test"
42
+ },
43
+ "devDependencies": {
44
+ "@crustjs/config": "workspace:*",
45
+ "bunup": "catalog:"
46
+ },
47
+ "peerDependencies": {
48
+ "typescript": "catalog:"
49
+ }
50
+ }