@sdxc/cli 0.0.0-stage → 2026.10.16
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.md +21 -0
- package/README.md +363 -2
- package/dist/argument.d.ts +120 -0
- package/dist/argument.js +104 -0
- package/dist/cli.d.ts +68 -0
- package/dist/cli.js +186 -0
- package/dist/command.d.ts +139 -0
- package/dist/command.js +79 -0
- package/dist/context.d.ts +138 -0
- package/dist/context.js +175 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +82 -0
- package/dist/exec.d.ts +83 -0
- package/dist/exec.js +136 -0
- package/dist/flag.d.ts +175 -0
- package/dist/flag.js +91 -0
- package/dist/handler.d.ts +73 -0
- package/dist/handler.js +30 -0
- package/dist/help.d.ts +18 -0
- package/dist/help.js +141 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +20 -0
- package/dist/interactions.d.ts +27 -0
- package/dist/interactions.js +373 -0
- package/dist/keys.d.ts +49 -0
- package/dist/keys.js +146 -0
- package/dist/lazy.d.ts +29 -0
- package/dist/lazy.js +23 -0
- package/dist/middleware.d.ts +78 -0
- package/dist/middleware.js +9 -0
- package/dist/parse.d.ts +38 -0
- package/dist/parse.js +218 -0
- package/dist/prompt.d.ts +153 -0
- package/dist/prompt.js +53 -0
- package/dist/resolve.d.ts +27 -0
- package/dist/resolve.js +294 -0
- package/dist/schema.d.ts +27 -0
- package/dist/schema.js +34 -0
- package/dist/screen.d.ts +42 -0
- package/dist/screen.js +114 -0
- package/dist/session.d.ts +84 -0
- package/dist/session.js +219 -0
- package/dist/style.d.ts +50 -0
- package/dist/style.js +72 -0
- package/dist/suggest.d.ts +15 -0
- package/dist/suggest.js +50 -0
- package/dist/task.d.ts +47 -0
- package/dist/task.js +213 -0
- package/dist/terminal.d.ts +21 -0
- package/dist/terminal.js +101 -0
- package/dist/test.d.ts +89 -0
- package/dist/test.js +140 -0
- package/dist/tree.d.ts +58 -0
- package/dist/tree.js +97 -0
- package/package.json +40 -5
package/dist/context.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context a command's middleware and handler receive: its parsed arguments and flags, the
|
|
3
|
+
* process it runs in (working directory, environment, output, prompts, tasks, child processes)
|
|
4
|
+
* and the values middleware published, readable by key or as installed properties.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* One command's run. A test builds one through `invoke()` from `@sdxc/cli/test`; a middleware
|
|
11
|
+
* publishes services with `set()`, which a handler reads as `ctx.<property>`.
|
|
12
|
+
*
|
|
13
|
+
* @example ctx.set(Database, connect(ctx.env.DATABASE_URL), { property: "db" });
|
|
14
|
+
*/
|
|
15
|
+
export class CommandContext {
|
|
16
|
+
/**
|
|
17
|
+
* The arguments the run was given, exactly as `cli.run` received them: the command's names,
|
|
18
|
+
* flags and values before parsing, for logging a run or starting it again.
|
|
19
|
+
*/
|
|
20
|
+
argv;
|
|
21
|
+
/** The command's names after the program, `["ui", "add"]` for `sdxc ui add`. */
|
|
22
|
+
path;
|
|
23
|
+
/** The positional arguments, parsed, prompted for, or defaulted. */
|
|
24
|
+
arguments;
|
|
25
|
+
/** The flags, parsed, prompted for, or defaulted. */
|
|
26
|
+
flags;
|
|
27
|
+
cwd;
|
|
28
|
+
env;
|
|
29
|
+
/** Styling for stdout; `style.stderr` styles stderr, each off for a stream that is not a terminal. */
|
|
30
|
+
style;
|
|
31
|
+
/** Aborts on Ctrl+C. */
|
|
32
|
+
signal;
|
|
33
|
+
/**
|
|
34
|
+
* Questions a handler asks itself. Each takes a `default`, which answers without a
|
|
35
|
+
* terminal, so the command stays runnable in CI.
|
|
36
|
+
*/
|
|
37
|
+
prompt;
|
|
38
|
+
/**
|
|
39
|
+
* Runs async work under a spinner, answering a `Result`; the label is optional.
|
|
40
|
+
*
|
|
41
|
+
* @example let posts = await ctx.task("Fetching posts", (task) => fetch(url, { signal: task.signal }));
|
|
42
|
+
*/
|
|
43
|
+
task;
|
|
44
|
+
/**
|
|
45
|
+
* What the command was piped. `stdin.isTTY` is true at a terminal, where `text()` would wait
|
|
46
|
+
* for the user to end input, so a handler reads it only when `isTTY` is false.
|
|
47
|
+
*
|
|
48
|
+
* @example let sql = ctx.flags.execute ?? (ctx.stdin.isTTY ? undefined : await ctx.stdin.text());
|
|
49
|
+
*/
|
|
50
|
+
stdin;
|
|
51
|
+
#session;
|
|
52
|
+
#values = new Map();
|
|
53
|
+
#execEnv = {};
|
|
54
|
+
/** @param init - The parsed command and the run it belongs to */
|
|
55
|
+
constructor(init) {
|
|
56
|
+
this.argv = init.argv;
|
|
57
|
+
this.path = init.path;
|
|
58
|
+
this.arguments = init.arguments;
|
|
59
|
+
this.flags = init.flags;
|
|
60
|
+
this.cwd = init.session.cwd;
|
|
61
|
+
this.env = init.session.env;
|
|
62
|
+
this.style = init.session.style;
|
|
63
|
+
this.signal = init.session.signal;
|
|
64
|
+
this.prompt = init.prompt;
|
|
65
|
+
this.task = init.session.tasks.task;
|
|
66
|
+
this.stdin = createStdin(init.session.stdin);
|
|
67
|
+
this.#session = init.session;
|
|
68
|
+
}
|
|
69
|
+
/** Writes to stdout, above any running task. */
|
|
70
|
+
stdout = (text) => {
|
|
71
|
+
this.#session.screen.write(this.#session.stdout, text);
|
|
72
|
+
};
|
|
73
|
+
/** Writes to stderr, above any running task. */
|
|
74
|
+
stderr = (text) => {
|
|
75
|
+
this.#session.screen.write(this.#session.stderr, text);
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Runs a program with no shell, answering its exit code and output, or an `ExecError` when
|
|
79
|
+
* it fails to start or exits non-zero. `"inherit"` and `"stderr"` output pauses any spinner
|
|
80
|
+
* until the child exits.
|
|
81
|
+
*
|
|
82
|
+
* @param command - The program and its arguments, passed through exactly
|
|
83
|
+
* @example let status = await ctx.exec(["git", "status", "--short"], { stdio: "collect" });
|
|
84
|
+
*/
|
|
85
|
+
exec(command, options) {
|
|
86
|
+
return this.#session.exec(command, {
|
|
87
|
+
...options,
|
|
88
|
+
env: { ...this.#execEnv, ...options?.env },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Adds variables every child `exec` starts with, over the run's `env` and under a call's own
|
|
93
|
+
* `options.env`, so a middleware hands children what it knows (a trace parent) and a caller
|
|
94
|
+
* still overrides it. An `undefined` value removes the variable from children.
|
|
95
|
+
*
|
|
96
|
+
* @param variables - Merged over what earlier calls added
|
|
97
|
+
* @example ctx.setExecEnv({ TRACEPARENT: toTraceParent(trace) });
|
|
98
|
+
*/
|
|
99
|
+
setExecEnv(variables) {
|
|
100
|
+
this.#execEnv = { ...this.#execEnv, ...variables };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Reads a value some middleware published.
|
|
104
|
+
*
|
|
105
|
+
* @param key - The context key to read
|
|
106
|
+
* @returns Its value, the key's default, or `undefined`
|
|
107
|
+
*/
|
|
108
|
+
get(key) {
|
|
109
|
+
if (this.#values.has(key))
|
|
110
|
+
return this.#values.get(key);
|
|
111
|
+
return key.defaultValue;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Reads a value an earlier middleware must have published. A middleware sees the bare
|
|
115
|
+
* context, without the properties earlier ones installed, so this is how it reads them
|
|
116
|
+
* without an `undefined` it has no answer for; a missing value fails the run with exit 1.
|
|
117
|
+
*
|
|
118
|
+
* @param key - The context key to read
|
|
119
|
+
* @throws Error When nothing published a value for the key
|
|
120
|
+
* @example let db = ctx.require(Database);
|
|
121
|
+
*/
|
|
122
|
+
require(key) {
|
|
123
|
+
let value = this.get(key);
|
|
124
|
+
if (value === undefined || value === null) {
|
|
125
|
+
throw new Error(`${this.path.join(" ")} read a context key that no middleware published`);
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Whether a value has been published for a key.
|
|
131
|
+
*
|
|
132
|
+
* @param key - The context key to check
|
|
133
|
+
*/
|
|
134
|
+
has(key) {
|
|
135
|
+
return this.#values.has(key);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Publishes a value for a key, optionally installing it as a read-only property so handlers
|
|
139
|
+
* read `ctx.db` rather than `ctx.get(Database)`.
|
|
140
|
+
*
|
|
141
|
+
* @param key - The context key to write
|
|
142
|
+
* @param value - The value to publish
|
|
143
|
+
* @param options - The property name to install it under
|
|
144
|
+
*/
|
|
145
|
+
set(key, value, options) {
|
|
146
|
+
this.#values.set(key, value);
|
|
147
|
+
if (options === undefined)
|
|
148
|
+
return;
|
|
149
|
+
Object.defineProperty(this, options.property, {
|
|
150
|
+
value,
|
|
151
|
+
configurable: true,
|
|
152
|
+
enumerable: true,
|
|
153
|
+
writable: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** A reader over `input` that reads it to the end at most once. */
|
|
158
|
+
function createStdin(input) {
|
|
159
|
+
let reading;
|
|
160
|
+
return {
|
|
161
|
+
isTTY: input.isTTY === true,
|
|
162
|
+
text() {
|
|
163
|
+
reading ??= new Promise((resolve, reject) => {
|
|
164
|
+
let chunks = [];
|
|
165
|
+
input.on("data", (chunk) => {
|
|
166
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
167
|
+
});
|
|
168
|
+
input.once("end", () => resolve(chunks.join("")));
|
|
169
|
+
input.once("error", reject);
|
|
170
|
+
input.resume();
|
|
171
|
+
});
|
|
172
|
+
return reading;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failures a command answers and the exit codes they map to: a usage failure prints the
|
|
3
|
+
* command's help and exits 2, a cancelled one exits 130 in silence, an exit failure picks its
|
|
4
|
+
* own code, and any other failure prints its message and exits 1.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Failure, Success } from "@sdxc/result";
|
|
10
|
+
/** The exit code each outcome of a run maps to, shared by every command line built here. */
|
|
11
|
+
export declare const EXIT_CODES: {
|
|
12
|
+
readonly success: 0;
|
|
13
|
+
readonly failure: 1;
|
|
14
|
+
readonly usage: 2;
|
|
15
|
+
readonly cancelled: 130;
|
|
16
|
+
};
|
|
17
|
+
/** A command line the user wrote wrong; the run prints the message and the command's help. */
|
|
18
|
+
export declare class UsageError extends Error {
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The user stopped the run, with Ctrl+C or Escape at a prompt or Ctrl+C during a task. The run
|
|
23
|
+
* exits 130 and writes nothing more, since the user already knows why it stopped.
|
|
24
|
+
*/
|
|
25
|
+
export declare class CancelledError extends Error {
|
|
26
|
+
name: string;
|
|
27
|
+
/** @param message - Read only by code that logs the failure; the run itself prints nothing */
|
|
28
|
+
constructor(message?: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A failure that picks its own exit code. An empty message prints nothing, for a command that
|
|
32
|
+
* already reported what went wrong, such as a test runner that printed its failed tests.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ExitError extends Error {
|
|
35
|
+
name: string;
|
|
36
|
+
/** The process exit code the run answers. */
|
|
37
|
+
code: number;
|
|
38
|
+
/**
|
|
39
|
+
* @param code - The exit code
|
|
40
|
+
* @param message - What the run prints on stderr, nothing when empty
|
|
41
|
+
*/
|
|
42
|
+
constructor(code: number, message?: string);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A successful outcome; a command that has nothing to hand back calls it with no argument.
|
|
46
|
+
*
|
|
47
|
+
* @example return success();
|
|
48
|
+
*/
|
|
49
|
+
export declare function success(): Success<undefined>;
|
|
50
|
+
export declare function success<T>(data: T): Success<T>;
|
|
51
|
+
/**
|
|
52
|
+
* A usage failure: the run prints `message`, then the help of the command that answered it, and
|
|
53
|
+
* exits 2.
|
|
54
|
+
*
|
|
55
|
+
* @example if (ctx.flags.prompt && !ctx.flags.from) return usage("--prompt needs --from");
|
|
56
|
+
*/
|
|
57
|
+
export declare function usage(message: string): Failure<UsageError>;
|
|
58
|
+
/** A cancelled failure, which exits 130 without printing anything. */
|
|
59
|
+
export declare function cancelled(): Failure<CancelledError>;
|
|
60
|
+
/**
|
|
61
|
+
* A failure that exits with `code`, printing `message` when one is given.
|
|
62
|
+
*
|
|
63
|
+
* @example return run.failed > 0 ? exit(1) : success();
|
|
64
|
+
*/
|
|
65
|
+
export declare function exit(code: number, message?: string): Failure<ExitError>;
|
|
66
|
+
/** The exit code a run's failure maps to. */
|
|
67
|
+
export declare function exitCodeOf(error: Error): number;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failures a command answers and the exit codes they map to: a usage failure prints the
|
|
3
|
+
* command's help and exits 2, a cancelled one exits 130 in silence, an exit failure picks its
|
|
4
|
+
* own code, and any other failure prints its message and exits 1.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success as succeed } from "@sdxc/result";
|
|
10
|
+
/** The exit code each outcome of a run maps to, shared by every command line built here. */
|
|
11
|
+
export const EXIT_CODES = {
|
|
12
|
+
success: 0,
|
|
13
|
+
failure: 1,
|
|
14
|
+
usage: 2,
|
|
15
|
+
cancelled: 130,
|
|
16
|
+
};
|
|
17
|
+
/** A command line the user wrote wrong; the run prints the message and the command's help. */
|
|
18
|
+
export class UsageError extends Error {
|
|
19
|
+
name = "UsageError";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The user stopped the run, with Ctrl+C or Escape at a prompt or Ctrl+C during a task. The run
|
|
23
|
+
* exits 130 and writes nothing more, since the user already knows why it stopped.
|
|
24
|
+
*/
|
|
25
|
+
export class CancelledError extends Error {
|
|
26
|
+
name = "CancelledError";
|
|
27
|
+
/** @param message - Read only by code that logs the failure; the run itself prints nothing */
|
|
28
|
+
constructor(message = "Cancelled") {
|
|
29
|
+
super(message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A failure that picks its own exit code. An empty message prints nothing, for a command that
|
|
34
|
+
* already reported what went wrong, such as a test runner that printed its failed tests.
|
|
35
|
+
*/
|
|
36
|
+
export class ExitError extends Error {
|
|
37
|
+
name = "ExitError";
|
|
38
|
+
/** The process exit code the run answers. */
|
|
39
|
+
code;
|
|
40
|
+
/**
|
|
41
|
+
* @param code - The exit code
|
|
42
|
+
* @param message - What the run prints on stderr, nothing when empty
|
|
43
|
+
*/
|
|
44
|
+
constructor(code, message = "") {
|
|
45
|
+
super(message);
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function success(data) {
|
|
50
|
+
return succeed(data);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A usage failure: the run prints `message`, then the help of the command that answered it, and
|
|
54
|
+
* exits 2.
|
|
55
|
+
*
|
|
56
|
+
* @example if (ctx.flags.prompt && !ctx.flags.from) return usage("--prompt needs --from");
|
|
57
|
+
*/
|
|
58
|
+
export function usage(message) {
|
|
59
|
+
return failure(new UsageError(message));
|
|
60
|
+
}
|
|
61
|
+
/** A cancelled failure, which exits 130 without printing anything. */
|
|
62
|
+
export function cancelled() {
|
|
63
|
+
return failure(new CancelledError());
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A failure that exits with `code`, printing `message` when one is given.
|
|
67
|
+
*
|
|
68
|
+
* @example return run.failed > 0 ? exit(1) : success();
|
|
69
|
+
*/
|
|
70
|
+
export function exit(code, message) {
|
|
71
|
+
return failure(new ExitError(code, message));
|
|
72
|
+
}
|
|
73
|
+
/** The exit code a run's failure maps to. */
|
|
74
|
+
export function exitCodeOf(error) {
|
|
75
|
+
if (error instanceof UsageError)
|
|
76
|
+
return EXIT_CODES.usage;
|
|
77
|
+
if (error instanceof CancelledError)
|
|
78
|
+
return EXIT_CODES.cancelled;
|
|
79
|
+
if (error instanceof ExitError)
|
|
80
|
+
return error.code;
|
|
81
|
+
return EXIT_CODES.failure;
|
|
82
|
+
}
|
package/dist/exec.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child processes without a shell: arguments reach the program exactly as given, so nothing in
|
|
3
|
+
* them is expanded or split. The answer is a `Result` carrying the exit code and what the child
|
|
4
|
+
* printed, and an aborted signal stops the child and answers a cancelled failure.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { Output } from "./screen.js";
|
|
11
|
+
import type { Env } from "./style.js";
|
|
12
|
+
import { CancelledError } from "./errors.js";
|
|
13
|
+
/**
|
|
14
|
+
* Where a child's output goes: `"inherit"` to the CLI's own streams, `"ignore"` nowhere,
|
|
15
|
+
* `"collect"` into the answer only, and `"stderr"` sends the child's stdout to the CLI's stderr,
|
|
16
|
+
* so a command whose stdout is piped keeps it clean while an install prints.
|
|
17
|
+
*/
|
|
18
|
+
export type Stdio = "inherit" | "ignore" | "collect" | "stderr";
|
|
19
|
+
/** How `ctx.exec` runs one command. */
|
|
20
|
+
export interface ExecOptions {
|
|
21
|
+
/** @default the command's working directory */
|
|
22
|
+
cwd?: string;
|
|
23
|
+
/** Variables set on top of the command's environment. */
|
|
24
|
+
env?: Env;
|
|
25
|
+
/** @default "inherit" */
|
|
26
|
+
stdio?: Stdio;
|
|
27
|
+
/** Stops the child. @default the run's signal, which aborts on Ctrl+C */
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
/** Text written to the child's stdin, which is otherwise closed (or the terminal for "inherit"). */
|
|
30
|
+
input?: string;
|
|
31
|
+
}
|
|
32
|
+
/** Everything an {@link Exec} receives, with every default filled in. */
|
|
33
|
+
export interface ExecRequest {
|
|
34
|
+
cwd: string;
|
|
35
|
+
env: Env;
|
|
36
|
+
stdio: Stdio;
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
input: string | undefined;
|
|
39
|
+
/** The CLI's streams, for `"inherit"` and `"stderr"` output. */
|
|
40
|
+
stdout: Output;
|
|
41
|
+
stderr: Output;
|
|
42
|
+
}
|
|
43
|
+
/** A child that exited 0. */
|
|
44
|
+
export interface ExecOutput {
|
|
45
|
+
code: number;
|
|
46
|
+
/** What the child printed on stdout, empty unless it was collected. */
|
|
47
|
+
stdout: string;
|
|
48
|
+
stderr: string;
|
|
49
|
+
/** Both streams in the order they arrived. */
|
|
50
|
+
output: string;
|
|
51
|
+
}
|
|
52
|
+
/** A child that could not start or exited with a code other than 0. */
|
|
53
|
+
export declare class ExecError extends Error {
|
|
54
|
+
name: string;
|
|
55
|
+
/** The command as given. */
|
|
56
|
+
command: readonly string[];
|
|
57
|
+
/** The exit code, or `null` when the child never started or ended on a signal. */
|
|
58
|
+
code: number | null;
|
|
59
|
+
stdout: string;
|
|
60
|
+
stderr: string;
|
|
61
|
+
output: string;
|
|
62
|
+
/**
|
|
63
|
+
* @param message - What went wrong
|
|
64
|
+
* @param details - The command and what it printed
|
|
65
|
+
*/
|
|
66
|
+
constructor(message: string, details: {
|
|
67
|
+
command: readonly string[];
|
|
68
|
+
code: number | null;
|
|
69
|
+
stdout?: string;
|
|
70
|
+
stderr?: string;
|
|
71
|
+
output?: string;
|
|
72
|
+
cause?: unknown;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Runs one command; `cli.run`'s `exec` option replaces it, as the test harness does. */
|
|
76
|
+
export type Exec = (command: readonly string[], request: ExecRequest) => Promise<Result<ExecOutput, ExecError | CancelledError>>;
|
|
77
|
+
/** The command as a shell user would read it, for messages. */
|
|
78
|
+
export declare function displayCommand(command: readonly string[]): string;
|
|
79
|
+
/**
|
|
80
|
+
* Spawns `command` with `node:child_process`. Output bound for the process's own streams is
|
|
81
|
+
* inherited directly; output bound for any other stream is piped and forwarded to it.
|
|
82
|
+
*/
|
|
83
|
+
export declare const spawnExec: Exec;
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child processes without a shell: arguments reach the program exactly as given, so nothing in
|
|
3
|
+
* them is expanded or split. The answer is a `Result` carrying the exit code and what the child
|
|
4
|
+
* printed, and an aborted signal stops the child and answers a cancelled failure.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { failure, success } from "@sdxc/result";
|
|
11
|
+
import { CancelledError } from "./errors.js";
|
|
12
|
+
/** A child that could not start or exited with a code other than 0. */
|
|
13
|
+
export class ExecError extends Error {
|
|
14
|
+
name = "ExecError";
|
|
15
|
+
/** The command as given. */
|
|
16
|
+
command;
|
|
17
|
+
/** The exit code, or `null` when the child never started or ended on a signal. */
|
|
18
|
+
code;
|
|
19
|
+
stdout;
|
|
20
|
+
stderr;
|
|
21
|
+
output;
|
|
22
|
+
/**
|
|
23
|
+
* @param message - What went wrong
|
|
24
|
+
* @param details - The command and what it printed
|
|
25
|
+
*/
|
|
26
|
+
constructor(message, details) {
|
|
27
|
+
super(message, { cause: details.cause });
|
|
28
|
+
this.command = details.command;
|
|
29
|
+
this.code = details.code;
|
|
30
|
+
this.stdout = details.stdout ?? "";
|
|
31
|
+
this.stderr = details.stderr ?? "";
|
|
32
|
+
this.output = details.output ?? "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** The command as a shell user would read it, for messages. */
|
|
36
|
+
export function displayCommand(command) {
|
|
37
|
+
return command
|
|
38
|
+
.map((part) => (/^[\w./:=@-]+$/.test(part) ? part : JSON.stringify(part)))
|
|
39
|
+
.join(" ");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Spawns `command` with `node:child_process`. Output bound for the process's own streams is
|
|
43
|
+
* inherited directly; output bound for any other stream is piped and forwarded to it.
|
|
44
|
+
*/
|
|
45
|
+
export const spawnExec = (command, request) => new Promise((resolve) => {
|
|
46
|
+
let settled = false;
|
|
47
|
+
let settle = (outcome) => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
resolve(outcome);
|
|
52
|
+
};
|
|
53
|
+
let [program, ...args] = command;
|
|
54
|
+
if (program === undefined) {
|
|
55
|
+
settle(failure(new ExecError("exec needs a command", { command, code: null })));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (request.signal.aborted) {
|
|
59
|
+
settle(failure(new CancelledError()));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let shown = displayCommand(command);
|
|
63
|
+
let direct = (target, stream) => target === stream;
|
|
64
|
+
let outTarget = request.stdio === "inherit"
|
|
65
|
+
? request.stdout
|
|
66
|
+
: request.stdio === "stderr"
|
|
67
|
+
? request.stderr
|
|
68
|
+
: null;
|
|
69
|
+
let errTarget = request.stdio === "inherit" || request.stdio === "stderr" ? request.stderr : null;
|
|
70
|
+
let stdout = request.stdio === "ignore"
|
|
71
|
+
? "ignore"
|
|
72
|
+
: outTarget === null
|
|
73
|
+
? "pipe"
|
|
74
|
+
: direct(outTarget, process.stdout)
|
|
75
|
+
? "inherit"
|
|
76
|
+
: direct(outTarget, process.stderr)
|
|
77
|
+
? 2
|
|
78
|
+
: "pipe";
|
|
79
|
+
let stderr = request.stdio === "ignore"
|
|
80
|
+
? "ignore"
|
|
81
|
+
: errTarget !== null && direct(errTarget, process.stderr)
|
|
82
|
+
? "inherit"
|
|
83
|
+
: "pipe";
|
|
84
|
+
let stdin = request.input !== undefined ? "pipe" : request.stdio === "inherit" ? "inherit" : "ignore";
|
|
85
|
+
let child = spawn(program, args, {
|
|
86
|
+
cwd: request.cwd,
|
|
87
|
+
env: { ...request.env },
|
|
88
|
+
shell: false,
|
|
89
|
+
stdio: [stdin, stdout, stderr],
|
|
90
|
+
});
|
|
91
|
+
let collected = { stdout: "", stderr: "", output: "" };
|
|
92
|
+
child.stdout?.on("data", (chunk) => {
|
|
93
|
+
let text = chunk.toString();
|
|
94
|
+
if (outTarget === null) {
|
|
95
|
+
collected.stdout += text;
|
|
96
|
+
collected.output += text;
|
|
97
|
+
}
|
|
98
|
+
else
|
|
99
|
+
outTarget.write(text);
|
|
100
|
+
});
|
|
101
|
+
child.stderr?.on("data", (chunk) => {
|
|
102
|
+
let text = chunk.toString();
|
|
103
|
+
if (errTarget === null) {
|
|
104
|
+
collected.stderr += text;
|
|
105
|
+
collected.output += text;
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
errTarget.write(text);
|
|
109
|
+
});
|
|
110
|
+
if (request.input !== undefined)
|
|
111
|
+
child.stdin?.end(request.input);
|
|
112
|
+
let abort = () => child.kill("SIGTERM");
|
|
113
|
+
request.signal.addEventListener("abort", abort, { once: true });
|
|
114
|
+
child.once("error", (error) => {
|
|
115
|
+
request.signal.removeEventListener("abort", abort);
|
|
116
|
+
settle(failure(new ExecError(`${shown} could not start: ${error.message}`, {
|
|
117
|
+
command,
|
|
118
|
+
code: null,
|
|
119
|
+
cause: error,
|
|
120
|
+
...collected,
|
|
121
|
+
})));
|
|
122
|
+
});
|
|
123
|
+
child.once("close", (code, signal) => {
|
|
124
|
+
request.signal.removeEventListener("abort", abort);
|
|
125
|
+
if (request.signal.aborted) {
|
|
126
|
+
settle(failure(new CancelledError()));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (code === 0) {
|
|
130
|
+
settle(success({ code, ...collected }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let reason = code === null ? `was stopped by ${signal ?? "a signal"}` : `exited with ${code}`;
|
|
134
|
+
settle(failure(new ExecError(`${shown} ${reason}`, { command, code, ...collected })));
|
|
135
|
+
});
|
|
136
|
+
});
|