@procedures/cli-kit 0.0.0-stage → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +62 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/args.d.ts +109 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +399 -0
- package/dist/args.js.map +1 -0
- package/dist/ask.d.ts +49 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +94 -0
- package/dist/ask.js.map +1 -0
- package/dist/cli.d.ts +117 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +253 -0
- package/dist/cli.js.map +1 -0
- package/dist/failure.d.ts +16 -0
- package/dist/failure.d.ts.map +1 -0
- package/dist/failure.js +47 -0
- package/dist/failure.js.map +1 -0
- package/dist/fields.d.ts +125 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +339 -0
- package/dist/fields.js.map +1 -0
- package/dist/format.d.ts +35 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +145 -0
- package/dist/format.js.map +1 -0
- package/dist/help.d.ts +42 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +188 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -3
- package/src/args.ts +511 -0
- package/src/ask.ts +119 -0
- package/src/cli.ts +360 -0
- package/src/failure.ts +47 -0
- package/src/fields.ts +454 -0
- package/src/format.ts +196 -0
- package/src/help.ts +241 -0
- package/src/index.ts +71 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A command line for a procedure registry.
|
|
3
|
+
*
|
|
4
|
+
* `createProcedureCli()` makes a program from the procedures: the flags come from the input
|
|
5
|
+
* schemas, and the help comes from the metadata. A missing argument is a question in a
|
|
6
|
+
* terminal. A value `@<file>` is the text of the file. A format hook of a procedure writes its
|
|
7
|
+
* human output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import {
|
|
12
|
+
Client,
|
|
13
|
+
LocalTransport,
|
|
14
|
+
ProcedureRegistry,
|
|
15
|
+
pathToMethod,
|
|
16
|
+
type AnyProcedure,
|
|
17
|
+
type Method,
|
|
18
|
+
} from "@procedures/client";
|
|
19
|
+
import { findChildren, parseCommandLine, type GlobalFlag, type Globals } from "./args.js";
|
|
20
|
+
import { askMissing, canAsk, missingFields, processTerminal, terminalAsker, type Asker, type Terminal } from "./ask.js";
|
|
21
|
+
import { failureOf } from "./failure.js";
|
|
22
|
+
import { fieldsOf, resultFieldsOf, type CLIMeta, type CommandLike } from "./fields.js";
|
|
23
|
+
import { formatOutput, plainPrint, type OutputFormat, type Print } from "./format.js";
|
|
24
|
+
import { generateHelp, groupHelp, rootHelp, validationMessage } from "./help.js";
|
|
25
|
+
|
|
26
|
+
/** The global flags of each program of `createProcedureCli()`. The option `globals` adds more. */
|
|
27
|
+
export const STANDARD_GLOBAL_FLAGS: readonly GlobalFlag[] = [
|
|
28
|
+
{ long: "help", short: "h", takesValue: false, description: "Show the help" },
|
|
29
|
+
{ long: "version", short: "v", takesValue: false, hidden: true, description: "Show the version" },
|
|
30
|
+
{ long: "format", short: "f", takesValue: true, description: "The output format: text, json, table or streaming" },
|
|
31
|
+
{ long: "json", takesValue: false, description: "Write the result as JSON (the same as --format json)" },
|
|
32
|
+
{ long: "data", takesValue: true, description: "The input as a JSON object, or @<file> for a file with the JSON object" },
|
|
33
|
+
{ long: "no-input", takesValue: false, description: "Never ask for a missing argument (also when CI is set)" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const OUTPUT_FORMATS: readonly OutputFormat[] = ["text", "json", "table", "streaming"];
|
|
37
|
+
|
|
38
|
+
/** The commands of a program: a list of procedures or descriptions, or a registry. */
|
|
39
|
+
export type CommandSource = readonly CommandLike[] | { getAll(): readonly CommandLike[] };
|
|
40
|
+
|
|
41
|
+
/** The options of a call of a `CliClient`. */
|
|
42
|
+
export interface CliCallOptions {
|
|
43
|
+
/** The signal that aborts the call. */
|
|
44
|
+
signal?: AbortSignal | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The client that runs the commands, for example a `Client` of `@procedures/client` over HTTP.
|
|
49
|
+
* The command line uses `stream()` when the client has it, and else `call()`.
|
|
50
|
+
*/
|
|
51
|
+
export interface CliClient {
|
|
52
|
+
/** This function gives each item of the result of a procedure. */
|
|
53
|
+
stream?(method: Method, payload: unknown, options?: CliCallOptions): AsyncIterable<unknown>;
|
|
54
|
+
/** This function gives the result of a procedure. */
|
|
55
|
+
call?(method: Method, payload: unknown, options?: CliCallOptions): Promise<unknown>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The data that a format hook gets. */
|
|
59
|
+
export interface FormatContext {
|
|
60
|
+
/** The path of the command. */
|
|
61
|
+
path: readonly string[];
|
|
62
|
+
/** The input of the command. */
|
|
63
|
+
input: unknown;
|
|
64
|
+
/** The output format: `text` or `streaming`. */
|
|
65
|
+
format: OutputFormat;
|
|
66
|
+
/** The print functions of the program. */
|
|
67
|
+
print: Print;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A format hook writes the human output of one procedure. It gives a text or lines, and the
|
|
72
|
+
* program prints them. Or it prints with `context.print` and gives nothing. The program uses a
|
|
73
|
+
* hook only for the formats `text` and `streaming`. `--json` and `--format table` do not use it.
|
|
74
|
+
*/
|
|
75
|
+
export type FormatHook = (result: unknown, context: FormatContext) => string | readonly string[] | undefined | void;
|
|
76
|
+
|
|
77
|
+
/** The options of `createProcedureCli()`. */
|
|
78
|
+
export interface ProcedureCliOptions {
|
|
79
|
+
/** The name of the program, as a person types it. */
|
|
80
|
+
name: string;
|
|
81
|
+
/** The version that `--version` shows. Without it, `--version` shows the help. */
|
|
82
|
+
version?: string | undefined;
|
|
83
|
+
/** The text after the name in the first line of the help. */
|
|
84
|
+
description?: string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* The commands: procedures, descriptions of procedures, or a registry. A function gives them
|
|
87
|
+
* when the program starts, for example from a lazy import.
|
|
88
|
+
*/
|
|
89
|
+
procedures: CommandSource | (() => CommandSource | Promise<CommandSource>);
|
|
90
|
+
/**
|
|
91
|
+
* The client that runs the commands. A function makes the client from the global flags, for
|
|
92
|
+
* example from `--port`. Without a client, the commands run in this process on the procedures.
|
|
93
|
+
*/
|
|
94
|
+
client?: CliClient | ((globals: Globals) => CliClient | Promise<CliClient>) | undefined;
|
|
95
|
+
/** More global flags. A flag with the long name of a standard flag replaces it. */
|
|
96
|
+
globals?: readonly GlobalFlag[] | undefined;
|
|
97
|
+
/** The format hooks, by the path of the procedure with dots, for example `"job.status"`. */
|
|
98
|
+
format?: Readonly<Record<string, FormatHook>> | undefined;
|
|
99
|
+
/** The print functions. The default writes plain text to the console. */
|
|
100
|
+
print?: Print | undefined;
|
|
101
|
+
/** The lines of the help after the list of the commands. */
|
|
102
|
+
footer?: readonly string[] | undefined;
|
|
103
|
+
/** The terminal state that decides if the program asks. Tests give a fake. */
|
|
104
|
+
terminal?: Terminal | undefined;
|
|
105
|
+
/** The function that asks one question. Tests give a fake. */
|
|
106
|
+
ask?: Asker | undefined;
|
|
107
|
+
/**
|
|
108
|
+
* The function that reads a file for a value `@<file>`. The default reads the file as UTF-8.
|
|
109
|
+
* `false` turns `@<file>` off: a value that starts with `@` stays text.
|
|
110
|
+
*/
|
|
111
|
+
readFile?: ((path: string) => string) | false | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The options of one run. */
|
|
115
|
+
export interface ProcedureCliRunOptions {
|
|
116
|
+
/** The signal that aborts the call of the command. */
|
|
117
|
+
signal?: AbortSignal | undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A program of `createProcedureCli()`. */
|
|
121
|
+
export interface ProcedureCli {
|
|
122
|
+
/** The name of the program. */
|
|
123
|
+
readonly name: string;
|
|
124
|
+
/** The global flags of the program. */
|
|
125
|
+
readonly globals: readonly GlobalFlag[];
|
|
126
|
+
/**
|
|
127
|
+
* This function starts one command line (without the program name) and gives the exit code.
|
|
128
|
+
* The exit code is 1 for an unknown command, an invalid input, an error or a failed result.
|
|
129
|
+
* Else the exit code is 0.
|
|
130
|
+
*/
|
|
131
|
+
run(argv: readonly string[], options?: ProcedureCliRunOptions): Promise<number>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** This function gives the global flags: the standard flags, then the flags of the options. */
|
|
135
|
+
function mergeGlobals(extra: readonly GlobalFlag[]): GlobalFlag[] {
|
|
136
|
+
const names = new Set(extra.map((flag) => flag.long));
|
|
137
|
+
return [...STANDARD_GLOBAL_FLAGS.filter((flag) => !names.has(flag.long)), ...extra];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** This function gives the list of a command source. */
|
|
141
|
+
function commandsOf(source: CommandSource): readonly CommandLike[] {
|
|
142
|
+
return "getAll" in source ? source.getAll() : source;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** This function gives the text of an error. */
|
|
146
|
+
function messageOf(error: unknown): string {
|
|
147
|
+
return error instanceof Error ? error.message : String(error);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** This function makes a client in this process over the procedures of a source. */
|
|
151
|
+
function localClient(source: CommandSource): CliClient {
|
|
152
|
+
let registry: ProcedureRegistry;
|
|
153
|
+
if (source instanceof ProcedureRegistry) {
|
|
154
|
+
registry = source;
|
|
155
|
+
} else {
|
|
156
|
+
registry = new ProcedureRegistry();
|
|
157
|
+
for (const procedure of commandsOf(source)) registry.register(procedure as AnyProcedure);
|
|
158
|
+
}
|
|
159
|
+
const client = new Client({ transport: new LocalTransport({ registry }) }).useRegistry(registry);
|
|
160
|
+
return {
|
|
161
|
+
stream: (method, payload, options) => client.stream(method, payload, options?.signal ? { signal: options.signal } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** This function gives each item of the result of a call. */
|
|
166
|
+
async function* itemsOf(client: CliClient, method: Method, payload: unknown, options: CliCallOptions): AsyncIterable<unknown> {
|
|
167
|
+
if (client.stream) {
|
|
168
|
+
yield* client.stream(method, payload, options);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (client.call) {
|
|
172
|
+
yield await client.call(method, payload, options);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
throw new Error("The client has no stream() and no call()");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* This function makes a command line for a procedure registry. The program gives help, `--json`,
|
|
180
|
+
* questions for missing arguments, `@<file>` input and short flags from `meta.shorts`.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* const cli = createProcedureCli({ name: "tool", version: "1.0.0", procedures: registry });
|
|
185
|
+
* process.exitCode = await cli.run(process.argv.slice(2));
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export function createProcedureCli(options: ProcedureCliOptions): ProcedureCli {
|
|
189
|
+
const { name } = options;
|
|
190
|
+
const globalFlags = mergeGlobals(options.globals ?? []);
|
|
191
|
+
const readFile = options.readFile === false ? undefined : (options.readFile ?? ((path: string) => readFileSync(path, "utf8")));
|
|
192
|
+
|
|
193
|
+
const run = async (argv: readonly string[], runOptions: ProcedureCliRunOptions = {}): Promise<number> => {
|
|
194
|
+
const print = options.print ?? plainPrint();
|
|
195
|
+
let code = 0;
|
|
196
|
+
const fail = (message: string, hint?: string): void => {
|
|
197
|
+
print.error(message);
|
|
198
|
+
if (hint) print.info(hint);
|
|
199
|
+
code = 1;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const source = typeof options.procedures === "function" ? await options.procedures() : options.procedures;
|
|
204
|
+
const commands = commandsOf(source);
|
|
205
|
+
const parsed = parseCommandLine(argv, commands, { name, globals: globalFlags, readFile });
|
|
206
|
+
const { path, procedure, globals } = parsed;
|
|
207
|
+
const command = `${name} ${path.join(" ")}`;
|
|
208
|
+
|
|
209
|
+
// An unknown command is the first problem to report
|
|
210
|
+
if (path.length > 0 && !procedure) {
|
|
211
|
+
const isGroup = findChildren(commands, path).length > 0;
|
|
212
|
+
if (!isGroup || parsed.extraArguments.length > 0) {
|
|
213
|
+
const words = [...path, ...parsed.extraArguments.slice(0, 1)];
|
|
214
|
+
fail(`Unknown command: ${name} ${words.join(" ")}`, `Run '${name} --help' for available commands.`);
|
|
215
|
+
return code;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (parsed.errors.length > 0) {
|
|
219
|
+
for (const error of parsed.errors) print.error(error);
|
|
220
|
+
print.info(`Run '${path.length > 0 ? `${command} --help` : `${name} --help`}' for the options.`);
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (globals["version"] === true && options.version !== undefined) {
|
|
225
|
+
print.info(`${name} v${options.version}`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const formatOverride = globals["json"] === true ? "json" : globals["format"] === undefined ? undefined : String(globals["format"]);
|
|
230
|
+
if (formatOverride !== undefined && !OUTPUT_FORMATS.includes(formatOverride as OutputFormat)) {
|
|
231
|
+
fail(`Invalid format: ${formatOverride}. Valid formats: ${OUTPUT_FORMATS.join(", ")}`);
|
|
232
|
+
return code;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The help of the program, of a group or of a command
|
|
236
|
+
if (path.length === 0) {
|
|
237
|
+
const title = `${name}${options.version !== undefined ? ` v${options.version}` : ""}${options.description ? ` - ${options.description}` : ""}`;
|
|
238
|
+
for (const line of rootHelp(commands, { name, title, footer: options.footer })) print.info(line);
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
if (globals["help"] === true || !procedure) {
|
|
242
|
+
if (procedure) {
|
|
243
|
+
const meta = (procedure.metadata ?? {}) as CLIMeta;
|
|
244
|
+
print.info(generateHelp(path, meta, fieldsOf(procedure), resultFieldsOf(procedure), { name, globals: globalFlags }));
|
|
245
|
+
} else {
|
|
246
|
+
for (const line of groupHelp(name, commands, path) ?? []) print.info(line);
|
|
247
|
+
}
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const meta = (procedure.metadata ?? {}) as CLIMeta;
|
|
252
|
+
const outputFormat = (formatOverride ?? meta.output ?? "text") as OutputFormat;
|
|
253
|
+
|
|
254
|
+
// --data: the input as one JSON object. The flags and the arguments go over its fields.
|
|
255
|
+
let input = parsed.input;
|
|
256
|
+
if (globals["data"] !== undefined) {
|
|
257
|
+
let text = String(globals["data"]);
|
|
258
|
+
if (text.startsWith("@") && !text.startsWith("@@") && text.length > 1) {
|
|
259
|
+
if (!readFile) {
|
|
260
|
+
fail(`--data ${text}: this program reads no files`);
|
|
261
|
+
return code;
|
|
262
|
+
}
|
|
263
|
+
text = readFile(text.slice(1));
|
|
264
|
+
} else if (text.startsWith("@@")) {
|
|
265
|
+
text = text.slice(1);
|
|
266
|
+
}
|
|
267
|
+
let data: unknown;
|
|
268
|
+
try {
|
|
269
|
+
data = JSON.parse(text);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
fail(`--data needs a JSON object: ${messageOf(error)}`);
|
|
272
|
+
return code;
|
|
273
|
+
}
|
|
274
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
275
|
+
fail("--data needs a JSON object");
|
|
276
|
+
return code;
|
|
277
|
+
}
|
|
278
|
+
input = { ...(data as Record<string, unknown>), ...input };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A required argument that is missing: in a terminal, the program asks for it
|
|
282
|
+
const missing = missingFields(fieldsOf(procedure), input);
|
|
283
|
+
if (missing.length > 0 && canAsk(globals["no-input"] === true, meta, options.terminal ?? processTerminal())) {
|
|
284
|
+
const asked = await askMissing(missing, input, options.ask ?? terminalAsker);
|
|
285
|
+
if (!asked.ok) {
|
|
286
|
+
fail(asked.cancelled ? "Cancelled" : `Invalid input for ${command}:\n ${asked.error}`);
|
|
287
|
+
return code;
|
|
288
|
+
}
|
|
289
|
+
input = asked.input;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A procedure with a schema validates the input here. A description without a schema
|
|
293
|
+
// leaves the validation to the client.
|
|
294
|
+
let validated: unknown = input;
|
|
295
|
+
const schema = procedure.input as { parse?: (data: unknown) => unknown } | undefined;
|
|
296
|
+
if (typeof schema?.parse === "function") {
|
|
297
|
+
try {
|
|
298
|
+
validated = schema.parse(input);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
fail(`Invalid input for ${command}:\n ${validationMessage(error)}`, `Run '${command} --help' for the options.`);
|
|
301
|
+
return code;
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
const stillMissing = missingFields(fieldsOf(procedure), input);
|
|
305
|
+
if (stillMissing.length > 0) {
|
|
306
|
+
const names = stillMissing.map((field) => `${field.name}: Required`).join("\n ");
|
|
307
|
+
fail(`Invalid input for ${command}:\n ${names}`, `Run '${command} --help' for the options.`);
|
|
308
|
+
return code;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const client =
|
|
313
|
+
options.client === undefined
|
|
314
|
+
? localClient(source)
|
|
315
|
+
: typeof options.client === "function"
|
|
316
|
+
? await options.client(globals)
|
|
317
|
+
: options.client;
|
|
318
|
+
|
|
319
|
+
// Each item prints when it arrives. A failed item makes the command fail.
|
|
320
|
+
const hook = outputFormat === "text" || outputFormat === "streaming" ? options.format?.[path.join(".")] : undefined;
|
|
321
|
+
let failure: string | undefined;
|
|
322
|
+
const printItem = (item: unknown): void => {
|
|
323
|
+
const text = hook ? hook(item, { path, input: validated, format: outputFormat, print }) : undefined;
|
|
324
|
+
if (typeof text === "string") {
|
|
325
|
+
print.info(text);
|
|
326
|
+
} else if (Array.isArray(text)) {
|
|
327
|
+
for (const line of text as readonly string[]) print.info(line);
|
|
328
|
+
} else if (!hook) {
|
|
329
|
+
formatOutput(print, item, outputFormat);
|
|
330
|
+
}
|
|
331
|
+
failure ??= failureOf(item);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const spinner = outputFormat === "streaming" ? print.spin(`Running ${path.join(" ")}...`) : undefined;
|
|
335
|
+
try {
|
|
336
|
+
let items = 0;
|
|
337
|
+
for await (const item of itemsOf(client, pathToMethod(path), validated, { signal: runOptions.signal })) {
|
|
338
|
+
if (spinner && items === 0) spinner.stop();
|
|
339
|
+
items++;
|
|
340
|
+
printItem(item);
|
|
341
|
+
}
|
|
342
|
+
if (spinner) {
|
|
343
|
+
if (failure === undefined) spinner.succeed(`${path.join(" ")} complete`);
|
|
344
|
+
else spinner.fail(`${path.join(" ")} failed`);
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
spinner?.stop();
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
if (failure !== undefined) {
|
|
351
|
+
fail(`${command} failed: ${failure}`);
|
|
352
|
+
}
|
|
353
|
+
} catch (error) {
|
|
354
|
+
fail(`Error: ${messageOf(error)}`);
|
|
355
|
+
}
|
|
356
|
+
return code;
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
return { name, globals: globalFlags, run };
|
|
360
|
+
}
|
package/src/failure.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedure-level failure
|
|
3
|
+
*
|
|
4
|
+
* A procedure reports a failure in one of three ways, and the command line exits with code 1 for each:
|
|
5
|
+
*
|
|
6
|
+
* 1. It throws an error. This is the usual way.
|
|
7
|
+
* 2. It returns an object with `success: false`. Use this when the result has more to show,
|
|
8
|
+
* for example the list of problems of `lib audit`.
|
|
9
|
+
* 3. It returns an object with a numeric `exitCode` that is not 0. Use this for a procedure that
|
|
10
|
+
* runs an external command (`vitest run`, `shell run`), so the caller sees the command's result.
|
|
11
|
+
*
|
|
12
|
+
* For a streaming procedure, each item is examined: one failed item makes the command fail.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A short description of the failure that a result reports, or undefined when it reports none */
|
|
16
|
+
export function failureOf(result: unknown): string | undefined {
|
|
17
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const record = result as Record<string, unknown>;
|
|
21
|
+
const exitCode = record["exitCode"];
|
|
22
|
+
if (typeof exitCode === "number" && exitCode !== 0) {
|
|
23
|
+
return `exit code ${exitCode}`;
|
|
24
|
+
}
|
|
25
|
+
if (record["success"] === false) {
|
|
26
|
+
return firstText(record["error"]) ?? firstText(record["errors"]) ?? firstText(record["message"]) ?? "success: false";
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The first text of a value: a string, the message of an error object, or the first of a list */
|
|
32
|
+
function firstText(value: unknown): string | undefined {
|
|
33
|
+
if (typeof value === "string" && value.length > 0) {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
for (const item of value) {
|
|
38
|
+
const text = firstText(item);
|
|
39
|
+
if (text) return text;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (value && typeof value === "object" && typeof (value as { message?: unknown }).message === "string") {
|
|
44
|
+
return (value as { message: string }).message;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|