@crustjs/core 0.0.18 → 0.2.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/LICENSE +21 -0
- package/README.md +3 -48
- package/dist/index.d.ts +1203 -1209
- package/dist/index.js +232 -2
- package/dist/invocation-DcA5FqF7.js +1875 -0
- package/dist/tooling.d.ts +160 -0
- package/dist/tooling.js +96 -0
- package/dist/types-DjMHz7M6.d.ts +828 -0
- package/package.json +27 -11
- package/dist/shared/chunk-1670njz2.js +0 -2
- package/dist/shared/chunk-q8y07jw2.js +0 -3
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, posix, resolve, win32 } from "node:path";
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { Writable } from "node:stream";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { isPromise } from "node:util/types";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
//#region src/errors.ts
|
|
9
|
+
/**
|
|
10
|
+
* A typed error for runtime recipe, Extension, Context, documentation, argv, and validation failures.
|
|
11
|
+
*
|
|
12
|
+
* Every `CrustError` carries a {@link CrustErrorCode} that identifies the specific
|
|
13
|
+
* failure, enabling programmatic error handling without fragile message parsing.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { CrustError } from "@crustjs/core";
|
|
18
|
+
*
|
|
19
|
+
* const outcome = await app.run(["deploy"], { args: { target: "prod" } });
|
|
20
|
+
* if (outcome.status === "failed") {
|
|
21
|
+
* const err = outcome.error;
|
|
22
|
+
* if (err instanceof CrustError) {
|
|
23
|
+
* console.error(`[${err.code}] ${err.message}`);
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
var CrustError = class extends Error {
|
|
29
|
+
/** Machine-readable error code for programmatic handling */
|
|
30
|
+
code;
|
|
31
|
+
/** Structured payload for programmatic handling */
|
|
32
|
+
details;
|
|
33
|
+
/** Optional wrapped original error/value */
|
|
34
|
+
cause;
|
|
35
|
+
constructor(code, message, ...details) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "CrustError";
|
|
38
|
+
this.code = code;
|
|
39
|
+
this.details = details[0];
|
|
40
|
+
}
|
|
41
|
+
is(code) {
|
|
42
|
+
return Object.is(this.code, code);
|
|
43
|
+
}
|
|
44
|
+
withCause(cause) {
|
|
45
|
+
this.cause = cause;
|
|
46
|
+
return this;
|
|
47
|
+
}
|
|
48
|
+
toJSON() {
|
|
49
|
+
return {
|
|
50
|
+
code: this.code,
|
|
51
|
+
message: this.message,
|
|
52
|
+
details: this.details
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
//#region src/parsing/spellings.ts
|
|
58
|
+
function assertUsableSpelling(spelling, kind) {
|
|
59
|
+
if (spelling === "") throw new CrustError("DEFINITION", `Flag ${kind} spellings must be non-empty`, {
|
|
60
|
+
subject: "flag",
|
|
61
|
+
name: spelling,
|
|
62
|
+
reason: "empty-spelling"
|
|
63
|
+
});
|
|
64
|
+
if (kind !== "short" && spelling.startsWith("no-")) throw new CrustError("DEFINITION", `Flag ${kind} "${spelling}" must not start with "no-"; the prefix is reserved for boolean negation`, {
|
|
65
|
+
subject: "flag",
|
|
66
|
+
name: spelling,
|
|
67
|
+
reason: "reserved-no-prefix"
|
|
68
|
+
});
|
|
69
|
+
if (spelling === "__proto__") throw new CrustError("DEFINITION", `Flag ${kind} "__proto__" is a reserved spelling`, {
|
|
70
|
+
subject: "flag",
|
|
71
|
+
name: spelling,
|
|
72
|
+
reason: "reserved-spelling"
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** All canonical, short, and alias spellings carried by a flag definition. */
|
|
76
|
+
function flagSpellings(name, def) {
|
|
77
|
+
return [
|
|
78
|
+
name,
|
|
79
|
+
...def.short === void 0 ? [] : [def.short],
|
|
80
|
+
...def.aliases ?? []
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
/** Whether a flag accepts generated `--no-` spellings. */
|
|
84
|
+
function isFlagNegatable(def) {
|
|
85
|
+
return def.type === "boolean" && def.noNegate !== true;
|
|
86
|
+
}
|
|
87
|
+
/** Convert named authoring definitions to the runtime flag record. */
|
|
88
|
+
function toFlagsRecord(definitions, initial = {}) {
|
|
89
|
+
const flags = { ...initial };
|
|
90
|
+
const seen = new Set(Object.entries(initial).flatMap(([name, def]) => flagSpellings(name, def)));
|
|
91
|
+
for (const definition of definitions) {
|
|
92
|
+
const { name, ...flag } = definition;
|
|
93
|
+
const normalized = normalizeFlag(name, flag);
|
|
94
|
+
for (const spelling of flagSpellings(name, normalized)) {
|
|
95
|
+
if (seen.has(spelling)) throw new CrustError("DEFINITION", `Flag "${name}" collides with an existing flag`, {
|
|
96
|
+
subject: "flag",
|
|
97
|
+
name,
|
|
98
|
+
reason: "flag-collision"
|
|
99
|
+
});
|
|
100
|
+
seen.add(spelling);
|
|
101
|
+
}
|
|
102
|
+
flags[name] = normalized;
|
|
103
|
+
}
|
|
104
|
+
return flags;
|
|
105
|
+
}
|
|
106
|
+
/** Copy only definition-owned collections, not JSON/URL/schema payloads. */
|
|
107
|
+
function ownDefinition(def) {
|
|
108
|
+
if (def.choices && def.default !== void 0) {
|
|
109
|
+
const values = "multiple" in def && def.multiple ? def.default : [def.default];
|
|
110
|
+
for (const value of values) if (!def.choices.includes(value)) throw new CrustError("DEFINITION", "default must be one of choices");
|
|
111
|
+
}
|
|
112
|
+
return Object.freeze({
|
|
113
|
+
...def,
|
|
114
|
+
..."aliases" in def && def.aliases ? { aliases: Object.freeze([...def.aliases]) } : {},
|
|
115
|
+
...def.choices ? { choices: Object.freeze([...def.choices]) } : {},
|
|
116
|
+
..."multiple" in def && def.multiple && Array.isArray(def.default) ? { default: Object.freeze([...def.default]) } : {}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function normalizeFlag(name, def) {
|
|
120
|
+
assertUsableSpelling(name, "canonical");
|
|
121
|
+
if (def.short !== void 0) assertUsableSpelling(def.short, "short");
|
|
122
|
+
for (const alias of def.aliases ?? []) assertUsableSpelling(alias, "alias");
|
|
123
|
+
const spellings = flagSpellings(name, def);
|
|
124
|
+
if (new Set(spellings).size !== spellings.length) throw new CrustError("DEFINITION", `Flag "${name}" repeats one of its own spellings`, {
|
|
125
|
+
subject: "flag",
|
|
126
|
+
name,
|
|
127
|
+
reason: "flag-collision"
|
|
128
|
+
});
|
|
129
|
+
if (def.short !== void 0 && def.short.length !== 1) throw new CrustError("DEFINITION", "Short flags must be one character");
|
|
130
|
+
return ownDefinition(def);
|
|
131
|
+
}
|
|
132
|
+
function normalizeArg(def) {
|
|
133
|
+
if (def.name === "") throw new CrustError("DEFINITION", "Argument names must be non-empty", {
|
|
134
|
+
subject: "argument",
|
|
135
|
+
name: def.name,
|
|
136
|
+
reason: "empty-name"
|
|
137
|
+
});
|
|
138
|
+
return ownDefinition(def);
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/api/context.ts
|
|
142
|
+
const defining = Symbol("crust.defining");
|
|
143
|
+
/** @internal */
|
|
144
|
+
function definingOf(value) {
|
|
145
|
+
return value[defining];
|
|
146
|
+
}
|
|
147
|
+
/** @internal */
|
|
148
|
+
function seal(value) {
|
|
149
|
+
return Object.freeze(Object.assign(value, { [defining]: value }));
|
|
150
|
+
}
|
|
151
|
+
/** Check only declared availability; setup, callback values, and cycles belong to invocation. */
|
|
152
|
+
function validateContextAvailability(contexts, sources) {
|
|
153
|
+
const names = new Set(contexts.map((instance) => instance.name));
|
|
154
|
+
const visited = /* @__PURE__ */ new Set();
|
|
155
|
+
const visit = (source) => {
|
|
156
|
+
if (visited.has(source)) return;
|
|
157
|
+
visited.add(source);
|
|
158
|
+
const name = "contextName" in source ? source.contextName : source.name;
|
|
159
|
+
if (!names.has(name)) throw new CrustError("DEFINITION", `No provider for Context "${name}"`, {
|
|
160
|
+
subject: "context",
|
|
161
|
+
name,
|
|
162
|
+
reason: "missing-context"
|
|
163
|
+
});
|
|
164
|
+
for (const dependency of source.uses) visit(dependency);
|
|
165
|
+
};
|
|
166
|
+
for (const source of sources) visit(source);
|
|
167
|
+
}
|
|
168
|
+
function isContextSetup(value) {
|
|
169
|
+
return typeof value === "function";
|
|
170
|
+
}
|
|
171
|
+
function defineContext(name, configOrSetup, maybeSetup) {
|
|
172
|
+
const hasConfig = !isContextSetup(configOrSetup);
|
|
173
|
+
const config = hasConfig ? configOrSetup : {};
|
|
174
|
+
const setup = hasConfig ? maybeSetup : configOrSetup;
|
|
175
|
+
const ownedFlags = Object.freeze(toFlagsRecord(config.flags ?? []));
|
|
176
|
+
const uses = Object.freeze((config.uses ?? []).map(definingOf));
|
|
177
|
+
const instance = (instanceUses, run) => {
|
|
178
|
+
return seal({
|
|
179
|
+
name,
|
|
180
|
+
ownedFlags,
|
|
181
|
+
uses: instanceUses,
|
|
182
|
+
setup: run
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
const factory = (options) => instance(uses, (input) => {
|
|
186
|
+
return setup({
|
|
187
|
+
options,
|
|
188
|
+
...input
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
factory.contextName = name;
|
|
192
|
+
factory.uses = uses;
|
|
193
|
+
factory.of = (value) => instance(Object.freeze([]), () => value);
|
|
194
|
+
return seal(factory);
|
|
195
|
+
}
|
|
196
|
+
function isDisposeCallback(value) {
|
|
197
|
+
return typeof value === "function";
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Minimal `AsyncDisposableStack` stand-in for runtimes without the global
|
|
201
|
+
* (Node 22): LIFO disposal of used resources and deferred callbacks,
|
|
202
|
+
* preferring `Symbol.asyncDispose` over `Symbol.dispose`.
|
|
203
|
+
*
|
|
204
|
+
* ponytail: multiple disposal errors aggregate as `AggregateError` instead of
|
|
205
|
+
* the native `SuppressedError` chain; delete this class when Node 22 leaves
|
|
206
|
+
* the support floor.
|
|
207
|
+
*
|
|
208
|
+
* @internal Exported for unit testing and invocation wiring.
|
|
209
|
+
*/
|
|
210
|
+
var FallbackAsyncDisposableStack = class {
|
|
211
|
+
#entries = [];
|
|
212
|
+
#disposed = false;
|
|
213
|
+
#assertPending() {
|
|
214
|
+
if (this.#disposed) throw new ReferenceError("AsyncDisposableStack is already disposed");
|
|
215
|
+
}
|
|
216
|
+
use(value) {
|
|
217
|
+
this.#assertPending();
|
|
218
|
+
const dispose = hasAsyncDispose(value) ? value[Symbol.asyncDispose] : value[Symbol.dispose];
|
|
219
|
+
this.#entries.push(() => dispose.call(value));
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
defer(onDisposeAsync) {
|
|
223
|
+
this.#assertPending();
|
|
224
|
+
if (!isDisposeCallback(onDisposeAsync)) throw new TypeError("defer callback is not callable");
|
|
225
|
+
this.#entries.push(onDisposeAsync);
|
|
226
|
+
}
|
|
227
|
+
async [Symbol.asyncDispose]() {
|
|
228
|
+
this.#disposed = true;
|
|
229
|
+
const errors = [];
|
|
230
|
+
for (let index = this.#entries.length - 1; index >= 0; index--) try {
|
|
231
|
+
await this.#entries[index]();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
errors.push(error);
|
|
234
|
+
}
|
|
235
|
+
if (errors.length === 1) throw errors[0];
|
|
236
|
+
if (errors.length > 1) throw new AggregateError(errors, "Disposal failed");
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const DisposalStack = globalThis.AsyncDisposableStack ?? FallbackAsyncDisposableStack;
|
|
240
|
+
function hasAsyncDispose(value) {
|
|
241
|
+
return typeof value[Symbol.asyncDispose] === "function";
|
|
242
|
+
}
|
|
243
|
+
function isDisposableValue(value) {
|
|
244
|
+
if (value === null || typeof value !== "object" && typeof value !== "function") return false;
|
|
245
|
+
const candidate = value;
|
|
246
|
+
return typeof candidate[Symbol.asyncDispose] === "function" || typeof candidate[Symbol.dispose] === "function";
|
|
247
|
+
}
|
|
248
|
+
function registerDisposable(value, disposal, registered) {
|
|
249
|
+
if (!isDisposableValue(value) || registered.has(value)) return;
|
|
250
|
+
registered.add(value);
|
|
251
|
+
disposal.use(value);
|
|
252
|
+
}
|
|
253
|
+
/** Internal lazy invocation container. Public consumers receive scoped Context bags. */
|
|
254
|
+
function createContextResolver(contexts, io, disposal) {
|
|
255
|
+
const byName = new Map(contexts.map((context) => [context.name, context]));
|
|
256
|
+
const entries = /* @__PURE__ */ new Map();
|
|
257
|
+
const registered = /* @__PURE__ */ new WeakSet();
|
|
258
|
+
let validatedFlags;
|
|
259
|
+
let disposed = false;
|
|
260
|
+
disposal.defer(() => {
|
|
261
|
+
disposed = true;
|
|
262
|
+
});
|
|
263
|
+
const pathTo = (from, target, visited = /* @__PURE__ */ new Set()) => {
|
|
264
|
+
if (from.name === target) return [from.name];
|
|
265
|
+
if (visited.has(from.name)) return void 0;
|
|
266
|
+
visited.add(from.name);
|
|
267
|
+
for (const name of from.waitingOn) {
|
|
268
|
+
const next = entries.get(name);
|
|
269
|
+
if (!next) continue;
|
|
270
|
+
const path = pathTo(next, target, visited);
|
|
271
|
+
if (path) return [from.name, ...path];
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const isFlagValidationError = (error) => {
|
|
275
|
+
try {
|
|
276
|
+
const seen = /* @__PURE__ */ new Set();
|
|
277
|
+
for (let e = error; e != null && !seen.has(e); e = e.cause) {
|
|
278
|
+
seen.add(e);
|
|
279
|
+
if (e instanceof CrustError && e.code === "DEFINITION" && e.details?.reason === "flags-before-validation") return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
} catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
function handledRejection(reason) {
|
|
287
|
+
const rejection = Promise.reject(reason);
|
|
288
|
+
rejection.catch(() => {});
|
|
289
|
+
return rejection;
|
|
290
|
+
}
|
|
291
|
+
const makePull = (origin) => (name) => {
|
|
292
|
+
const originSuffix = origin ? ` (pulled while constructing Context "${origin.name}")` : "";
|
|
293
|
+
if (disposed) return handledRejection(new CrustError("DEFINITION", `Context "${name}" cannot be pulled from onError because invocation Contexts have already been disposed.`, {
|
|
294
|
+
subject: "context",
|
|
295
|
+
name,
|
|
296
|
+
reason: "context-after-disposal"
|
|
297
|
+
}));
|
|
298
|
+
const context = byName.get(name);
|
|
299
|
+
if (!context) return handledRejection(new CrustError("DEFINITION", `No provider for Context "${name}". Add .provide(${name}(...)) to the app or an ancestor command.${originSuffix}`, {
|
|
300
|
+
subject: "context",
|
|
301
|
+
name,
|
|
302
|
+
reason: "missing-context"
|
|
303
|
+
}));
|
|
304
|
+
if (validatedFlags === void 0 && Object.keys(context.ownedFlags).length > 0) return handledRejection(new CrustError("DEFINITION", `Context "${name}" owns flags and cannot be pulled before flag validation${originSuffix}. Pull it from an action or a postRun hook after a validated invocation.`, {
|
|
305
|
+
subject: "context",
|
|
306
|
+
name,
|
|
307
|
+
reason: "flags-before-validation"
|
|
308
|
+
}));
|
|
309
|
+
let entry = entries.get(name);
|
|
310
|
+
if (!entry) {
|
|
311
|
+
const deferred = Promise.withResolvers();
|
|
312
|
+
deferred.promise.catch(() => {});
|
|
313
|
+
entry = {
|
|
314
|
+
name,
|
|
315
|
+
promise: deferred.promise,
|
|
316
|
+
resolve: deferred.resolve,
|
|
317
|
+
reject: deferred.reject,
|
|
318
|
+
waitingOn: /* @__PURE__ */ new Set(),
|
|
319
|
+
settled: false
|
|
320
|
+
};
|
|
321
|
+
entries.set(name, entry);
|
|
322
|
+
const current = entry;
|
|
323
|
+
(async () => {
|
|
324
|
+
try {
|
|
325
|
+
const ownedFlags = Object.fromEntries(Object.keys(context.ownedFlags).map((flag) => [flag, validatedFlags?.[flag]]));
|
|
326
|
+
const value = await context.setup({
|
|
327
|
+
...io,
|
|
328
|
+
flags: ownedFlags,
|
|
329
|
+
ctx: makeBag(context.uses, current),
|
|
330
|
+
defer(cleanup) {
|
|
331
|
+
if (current.settled) throw new CrustError("DEFINITION", `Context "${name}" cannot register cleanup after its setup has finished.`, {
|
|
332
|
+
subject: "context",
|
|
333
|
+
name,
|
|
334
|
+
reason: "context-defer-after-setup"
|
|
335
|
+
});
|
|
336
|
+
disposal.defer(cleanup);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
registerDisposable(value, disposal, registered);
|
|
340
|
+
current.resolve(value);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (isFlagValidationError(error)) entries.delete(name);
|
|
343
|
+
current.reject(error);
|
|
344
|
+
} finally {
|
|
345
|
+
current.settled = true;
|
|
346
|
+
}
|
|
347
|
+
})();
|
|
348
|
+
}
|
|
349
|
+
if (origin && !entry.settled) {
|
|
350
|
+
const path = pathTo(entry, origin.name);
|
|
351
|
+
if (path) return handledRejection(new CrustError("DEFINITION", `Context dependency cycle: ${[origin.name, ...path].map((part) => `"${part}"`).join(" -> ")}`, {
|
|
352
|
+
subject: "context",
|
|
353
|
+
name: origin.name,
|
|
354
|
+
reason: "context-cycle"
|
|
355
|
+
}));
|
|
356
|
+
origin.waitingOn.add(name);
|
|
357
|
+
return entry.promise.finally(() => origin.waitingOn.delete(name));
|
|
358
|
+
}
|
|
359
|
+
return entry.promise;
|
|
360
|
+
};
|
|
361
|
+
const makeBag = (sources, origin) => {
|
|
362
|
+
const bag = {};
|
|
363
|
+
const add = (source) => {
|
|
364
|
+
const name = "contextName" in source ? source.contextName : source.name;
|
|
365
|
+
if (Object.hasOwn(bag, name)) return;
|
|
366
|
+
Object.defineProperty(bag, name, {
|
|
367
|
+
enumerable: true,
|
|
368
|
+
get: () => makePull(origin)(name)
|
|
369
|
+
});
|
|
370
|
+
for (const dependency of source.uses ?? []) add(dependency);
|
|
371
|
+
};
|
|
372
|
+
for (const source of sources) add(source);
|
|
373
|
+
return Object.freeze(bag);
|
|
374
|
+
};
|
|
375
|
+
return {
|
|
376
|
+
bag: (sources) => makeBag(sources, null),
|
|
377
|
+
setValidatedFlags(flags) {
|
|
378
|
+
validatedFlags = flags;
|
|
379
|
+
},
|
|
380
|
+
async settle() {
|
|
381
|
+
for (;;) {
|
|
382
|
+
const pending = [...entries.values()].filter((entry) => !entry.settled);
|
|
383
|
+
if (pending.length === 0) return;
|
|
384
|
+
await Promise.allSettled(pending.map((entry) => entry.promise));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/api/extension.ts
|
|
391
|
+
const FINISHED = Object.freeze({ [Symbol("crust.finished")]: true });
|
|
392
|
+
/** @internal */
|
|
393
|
+
function finishInvocation() {
|
|
394
|
+
return FINISHED;
|
|
395
|
+
}
|
|
396
|
+
function isExtensionFactory(value) {
|
|
397
|
+
return typeof value === "function";
|
|
398
|
+
}
|
|
399
|
+
function defineExtension(id, config = {}) {
|
|
400
|
+
if (id === void 0) return defineExtension;
|
|
401
|
+
if (isExtensionFactory(config)) return Object.assign((...args) => defineExtension(id, config(...args)), { id });
|
|
402
|
+
const ownedFlags = Object.freeze(toFlagsRecord(config.flags ?? []));
|
|
403
|
+
toFlagsRecord((config.provides ?? []).flatMap((instance) => Object.entries(definingOf(instance).ownedFlags).map(([name, def]) => ({
|
|
404
|
+
...def,
|
|
405
|
+
name
|
|
406
|
+
}))), ownedFlags);
|
|
407
|
+
return seal({
|
|
408
|
+
...config,
|
|
409
|
+
uses: Object.freeze((config.uses ?? []).map(definingOf)),
|
|
410
|
+
...config.provides ? { provides: Object.freeze(config.provides.map(definingOf)) } : {},
|
|
411
|
+
...config.commands ? { commands: Object.freeze([...config.commands]) } : {},
|
|
412
|
+
id,
|
|
413
|
+
...config.flags === void 0 ? {} : { flags: ownedFlags }
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/command/node.ts
|
|
418
|
+
/**
|
|
419
|
+
* Creates a new `CommandNode` with all fields initialized to defaults.
|
|
420
|
+
*
|
|
421
|
+
* @param name - The command name.
|
|
422
|
+
* @returns A fresh `CommandNode` with empty flags, no args, no subcommands,
|
|
423
|
+
* no extensions, and no action.
|
|
424
|
+
*/
|
|
425
|
+
function createCommandNode(name) {
|
|
426
|
+
return {
|
|
427
|
+
meta: { name },
|
|
428
|
+
localFlags: {},
|
|
429
|
+
ownedFlags: {},
|
|
430
|
+
effectiveFlags: {},
|
|
431
|
+
flagSpellings: /* @__PURE__ */ new Map(),
|
|
432
|
+
args: [],
|
|
433
|
+
subCommands: {},
|
|
434
|
+
contexts: [],
|
|
435
|
+
demands: [],
|
|
436
|
+
extensions: [],
|
|
437
|
+
run: void 0
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/** Register one effective flag and its source-owned state with a single collision policy. */
|
|
441
|
+
function registerFlag(node, name, def, source) {
|
|
442
|
+
def = normalizeFlag(name, def);
|
|
443
|
+
const incomingSpellings = flagSpellings(name, def);
|
|
444
|
+
const existingName = Object.hasOwn(node.effectiveFlags, name) ? name : incomingSpellings.map((spelling) => node.flagSpellings.get(spelling)?.canonicalName).find((existing) => existing !== void 0);
|
|
445
|
+
if (existingName !== void 0) throw new CrustError("DEFINITION", `Flag "${name}" collides with existing flag "${existingName}" on command "${node.meta.name}"`, {
|
|
446
|
+
subject: "flag",
|
|
447
|
+
name,
|
|
448
|
+
reason: "flag-collision"
|
|
449
|
+
});
|
|
450
|
+
(source === "local" ? node.localFlags : node.ownedFlags)[name] = def;
|
|
451
|
+
node.effectiveFlags[name] = def;
|
|
452
|
+
const entry = {
|
|
453
|
+
canonicalName: name,
|
|
454
|
+
def,
|
|
455
|
+
negatable: isFlagNegatable(def)
|
|
456
|
+
};
|
|
457
|
+
node.flagSpellings.set(name, {
|
|
458
|
+
...entry,
|
|
459
|
+
kind: "canonical"
|
|
460
|
+
});
|
|
461
|
+
if (def.short !== void 0) node.flagSpellings.set(def.short, {
|
|
462
|
+
...entry,
|
|
463
|
+
kind: "short"
|
|
464
|
+
});
|
|
465
|
+
for (const alias of def.aliases ?? []) node.flagSpellings.set(alias, {
|
|
466
|
+
...entry,
|
|
467
|
+
kind: "alias"
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/command/extensions-install.ts
|
|
472
|
+
/** Inject an Extension-owned flag into a node and, when recursive, its descendants. */
|
|
473
|
+
function injectExtensionFlag(node, name, def, recursive) {
|
|
474
|
+
registerFlag(node, name, def, "owned");
|
|
475
|
+
if (!recursive) return;
|
|
476
|
+
for (const sub of Object.values(node.subCommands)) injectExtensionFlag(sub, name, def, true);
|
|
477
|
+
}
|
|
478
|
+
/** Attach one Extension's owned root commands to a cloned tree. */
|
|
479
|
+
function applyExtensionCommands(root, extension, materializeCommandDefinition) {
|
|
480
|
+
for (const definition of extension.commands ?? []) root.subCommands[definition.name] = materializeCommandDefinition(definition, root, extension.id);
|
|
481
|
+
}
|
|
482
|
+
/** Inject one Extension's owned flags across a cloned tree. */
|
|
483
|
+
function applyExtensionFlags(root, extension) {
|
|
484
|
+
for (const [name, defWithScope] of Object.entries(extension.flags ?? {})) {
|
|
485
|
+
const { recursive = true, ...def } = defWithScope;
|
|
486
|
+
injectExtensionFlag(root, name, def, recursive);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Deep-clone a command subtree without mutating the builder graph. */
|
|
490
|
+
function cloneCommandNode(node) {
|
|
491
|
+
const subCommands = {};
|
|
492
|
+
for (const [name, sub] of Object.entries(node.subCommands)) subCommands[name] = cloneCommandNode(sub);
|
|
493
|
+
return {
|
|
494
|
+
...node,
|
|
495
|
+
meta: { ...node.meta },
|
|
496
|
+
localFlags: { ...node.localFlags },
|
|
497
|
+
ownedFlags: { ...node.ownedFlags },
|
|
498
|
+
effectiveFlags: { ...node.effectiveFlags },
|
|
499
|
+
flagSpellings: new Map([...node.flagSpellings].map(([spelling, entry]) => [spelling, {
|
|
500
|
+
...entry,
|
|
501
|
+
def: node.effectiveFlags[entry.canonicalName]
|
|
502
|
+
}])),
|
|
503
|
+
args: [...node.args],
|
|
504
|
+
subCommands,
|
|
505
|
+
contexts: node.contexts.map((context) => ({ ...context })),
|
|
506
|
+
demands: [...node.demands],
|
|
507
|
+
extensions: [...node.extensions],
|
|
508
|
+
run: node.run
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function invalidSections({ subject, name }) {
|
|
512
|
+
return new CrustError("DEFINITION", `${subject === "command" ? "Command" : "Extension"} "${name}" contains invalid documentation sections`, {
|
|
513
|
+
subject,
|
|
514
|
+
name,
|
|
515
|
+
reason: "invalid-sections"
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function normalizeSection(section, owner) {
|
|
519
|
+
const { title, body, only, except } = section;
|
|
520
|
+
if (!title.trim() || /[\r\n]/.test(title) || !body.trim() || only?.length === 0 || except?.length === 0 || only !== void 0 && except !== void 0) throw invalidSections(owner);
|
|
521
|
+
const audience = (ids) => {
|
|
522
|
+
return Object.freeze(ids.map((consumer) => typeof consumer === "string" ? consumer : consumer.id));
|
|
523
|
+
};
|
|
524
|
+
return Object.freeze({
|
|
525
|
+
title,
|
|
526
|
+
body,
|
|
527
|
+
...only ? { only: audience(only) } : except ? { except: audience(except) } : {}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function validateCommandSections(name, sections) {
|
|
531
|
+
return sections.map((section) => normalizeSection(section, {
|
|
532
|
+
subject: "command",
|
|
533
|
+
name
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
function contributionTarget(root, command, extension) {
|
|
537
|
+
let target = root;
|
|
538
|
+
for (const segment of command) {
|
|
539
|
+
const next = Object.hasOwn(target.subCommands, segment) ? target.subCommands[segment] : void 0;
|
|
540
|
+
if (!next) throw new CrustError("DEFINITION", `Extension "${extension.id}" section target "${command.join(" ")}" is not a canonical command path`, {
|
|
541
|
+
subject: "extension",
|
|
542
|
+
name: extension.id,
|
|
543
|
+
reason: "invalid-section-path"
|
|
544
|
+
});
|
|
545
|
+
target = next;
|
|
546
|
+
}
|
|
547
|
+
return target;
|
|
548
|
+
}
|
|
549
|
+
function applyExtensionSections(root, extension, snapshot) {
|
|
550
|
+
if (!extension.sections) return;
|
|
551
|
+
const owner = {
|
|
552
|
+
subject: "extension",
|
|
553
|
+
name: extension.id
|
|
554
|
+
};
|
|
555
|
+
const contributions = extension.sections(snapshot);
|
|
556
|
+
for (const contribution of contributions) {
|
|
557
|
+
const section = normalizeSection(contribution, owner);
|
|
558
|
+
const target = contributionTarget(root, contribution.command, extension);
|
|
559
|
+
target.meta.sections = [...target.meta.sections ?? [], section];
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function installExtensionContexts(node, extensions, reRegisteredIds) {
|
|
563
|
+
const cloned = cloneCommandNode(node);
|
|
564
|
+
const kept = new Set(extensions.flatMap((e) => !reRegisteredIds.has(e.id) && node.extensions.includes(e) ? [e.id] : []));
|
|
565
|
+
const prune = (target) => {
|
|
566
|
+
target.contexts = target.contexts.filter((context) => context.extensionId === void 0 || kept.has(context.extensionId));
|
|
567
|
+
const effectiveNames = Object.keys(target.effectiveFlags);
|
|
568
|
+
const localFlags = target.localFlags;
|
|
569
|
+
const ownedFlags = {};
|
|
570
|
+
for (const { instance } of target.contexts) Object.assign(ownedFlags, instance.ownedFlags);
|
|
571
|
+
target.localFlags = {};
|
|
572
|
+
target.ownedFlags = {};
|
|
573
|
+
target.effectiveFlags = {};
|
|
574
|
+
target.flagSpellings = /* @__PURE__ */ new Map();
|
|
575
|
+
for (const name of effectiveNames) {
|
|
576
|
+
const source = Object.hasOwn(ownedFlags, name) ? "owned" : "local";
|
|
577
|
+
const flags = source === "owned" ? ownedFlags : localFlags;
|
|
578
|
+
if (Object.hasOwn(flags, name)) registerFlag(target, name, flags[name], source);
|
|
579
|
+
}
|
|
580
|
+
for (const child of Object.values(target.subCommands)) prune(child);
|
|
581
|
+
};
|
|
582
|
+
prune(cloned);
|
|
583
|
+
for (const extension of extensions) {
|
|
584
|
+
if (kept.has(extension.id)) continue;
|
|
585
|
+
const instances = extension.provides ?? [];
|
|
586
|
+
if (instances.length === 0) continue;
|
|
587
|
+
const walk = (target, skip) => {
|
|
588
|
+
const installed = instances.filter((instance) => !skip.has(instance.name));
|
|
589
|
+
const registrations = installed.map((instance) => ({
|
|
590
|
+
instance,
|
|
591
|
+
extensionId: extension.id
|
|
592
|
+
}));
|
|
593
|
+
target.contexts.push(...registrations);
|
|
594
|
+
for (const instance of installed) for (const [name, def] of Object.entries(instance.ownedFlags)) registerFlag(target, name, def, "owned");
|
|
595
|
+
const inherited = new WeakSet(target.contexts.map(({ instance }) => instance));
|
|
596
|
+
for (const child of Object.values(target.subCommands)) {
|
|
597
|
+
const childSkip = new Set(skip);
|
|
598
|
+
for (const { instance } of child.contexts) if (!inherited.has(instance)) childSkip.add(instance.name);
|
|
599
|
+
walk(child, childSkip);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
walk(cloned, /* @__PURE__ */ new Set());
|
|
603
|
+
}
|
|
604
|
+
return cloned;
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region ../utils/src/terminal.ts
|
|
608
|
+
const STORAGE_KEY = Symbol.for("crustjs.terminal.io");
|
|
609
|
+
const AMBIENT_CALLBACKS_KEY = Symbol.for("crustjs.terminal.ambient-callbacks");
|
|
610
|
+
const globalWithStorage = globalThis;
|
|
611
|
+
const globalWithAmbientCallbacks = globalThis;
|
|
612
|
+
/** Bundled copies in Core, Prompts, and Progress share process-wide terminal state. */
|
|
613
|
+
const storage = globalWithStorage[STORAGE_KEY] ??= new AsyncLocalStorage();
|
|
614
|
+
const ambientCallbacks = globalWithAmbientCallbacks[AMBIENT_CALLBACKS_KEY] ??= /* @__PURE__ */ new WeakMap();
|
|
615
|
+
/** Run a function with terminal streams available in its async scope. */
|
|
616
|
+
function withTerminalIO(io, fn) {
|
|
617
|
+
const current = storage.getStore();
|
|
618
|
+
return storage.run({
|
|
619
|
+
input: io.input ?? current?.input,
|
|
620
|
+
output: io.output ?? current?.output
|
|
621
|
+
}, fn);
|
|
622
|
+
}
|
|
623
|
+
function lineBufferedOutput(io) {
|
|
624
|
+
let pending = "";
|
|
625
|
+
const output = new Writable({
|
|
626
|
+
decodeStrings: false,
|
|
627
|
+
write(chunk, _encoding, callback) {
|
|
628
|
+
pending += chunk.toString();
|
|
629
|
+
let newline = pending.indexOf("\n");
|
|
630
|
+
while (newline !== -1) {
|
|
631
|
+
io.stderr(pending.slice(0, newline));
|
|
632
|
+
pending = pending.slice(newline + 1);
|
|
633
|
+
newline = pending.indexOf("\n");
|
|
634
|
+
}
|
|
635
|
+
callback();
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
ambientCallbacks.set(output, io);
|
|
639
|
+
return output;
|
|
640
|
+
}
|
|
641
|
+
/** Run a function with Core's line-oriented output bridged into the terminal stream scope. */
|
|
642
|
+
function withAmbientTerminalIO(io, fn) {
|
|
643
|
+
const current = storage.getStore();
|
|
644
|
+
return withTerminalIO({
|
|
645
|
+
input: current?.input,
|
|
646
|
+
output: current?.output && !ambientCallbacks.has(current.output) ? current.output : lineBufferedOutput(io)
|
|
647
|
+
}, fn);
|
|
648
|
+
}
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region ../utils/src/primitive.ts
|
|
651
|
+
/**
|
|
652
|
+
* Attempts to coerce a string to a number, returning `undefined` only when the
|
|
653
|
+
* result is `NaN`. Callers decide whether `undefined` means throw or fallback.
|
|
654
|
+
*
|
|
655
|
+
* @example
|
|
656
|
+
* ```ts
|
|
657
|
+
* tryCoerceNumber("42"); // 42
|
|
658
|
+
* tryCoerceNumber("abc"); // undefined
|
|
659
|
+
* tryCoerceNumber(""); // 0
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
function tryCoerceNumber(raw) {
|
|
663
|
+
const num = Number(raw);
|
|
664
|
+
return Number.isNaN(num) ? void 0 : num;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Coerces Crust boolean strings using the existing strict truthy spellings.
|
|
668
|
+
*
|
|
669
|
+
* @example
|
|
670
|
+
* ```ts
|
|
671
|
+
* coerceBooleanString("true"); // true
|
|
672
|
+
* coerceBooleanString("1"); // true
|
|
673
|
+
* coerceBooleanString("false"); // false
|
|
674
|
+
* ```
|
|
675
|
+
*/
|
|
676
|
+
function coerceBooleanString(raw) {
|
|
677
|
+
return raw === "true" || raw === "1";
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/parsing/coercers.ts
|
|
681
|
+
/**
|
|
682
|
+
* Coerce a raw argv string into a {@link URL} instance via the WHATWG
|
|
683
|
+
* `URL` parser. Any protocol accepted by `new URL()` (https, http, file,
|
|
684
|
+
* ftp, …) is allowed.
|
|
685
|
+
*
|
|
686
|
+
* Throws `CrustError("PARSE", …)` when the input is not a valid URL.
|
|
687
|
+
* The original input is echoed in the message. When the input clearly
|
|
688
|
+
* lacks a URL scheme, we append a hint reminding the user to include
|
|
689
|
+
* one — a common foot-gun on the command line.
|
|
690
|
+
*/
|
|
691
|
+
function coerceUrl(raw) {
|
|
692
|
+
try {
|
|
693
|
+
return new URL(raw);
|
|
694
|
+
} catch {
|
|
695
|
+
throw new CrustError("PARSE", `Invalid URL "${raw}"${/^[a-z][a-z0-9+.-]*:/i.test(raw) ? "" : " (missing protocol — e.g. https://example.com)"}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Coerce a raw argv string into an absolute filesystem path.
|
|
700
|
+
*
|
|
701
|
+
* Steps:
|
|
702
|
+
* 1. Reject empty input.
|
|
703
|
+
* 2. Expand a leading `~` (followed by `/` or end-of-string) to the user's
|
|
704
|
+
* home directory. `~username` is intentionally NOT expanded.
|
|
705
|
+
* 3. Resolve against `process.cwd()` so the result is always absolute.
|
|
706
|
+
*
|
|
707
|
+
* Path-traversal (`..`) is allowed — coercion does not sandbox.
|
|
708
|
+
*/
|
|
709
|
+
function coercePath(raw) {
|
|
710
|
+
if (raw === "") throw new CrustError("PARSE", "Path cannot be empty");
|
|
711
|
+
const expanded = raw.replace(/^~(?=\/|$)/, homedir());
|
|
712
|
+
return resolve(process.cwd(), expanded);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Coerce a raw argv string into a parsed JSON value (`JsonValue`) via
|
|
716
|
+
* `JSON.parse`. Any valid JSON document is accepted — objects, arrays,
|
|
717
|
+
* strings, numbers, booleans, null.
|
|
718
|
+
*
|
|
719
|
+
* Throws `CrustError("PARSE", …)` when the input is not valid JSON.
|
|
720
|
+
* The original `SyntaxError.message` is included plus a shell-quoting
|
|
721
|
+
* hint, since unquoted JSON on the command line is a common foot-gun.
|
|
722
|
+
*
|
|
723
|
+
* Note: `JSON.parse` loses precision on integers above `Number.MAX_SAFE_INTEGER`.
|
|
724
|
+
*/
|
|
725
|
+
function coerceJson(raw) {
|
|
726
|
+
try {
|
|
727
|
+
return JSON.parse(raw);
|
|
728
|
+
} catch (err) {
|
|
729
|
+
throw new CrustError("PARSE", `Invalid JSON: ${err instanceof Error ? err.message : String(err)}. Tip: wrap JSON in single quotes on the command line, e.g. --flag '{"k":1}'`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
//#endregion
|
|
733
|
+
//#region src/parsing/parser.ts
|
|
734
|
+
/**
|
|
735
|
+
* Build the options config for `util.parseArgs` from the shared spelling table.
|
|
736
|
+
* Also returns a reverse alias→name mapping for resolving parsed results.
|
|
737
|
+
*/
|
|
738
|
+
function buildParseArgsOptionDescriptor(spellings) {
|
|
739
|
+
const options = {};
|
|
740
|
+
const aliasToName = {};
|
|
741
|
+
for (const [spelling, entry] of spellings) {
|
|
742
|
+
const descriptor = { type: entry.def.type === "boolean" ? "boolean" : "string" };
|
|
743
|
+
if (entry.def.multiple) descriptor.multiple = true;
|
|
744
|
+
if (entry.kind === "canonical") {
|
|
745
|
+
if (entry.def.short) descriptor.short = entry.def.short;
|
|
746
|
+
options[spelling] = descriptor;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
aliasToName[spelling] = entry.canonicalName;
|
|
750
|
+
if (entry.kind === "alias") options[spelling] = descriptor;
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
options,
|
|
754
|
+
aliasToName
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Coerce a string value to the expected type based on the type literal.
|
|
759
|
+
*/
|
|
760
|
+
function coerceValue(value, type, label) {
|
|
761
|
+
if (type === "number") {
|
|
762
|
+
const num = tryCoerceNumber(value);
|
|
763
|
+
if (num === void 0) throw new CrustError("PARSE", `Expected number for ${label}, got "${value}"`);
|
|
764
|
+
return num;
|
|
765
|
+
}
|
|
766
|
+
if (type === "boolean") return coerceBooleanString(value);
|
|
767
|
+
if (type === "url") return coerceUrl(value);
|
|
768
|
+
if (type === "path") return coercePath(value);
|
|
769
|
+
if (type === "json") return coerceJson(value);
|
|
770
|
+
return value;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Validate a raw argv string against a flag/arg `choices` list. Throws
|
|
774
|
+
* `CrustError("PARSE", …)` when the value is not in the allowed set.
|
|
775
|
+
*
|
|
776
|
+
* Runs *before* any `parse` transform so the user-facing comparison is on
|
|
777
|
+
* the raw token, not the post-`parse` value.
|
|
778
|
+
*/
|
|
779
|
+
function validateChoice(raw, choices, label) {
|
|
780
|
+
if (!choices.includes(raw)) throw new CrustError("PARSE", `Invalid value "${raw}" for ${label}. Expected one of: ${choices.join(", ")}`);
|
|
781
|
+
}
|
|
782
|
+
/** Invoke a user `parse` function on a raw token, wrapping errors. */
|
|
783
|
+
function invokeParse(parse, raw, label, index) {
|
|
784
|
+
const location = index === void 0 ? label : `${label} element [${index}]`;
|
|
785
|
+
let result;
|
|
786
|
+
try {
|
|
787
|
+
result = parse(raw);
|
|
788
|
+
if (isPromise(result)) {
|
|
789
|
+
result.catch(() => {});
|
|
790
|
+
throw new Error("parse must be synchronous");
|
|
791
|
+
}
|
|
792
|
+
} catch (err) {
|
|
793
|
+
throw new CrustError("PARSE", `Failed to parse ${location}: ${err instanceof Error ? err.message : String(err)}`).withCause(err);
|
|
794
|
+
}
|
|
795
|
+
return result;
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Resolve a flag/arg default to its runtime value, mirroring the argv-side
|
|
799
|
+
* coercion pipeline so omitted-flag behavior matches user-supplied behavior:
|
|
800
|
+
*
|
|
801
|
+
* raw default → parse | coerce → result
|
|
802
|
+
*
|
|
803
|
+
* Without this, `{ type: "path", default: "./dist" }` returns the raw
|
|
804
|
+
* relative string while `--out ./dist` returns an absolute path.
|
|
805
|
+
*
|
|
806
|
+
* `parse` is preferred when present (matches the escape-hatch contract).
|
|
807
|
+
* `type: "path"` defaults are coerced through `coercePath` because their
|
|
808
|
+
* default field is a raw string per `PathFlagDef`/`PathArgDef`. `url` and
|
|
809
|
+
* `json` defaults are already in their resolved form (`URL` / `unknown`)
|
|
810
|
+
* per the variant interfaces, so they pass through unchanged.
|
|
811
|
+
*/
|
|
812
|
+
function resolveDefault(def, label) {
|
|
813
|
+
const { default: defaultValue, parse } = def;
|
|
814
|
+
if (defaultValue === void 0) return void 0;
|
|
815
|
+
if (parse) {
|
|
816
|
+
if (Array.isArray(defaultValue)) return defaultValue.map((v, i) => invokeParse(parse, String(v), label, i));
|
|
817
|
+
return invokeParse(parse, String(defaultValue), label);
|
|
818
|
+
}
|
|
819
|
+
if (def.type === "path") {
|
|
820
|
+
if (Array.isArray(defaultValue)) return defaultValue.map((v) => coercePath(String(v)));
|
|
821
|
+
return coercePath(String(defaultValue));
|
|
822
|
+
}
|
|
823
|
+
return "multiple" in def && def.multiple && Array.isArray(defaultValue) ? [...defaultValue] : defaultValue;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Coerce a single flag's parsed value to its target type.
|
|
827
|
+
*
|
|
828
|
+
* Order on string-typed flags with `choices` and/or `parse`:
|
|
829
|
+
* raw token → choices validation → parse transform (if set) → result.
|
|
830
|
+
* For multi-value flags both steps run per element.
|
|
831
|
+
*/
|
|
832
|
+
function coerceFlagValue(name, def, parsed) {
|
|
833
|
+
if (parsed.kind === "boolean") return parsed.value;
|
|
834
|
+
const label = `--${name}`;
|
|
835
|
+
const coerce = (value, index) => {
|
|
836
|
+
if (def.choices) validateChoice(value, def.choices, label);
|
|
837
|
+
if (def.parse) return invokeParse(def.parse, value, label, index);
|
|
838
|
+
return coerceValue(value, def.type, label);
|
|
839
|
+
};
|
|
840
|
+
return Array.isArray(parsed.value) ? parsed.value.map(coerce) : coerce(parsed.value);
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Resolve parsed option tokens to canonical flag names, in argv order.
|
|
844
|
+
*
|
|
845
|
+
* Works from `parsed.tokens` rather than `parsed.values` because
|
|
846
|
+
* `util.parseArgs` groups values by option key: with aliases, the last *key*
|
|
847
|
+
* would win instead of the last *token* (`--verbose --no-loud --verbose`
|
|
848
|
+
* must be `true`), and `multiple` flags spread across aliases would lose
|
|
849
|
+
* their interleaved argv order.
|
|
850
|
+
*/
|
|
851
|
+
function resolveAliases(tokens, aliasToName, flagsDef) {
|
|
852
|
+
const canonical = {};
|
|
853
|
+
for (const token of tokens) {
|
|
854
|
+
if (token.kind !== "option") continue;
|
|
855
|
+
const canonicalName = aliasToName[token.name] ?? token.name;
|
|
856
|
+
const def = flagsDef[canonicalName];
|
|
857
|
+
const existing = canonical[canonicalName];
|
|
858
|
+
if (def.type === "boolean") {
|
|
859
|
+
const value = !token.rawName.startsWith("--no-");
|
|
860
|
+
if (def.multiple && existing?.kind === "boolean" && Array.isArray(existing.value)) existing.value.push(value);
|
|
861
|
+
else canonical[canonicalName] = {
|
|
862
|
+
kind: "boolean",
|
|
863
|
+
value: def.multiple ? [value] : value
|
|
864
|
+
};
|
|
865
|
+
} else {
|
|
866
|
+
const value = token.value;
|
|
867
|
+
if (def.multiple && existing?.kind === "string" && Array.isArray(existing.value)) existing.value.push(value);
|
|
868
|
+
else canonical[canonicalName] = {
|
|
869
|
+
kind: "string",
|
|
870
|
+
value: def.multiple ? [value] : value
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return canonical;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Resolve all flag definitions against the canonical parsed values.
|
|
878
|
+
* Handles coercion and default values.
|
|
879
|
+
*/
|
|
880
|
+
function resolveFlags(flagsDef, values, coerce) {
|
|
881
|
+
const resolved = {};
|
|
882
|
+
for (const name of Object.keys(values)) if (!Object.hasOwn(flagsDef, name) && values[name] !== void 0) throw new CrustError("PARSE", `Unknown flag "--${name}"`, {
|
|
883
|
+
flag: name,
|
|
884
|
+
reason: "unknown-flag"
|
|
885
|
+
});
|
|
886
|
+
for (const [name, def] of Object.entries(flagsDef)) {
|
|
887
|
+
const parsedValue = Object.hasOwn(values, name) ? values[name] : void 0;
|
|
888
|
+
if (!(parsedValue === void 0 || def.multiple && Array.isArray(parsedValue) && parsedValue.length === 0)) {
|
|
889
|
+
resolved[name] = coerce(name, def, parsedValue);
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
resolved[name] = resolveDefault(def, `--${name}`);
|
|
893
|
+
}
|
|
894
|
+
return resolved;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Validate required flags against already-resolved flag values.
|
|
898
|
+
*/
|
|
899
|
+
function validateRequiredFlags(flagsDef, resolvedFlags) {
|
|
900
|
+
for (const [name, def] of Object.entries(flagsDef)) if (def.required === true && def.default === void 0) {
|
|
901
|
+
if (resolvedFlags[name] === void 0) throw new CrustError("VALIDATION", `Missing required flag "--${name}"`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
function coerceArgToken(def, raw, label, index) {
|
|
905
|
+
if (def.schema) return raw;
|
|
906
|
+
if (def.choices) validateChoice(raw, def.choices, label);
|
|
907
|
+
if (def.parse) return invokeParse(def.parse, raw, label, index);
|
|
908
|
+
return coerceValue(raw, def.type, label);
|
|
909
|
+
}
|
|
910
|
+
function resolveArgs(argsDef, positionals, coerce) {
|
|
911
|
+
const resolved = {};
|
|
912
|
+
let index = 0;
|
|
913
|
+
for (const def of argsDef) {
|
|
914
|
+
const { name } = def;
|
|
915
|
+
const label = `<${name}>`;
|
|
916
|
+
if (def.variadic) {
|
|
917
|
+
resolved[name] = positionals.slice(index).map((v, i) => coerce(def, v, label, i));
|
|
918
|
+
index = positionals.length;
|
|
919
|
+
} else if (index < positionals.length) {
|
|
920
|
+
resolved[name] = coerce(def, positionals[index], label);
|
|
921
|
+
index++;
|
|
922
|
+
} else resolved[name] = resolveDefault(def, label);
|
|
923
|
+
}
|
|
924
|
+
return {
|
|
925
|
+
args: resolved,
|
|
926
|
+
consumed: index
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Enforce `noNegate` at parse time.
|
|
931
|
+
*
|
|
932
|
+
* `--no-<spelling>` works for the canonical name and every long alias
|
|
933
|
+
* (an alias is a perfect synonym), but a boolean that
|
|
934
|
+
* opted out via `noNegate` rejects every negated spelling. Without this
|
|
935
|
+
* pre-scan, `util.parseArgs` (`allowNegative`) would silently accept it.
|
|
936
|
+
*/
|
|
937
|
+
function validateNoNegateUsage(argv, spellings) {
|
|
938
|
+
for (const arg of argv) {
|
|
939
|
+
if (arg === "--") return;
|
|
940
|
+
if (!arg.startsWith("--no-")) continue;
|
|
941
|
+
const assignmentIndex = arg.indexOf("=");
|
|
942
|
+
const rawName = assignmentIndex === -1 ? arg.slice(5) : arg.slice(5, assignmentIndex);
|
|
943
|
+
const spelling = spellings.get(rawName);
|
|
944
|
+
if (!spelling || spelling.def.type !== "boolean" || spelling.negatable) continue;
|
|
945
|
+
throw new CrustError("PARSE", `Flag "--${spelling.canonicalName}" does not support negation ("--no-${rawName}")`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function tokenizeArgv(command, argv) {
|
|
949
|
+
const spellings = command.flagSpellings;
|
|
950
|
+
const { options: parseOptions, aliasToName } = buildParseArgsOptionDescriptor(spellings);
|
|
951
|
+
validateNoNegateUsage(argv, spellings);
|
|
952
|
+
let parsed;
|
|
953
|
+
try {
|
|
954
|
+
parsed = parseArgs({
|
|
955
|
+
args: argv,
|
|
956
|
+
options: parseOptions,
|
|
957
|
+
strict: true,
|
|
958
|
+
allowPositionals: true,
|
|
959
|
+
allowNegative: true,
|
|
960
|
+
tokens: true
|
|
961
|
+
});
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (error instanceof Error) {
|
|
964
|
+
const unknownMatch = error.message.match(/Unknown option '(.+?)'/);
|
|
965
|
+
if (unknownMatch) throw new CrustError("PARSE", `Unknown flag "${unknownMatch[1]}"`).withCause(error);
|
|
966
|
+
if ("code" in error && error.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && error.message.length > 0) throw new CrustError("PARSE", error.message).withCause(error);
|
|
967
|
+
}
|
|
968
|
+
throw new CrustError("PARSE", "Failed to parse command arguments").withCause(error);
|
|
969
|
+
}
|
|
970
|
+
const rawArgs = [];
|
|
971
|
+
const preSeparatorPositionals = [];
|
|
972
|
+
let afterSeparator = false;
|
|
973
|
+
for (const token of parsed.tokens) {
|
|
974
|
+
if (token.kind === "option-terminator") {
|
|
975
|
+
afterSeparator = true;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (token.kind === "positional") (afterSeparator ? rawArgs : preSeparatorPositionals).push(token.value);
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
positionals: preSeparatorPositionals,
|
|
982
|
+
flagValues: resolveAliases(parsed.tokens, aliasToName, command.effectiveFlags),
|
|
983
|
+
rawArgs
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
function validateStructuredValue(def, value, label) {
|
|
987
|
+
const type = def.schema ? def.type === "boolean" ? "boolean" : "string" : def.type;
|
|
988
|
+
let valid;
|
|
989
|
+
if (type === "url") valid = value instanceof URL;
|
|
990
|
+
else if (type === "json") {
|
|
991
|
+
const pending = [value];
|
|
992
|
+
const seen = /* @__PURE__ */ new Set();
|
|
993
|
+
valid = true;
|
|
994
|
+
while (pending.length > 0) {
|
|
995
|
+
const item = pending.pop();
|
|
996
|
+
if (item instanceof URL) {
|
|
997
|
+
valid = false;
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
if (Array.isArray(item) && !seen.has(item)) {
|
|
1001
|
+
seen.add(item);
|
|
1002
|
+
pending.push(...item);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
} else valid = typeof value === (type === "path" ? "string" : type);
|
|
1006
|
+
if (!valid) throw new CrustError("PARSE", `Expected ${type} for ${label}`);
|
|
1007
|
+
if (value === false && "noNegate" in def && def.noNegate) throw new CrustError("PARSE", `Flag "${label}" does not support negation`);
|
|
1008
|
+
}
|
|
1009
|
+
/** Validate supplied structured values before applying transforms. */
|
|
1010
|
+
function coerceStructuredValue(def, value, label, index) {
|
|
1011
|
+
validateStructuredValue(def, value, label);
|
|
1012
|
+
if (def.choices) validateChoice(String(value), def.choices, label);
|
|
1013
|
+
if (def.parse) return invokeParse(def.parse, String(value), label, index);
|
|
1014
|
+
if (def.type === "path") return coercePath(String(value));
|
|
1015
|
+
return value;
|
|
1016
|
+
}
|
|
1017
|
+
function coerceStructuredFlag(name, def, value) {
|
|
1018
|
+
const label = `--${name}`;
|
|
1019
|
+
if (def.multiple) {
|
|
1020
|
+
if (!Array.isArray(value)) throw new CrustError("PARSE", `Expected an occurrence array for ${label}`);
|
|
1021
|
+
return value.map((item, i) => coerceStructuredValue(def, item, label, i));
|
|
1022
|
+
}
|
|
1023
|
+
return coerceStructuredValue(def, value, label);
|
|
1024
|
+
}
|
|
1025
|
+
/** Both front doors share binding, defaults, and canonical flag validation. */
|
|
1026
|
+
function bind(command, positionals, flagValues, coerceArg, coerceFlag) {
|
|
1027
|
+
const flags = resolveFlags(command.effectiveFlags, flagValues, coerceFlag);
|
|
1028
|
+
return {
|
|
1029
|
+
...resolveArgs(command.args, positionals, coerceArg),
|
|
1030
|
+
flags
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Parse argv against a command's arg/flag definitions.
|
|
1035
|
+
*
|
|
1036
|
+
* Wraps Node's `util.parseArgs` with Crust's enhanced semantics:
|
|
1037
|
+
* positional arg mapping, type coercion, alias expansion, default values,
|
|
1038
|
+
* variadic args, and strict mode.
|
|
1039
|
+
*
|
|
1040
|
+
* This is a pure parse+coerce function — it never throws for missing required
|
|
1041
|
+
* values. Use {@link validateParsed} to enforce required constraints after
|
|
1042
|
+
* extensions have had a chance to finish an invocation (e.g. `--help`).
|
|
1043
|
+
*
|
|
1044
|
+
* @param command - The command whose arg/flag definitions drive the parsing
|
|
1045
|
+
* @param argv - The argv array to parse (typically `process.argv.slice(2)`)
|
|
1046
|
+
* @returns Parsed args, flags, excessArgs (positionals before `--` not consumed by a declared argument), and rawArgs (everything after `--`)
|
|
1047
|
+
* @throws {CrustError} On unknown flags or type coercion failure
|
|
1048
|
+
*/
|
|
1049
|
+
function parseArgs$1(command, argv) {
|
|
1050
|
+
const { positionals, flagValues, rawArgs } = tokenizeArgv(command, argv);
|
|
1051
|
+
const { args, flags, consumed } = bind(command, positionals, flagValues, coerceArgToken, coerceFlagValue);
|
|
1052
|
+
return {
|
|
1053
|
+
args,
|
|
1054
|
+
flags,
|
|
1055
|
+
excessArgs: positionals.slice(consumed),
|
|
1056
|
+
rawArgs
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
/** Bind typed input without producing argv; the path alone selects the command. */
|
|
1060
|
+
function parseStructured(command, input) {
|
|
1061
|
+
const { args: inputArgs, flags: inputFlags, raw } = input;
|
|
1062
|
+
const positionals = [];
|
|
1063
|
+
let omittedArgument;
|
|
1064
|
+
for (const definition of command.args) {
|
|
1065
|
+
const value = inputArgs && Object.hasOwn(inputArgs, definition.name) ? inputArgs[definition.name] : void 0;
|
|
1066
|
+
if (value === void 0) {
|
|
1067
|
+
omittedArgument = definition.name;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (omittedArgument !== void 0) throw new CrustError("PARSE", `Argument <${definition.name}> cannot be provided after omitted argument <${omittedArgument}>`, {
|
|
1071
|
+
argument: definition.name,
|
|
1072
|
+
reason: "positional-gap"
|
|
1073
|
+
});
|
|
1074
|
+
if (definition.variadic) {
|
|
1075
|
+
if (!Array.isArray(value)) throw new CrustError("PARSE", `Expected an occurrence array for <${definition.name}>`);
|
|
1076
|
+
positionals.push(...value);
|
|
1077
|
+
} else positionals.push(value);
|
|
1078
|
+
}
|
|
1079
|
+
for (const name of Object.keys(inputArgs ?? {})) if (!command.args.some((definition) => definition.name === name) && inputArgs?.[name] !== void 0) throw new CrustError("PARSE", `Unknown argument "${name}"`, {
|
|
1080
|
+
argument: name,
|
|
1081
|
+
reason: "unknown-argument"
|
|
1082
|
+
});
|
|
1083
|
+
const { args, flags } = bind(command, positionals, inputFlags ?? {}, coerceStructuredValue, coerceStructuredFlag);
|
|
1084
|
+
return {
|
|
1085
|
+
args,
|
|
1086
|
+
flags,
|
|
1087
|
+
excessArgs: [],
|
|
1088
|
+
rawArgs: [...raw ?? []]
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Validate a parse result against its command's required-value constraints.
|
|
1093
|
+
*
|
|
1094
|
+
* Separated from {@link parseArgs} so that middleware (e.g. `--help`) can
|
|
1095
|
+
* inspect the parse result before validation errors are surfaced.
|
|
1096
|
+
*
|
|
1097
|
+
* @param command - The command whose definitions drive the validation
|
|
1098
|
+
* @param parsed - The parse result from {@link parseArgs}
|
|
1099
|
+
* @throws {CrustError} On missing required args or flags
|
|
1100
|
+
*/
|
|
1101
|
+
function validateParsed(command, parsed) {
|
|
1102
|
+
const argsDef = command.args;
|
|
1103
|
+
const flagsDef = command.effectiveFlags;
|
|
1104
|
+
const args = parsed.args;
|
|
1105
|
+
const flags = parsed.flags;
|
|
1106
|
+
if (parsed.excessArgs.length > 0) throw new CrustError("VALIDATION", `Unexpected positional argument${parsed.excessArgs.length === 1 ? "" : "s"}: ${parsed.excessArgs.map((arg) => JSON.stringify(arg)).join(", ")}`);
|
|
1107
|
+
for (const def of argsDef) {
|
|
1108
|
+
const { name } = def;
|
|
1109
|
+
const label = `argument "<${name}>"`;
|
|
1110
|
+
const value = args[name];
|
|
1111
|
+
if (def.required === true && def.default === void 0) {
|
|
1112
|
+
if (def.variadic) {
|
|
1113
|
+
if (!Array.isArray(value) || value.length === 0) throw new CrustError("VALIDATION", `Missing required ${label}`);
|
|
1114
|
+
} else if (value === void 0) throw new CrustError("VALIDATION", `Missing required ${label}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
validateRequiredFlags(flagsDef, flags);
|
|
1118
|
+
}
|
|
1119
|
+
//#endregion
|
|
1120
|
+
//#region ../utils/src/schema.ts
|
|
1121
|
+
/**
|
|
1122
|
+
* Format an issue path into a dot-path string.
|
|
1123
|
+
*
|
|
1124
|
+
* - Numeric keys (array indexes) are rendered with bracket notation: `items[0]`
|
|
1125
|
+
* - String/symbol keys are joined with dots: `flags.verbose`
|
|
1126
|
+
* - An empty path array produces an empty string (root-level issue)
|
|
1127
|
+
*
|
|
1128
|
+
* @example
|
|
1129
|
+
* ```ts
|
|
1130
|
+
* formatPath(["flags", "verbose"]);
|
|
1131
|
+
* // => "flags.verbose"
|
|
1132
|
+
*
|
|
1133
|
+
* formatPath(["args", 0]);
|
|
1134
|
+
* // => "args[0]"
|
|
1135
|
+
*
|
|
1136
|
+
* formatPath([]);
|
|
1137
|
+
* // => ""
|
|
1138
|
+
* ```
|
|
1139
|
+
*/
|
|
1140
|
+
function isNumericPathSegment(segment) {
|
|
1141
|
+
return typeof segment === "number";
|
|
1142
|
+
}
|
|
1143
|
+
function formatPath(path) {
|
|
1144
|
+
return path.map((segment, index) => isNumericPathSegment(segment) ? `[${segment}]` : index > 0 ? `.${String(segment)}` : String(segment)).join("");
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Normalize a Standard Schema issue path to an array of `PropertyKey`.
|
|
1148
|
+
*
|
|
1149
|
+
* Standard Schema paths contain either bare `PropertyKey` values or
|
|
1150
|
+
* `{ key: PropertyKey }` segment objects; both forms are flattened to
|
|
1151
|
+
* plain `PropertyKey`. Returns an empty array for a root-level issue
|
|
1152
|
+
* (`undefined`).
|
|
1153
|
+
*/
|
|
1154
|
+
function isStandardPathSegment(segment) {
|
|
1155
|
+
return typeof segment === "object" && segment !== null && "key" in segment;
|
|
1156
|
+
}
|
|
1157
|
+
function normalizeStandardPath(path) {
|
|
1158
|
+
if (!path) return [];
|
|
1159
|
+
return path.map((segment) => isStandardPathSegment(segment) ? segment.key : segment);
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Normalize Standard Schema issues into canonical `ValidationIssue` objects.
|
|
1163
|
+
*
|
|
1164
|
+
* Applies an optional prefix (e.g. `["flags", "verbose"]`) to each issue
|
|
1165
|
+
* path, then formats each path to its canonical dot-path string.
|
|
1166
|
+
*
|
|
1167
|
+
* @param issues — Raw Standard Schema issues from a failed validation
|
|
1168
|
+
* @param prefix — Optional path segments prepended to each issue path
|
|
1169
|
+
*/
|
|
1170
|
+
function normalizeStandardIssues(issues, prefix = []) {
|
|
1171
|
+
return issues.map((issue) => {
|
|
1172
|
+
const resolvedPath = normalizeStandardPath(issue.path);
|
|
1173
|
+
const fullPath = [...prefix, ...resolvedPath];
|
|
1174
|
+
return {
|
|
1175
|
+
message: issue.message,
|
|
1176
|
+
path: formatPath(fullPath)
|
|
1177
|
+
};
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
//#endregion
|
|
1181
|
+
//#region src/parsing/schema.ts
|
|
1182
|
+
async function runSchema(schema, raw, path, issues) {
|
|
1183
|
+
let result = schema["~standard"].validate(raw);
|
|
1184
|
+
if (result instanceof Promise) result = await result;
|
|
1185
|
+
if (result.issues) {
|
|
1186
|
+
issues.push(...normalizeStandardIssues(result.issues, path));
|
|
1187
|
+
return { ok: false };
|
|
1188
|
+
}
|
|
1189
|
+
return {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
value: result.value
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Apply Standard Schemas declared on arg/flag definitions.
|
|
1196
|
+
*
|
|
1197
|
+
* Runs after syntax parsing and structural validation: each schema receives
|
|
1198
|
+
* the raw parsed value (`string | undefined` for args, the raw token value
|
|
1199
|
+
* for flags, arrays for variadic/multiple) and exclusively owns coercion,
|
|
1200
|
+
* defaults, requiredness, and validation. Returns transformed copies of
|
|
1201
|
+
* `args`/`flags`; definitions without a schema pass through unchanged.
|
|
1202
|
+
*
|
|
1203
|
+
* @throws {CrustError} `VALIDATION` aggregating every schema issue
|
|
1204
|
+
*/
|
|
1205
|
+
async function applySchemas(node, parsed) {
|
|
1206
|
+
const issues = [];
|
|
1207
|
+
const args = new Map(Object.entries(parsed.args));
|
|
1208
|
+
const flags = new Map(Object.entries(parsed.flags));
|
|
1209
|
+
for (const def of node.args) {
|
|
1210
|
+
if (def.schema === void 0) continue;
|
|
1211
|
+
const result = await runSchema(def.schema, args.get(def.name), ["args", def.name], issues);
|
|
1212
|
+
if (result.ok) args.set(def.name, result.value);
|
|
1213
|
+
}
|
|
1214
|
+
for (const [name, def] of Object.entries(node.effectiveFlags)) {
|
|
1215
|
+
if (def.schema === void 0) continue;
|
|
1216
|
+
const result = await runSchema(def.schema, flags.get(name), ["flags", name], issues);
|
|
1217
|
+
if (result.ok) flags.set(name, result.value);
|
|
1218
|
+
}
|
|
1219
|
+
if (issues.length > 0) throw new CrustError("VALIDATION", `Invalid input:\n${issues.map((issue) => ` - ${issue.path}: ${issue.message}`).join("\n")}`, { issues });
|
|
1220
|
+
return {
|
|
1221
|
+
args: Object.fromEntries(args),
|
|
1222
|
+
flags: Object.fromEntries(flags)
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
//#endregion
|
|
1226
|
+
//#region src/sections.ts
|
|
1227
|
+
/** Whether a command belongs in user-facing listings. */
|
|
1228
|
+
function isListed(command) {
|
|
1229
|
+
return command.meta.hidden !== true;
|
|
1230
|
+
}
|
|
1231
|
+
/** Select and merge sections visible to the given consumer. */
|
|
1232
|
+
function sectionsFor(sections, consumer) {
|
|
1233
|
+
const visible = (sections ?? []).filter((section) => {
|
|
1234
|
+
if (section.only) return section.only.includes(consumer);
|
|
1235
|
+
if (section.except) return !section.except.includes(consumer);
|
|
1236
|
+
return true;
|
|
1237
|
+
});
|
|
1238
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1239
|
+
for (const section of visible) {
|
|
1240
|
+
const existing = merged.get(section.title);
|
|
1241
|
+
merged.set(section.title, existing ? {
|
|
1242
|
+
title: section.title,
|
|
1243
|
+
body: `${existing.body}\n${section.body}`
|
|
1244
|
+
} : section);
|
|
1245
|
+
}
|
|
1246
|
+
return [...merged.values()];
|
|
1247
|
+
}
|
|
1248
|
+
/** Collect section-bearing visible commands in canonical path order. The root path is `[]`. */
|
|
1249
|
+
function visibleSectionsFor(snapshot, consumer) {
|
|
1250
|
+
const groups = [];
|
|
1251
|
+
function visit(command, path) {
|
|
1252
|
+
const sections = sectionsFor(command.meta.sections, consumer);
|
|
1253
|
+
if (sections.length > 0) groups.push({
|
|
1254
|
+
path,
|
|
1255
|
+
sections
|
|
1256
|
+
});
|
|
1257
|
+
for (const [name, child] of Object.entries(command.subCommands).sort(([a], [b]) => a.localeCompare(b))) if (isListed(child)) visit(child, [...path, name]);
|
|
1258
|
+
}
|
|
1259
|
+
visit(snapshot, []);
|
|
1260
|
+
return groups;
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/command/snapshot.ts
|
|
1264
|
+
/** Drop keys with `undefined` values so snapshots serialize cleanly, then freeze. */
|
|
1265
|
+
function freezeCompact(obj) {
|
|
1266
|
+
for (const key of Object.keys(obj)) if (obj[key] === void 0) delete obj[key];
|
|
1267
|
+
return Object.freeze(obj);
|
|
1268
|
+
}
|
|
1269
|
+
function serializableDefault(value) {
|
|
1270
|
+
if (value instanceof URL) return value.href;
|
|
1271
|
+
if (Array.isArray(value)) return Object.freeze(value.map(serializableDefault));
|
|
1272
|
+
return value;
|
|
1273
|
+
}
|
|
1274
|
+
function snapshotArg(def) {
|
|
1275
|
+
return freezeCompact({
|
|
1276
|
+
name: def.name,
|
|
1277
|
+
type: def.type,
|
|
1278
|
+
description: def.description,
|
|
1279
|
+
required: def.required,
|
|
1280
|
+
variadic: def.variadic,
|
|
1281
|
+
choices: def.choices ? Object.freeze([...def.choices]) : void 0,
|
|
1282
|
+
default: serializableDefault(def.default)
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
function snapshotFlag(def) {
|
|
1286
|
+
return freezeCompact({
|
|
1287
|
+
type: def.type,
|
|
1288
|
+
description: def.description,
|
|
1289
|
+
short: def.short,
|
|
1290
|
+
aliases: def.aliases ? Object.freeze([...def.aliases]) : void 0,
|
|
1291
|
+
required: def.required,
|
|
1292
|
+
multiple: def.multiple,
|
|
1293
|
+
negatable: isFlagNegatable(def),
|
|
1294
|
+
noNegate: "noNegate" in def ? def.noNegate : void 0,
|
|
1295
|
+
choices: def.choices ? Object.freeze([...def.choices]) : void 0,
|
|
1296
|
+
default: serializableDefault(def.default)
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Project an internal command node (and its whole subtree) into a
|
|
1301
|
+
* {@link CommandSnapshot}.
|
|
1302
|
+
*/
|
|
1303
|
+
function snapshotCommand(node) {
|
|
1304
|
+
const flags = {};
|
|
1305
|
+
for (const [name, def] of Object.entries(node.effectiveFlags)) flags[name] = snapshotFlag(def);
|
|
1306
|
+
const subCommands = {};
|
|
1307
|
+
for (const [name, sub] of Object.entries(node.subCommands)) subCommands[name] = snapshotCommand(sub);
|
|
1308
|
+
return Object.freeze({
|
|
1309
|
+
meta: freezeCompact({
|
|
1310
|
+
name: node.meta.name,
|
|
1311
|
+
description: node.meta.description,
|
|
1312
|
+
version: node.meta.version,
|
|
1313
|
+
usage: node.meta.usage,
|
|
1314
|
+
sections: node.meta.sections ? Object.freeze(node.meta.sections.map((section) => Object.freeze({ ...section }))) : void 0,
|
|
1315
|
+
aliases: node.meta.aliases ? Object.freeze([...node.meta.aliases]) : void 0,
|
|
1316
|
+
hidden: node.meta.hidden
|
|
1317
|
+
}),
|
|
1318
|
+
hasAction: node.run !== void 0,
|
|
1319
|
+
args: Object.freeze(node.args.map(snapshotArg)),
|
|
1320
|
+
flags: Object.freeze(flags),
|
|
1321
|
+
subCommands: Object.freeze(subCommands)
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
//#endregion
|
|
1325
|
+
//#region src/command/router.ts
|
|
1326
|
+
/**
|
|
1327
|
+
* Find a sibling whose `meta.aliases` contains the given candidate. Returns
|
|
1328
|
+
* the canonical sibling key and node when matched, otherwise `null`.
|
|
1329
|
+
*
|
|
1330
|
+
* Resolution intentionally records the **canonical** key on the
|
|
1331
|
+
* `CommandRoute.commandPath`, never the alias the user typed. This is
|
|
1332
|
+
* load-bearing: error messages, help titles, and downstream extensions read
|
|
1333
|
+
* `commandPath` and assume canonical names.
|
|
1334
|
+
*/
|
|
1335
|
+
function findAliasMatch(subCommands, candidate) {
|
|
1336
|
+
for (const [name, node] of Object.entries(subCommands)) {
|
|
1337
|
+
const aliases = node.meta.aliases;
|
|
1338
|
+
if (!aliases) continue;
|
|
1339
|
+
if (aliases.includes(candidate)) return {
|
|
1340
|
+
canonicalName: name,
|
|
1341
|
+
node
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Match a dash token against the parser's shared spelling table.
|
|
1348
|
+
*
|
|
1349
|
+
* The token walk stays here because routing stops at unknown flags while
|
|
1350
|
+
* parsing reports them as errors; only spelling and negation policy is shared.
|
|
1351
|
+
*/
|
|
1352
|
+
function matchKnownFlagToken(spellings, token) {
|
|
1353
|
+
if (token === "--") return null;
|
|
1354
|
+
if (token.startsWith("--")) {
|
|
1355
|
+
const eq = token.indexOf("=");
|
|
1356
|
+
const spelling = eq === -1 ? token.slice(2) : token.slice(2, eq);
|
|
1357
|
+
const entry = spellings.get(spelling);
|
|
1358
|
+
if (entry) return { consumesValue: entry.def.type !== "boolean" && eq === -1 };
|
|
1359
|
+
if (spelling.startsWith("no-")) {
|
|
1360
|
+
if (spellings.get(spelling.slice(3))?.negatable) return { consumesValue: false };
|
|
1361
|
+
}
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
const chars = token.slice(1);
|
|
1365
|
+
if (chars.length === 0) return null;
|
|
1366
|
+
for (let index = 0; index < chars.length; index++) {
|
|
1367
|
+
const entry = spellings.get(chars[index]);
|
|
1368
|
+
if (!entry) return null;
|
|
1369
|
+
if (entry.def.type !== "boolean") return { consumesValue: index === chars.length - 1 };
|
|
1370
|
+
}
|
|
1371
|
+
return { consumesValue: false };
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Resolve a command from an argv array by walking the subcommand tree.
|
|
1375
|
+
*
|
|
1376
|
+
* Subcommand matching happens BEFORE flag parsing, so:
|
|
1377
|
+
* `crust build --entry src/cli.ts` first resolves "build" as a subcommand,
|
|
1378
|
+
* then passes `["--entry", "src/cli.ts"]` to the build command's parser.
|
|
1379
|
+
*
|
|
1380
|
+
* Resolution rules:
|
|
1381
|
+
* 1. If `argv[0]` matches a subcommand key, recurse into that subcommand
|
|
1382
|
+
* 2. If `argv[0]` matches a sibling's `meta.aliases` entry, recurse into
|
|
1383
|
+
* that sibling and record the **canonical** name in `commandPath`
|
|
1384
|
+
* 3. If no match and the current command has `run()`, return it (args passed to parser)
|
|
1385
|
+
* 3a. Known flags encountered before a subcommand name are set aside and
|
|
1386
|
+
* re-prepended for the resolved command's parser — but only if every
|
|
1387
|
+
* command routing descends into recognizes them with the same token
|
|
1388
|
+
* shape. A flag the subcommand cannot parse (e.g. a parent-local flag)
|
|
1389
|
+
* is a PARSE error at the descend, never a silent forward-then-fail.
|
|
1390
|
+
* 4. If no match and the current command has NO `run()`, it signals the caller
|
|
1391
|
+
* should show help (the `showHelp` flag is set in the result)
|
|
1392
|
+
* 5. Unknown subcommands produce a structured COMMAND_NOT_FOUND error whose
|
|
1393
|
+
* `details.available` lists visible canonical sibling names (aliases are
|
|
1394
|
+
* discoverable via `details.parentCommand.subCommands[name].meta.aliases`)
|
|
1395
|
+
*
|
|
1396
|
+
* Implementation: linear scan over siblings on miss. Command trees are small
|
|
1397
|
+
* and resolution runs once per invocation, so the cost is negligible compared
|
|
1398
|
+
* to building/freezing a parallel alias→canonical map. The scan does NOT
|
|
1399
|
+
* mutate `CommandNode`.
|
|
1400
|
+
*
|
|
1401
|
+
* @param command - The root command to resolve from
|
|
1402
|
+
* @param argv - The argv array to resolve against
|
|
1403
|
+
* @returns The resolved command, argv, and the command path
|
|
1404
|
+
* @throws {CrustError} COMMAND_NOT_FOUND when an unknown subcommand is given and the parent has no run()
|
|
1405
|
+
* @throws {CrustError} PARSE when a flag set aside during routing is not parseable by the subcommand being descended into
|
|
1406
|
+
*/
|
|
1407
|
+
function resolveCommand(command, argv) {
|
|
1408
|
+
const path = [command.meta.name];
|
|
1409
|
+
let current = command;
|
|
1410
|
+
let routedArgv = argv;
|
|
1411
|
+
const skippedFlagTokens = [];
|
|
1412
|
+
const skippedFlagChecks = [];
|
|
1413
|
+
const assertFlagsForwardable = (child, candidate) => {
|
|
1414
|
+
if (skippedFlagChecks.length === 0) return;
|
|
1415
|
+
const childSpellings = child.flagSpellings;
|
|
1416
|
+
for (const { token, consumesValue } of skippedFlagChecks) {
|
|
1417
|
+
const match = matchKnownFlagToken(childSpellings, token);
|
|
1418
|
+
if (match !== null && match.consumesValue === consumesValue) continue;
|
|
1419
|
+
throw new CrustError("PARSE", `Flag "${token}" cannot be used before subcommand "${candidate}" because "${candidate}" does not accept it.`, {
|
|
1420
|
+
flag: token,
|
|
1421
|
+
reason: "flag-not-forwardable"
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
while (routedArgv.length > 0) {
|
|
1426
|
+
const subCommands = current.subCommands;
|
|
1427
|
+
if (Object.keys(subCommands).length === 0) break;
|
|
1428
|
+
const candidate = routedArgv[0];
|
|
1429
|
+
if (!candidate) break;
|
|
1430
|
+
if (candidate.startsWith("-")) {
|
|
1431
|
+
const match = matchKnownFlagToken(current.flagSpellings, candidate);
|
|
1432
|
+
if (!match) break;
|
|
1433
|
+
skippedFlagTokens.push(candidate);
|
|
1434
|
+
skippedFlagChecks.push({
|
|
1435
|
+
token: candidate,
|
|
1436
|
+
consumesValue: match.consumesValue
|
|
1437
|
+
});
|
|
1438
|
+
routedArgv = routedArgv.slice(1);
|
|
1439
|
+
const value = routedArgv[0];
|
|
1440
|
+
if (match.consumesValue && value !== void 0) {
|
|
1441
|
+
skippedFlagTokens.push(value);
|
|
1442
|
+
routedArgv = routedArgv.slice(1);
|
|
1443
|
+
}
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (Object.hasOwn(subCommands, candidate) && subCommands[candidate]) {
|
|
1447
|
+
assertFlagsForwardable(subCommands[candidate], candidate);
|
|
1448
|
+
current = subCommands[candidate];
|
|
1449
|
+
path.push(candidate);
|
|
1450
|
+
routedArgv = routedArgv.slice(1);
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
const aliasMatch = findAliasMatch(subCommands, candidate);
|
|
1454
|
+
if (aliasMatch) {
|
|
1455
|
+
assertFlagsForwardable(aliasMatch.node, candidate);
|
|
1456
|
+
current = aliasMatch.node;
|
|
1457
|
+
path.push(aliasMatch.canonicalName);
|
|
1458
|
+
routedArgv = routedArgv.slice(1);
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (current.run) break;
|
|
1462
|
+
const parentCommand = snapshotCommand(current);
|
|
1463
|
+
throw new CrustError("COMMAND_NOT_FOUND", `Unknown command "${candidate}".`, {
|
|
1464
|
+
input: candidate,
|
|
1465
|
+
available: Object.entries(parentCommand.subCommands).flatMap(([name, child]) => isListed(child) ? [name] : []),
|
|
1466
|
+
commandPath: [...path],
|
|
1467
|
+
parentCommand
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
command: current,
|
|
1472
|
+
argv: [...skippedFlagTokens, ...routedArgv],
|
|
1473
|
+
commandPath: path
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
//#endregion
|
|
1477
|
+
//#region \0@oxc-project+runtime@0.148.0/helpers/esm/usingCtx.js
|
|
1478
|
+
function _usingCtx() {
|
|
1479
|
+
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
1480
|
+
var n = Error();
|
|
1481
|
+
return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
|
|
1482
|
+
}, e = {}, n = [];
|
|
1483
|
+
function using(r, e) {
|
|
1484
|
+
if (null != e) {
|
|
1485
|
+
if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
|
|
1486
|
+
if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
|
|
1487
|
+
if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
|
|
1488
|
+
if ("function" != typeof o) throw new TypeError("Object is not disposable.");
|
|
1489
|
+
t && (o = function o() {
|
|
1490
|
+
try {
|
|
1491
|
+
t.call(e);
|
|
1492
|
+
} catch (r) {
|
|
1493
|
+
return Promise.reject(r);
|
|
1494
|
+
}
|
|
1495
|
+
}), n.push({
|
|
1496
|
+
v: e,
|
|
1497
|
+
d: o,
|
|
1498
|
+
a: r
|
|
1499
|
+
});
|
|
1500
|
+
} else r && n.push({
|
|
1501
|
+
d: e,
|
|
1502
|
+
a: r
|
|
1503
|
+
});
|
|
1504
|
+
return e;
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
e,
|
|
1508
|
+
u: using.bind(null, !1),
|
|
1509
|
+
a: using.bind(null, !0),
|
|
1510
|
+
d: function d() {
|
|
1511
|
+
var o, t = this.e, s = 0;
|
|
1512
|
+
function next() {
|
|
1513
|
+
for (; o = n.pop();) try {
|
|
1514
|
+
if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
|
|
1515
|
+
if (o.d) {
|
|
1516
|
+
var r = o.d.call(o.v);
|
|
1517
|
+
if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
|
|
1518
|
+
} else s |= 1;
|
|
1519
|
+
} catch (r) {
|
|
1520
|
+
return err(r);
|
|
1521
|
+
}
|
|
1522
|
+
if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
|
|
1523
|
+
if (t !== e) throw t;
|
|
1524
|
+
}
|
|
1525
|
+
function err(n) {
|
|
1526
|
+
return t = t !== e ? new r(n, t) : n, next();
|
|
1527
|
+
}
|
|
1528
|
+
return next();
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1533
|
+
//#region src/command/invocation.ts
|
|
1534
|
+
/** Terminal defaults: line-oriented writes to the process streams. */
|
|
1535
|
+
const DEFAULT_IO = {
|
|
1536
|
+
stdout: (text) => console.log(text),
|
|
1537
|
+
stderr: (text) => console.error(text)
|
|
1538
|
+
};
|
|
1539
|
+
/**
|
|
1540
|
+
* Snapshot subprocess protocol used by first-party build tooling.
|
|
1541
|
+
*
|
|
1542
|
+
* When set to a non-empty file path, `.execute()` prepares the command tree,
|
|
1543
|
+
* validates its documentation sections, optionally runs Extension build hooks when the
|
|
1544
|
+
* build output directory is set, writes its final JSON snapshot and Build Report, and exits
|
|
1545
|
+
* without dispatching a Command Action. In-process callers use `Crust.snapshot()`.
|
|
1546
|
+
*/
|
|
1547
|
+
const SNAPSHOT_PATH_ENV = "CRUST_INTERNAL_SNAPSHOT_PATH";
|
|
1548
|
+
const BUILD_OUT_DIR_ENV = "CRUST_INTERNAL_BUILD_OUT_DIR";
|
|
1549
|
+
const EXIT_CODE_CANCELLED = 130;
|
|
1550
|
+
function isAbortError(error) {
|
|
1551
|
+
if (!(error instanceof Error)) return false;
|
|
1552
|
+
return error.name === "AbortError";
|
|
1553
|
+
}
|
|
1554
|
+
function freezeTree(node) {
|
|
1555
|
+
Object.freeze(node);
|
|
1556
|
+
Object.freeze(node.localFlags);
|
|
1557
|
+
Object.freeze(node.ownedFlags);
|
|
1558
|
+
Object.freeze(node.effectiveFlags);
|
|
1559
|
+
if (node.meta.sections) Object.freeze(node.meta.sections);
|
|
1560
|
+
Object.freeze(node.meta);
|
|
1561
|
+
Object.freeze(node.contexts);
|
|
1562
|
+
Object.freeze(node.extensions);
|
|
1563
|
+
Object.freeze(node.args);
|
|
1564
|
+
for (const sub of Object.values(node.subCommands)) freezeTree(sub);
|
|
1565
|
+
Object.freeze(node.subCommands);
|
|
1566
|
+
}
|
|
1567
|
+
function isSymbol(value) {
|
|
1568
|
+
return typeof value === "symbol";
|
|
1569
|
+
}
|
|
1570
|
+
function normalizeArtifactPath(path) {
|
|
1571
|
+
const normalized = posix.normalize(path.replaceAll("\\", "/"));
|
|
1572
|
+
const drivePrefix = /^[A-Za-z]:/;
|
|
1573
|
+
if (posix.isAbsolute(normalized) || win32.isAbsolute(path) || win32.isAbsolute(normalized) || drivePrefix.test(path) || drivePrefix.test(normalized)) throw new Error(`Artifact path "${path}" must be relative to outDir.`);
|
|
1574
|
+
if (normalized === ".." || normalized.startsWith("../")) throw new Error(`Artifact path "${path}" escapes outDir.`);
|
|
1575
|
+
if (normalized === "." || normalized === "./") throw new Error(`Artifact path "${path}" must name a file inside outDir.`);
|
|
1576
|
+
return normalized;
|
|
1577
|
+
}
|
|
1578
|
+
const preparedInvocations = /* @__PURE__ */ new WeakMap();
|
|
1579
|
+
/** Clone and apply Extension commands and flags; recipes run exactly once here. */
|
|
1580
|
+
function buildExtensionTree(node, materializeCommandDefinition) {
|
|
1581
|
+
const rootNode = cloneCommandNode(node);
|
|
1582
|
+
const extensions = Object.freeze([...node.extensions]);
|
|
1583
|
+
for (const extension of extensions) applyExtensionCommands(rootNode, extension, materializeCommandDefinition);
|
|
1584
|
+
for (const extension of extensions) applyExtensionFlags(rootNode, extension);
|
|
1585
|
+
return {
|
|
1586
|
+
rootNode,
|
|
1587
|
+
extensions
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
/** Evaluate Extension section callbacks against current state and freeze the tree. */
|
|
1591
|
+
function applySectionsAndFreeze(rootNode, extensions) {
|
|
1592
|
+
const authoredSnapshot = snapshotCommand(rootNode);
|
|
1593
|
+
for (const extension of extensions) applyExtensionSections(rootNode, extension, authoredSnapshot);
|
|
1594
|
+
freezeTree(rootNode);
|
|
1595
|
+
return rootNode;
|
|
1596
|
+
}
|
|
1597
|
+
/** Clone, apply Extensions and sections, freeze, and cache ordinary invocation preparation. */
|
|
1598
|
+
function prepareInvocation(node, materializeCommandDefinition) {
|
|
1599
|
+
const cached = preparedInvocations.get(node);
|
|
1600
|
+
if (cached) return cached;
|
|
1601
|
+
const prepared = buildExtensionTree(node, materializeCommandDefinition);
|
|
1602
|
+
applySectionsAndFreeze(prepared.rootNode, prepared.extensions);
|
|
1603
|
+
preparedInvocations.set(node, prepared);
|
|
1604
|
+
return prepared;
|
|
1605
|
+
}
|
|
1606
|
+
function resolveArgvInput(root, argv) {
|
|
1607
|
+
const route = resolveCommand(root, [...argv]);
|
|
1608
|
+
return {
|
|
1609
|
+
argv,
|
|
1610
|
+
route,
|
|
1611
|
+
parsed: parseArgs$1(route.command, route.argv)
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function resolveStructuredInput(root, path, input) {
|
|
1615
|
+
const route = resolveCommand(root, [...path]);
|
|
1616
|
+
if (route.argv.length > 0) {
|
|
1617
|
+
const candidate = route.argv[0];
|
|
1618
|
+
const parentCommand = snapshotCommand(route.command);
|
|
1619
|
+
throw new CrustError("COMMAND_NOT_FOUND", `Unknown command "${candidate}".`, {
|
|
1620
|
+
input: candidate,
|
|
1621
|
+
available: Object.entries(parentCommand.subCommands).flatMap(([name, child]) => isListed(child) ? [name] : []),
|
|
1622
|
+
commandPath: route.commandPath,
|
|
1623
|
+
parentCommand
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
return {
|
|
1627
|
+
argv: path,
|
|
1628
|
+
route,
|
|
1629
|
+
parsed: parseStructured(route.command, input)
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
/** Resolve, parse, and run one invocation without rendering failures. */
|
|
1633
|
+
async function dispatch(input, prepared, io, onExtensionContext, onFailure) {
|
|
1634
|
+
try {
|
|
1635
|
+
var _usingCtx$1 = _usingCtx();
|
|
1636
|
+
const { rootNode, extensions } = prepared;
|
|
1637
|
+
const { argv, route, parsed } = "argv" in input ? resolveArgvInput(rootNode, input.argv) : resolveStructuredInput(rootNode, input.path, input.input);
|
|
1638
|
+
const resolvedNode = route.command;
|
|
1639
|
+
const disposal = _usingCtx$1.a(new DisposalStack());
|
|
1640
|
+
const contexts = resolvedNode.contexts.map(({ instance }) => instance);
|
|
1641
|
+
const resolver = createContextResolver(contexts, io, disposal);
|
|
1642
|
+
const rootSnapshot = snapshotCommand(rootNode);
|
|
1643
|
+
const extensionContext = Object.freeze({
|
|
1644
|
+
argv: [...argv],
|
|
1645
|
+
rootCommand: rootSnapshot,
|
|
1646
|
+
command: resolvedNode === rootNode ? rootSnapshot : snapshotCommand(resolvedNode),
|
|
1647
|
+
commandPath: Object.freeze([...route.commandPath]),
|
|
1648
|
+
args: parsed.args,
|
|
1649
|
+
flags: parsed.flags,
|
|
1650
|
+
rawArgs: parsed.rawArgs,
|
|
1651
|
+
ctx: resolver.bag(extensions.flatMap((extension) => extension.uses ?? [])),
|
|
1652
|
+
finish: finishInvocation,
|
|
1653
|
+
stdout: io.stdout,
|
|
1654
|
+
stderr: io.stderr
|
|
1655
|
+
});
|
|
1656
|
+
onExtensionContext?.(extensionContext);
|
|
1657
|
+
const terminal = async () => {
|
|
1658
|
+
validateParsed(resolvedNode, parsed);
|
|
1659
|
+
const validated = await applySchemas(resolvedNode, parsed);
|
|
1660
|
+
resolver.setValidatedFlags(validated.flags);
|
|
1661
|
+
if (!resolvedNode.run) return;
|
|
1662
|
+
const context = {
|
|
1663
|
+
args: validated.args,
|
|
1664
|
+
flags: validated.flags,
|
|
1665
|
+
ctx: resolver.bag(contexts),
|
|
1666
|
+
rawArgs: parsed.rawArgs,
|
|
1667
|
+
command: extensionContext.command,
|
|
1668
|
+
rootCommand: rootSnapshot,
|
|
1669
|
+
stdout: io.stdout,
|
|
1670
|
+
stderr: io.stderr
|
|
1671
|
+
};
|
|
1672
|
+
return await resolvedNode.run(context);
|
|
1673
|
+
};
|
|
1674
|
+
let result;
|
|
1675
|
+
let outcome = { status: "completed" };
|
|
1676
|
+
try {
|
|
1677
|
+
try {
|
|
1678
|
+
for (const extension of extensions) if (await extension.hooks?.preRun?.(extensionContext) === finishInvocation()) {
|
|
1679
|
+
outcome = {
|
|
1680
|
+
status: "finished",
|
|
1681
|
+
by: extension.id
|
|
1682
|
+
};
|
|
1683
|
+
break;
|
|
1684
|
+
}
|
|
1685
|
+
if (outcome.status !== "finished") result = await terminal();
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
const by = await onFailure?.(error, extensionContext);
|
|
1688
|
+
outcome = {
|
|
1689
|
+
status: "failed",
|
|
1690
|
+
error,
|
|
1691
|
+
...by === void 0 ? {} : { by }
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
Object.freeze(outcome);
|
|
1695
|
+
let postRunFailed = false;
|
|
1696
|
+
let postRunError;
|
|
1697
|
+
for (const extension of extensions.toReversed()) try {
|
|
1698
|
+
await extension.hooks?.postRun?.(extensionContext, outcome);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
if (outcome.status !== "failed" && !postRunFailed) {
|
|
1701
|
+
postRunFailed = true;
|
|
1702
|
+
postRunError = error;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
if (outcome.status === "failed") throw outcome.error;
|
|
1706
|
+
if (postRunFailed) throw postRunError;
|
|
1707
|
+
} finally {
|
|
1708
|
+
await resolver.settle();
|
|
1709
|
+
}
|
|
1710
|
+
return outcome.status === "finished" ? {
|
|
1711
|
+
status: "finished",
|
|
1712
|
+
by: outcome.by
|
|
1713
|
+
} : {
|
|
1714
|
+
status: "completed",
|
|
1715
|
+
result
|
|
1716
|
+
};
|
|
1717
|
+
} catch (_) {
|
|
1718
|
+
_usingCtx$1.e = _;
|
|
1719
|
+
} finally {
|
|
1720
|
+
await _usingCtx$1.d();
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
/** Render one failure through Extension onError hooks, ending in Core's default renderer. */
|
|
1724
|
+
async function renderFailure(error, argv, prepared, io, extensionContext, silentDefault = false) {
|
|
1725
|
+
const renderDefault = () => {
|
|
1726
|
+
if (silentDefault) return;
|
|
1727
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1728
|
+
io.stderr(`Error: ${message}`);
|
|
1729
|
+
};
|
|
1730
|
+
function unavailable(property) {
|
|
1731
|
+
return Promise.reject(new CrustError("DEFINITION", `Context "${String(property)}" cannot be pulled from onError because invocation Contexts have already been disposed.`, {
|
|
1732
|
+
subject: "context",
|
|
1733
|
+
name: String(property),
|
|
1734
|
+
reason: "context-after-disposal"
|
|
1735
|
+
}));
|
|
1736
|
+
}
|
|
1737
|
+
const unavailableContext = new Proxy({}, { get: (_, property) => property === "then" || isSymbol(property) ? void 0 : unavailable(property) });
|
|
1738
|
+
const context = extensionContext ?? Object.freeze({
|
|
1739
|
+
argv: [...argv],
|
|
1740
|
+
rootCommand: snapshotCommand(prepared.rootNode),
|
|
1741
|
+
command: snapshotCommand(prepared.rootNode),
|
|
1742
|
+
commandPath: Object.freeze([prepared.rootNode.meta.name]),
|
|
1743
|
+
args: Object.freeze({}),
|
|
1744
|
+
flags: Object.freeze({}),
|
|
1745
|
+
rawArgs: [],
|
|
1746
|
+
finish: finishInvocation,
|
|
1747
|
+
stdout: io.stdout,
|
|
1748
|
+
stderr: io.stderr,
|
|
1749
|
+
ctx: unavailableContext
|
|
1750
|
+
});
|
|
1751
|
+
try {
|
|
1752
|
+
for (const extension of prepared.extensions) if (await extension.hooks?.onError?.(error, context)) return extension.id;
|
|
1753
|
+
} catch {}
|
|
1754
|
+
renderDefault();
|
|
1755
|
+
}
|
|
1756
|
+
/** Explicitly injected IO opts an invocation into the ambient terminal scope. */
|
|
1757
|
+
function hasInjectedIO(io) {
|
|
1758
|
+
return io !== void 0 && Object.keys(io).length > 0;
|
|
1759
|
+
}
|
|
1760
|
+
/** Quiet programmatic boundary: capture output and the failure escaping the complete lifecycle. */
|
|
1761
|
+
async function runInvocation(node, input, io, materializeCommandDefinition) {
|
|
1762
|
+
const stdout = [];
|
|
1763
|
+
const stderr = [];
|
|
1764
|
+
try {
|
|
1765
|
+
const sinks = { ...io };
|
|
1766
|
+
const resolvedIO = {
|
|
1767
|
+
stdout(text) {
|
|
1768
|
+
stdout.push(text);
|
|
1769
|
+
sinks.stdout?.(text);
|
|
1770
|
+
},
|
|
1771
|
+
stderr(text) {
|
|
1772
|
+
stderr.push(text);
|
|
1773
|
+
sinks.stderr?.(text);
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
return {
|
|
1777
|
+
...await withAmbientTerminalIO(resolvedIO, async () => {
|
|
1778
|
+
return await dispatch(input, prepareInvocation(node, materializeCommandDefinition), resolvedIO);
|
|
1779
|
+
}),
|
|
1780
|
+
stdout: stdout.join("\n"),
|
|
1781
|
+
stderr: stderr.join("\n")
|
|
1782
|
+
};
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
return {
|
|
1785
|
+
status: "failed",
|
|
1786
|
+
error,
|
|
1787
|
+
stdout: stdout.join("\n"),
|
|
1788
|
+
stderr: stderr.join("\n")
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
/** Terminal CLI boundary: render failures and set the process exit status. */
|
|
1793
|
+
async function executeInvocation(node, options, materializeCommandDefinition) {
|
|
1794
|
+
const argv = options?.argv ?? process.argv.slice(2);
|
|
1795
|
+
const io = {
|
|
1796
|
+
...DEFAULT_IO,
|
|
1797
|
+
...options?.io
|
|
1798
|
+
};
|
|
1799
|
+
const snapshotPath = process.env[SNAPSHOT_PATH_ENV];
|
|
1800
|
+
if (snapshotPath) {
|
|
1801
|
+
try {
|
|
1802
|
+
const base = buildExtensionTree(node, materializeCommandDefinition);
|
|
1803
|
+
const takeSnapshot = () => snapshotCommand(applySectionsAndFreeze(cloneCommandNode(base.rootNode), base.extensions));
|
|
1804
|
+
let snapshot = takeSnapshot();
|
|
1805
|
+
const buildOutDir = process.env[BUILD_OUT_DIR_ENV];
|
|
1806
|
+
if (buildOutDir) {
|
|
1807
|
+
const extensions = [];
|
|
1808
|
+
for (const extension of base.extensions) {
|
|
1809
|
+
if (!extension.build) continue;
|
|
1810
|
+
try {
|
|
1811
|
+
const artifacts = await extension.build({
|
|
1812
|
+
snapshot,
|
|
1813
|
+
outDir: buildOutDir
|
|
1814
|
+
});
|
|
1815
|
+
extensions.push({
|
|
1816
|
+
id: extension.id,
|
|
1817
|
+
files: artifacts === void 0 ? "unknown" : artifacts.map(normalizeArtifactPath)
|
|
1818
|
+
});
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1821
|
+
throw new Error(`Extension "${extension.id}" build failed: ${message}`, { cause: error });
|
|
1822
|
+
}
|
|
1823
|
+
snapshot = takeSnapshot();
|
|
1824
|
+
}
|
|
1825
|
+
await writeFile(join(dirname(snapshotPath), "build-report.json"), JSON.stringify({ extensions }));
|
|
1826
|
+
}
|
|
1827
|
+
await writeFile(snapshotPath, JSON.stringify(snapshot));
|
|
1828
|
+
} catch (error) {
|
|
1829
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1830
|
+
console.error(message);
|
|
1831
|
+
return process.exit(1);
|
|
1832
|
+
}
|
|
1833
|
+
return process.exit(0);
|
|
1834
|
+
}
|
|
1835
|
+
const invoke = async () => {
|
|
1836
|
+
let prepared;
|
|
1837
|
+
try {
|
|
1838
|
+
prepared = prepareInvocation(node, materializeCommandDefinition);
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
if (isAbortError(error)) {
|
|
1841
|
+
process.exitCode = EXIT_CODE_CANCELLED;
|
|
1842
|
+
return EXIT_CODE_CANCELLED;
|
|
1843
|
+
}
|
|
1844
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1845
|
+
io.stderr(`Error: ${message}`);
|
|
1846
|
+
process.exitCode = 1;
|
|
1847
|
+
return 1;
|
|
1848
|
+
}
|
|
1849
|
+
let extensionContext;
|
|
1850
|
+
let renderedInDispatch = false;
|
|
1851
|
+
try {
|
|
1852
|
+
await dispatch({ argv }, prepared, io, (context) => {
|
|
1853
|
+
extensionContext = context;
|
|
1854
|
+
}, async (error, context) => {
|
|
1855
|
+
renderedInDispatch = true;
|
|
1856
|
+
const cancelled = isAbortError(error);
|
|
1857
|
+
process.exitCode = cancelled ? EXIT_CODE_CANCELLED : 1;
|
|
1858
|
+
return renderFailure(error, argv, prepared, io, context, cancelled);
|
|
1859
|
+
});
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
if (isAbortError(error)) {
|
|
1862
|
+
if (!renderedInDispatch) await renderFailure(error, argv, prepared, io, extensionContext, true);
|
|
1863
|
+
process.exitCode = EXIT_CODE_CANCELLED;
|
|
1864
|
+
return EXIT_CODE_CANCELLED;
|
|
1865
|
+
}
|
|
1866
|
+
process.exitCode = 1;
|
|
1867
|
+
if (!renderedInDispatch) await renderFailure(error, argv, prepared, io, extensionContext);
|
|
1868
|
+
return 1;
|
|
1869
|
+
}
|
|
1870
|
+
return 0;
|
|
1871
|
+
};
|
|
1872
|
+
return await (hasInjectedIO(options?.io) ? withAmbientTerminalIO(io, invoke) : invoke());
|
|
1873
|
+
}
|
|
1874
|
+
//#endregion
|
|
1875
|
+
export { definingOf as _, runInvocation as a, normalizeFlag as b, sectionsFor as c, installExtensionContexts as d, validateCommandSections as f, defineContext as g, defineExtension as h, prepareInvocation as i, visibleSectionsFor as l, registerFlag as m, SNAPSHOT_PATH_ENV as n, snapshotCommand as o, createCommandNode as p, executeInvocation as r, isListed as s, BUILD_OUT_DIR_ENV as t, cloneCommandNode as u, validateContextAvailability as v, CrustError as x, normalizeArg as y };
|