Repository navigation
Expand file tree
/
Copy pathrun-command.ts
More file actions
131 lines (123 loc) · 4.82 KB
/
Copy pathrun-command.ts
File metadata and controls
131 lines (123 loc) · 4.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/**
* One way for a command to finish.
*
* Every command reports through `runCommand`, so `--json` always yields one
* envelope on stdout and the exit code always follows `output.ts`. Human text
* stays the default. The exit code is set on `process.exitCode` instead of
* calling `process.exit`, so piped stdout drains and fire-and-forget requests
* (install tracking) are not cut off.
*/
import chalk from 'chalk';
import ora from 'ora';
import { CommanderError, type Command } from 'commander';
import {
EXIT_CODES,
exitCodeFor,
failure,
fromError,
serialize,
success,
type CliEnvelope,
type CliFailure,
type ErrorContext,
type ExitCode,
} from './output.js';
export interface RunOptions<T> {
/** `--json` was passed: print the envelope instead of human text. */
json: boolean;
/** Spinner text shown in human mode while the task runs. */
spinner?: string;
/** Exit code for a successful result, when success is not always 0 (e.g. `check`). */
exitCode?: (data: T) => ExitCode;
/** Wording for errors whose subject varies by command. */
errors?: ErrorContext;
/** Human failures read `<label> failed: <message>` instead of `✗ <message>`. */
failureLabel?: string;
/** Human mode only: prints what a failure's details add, after its message and hint. */
failureDetails?: (envelope: CliFailure) => void;
}
/** Writes an envelope (or human text) and sets the exit code. */
export function emit<T>(
envelope: CliEnvelope<T>,
json: boolean,
human: (data: T) => void,
exitCode?: ExitCode,
failureLabel?: string,
): void {
if (json) console.log(serialize(envelope));
else if (envelope.ok) human(envelope.data);
else printFailure(envelope, failureLabel);
process.exitCode = exitCode ?? exitCodeFor(envelope);
}
/** Human rendering of a failure: the message, then the hint when there is one. */
export function printFailure(envelope: CliFailure, label?: string): void {
console.error(chalk.red(label ? `${label} failed: ${envelope.error.message}` : `\n✗ ${envelope.error.message}`));
if (envelope.error.hint) console.error(chalk.dim(envelope.error.hint));
}
/**
* Runs a command body and reports its result.
*
* `task` returns the data that becomes `envelope.data`; throwing (a `CliError`,
* an `ApiError`, anything) becomes a failure envelope. `human` renders the same
* data as text, so both modes describe one result. A human renderer may itself
* continue into further requests (`search --use`); its failure is reported the
* same way.
*/
export async function runCommand<T>(
options: RunOptions<T>,
task: (progress: (text: string) => void) => Promise<T>,
human: (data: T) => void | Promise<void>,
): Promise<void> {
const spinner = !options.json && options.spinner ? ora(options.spinner).start() : null;
const progress = (text: string) => {
if (spinner) spinner.text = text;
};
let envelope: CliEnvelope<T>;
try {
envelope = success(await task(progress));
} catch (error) {
envelope = fromError(error, options.errors);
} finally {
spinner?.stop();
}
const code = envelope.ok && options.exitCode ? options.exitCode(envelope.data) : undefined;
if (options.json || !envelope.ok) {
emit(envelope, options.json, () => {}, code, options.failureLabel);
if (!options.json && !envelope.ok) options.failureDetails?.(envelope);
return;
}
try {
await human(envelope.data);
process.exitCode = code ?? EXIT_CODES.ok;
} catch (error) {
const failed = fromError(error, options.errors);
emit(failed, false, () => {}, undefined, options.failureLabel);
options.failureDetails?.(failed);
}
}
/**
* Commander exits 1 on every parse problem. A caller has to tell "you called me
* wrong" (2) from a failed run (1), and with `--json` it expects an envelope on
* stdout, so parse errors are routed through the same vocabulary. Help and
* `--version` still exit 0; help shown because a command was called without
* its subcommand (`skillx`, `skillx config`) is a usage error.
*/
export function routeUsageErrors(program: Command, argv: readonly string[] = process.argv): void {
// After `--` everything is an operand, so a literal "--json" there is not the flag.
const end = argv.indexOf('--');
const json = (end < 0 ? argv : argv.slice(0, end)).includes('--json');
const apply = (command: Command) => {
command.exitOverride((error: CommanderError) => {
// Help and version (`--help`, `help <cmd>`, `--version`) are answers, not errors.
if (error.exitCode === 0) process.exit(EXIT_CODES.ok);
const envelope = failure('usage_error', error.message.replace(/^error:\s*/, ''), {
details: { reason: error.code },
hint: 'Run `skillx --help` or `skillx <command> --help`.',
});
if (json) console.log(serialize(envelope));
process.exit(EXIT_CODES.usage);
});
command.commands.forEach(apply);
};
apply(program);
}