@procedures/shell 0.34.0 → 0.35.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 +18 -0
- package/README.md +23 -14
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/argv.d.ts +29 -0
- package/dist/cli/argv.d.ts.map +1 -0
- package/dist/cli/argv.js +71 -0
- package/dist/cli/argv.js.map +1 -0
- package/dist/cli/index.d.ts +17 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.d.ts +38 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +27 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/program.d.ts +29 -0
- package/dist/cli/program.d.ts.map +1 -0
- package/dist/cli/program.js +41 -0
- package/dist/cli/program.js.map +1 -0
- package/dist/cli/tool.d.ts +75 -0
- package/dist/cli/tool.d.ts.map +1 -0
- package/dist/cli/tool.js +104 -0
- package/dist/cli/tool.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +67 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +114 -0
- package/dist/testing.js.map +1 -0
- package/package.json +11 -3
- package/src/cli/argv.ts +71 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/output.ts +59 -0
- package/src/cli/program.ts +47 -0
- package/src/cli/tool.ts +164 -0
- package/src/index.ts +6 -1
- package/src/testing.ts +163 -0
package/dist/testing.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@procedures/shell/testing`: a fake shell for the tests of the wrapper packages (dedupe review F2).
|
|
3
|
+
*
|
|
4
|
+
* `fakeShell(results)` gives the shell procedures with their real schemas, effects and rules of
|
|
5
|
+
* the root, and handlers that record each input and answer with a scripted result. No program
|
|
6
|
+
* starts. It gives the same fake in two forms:
|
|
7
|
+
* - `module`: put it before the module of the wrapper in `createRegistry()`. The first module of
|
|
8
|
+
* a name counts, so the fake takes the place of the real shell.
|
|
9
|
+
* - `client`: give it as `ctx.client` to a handler that a test calls directly.
|
|
10
|
+
*
|
|
11
|
+
* This entry point has no side effects: it registers no procedures.
|
|
12
|
+
*/
|
|
13
|
+
import { defineModule } from "@procedures/client";
|
|
14
|
+
import { shellModule } from "./module.js";
|
|
15
|
+
/** The full output of a scripted result. */
|
|
16
|
+
function outputOf(result) {
|
|
17
|
+
const exitCode = result.exitCode ?? 0;
|
|
18
|
+
const output = { exitCode, stdout: result.stdout ?? "", stderr: result.stderr ?? "", success: exitCode === 0, duration: 1 };
|
|
19
|
+
if (result.signal !== undefined)
|
|
20
|
+
output.signal = result.signal;
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
/** The lines of a text, without the line ends and without a last empty line. */
|
|
24
|
+
function linesOf(text) {
|
|
25
|
+
const lines = text.split(/\r?\n/);
|
|
26
|
+
if (lines.at(-1) === "")
|
|
27
|
+
lines.pop();
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* This function gives a fake shell. Each `shell.run` call records its input in `calls` and gives
|
|
32
|
+
* the next scripted result. Each `shell.stream` call records its input in `streams` and gives the
|
|
33
|
+
* lines of the result, then the exit item. `shell.exec` throws: a wrapper never runs a shell string.
|
|
34
|
+
*/
|
|
35
|
+
export function fakeShell(results = {}, options = {}) {
|
|
36
|
+
const calls = [];
|
|
37
|
+
const streams = [];
|
|
38
|
+
let index = 0;
|
|
39
|
+
const next = (input) => {
|
|
40
|
+
const at = index++;
|
|
41
|
+
if (typeof results === "function")
|
|
42
|
+
return results(input, at);
|
|
43
|
+
if (Array.isArray(results))
|
|
44
|
+
return results[at] ?? options.fallback ?? {};
|
|
45
|
+
return results;
|
|
46
|
+
};
|
|
47
|
+
const run = (input) => {
|
|
48
|
+
calls.push(input);
|
|
49
|
+
return outputOf(next(input));
|
|
50
|
+
};
|
|
51
|
+
async function* stream(input) {
|
|
52
|
+
streams.push(input);
|
|
53
|
+
const output = outputOf(next(input));
|
|
54
|
+
for (const line of linesOf(output.stdout))
|
|
55
|
+
yield { type: "stdout", line };
|
|
56
|
+
for (const line of linesOf(output.stderr))
|
|
57
|
+
yield { type: "stderr", line };
|
|
58
|
+
const exit = { type: "exit", exitCode: output.exitCode, duration: output.duration };
|
|
59
|
+
if (output.signal !== undefined)
|
|
60
|
+
exit.signal = output.signal;
|
|
61
|
+
yield exit;
|
|
62
|
+
}
|
|
63
|
+
const procedures = shellModule.procedures.map((procedure) => {
|
|
64
|
+
switch (procedure.path.join(".")) {
|
|
65
|
+
case "shell.run":
|
|
66
|
+
return { ...procedure, handler: async (input) => run(input) };
|
|
67
|
+
case "shell.stream":
|
|
68
|
+
return { ...procedure, handler: stream };
|
|
69
|
+
case "shell.exec":
|
|
70
|
+
return {
|
|
71
|
+
...procedure,
|
|
72
|
+
handler: async () => {
|
|
73
|
+
throw new Error("fakeShell: shell.exec runs a shell string, and a wrapper must call shell.run with argv");
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
default:
|
|
77
|
+
return procedure;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
const refuse = (path) => new Error(`fakeShell: the fake client answers only shell.run and shell.stream, not ${path.join(".")}`);
|
|
81
|
+
const client = {
|
|
82
|
+
async call(path, input) {
|
|
83
|
+
const key = path.join(".");
|
|
84
|
+
if (key === "shell.run")
|
|
85
|
+
return run(input);
|
|
86
|
+
if (key === "shell.stream") {
|
|
87
|
+
let last;
|
|
88
|
+
for await (const item of stream(input))
|
|
89
|
+
last = item;
|
|
90
|
+
return last;
|
|
91
|
+
}
|
|
92
|
+
throw refuse(path);
|
|
93
|
+
},
|
|
94
|
+
stream(path, input) {
|
|
95
|
+
if (path.join(".") === "shell.stream")
|
|
96
|
+
return stream(input);
|
|
97
|
+
throw refuse(path);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
calls,
|
|
102
|
+
streams,
|
|
103
|
+
procedures,
|
|
104
|
+
module: defineModule("@procedures/shell", procedures),
|
|
105
|
+
client,
|
|
106
|
+
args(at = 0) {
|
|
107
|
+
const call = calls[at];
|
|
108
|
+
if (call === undefined)
|
|
109
|
+
throw new Error(`fakeShell: there is no shell.run call ${at} (${calls.length} calls)`);
|
|
110
|
+
return call.args;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package",
|
|
3
3
|
"name": "@procedures/shell",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.35.0",
|
|
5
5
|
"description": "Safe command execution for TypeScript: shell.run with argv and no shell, shell.stream line streaming with backpressure, process-tree kill, timeouts and output caps.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"shell",
|
|
@@ -41,6 +41,14 @@
|
|
|
41
41
|
"./command": {
|
|
42
42
|
"types": "./dist/command/index.d.ts",
|
|
43
43
|
"import": "./dist/command/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./cli": {
|
|
46
|
+
"types": "./dist/cli/index.d.ts",
|
|
47
|
+
"import": "./dist/cli/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./testing": {
|
|
50
|
+
"types": "./dist/testing.d.ts",
|
|
51
|
+
"import": "./dist/testing.js"
|
|
44
52
|
}
|
|
45
53
|
},
|
|
46
54
|
"files": [
|
|
@@ -63,13 +71,13 @@
|
|
|
63
71
|
},
|
|
64
72
|
"devDependencies": {
|
|
65
73
|
"@mark1russell7/cue": "^1.0.0",
|
|
66
|
-
"@procedures/fs": "0.
|
|
74
|
+
"@procedures/fs": "0.35.0",
|
|
67
75
|
"@types/node": "^22.0.0",
|
|
68
76
|
"typescript": "^7.0.2",
|
|
69
77
|
"vitest": "^5.0.3"
|
|
70
78
|
},
|
|
71
79
|
"dependencies": {
|
|
72
|
-
"@procedures/client": "0.
|
|
80
|
+
"@procedures/client": "0.35.0",
|
|
73
81
|
"zod": "^4.6.5"
|
|
74
82
|
},
|
|
75
83
|
"client": {
|
package/src/cli/argv.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The argument helpers of a CLI tool (dedupe review F2). A wrapper starts its program with argv,
|
|
3
|
+
* never through a shell, so these helpers only keep a value from becoming an option.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This function gives a value that the program reads as an argument (a package, a branch, a
|
|
8
|
+
* path). It throws when the value starts with "-", because the program then reads it as an option.
|
|
9
|
+
*/
|
|
10
|
+
export function cliArg(name: string, value: string): string {
|
|
11
|
+
if (value.startsWith("-")) {
|
|
12
|
+
throw new Error(`Invalid ${name} (starts with "-"): ${value}`);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The value of a named argument of `buildArgv()`. */
|
|
18
|
+
export type ArgValue = string | number | boolean | readonly string[];
|
|
19
|
+
|
|
20
|
+
/** A flag name: lowercase letters, digits and hyphens. */
|
|
21
|
+
export const FLAG_NAME: RegExp = /^[a-z][a-z0-9-]*$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This function gives the argv: the path, then each named argument, then `--` and the positional
|
|
25
|
+
* arguments. `true` gives `--key`, `false` gives nothing, a list gives `--key=value` for each
|
|
26
|
+
* value, and another value gives `--key=value`.
|
|
27
|
+
*/
|
|
28
|
+
export function buildArgv(input: {
|
|
29
|
+
readonly path: readonly string[];
|
|
30
|
+
readonly args?: Readonly<Record<string, ArgValue>> | undefined;
|
|
31
|
+
readonly positional?: readonly string[] | undefined;
|
|
32
|
+
readonly flags?: readonly string[] | undefined;
|
|
33
|
+
}): string[] {
|
|
34
|
+
const argv = [...input.path, ...(input.flags ?? [])];
|
|
35
|
+
for (const [key, value] of Object.entries(input.args ?? {})) {
|
|
36
|
+
if (!FLAG_NAME.test(key)) throw new Error(`The argument name "${key}" is not a flag name (lowercase letters, digits, hyphens)`);
|
|
37
|
+
if (value === false) continue;
|
|
38
|
+
if (value === true) argv.push(`--${key}`);
|
|
39
|
+
else if (Array.isArray(value)) for (const item of value) argv.push(`--${key}=${item}`);
|
|
40
|
+
else argv.push(`--${key}=${String(value)}`);
|
|
41
|
+
}
|
|
42
|
+
if (input.positional && input.positional.length > 0) argv.push("--", ...input.positional);
|
|
43
|
+
return argv;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** This function reads one JSON document from a standard output. Output that is not JSON gives undefined. */
|
|
47
|
+
export function parseJsonDocument(stdout: string): unknown {
|
|
48
|
+
const text = stdout.trim();
|
|
49
|
+
if (text === "") return undefined;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** This function reads JSON output: one document, or one document on each line (NDJSON). */
|
|
58
|
+
export function parseJsonOutput(stdout: string): unknown {
|
|
59
|
+
const text = stdout.trim();
|
|
60
|
+
if (text.length === 0) return undefined;
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text);
|
|
63
|
+
} catch {
|
|
64
|
+
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
65
|
+
try {
|
|
66
|
+
return lines.map((line) => JSON.parse(line) as unknown);
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@procedures/shell/cli`: the shared parts of the CLI wrapper packages (dedupe review F2).
|
|
3
|
+
*
|
|
4
|
+
* - `defineCliTool()` gives the runner of a program: `run`, `runJson`, `output`, `json` and the
|
|
5
|
+
* argument guard. Each command goes as argv to the `shell.run` procedure, never as a shell string.
|
|
6
|
+
* - `cliArg()`, `buildArgv()`, `parseJsonDocument()` and `parseJsonOutput()` build the argv and
|
|
7
|
+
* read the output.
|
|
8
|
+
* - `CliToolOutput` and `cliToolOutputSchema()` are the shared result and its schema.
|
|
9
|
+
* - `nodeScript()`, `execPathProgram()` and `findInPath()` find the program without a shell.
|
|
10
|
+
*
|
|
11
|
+
* This entry point has no side effects: it registers no procedures.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { cliArg, buildArgv, parseJsonDocument, parseJsonOutput, FLAG_NAME, type ArgValue } from "./argv.js";
|
|
15
|
+
export {
|
|
16
|
+
cliToolOutputFields,
|
|
17
|
+
cliToolOutputSchema,
|
|
18
|
+
cliToolJsonOutputSchema,
|
|
19
|
+
type CliToolOutput,
|
|
20
|
+
type CliToolJsonOutput,
|
|
21
|
+
type CliToolOutputFields,
|
|
22
|
+
} from "./output.js";
|
|
23
|
+
export { nodeScript, execPathProgram, findInPath, type CliProgram } from "./program.js";
|
|
24
|
+
export {
|
|
25
|
+
defineCliTool,
|
|
26
|
+
CliToolError,
|
|
27
|
+
type CliTool,
|
|
28
|
+
type CliToolOptions,
|
|
29
|
+
type CliToolContext,
|
|
30
|
+
type CliRunOptions,
|
|
31
|
+
type CliShellInput,
|
|
32
|
+
} from "./tool.js";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared output of a CLI tool procedure: the result of `shell.run` with `success` and the
|
|
3
|
+
* time of the call. The schemas are loose, so a field that a schema does not name stays.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** The result of one command of a CLI tool. */
|
|
9
|
+
export interface CliToolOutput {
|
|
10
|
+
/** The exit code of the program (0 is success) */
|
|
11
|
+
exitCode: number;
|
|
12
|
+
/** The standard output */
|
|
13
|
+
stdout: string;
|
|
14
|
+
/** The standard error, or the error message when the program did not start */
|
|
15
|
+
stderr: string;
|
|
16
|
+
/** True when the exit code is 0 */
|
|
17
|
+
success: boolean;
|
|
18
|
+
/** The time of the command, in milliseconds */
|
|
19
|
+
duration: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The result of a command that writes JSON: `data` is its parsed standard output. */
|
|
23
|
+
export interface CliToolJsonOutput extends CliToolOutput {
|
|
24
|
+
/** The parsed JSON of the standard output. It is absent when the output is not JSON. */
|
|
25
|
+
data?: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The fields of the output schema of a tool, as a shape for `z.looseObject()`. */
|
|
29
|
+
export interface CliToolOutputFields {
|
|
30
|
+
exitCode: z.ZodNumber;
|
|
31
|
+
stdout: z.ZodString;
|
|
32
|
+
stderr: z.ZodString;
|
|
33
|
+
success: z.ZodBoolean;
|
|
34
|
+
duration: z.ZodNumber;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** This function gives the fields of the output schema, with descriptions that name the program. */
|
|
38
|
+
export function cliToolOutputFields(program: string): CliToolOutputFields {
|
|
39
|
+
return {
|
|
40
|
+
exitCode: z.number().describe(`The exit code of ${program} (0 is success)`),
|
|
41
|
+
stdout: z.string().describe(`The standard output of ${program}`),
|
|
42
|
+
stderr: z.string().describe(`The standard error of ${program}, or the error message when ${program} did not start`),
|
|
43
|
+
success: z.boolean().describe("True when the exit code is 0"),
|
|
44
|
+
duration: z.number().describe("The time of the command, in milliseconds"),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** This function gives the loose output schema of a command of the program. */
|
|
49
|
+
export function cliToolOutputSchema(program: string): z.ZodType<CliToolOutput> {
|
|
50
|
+
return z.looseObject(cliToolOutputFields(program));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** This function gives the loose output schema of a command of the program that writes JSON. */
|
|
54
|
+
export function cliToolJsonOutputSchema(program: string): z.ZodType<CliToolJsonOutput> {
|
|
55
|
+
return z.looseObject({
|
|
56
|
+
...cliToolOutputFields(program),
|
|
57
|
+
data: z.unknown().optional().describe("The parsed JSON of the standard output, when it is JSON"),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lookup of the program of a CLI tool. On Windows, a package manager is often a `.cmd` file,
|
|
3
|
+
* and Node runs a `.cmd` file only through a shell. So a tool finds the real program: an
|
|
4
|
+
* executable, or the script that the `.cmd` file starts, run with Node.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { delimiter } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** The program and the first arguments that start a CLI tool without a shell. */
|
|
11
|
+
export interface CliProgram {
|
|
12
|
+
/** The program that `shell.run` starts */
|
|
13
|
+
command: string;
|
|
14
|
+
/** The arguments before the arguments of the command (for example the script of the tool) */
|
|
15
|
+
prefix: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** This function gives the program that runs a JavaScript script with the Node of this process. */
|
|
19
|
+
export function nodeScript(script: string): CliProgram {
|
|
20
|
+
return { command: process.execPath, prefix: [script] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This function gives the program of `npm_execpath`, when the package manager that runs this
|
|
25
|
+
* process matches. A `script` match runs with Node, and an `executable` match runs directly.
|
|
26
|
+
* The file must exist.
|
|
27
|
+
*/
|
|
28
|
+
export function execPathProgram(match: { script?: RegExp | undefined; executable?: RegExp | undefined }): CliProgram | undefined {
|
|
29
|
+
const execPath = process.env["npm_execpath"];
|
|
30
|
+
if (!execPath || !existsSync(execPath)) return undefined;
|
|
31
|
+
if (match.script?.test(execPath)) return nodeScript(execPath);
|
|
32
|
+
if (match.executable?.test(execPath)) return { command: execPath, prefix: [] };
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* This function gives the first program that `find` gives for a folder of PATH, in the order of
|
|
38
|
+
* PATH. It skips empty entries, and it gives undefined when no folder has the program.
|
|
39
|
+
*/
|
|
40
|
+
export function findInPath(find: (dir: string) => CliProgram | undefined, path: string = process.env["PATH"] ?? ""): CliProgram | undefined {
|
|
41
|
+
for (const dir of path.split(delimiter)) {
|
|
42
|
+
if (!dir) continue;
|
|
43
|
+
const program = find(dir);
|
|
44
|
+
if (program !== undefined) return program;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
package/src/cli/tool.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineCliTool()`: the one runner of the CLI wrapper packages (dedupe review F2).
|
|
3
|
+
*
|
|
4
|
+
* Each command goes as an argument list to the `shell.run` procedure, through `ctx.client`. No
|
|
5
|
+
* shell runs, so a value cannot start a second command. The nested call keeps the rules of the
|
|
6
|
+
* host: the declared calls, the effects, the root of the files and the signal of the call.
|
|
7
|
+
* `packages/documentation/SERVER-LAYERS.md` gives the reason for this run path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ProcedureContext } from "@procedures/client";
|
|
11
|
+
import { cliArg, parseJsonDocument } from "./argv.js";
|
|
12
|
+
import type { CliToolJsonOutput, CliToolOutput } from "./output.js";
|
|
13
|
+
import type { CliProgram } from "./program.js";
|
|
14
|
+
|
|
15
|
+
/** The part of the procedure context that a CLI tool uses: the client of the nested call. */
|
|
16
|
+
export type CliToolContext = Pick<ProcedureContext, "client">;
|
|
17
|
+
|
|
18
|
+
/** The options of one command of a CLI tool. */
|
|
19
|
+
export interface CliRunOptions {
|
|
20
|
+
/** The working folder of the program */
|
|
21
|
+
cwd?: string | undefined;
|
|
22
|
+
/** The time limit, in milliseconds. At the limit, `shell.run` kills the process tree. */
|
|
23
|
+
timeout?: number | undefined;
|
|
24
|
+
/** The environment variables that the program gets in addition to the environment of the host */
|
|
25
|
+
env?: Record<string, string> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The input of a `shell.run` call that a CLI tool makes. */
|
|
29
|
+
export interface CliShellInput {
|
|
30
|
+
command: string;
|
|
31
|
+
args: string[];
|
|
32
|
+
cwd?: string;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
env?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A failed command of a CLI tool: its exit code and its standard error. */
|
|
38
|
+
export class CliToolError extends Error {
|
|
39
|
+
override readonly name: string = "CliToolError";
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
readonly exitCode: number,
|
|
43
|
+
readonly stderr: string,
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The definition of a CLI tool. */
|
|
50
|
+
export interface CliToolOptions {
|
|
51
|
+
/** The name of the program in the error messages. Without `resolve`, it is also the program. */
|
|
52
|
+
readonly name: string;
|
|
53
|
+
/**
|
|
54
|
+
* This function gives the program at each command: a command name, or a program with the first
|
|
55
|
+
* arguments. A throw is the error of the command (the program was not found).
|
|
56
|
+
*/
|
|
57
|
+
readonly resolve?: (() => CliProgram | string) | undefined;
|
|
58
|
+
/** This function makes the error of a failed command for `output()` and `json()` (default: `CliToolError`). */
|
|
59
|
+
readonly error?: ((message: string, exitCode: number, stderr: string) => Error) | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The runner of a CLI tool that `defineCliTool()` gives. */
|
|
63
|
+
export interface CliTool {
|
|
64
|
+
/** The name of the program */
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** This function gives a value that the program reads as an argument. It throws for a value that starts with "-". */
|
|
67
|
+
arg(name: string, value: string): string;
|
|
68
|
+
/** This function gives the `shell.run` input of a command. It throws when the program is not found. */
|
|
69
|
+
shellInput(args: readonly string[], options?: CliRunOptions): CliShellInput;
|
|
70
|
+
/** This function runs a command and gives its result. It throws when the program is not found or `shell.run` fails. */
|
|
71
|
+
call(args: readonly string[], options: CliRunOptions, ctx: CliToolContext): Promise<CliToolOutput>;
|
|
72
|
+
/** This function runs a command and gives its result. It does not throw: an error gives exit code 1 and its message as `stderr`. */
|
|
73
|
+
run(args: readonly string[], options: CliRunOptions, ctx: CliToolContext): Promise<CliToolOutput>;
|
|
74
|
+
/** This function runs a command that writes JSON, and gives `data` when `parse` reads the output. It does not throw. */
|
|
75
|
+
runJson(args: readonly string[], options: CliRunOptions, ctx: CliToolContext, parse?: (stdout: string) => unknown): Promise<CliToolJsonOutput>;
|
|
76
|
+
/** This function runs a command and gives its standard output. It throws the tool error for an exit code other than 0. */
|
|
77
|
+
output(args: readonly string[], options: CliRunOptions, ctx: CliToolContext): Promise<string>;
|
|
78
|
+
/** This function runs a command that writes JSON and gives the parsed value. It throws the tool error for a failure or for output that is not JSON. */
|
|
79
|
+
json<T>(args: readonly string[], options: CliRunOptions, ctx: CliToolContext): Promise<T>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first two words of a command, for an error message. */
|
|
83
|
+
function commandWords(name: string, args: readonly string[]): string {
|
|
84
|
+
return `${name} ${args[0] ?? ""} ${args[1] ?? ""}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* This function defines the runner of a CLI tool. The wrapper package keeps its procedures, its
|
|
89
|
+
* schemas and its own argument rules. The runner gives the run, the JSON, the argument guard
|
|
90
|
+
* and the shared result.
|
|
91
|
+
*/
|
|
92
|
+
export function defineCliTool(options: CliToolOptions): CliTool {
|
|
93
|
+
const { name } = options;
|
|
94
|
+
const makeError = options.error ?? ((message: string, exitCode: number, stderr: string) => new CliToolError(message, exitCode, stderr));
|
|
95
|
+
|
|
96
|
+
const shellInput = (args: readonly string[], run: CliRunOptions = {}): CliShellInput => {
|
|
97
|
+
const resolved = options.resolve?.() ?? name;
|
|
98
|
+
const program: CliProgram = typeof resolved === "string" ? { command: resolved, prefix: [] } : resolved;
|
|
99
|
+
const input: CliShellInput = { command: program.command, args: [...program.prefix, ...args] };
|
|
100
|
+
if (run.cwd !== undefined) input.cwd = run.cwd;
|
|
101
|
+
if (run.timeout !== undefined) input.timeout = run.timeout;
|
|
102
|
+
if (run.env !== undefined) input.env = run.env;
|
|
103
|
+
return input;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const call = async (args: readonly string[], run: CliRunOptions, ctx: CliToolContext): Promise<CliToolOutput> => {
|
|
107
|
+
const startTime = Date.now();
|
|
108
|
+
const result = await ctx.client.call<CliShellInput, { exitCode: number; stdout: string; stderr: string }>(["shell", "run"], shellInput(args, run));
|
|
109
|
+
return {
|
|
110
|
+
exitCode: result.exitCode,
|
|
111
|
+
stdout: result.stdout,
|
|
112
|
+
stderr: result.stderr,
|
|
113
|
+
success: result.exitCode === 0,
|
|
114
|
+
duration: Date.now() - startTime,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const runCommand = async (args: readonly string[], run: CliRunOptions, ctx: CliToolContext): Promise<CliToolOutput> => {
|
|
119
|
+
const startTime = Date.now();
|
|
120
|
+
try {
|
|
121
|
+
return await call(args, run, ctx);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return {
|
|
124
|
+
exitCode: 1,
|
|
125
|
+
stdout: "",
|
|
126
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
127
|
+
success: false,
|
|
128
|
+
duration: Date.now() - startTime,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const output = async (args: readonly string[], run: CliRunOptions, ctx: CliToolContext): Promise<string> => {
|
|
134
|
+
const result = await call(args, run, ctx);
|
|
135
|
+
if (result.exitCode !== 0) {
|
|
136
|
+
const text = result.stderr.trim() || result.stdout.trim();
|
|
137
|
+
throw makeError(`${commandWords(name, args)} failed (exit ${result.exitCode}): ${text}`.trim(), result.exitCode, result.stderr);
|
|
138
|
+
}
|
|
139
|
+
return result.stdout;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name,
|
|
144
|
+
arg: cliArg,
|
|
145
|
+
shellInput,
|
|
146
|
+
call,
|
|
147
|
+
run: runCommand,
|
|
148
|
+
async runJson(args, run, ctx, parse = parseJsonDocument) {
|
|
149
|
+
const result: CliToolJsonOutput = await runCommand(args, run, ctx);
|
|
150
|
+
const data = parse(result.stdout);
|
|
151
|
+
if (data !== undefined) result.data = data;
|
|
152
|
+
return result;
|
|
153
|
+
},
|
|
154
|
+
output,
|
|
155
|
+
async json<T>(args: readonly string[], run: CliRunOptions, ctx: CliToolContext): Promise<T> {
|
|
156
|
+
const stdout = await output(args, run, ctx);
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(stdout) as T;
|
|
159
|
+
} catch {
|
|
160
|
+
throw makeError(`${commandWords(name, args)} gave no JSON: ${stdout.slice(0, 200)}`, 0, "");
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* - shell.stream - Run command with args and stream each output line
|
|
11
11
|
*
|
|
12
12
|
* The command helpers (`runCommand`, `streamCommand`, `processes`) are also available without
|
|
13
|
-
* the registration of the procedures, from `@procedures/shell/command`.
|
|
13
|
+
* the registration of the procedures, from `@procedures/shell/command`. The runner of the CLI
|
|
14
|
+
* wrapper packages (`defineCliTool`) is in `@procedures/shell/cli`, and the fake shell of their
|
|
15
|
+
* tests (`fakeShell`) is in `@procedures/shell/testing`.
|
|
14
16
|
*
|
|
15
17
|
* @example
|
|
16
18
|
* ```typescript
|
|
@@ -49,5 +51,8 @@ export { shellRun, shellExec, shellWhich, shellStream } from "./procedures/shell
|
|
|
49
51
|
// Command helpers
|
|
50
52
|
export * from "./command/index.js";
|
|
51
53
|
|
|
54
|
+
// The runner of the CLI wrapper packages (also from `@procedures/shell/cli`)
|
|
55
|
+
export * from "./cli/index.js";
|
|
56
|
+
|
|
52
57
|
// Registration
|
|
53
58
|
export { registerShellProcedures } from "./module.js";
|