@better-fs-tools/shell 0.0.0-tegami-trusted-publish-setup → 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 +269 -6
- package/dist/contract/context.d.ts +10 -0
- package/dist/contract/context.js +0 -0
- package/dist/contract/deps.d.ts +43 -0
- package/dist/contract/deps.js +0 -0
- package/dist/contract/extensions.d.ts +94 -0
- package/dist/contract/extensions.js +0 -0
- package/dist/contract/format.d.ts +22 -0
- package/dist/contract/format.js +0 -0
- package/dist/contract/input.d.ts +15 -0
- package/dist/contract/input.js +0 -0
- package/dist/contract/limits.d.ts +16 -0
- package/dist/contract/limits.js +0 -0
- package/dist/contract/messages.d.ts +69 -0
- package/dist/contract/messages.js +0 -0
- package/dist/contract/result.d.ts +77 -0
- package/dist/contract/result.js +0 -0
- package/dist/contract/runner.d.ts +55 -0
- package/dist/contract/runner.js +0 -0
- package/dist/core/abort.d.ts +12 -0
- package/dist/core/abort.js +41 -0
- package/dist/core/capture.d.ts +11 -0
- package/dist/core/capture.js +171 -0
- package/dist/core/create-bash-tool.d.ts +8 -0
- package/dist/core/create-bash-tool.js +18 -0
- package/dist/core/deps.d.ts +5 -0
- package/dist/core/deps.js +103 -0
- package/dist/core/env.d.ts +14 -0
- package/dist/core/env.js +32 -0
- package/dist/core/execute.d.ts +11 -0
- package/dist/core/execute.js +265 -0
- package/dist/core/input.d.ts +12 -0
- package/dist/core/input.js +46 -0
- package/dist/core/limits.d.ts +10 -0
- package/dist/core/limits.js +44 -0
- package/dist/core/messages.d.ts +12 -0
- package/dist/core/messages.js +94 -0
- package/dist/core/pipeline.d.ts +4 -0
- package/dist/core/pipeline.js +179 -0
- package/dist/core/stages.d.ts +20 -0
- package/dist/core/stages.js +166 -0
- package/dist/core/stop.d.ts +17 -0
- package/dist/core/stop.js +50 -0
- package/dist/formatters/default.d.ts +20 -0
- package/dist/formatters/default.js +77 -0
- package/dist/formatters/index.d.ts +2 -0
- package/dist/formatters/index.js +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +7 -0
- package/dist/signature/bash.d.ts +28 -0
- package/dist/signature/bash.js +158 -0
- package/dist/signature/index.d.ts +3 -0
- package/dist/signature/index.js +2 -0
- package/docs/architecture.md +74 -0
- package/docs/result-schema.md +151 -0
- package/package.json +41 -2
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ContentPart, Note, ToolError } from "@better-fs-tools/read";
|
|
2
|
+
import type { BashRequest } from "./input.ts";
|
|
3
|
+
/**
|
|
4
|
+
* "ok", "failed", and "timeout" describe a command that ran. "failed": it
|
|
5
|
+
* exited with a code other than 0. It is a normal result, not a tool error.
|
|
6
|
+
* "error": the call stopped, or the tool stopped the command for a reason
|
|
7
|
+
* other than its timeout. `error.code` says why.
|
|
8
|
+
*/
|
|
9
|
+
export type ShellStatus = "ok" | "failed" | "timeout" | "error";
|
|
10
|
+
/**
|
|
11
|
+
* The error note's code is the kebab-case form. DENIED: the authorizer
|
|
12
|
+
* refused. REFUSED: a beforeRun hook refused. ABORTED: the caller's signal fired.
|
|
13
|
+
*/
|
|
14
|
+
export type ShellErrorCode = "INVALID_INPUT" | "CWD_NOT_FOUND" | "CWD_NOT_A_DIRECTORY" | "DENIED" | "REFUSED" | "ABORTED" | "SPAWN_FAILED" | "OUTPUT_CAP" | "EXTENSION_FAILED";
|
|
15
|
+
export type ShellPhase = "input" | "resolve" | "authorize" | "beforeRun" | "env" | "run" | "spill" | "afterRun" | "format";
|
|
16
|
+
/** The error of a failed call. */
|
|
17
|
+
export type ShellError = ToolError<ShellErrorCode, ShellPhase>;
|
|
18
|
+
/** Facts about one run. null in an error report when the command did not start. */
|
|
19
|
+
export interface ShellRun {
|
|
20
|
+
/** As run, after any beforeRun rewrite. */
|
|
21
|
+
readonly command: string;
|
|
22
|
+
readonly cwd: string;
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
readonly exitCode: number | null;
|
|
25
|
+
readonly signal: string | null;
|
|
26
|
+
readonly durationMs: number;
|
|
27
|
+
/** What stopped the command before it ended by itself. null when it ended by itself. */
|
|
28
|
+
readonly stoppedBy: "timeout" | "abort" | "output-cap" | null;
|
|
29
|
+
/** true when the runner did not settle its exit in the grace time after a stop. */
|
|
30
|
+
readonly unconfirmedStop: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The bounded view of the merged output. `head` holds all of it when
|
|
34
|
+
* `omittedBytes` is 0. Otherwise the view is `head`, a gap, then `tail`.
|
|
35
|
+
*/
|
|
36
|
+
export interface ShellOutput {
|
|
37
|
+
readonly head: string;
|
|
38
|
+
/** null when nothing was cut. */
|
|
39
|
+
readonly tail: string | null;
|
|
40
|
+
readonly totalBytes: number;
|
|
41
|
+
readonly totalLines: number;
|
|
42
|
+
readonly omittedBytes: number;
|
|
43
|
+
readonly omittedLines: number;
|
|
44
|
+
readonly stdoutBytes: number;
|
|
45
|
+
readonly stderrBytes: number;
|
|
46
|
+
/** The spill sink's reference to the full output. null without a sink. */
|
|
47
|
+
readonly spill: string | null;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One variant for a command that ran to its own end or its timeout, and one
|
|
51
|
+
* for an error. `status` narrows the union: only the "error" variant has
|
|
52
|
+
* `error`, and it is never null there.
|
|
53
|
+
*/
|
|
54
|
+
export type ShellReport = ShellRunReport | ShellFailure;
|
|
55
|
+
export interface ShellRunReport {
|
|
56
|
+
readonly tool: "bash";
|
|
57
|
+
readonly status: "ok" | "failed" | "timeout";
|
|
58
|
+
readonly request: BashRequest;
|
|
59
|
+
readonly run: ShellRun;
|
|
60
|
+
readonly output: ShellOutput;
|
|
61
|
+
readonly notes: readonly Note[];
|
|
62
|
+
}
|
|
63
|
+
export interface ShellFailure {
|
|
64
|
+
readonly tool: "bash";
|
|
65
|
+
readonly status: "error";
|
|
66
|
+
readonly error: ShellError;
|
|
67
|
+
/** null when parse failed. */
|
|
68
|
+
readonly request: BashRequest | null;
|
|
69
|
+
/** Set when the command started: an abort, the capture cap, or a failed afterRun hook. */
|
|
70
|
+
readonly run: ShellRun | null;
|
|
71
|
+
readonly output: ShellOutput | null;
|
|
72
|
+
readonly notes: readonly Note[];
|
|
73
|
+
}
|
|
74
|
+
/** The report plus the formatter's model-facing content. */
|
|
75
|
+
export type ShellResult = ShellReport & {
|
|
76
|
+
readonly content: readonly ContentPart[];
|
|
77
|
+
};
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts one command for the bash tool. The tool handles the timeout, the
|
|
3
|
+
* abort, and the output budget.
|
|
4
|
+
*/
|
|
5
|
+
export interface CommandRunner {
|
|
6
|
+
/** Shown in the tool description, for example "node" or "just-bash (emulated)". */
|
|
7
|
+
readonly id: string;
|
|
8
|
+
/** The default working directory. The tool resolves the cwd input against it. */
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
/**
|
|
11
|
+
* Starts the command. Must not throw for a failed command or a failed
|
|
12
|
+
* start: a start that fails is an exit with `code: null` and an `error`.
|
|
13
|
+
*/
|
|
14
|
+
run(request: RunRequest): RunHandle;
|
|
15
|
+
}
|
|
16
|
+
export interface RunRequest {
|
|
17
|
+
readonly command: string;
|
|
18
|
+
/** Absolute and lexically resolved. The runner checks that it is a directory. */
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
/** The whole environment of the command. The runner adds nothing to it. */
|
|
21
|
+
readonly env: Readonly<Record<string, string>>;
|
|
22
|
+
/**
|
|
23
|
+
* The tool aborts it on a timeout, on the caller's abort, and past the
|
|
24
|
+
* capture cap. The runner must then stop the whole process tree.
|
|
25
|
+
*/
|
|
26
|
+
readonly signal: AbortSignal;
|
|
27
|
+
/** After the stop signal, the runner waits this long before it forces the stop (SIGKILL). */
|
|
28
|
+
readonly killGraceMs: number;
|
|
29
|
+
}
|
|
30
|
+
export interface OutputChunk {
|
|
31
|
+
readonly stream: "stdout" | "stderr";
|
|
32
|
+
readonly bytes: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
export interface RunHandle {
|
|
35
|
+
/**
|
|
36
|
+
* stdout and stderr chunks in arrival order. Ends soon after the process
|
|
37
|
+
* ends, even when a background child still holds the pipes open.
|
|
38
|
+
*/
|
|
39
|
+
readonly output: AsyncIterable<OutputChunk>;
|
|
40
|
+
/** Settles when the process ends. Never rejects. */
|
|
41
|
+
readonly exit: Promise<RunExit>;
|
|
42
|
+
}
|
|
43
|
+
export interface RunExit {
|
|
44
|
+
/** null when a signal ended the process or the start failed. */
|
|
45
|
+
readonly code: number | null;
|
|
46
|
+
/** The signal name, for example "SIGKILL". null for a normal exit. */
|
|
47
|
+
readonly signal: string | null;
|
|
48
|
+
/** Set when the command did not start. */
|
|
49
|
+
readonly error?: RunStartError;
|
|
50
|
+
}
|
|
51
|
+
export interface RunStartError {
|
|
52
|
+
readonly reason: "cwd-not-found" | "cwd-not-a-directory" | "spawn-failed";
|
|
53
|
+
/** Non-sensitive detail for the note. */
|
|
54
|
+
readonly detail?: string;
|
|
55
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ShellPhase } from "../contract/result.ts";
|
|
2
|
+
/** Thrown when the caller's signal aborted before the command started. `phase` names the stage. */
|
|
3
|
+
export declare class AbortStop extends Error {
|
|
4
|
+
readonly phase: ShellPhase;
|
|
5
|
+
constructor(phase?: ShellPhase);
|
|
6
|
+
}
|
|
7
|
+
export declare function raceAbort<T>(start: () => T | Promise<T>, signal: AbortSignal | undefined): Promise<T>;
|
|
8
|
+
export declare function delay(ms: number): {
|
|
9
|
+
readonly done: Promise<void>;
|
|
10
|
+
cancel(): void;
|
|
11
|
+
};
|
|
12
|
+
export declare function unref(timer: unknown): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Thrown when the caller's signal aborted before the command started. `phase` names the stage. */
|
|
2
|
+
export class AbortStop extends Error {
|
|
3
|
+
phase;
|
|
4
|
+
constructor(phase = "run") {
|
|
5
|
+
super("aborted");
|
|
6
|
+
this.phase = phase;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function raceAbort(start, signal) {
|
|
10
|
+
if (!signal)
|
|
11
|
+
return start();
|
|
12
|
+
if (signal.aborted)
|
|
13
|
+
throw new AbortStop();
|
|
14
|
+
let removeListener;
|
|
15
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
16
|
+
const onAbort = () => reject(new AbortStop());
|
|
17
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
18
|
+
removeListener = () => signal.removeEventListener("abort", onAbort);
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([(async () => start())(), aborted]);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
removeListener?.();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function delay(ms) {
|
|
28
|
+
let timer;
|
|
29
|
+
const done = new Promise((resolve) => {
|
|
30
|
+
timer = setTimeout(resolve, ms);
|
|
31
|
+
unref(timer);
|
|
32
|
+
});
|
|
33
|
+
return { done, cancel: () => clearTimeout(timer) };
|
|
34
|
+
}
|
|
35
|
+
export function unref(timer) {
|
|
36
|
+
if (timer !== null && typeof timer === "object" && "unref" in timer) {
|
|
37
|
+
const { unref: release } = timer;
|
|
38
|
+
if (typeof release === "function")
|
|
39
|
+
release.call(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ShellLimits } from "../contract/limits.ts";
|
|
2
|
+
import type { ShellOutput } from "../contract/result.ts";
|
|
3
|
+
import type { OutputChunk } from "../contract/runner.ts";
|
|
4
|
+
/** Keeps the first and last `maxOutputBytes` bytes of the merged output, and counts. */
|
|
5
|
+
export declare class OutputCapture {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(limits: Readonly<ShellLimits>);
|
|
8
|
+
get totalBytes(): number;
|
|
9
|
+
push(chunk: OutputChunk): void;
|
|
10
|
+
view(limits: Readonly<ShellLimits>, spill: string | null): ShellOutput;
|
|
11
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const NEWLINE = 0x0a;
|
|
2
|
+
/** Keeps the first and last `maxOutputBytes` bytes of the merged output, and counts. */
|
|
3
|
+
export class OutputCapture {
|
|
4
|
+
#cap;
|
|
5
|
+
#head;
|
|
6
|
+
#headLength = 0;
|
|
7
|
+
#tail = [];
|
|
8
|
+
#tailLength = 0;
|
|
9
|
+
#total = 0;
|
|
10
|
+
#newlines = 0;
|
|
11
|
+
#lastByte = -1;
|
|
12
|
+
#stdout = 0;
|
|
13
|
+
#stderr = 0;
|
|
14
|
+
constructor(limits) {
|
|
15
|
+
this.#cap = limits.maxOutputBytes;
|
|
16
|
+
this.#head = new Uint8Array(this.#cap);
|
|
17
|
+
}
|
|
18
|
+
get totalBytes() {
|
|
19
|
+
return this.#total;
|
|
20
|
+
}
|
|
21
|
+
push(chunk) {
|
|
22
|
+
const { bytes } = chunk;
|
|
23
|
+
if (bytes.byteLength === 0)
|
|
24
|
+
return;
|
|
25
|
+
this.#total += bytes.byteLength;
|
|
26
|
+
if (chunk.stream === "stderr")
|
|
27
|
+
this.#stderr += bytes.byteLength;
|
|
28
|
+
else
|
|
29
|
+
this.#stdout += bytes.byteLength;
|
|
30
|
+
for (const byte of bytes)
|
|
31
|
+
if (byte === NEWLINE)
|
|
32
|
+
this.#newlines += 1;
|
|
33
|
+
this.#lastByte = bytes[bytes.byteLength - 1] ?? this.#lastByte;
|
|
34
|
+
if (this.#headLength < this.#cap) {
|
|
35
|
+
const take = Math.min(this.#cap - this.#headLength, bytes.byteLength);
|
|
36
|
+
this.#head.set(bytes.subarray(0, take), this.#headLength);
|
|
37
|
+
this.#headLength += take;
|
|
38
|
+
}
|
|
39
|
+
// Copy: the runner may reuse its buffer.
|
|
40
|
+
const kept = bytes.byteLength > this.#cap ? bytes.slice(-this.#cap) : bytes.slice();
|
|
41
|
+
this.#tail.push(kept);
|
|
42
|
+
this.#tailLength += kept.byteLength;
|
|
43
|
+
while (this.#tail.length > 1 &&
|
|
44
|
+
this.#tailLength - (this.#tail[0]?.byteLength ?? 0) >= this.#cap) {
|
|
45
|
+
this.#tailLength -= this.#tail.shift()?.byteLength ?? 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
view(limits, spill) {
|
|
49
|
+
const total = this.#total;
|
|
50
|
+
const totalLines = this.#newlines + (total > 0 && this.#lastByte !== NEWLINE ? 1 : 0);
|
|
51
|
+
const counts = {
|
|
52
|
+
totalBytes: total,
|
|
53
|
+
totalLines,
|
|
54
|
+
stdoutBytes: this.#stdout,
|
|
55
|
+
stderrBytes: this.#stderr,
|
|
56
|
+
spill,
|
|
57
|
+
};
|
|
58
|
+
const head = this.#head.subarray(0, this.#headLength);
|
|
59
|
+
if (total <= limits.maxOutputBytes && totalLines <= limits.maxOutputLines) {
|
|
60
|
+
return { ...counts, head: text(head), tail: null, omittedBytes: 0, omittedLines: 0 };
|
|
61
|
+
}
|
|
62
|
+
const headBytes = Math.floor((limits.maxOutputBytes * limits.headPercent) / 100);
|
|
63
|
+
const headLines = Math.floor((limits.maxOutputLines * limits.headPercent) / 100);
|
|
64
|
+
const headEnd = headCut(head, headBytes, headLines);
|
|
65
|
+
const tailStore = concat(this.#tail, this.#tailLength);
|
|
66
|
+
const storeStart = total - tailStore.byteLength;
|
|
67
|
+
const tailBytes = limits.maxOutputBytes - headBytes;
|
|
68
|
+
const tailLines = limits.maxOutputLines - headLines;
|
|
69
|
+
const from = Math.max(total - tailBytes, headEnd);
|
|
70
|
+
const startsLine = this.#isLineStart(from, tailStore, storeStart);
|
|
71
|
+
const tailStart = tailCut(tailStore, from - storeStart, tailLines, startsLine);
|
|
72
|
+
const shownTail = tailStore.subarray(tailStart);
|
|
73
|
+
const shownHead = head.subarray(0, headEnd);
|
|
74
|
+
const omittedBytes = total - shownHead.byteLength - shownTail.byteLength;
|
|
75
|
+
const omittedLines = Math.max(0, totalLines - lineCount(shownHead) - lineCount(shownTail));
|
|
76
|
+
return {
|
|
77
|
+
...counts,
|
|
78
|
+
head: text(shownHead),
|
|
79
|
+
tail: text(shownTail),
|
|
80
|
+
omittedBytes,
|
|
81
|
+
omittedLines,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
#isLineStart(position, tailStore, storeStart) {
|
|
85
|
+
const before = position - 1;
|
|
86
|
+
return (position === 0 ||
|
|
87
|
+
(before >= storeStart
|
|
88
|
+
? tailStore[before - storeStart] === NEWLINE
|
|
89
|
+
: before < this.#headLength && this.#head[before] === NEWLINE));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function headCut(head, bytes, lines) {
|
|
93
|
+
if (lines <= 0)
|
|
94
|
+
return 0;
|
|
95
|
+
const limit = Math.min(bytes, head.byteLength);
|
|
96
|
+
let seen = 0;
|
|
97
|
+
let lastNewline = -1;
|
|
98
|
+
for (let index = 0; index < limit; index += 1) {
|
|
99
|
+
if (head[index] !== NEWLINE)
|
|
100
|
+
continue;
|
|
101
|
+
seen += 1;
|
|
102
|
+
lastNewline = index;
|
|
103
|
+
if (seen >= lines)
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (lastNewline >= 0)
|
|
107
|
+
return lastNewline + 1;
|
|
108
|
+
return charBoundaryBack(head, limit);
|
|
109
|
+
}
|
|
110
|
+
function tailCut(store, from, lines, startsLine) {
|
|
111
|
+
let start = from;
|
|
112
|
+
if (!startsLine) {
|
|
113
|
+
const next = store.indexOf(NEWLINE, start);
|
|
114
|
+
if (next >= 0 && next < store.byteLength - 1)
|
|
115
|
+
start = next + 1;
|
|
116
|
+
else
|
|
117
|
+
start = charBoundaryForward(store, start);
|
|
118
|
+
}
|
|
119
|
+
const endBeforeFinalNewline = store[store.byteLength - 1] === NEWLINE ? store.byteLength - 1 : store.byteLength;
|
|
120
|
+
let seen = 0;
|
|
121
|
+
for (let index = endBeforeFinalNewline - 1; index >= start; index -= 1) {
|
|
122
|
+
if (store[index] !== NEWLINE)
|
|
123
|
+
continue;
|
|
124
|
+
seen += 1;
|
|
125
|
+
if (seen >= lines)
|
|
126
|
+
return index + 1;
|
|
127
|
+
}
|
|
128
|
+
return lines === 0 ? store.byteLength : start;
|
|
129
|
+
}
|
|
130
|
+
function charBoundaryBack(bytes, end) {
|
|
131
|
+
let cut = end;
|
|
132
|
+
while (cut > 0 && cut < bytes.byteLength && isContinuation(bytes[cut]))
|
|
133
|
+
cut -= 1;
|
|
134
|
+
return cut;
|
|
135
|
+
}
|
|
136
|
+
function charBoundaryForward(bytes, start) {
|
|
137
|
+
let cut = start;
|
|
138
|
+
while (cut < bytes.byteLength && isContinuation(bytes[cut]))
|
|
139
|
+
cut += 1;
|
|
140
|
+
return cut;
|
|
141
|
+
}
|
|
142
|
+
function isContinuation(byte) {
|
|
143
|
+
return byte !== undefined && (byte & 0xc0) === 0x80;
|
|
144
|
+
}
|
|
145
|
+
function lineCount(bytes) {
|
|
146
|
+
if (bytes.byteLength === 0)
|
|
147
|
+
return 0;
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (const byte of bytes)
|
|
150
|
+
if (byte === NEWLINE)
|
|
151
|
+
count += 1;
|
|
152
|
+
return bytes[bytes.byteLength - 1] === NEWLINE ? count : count + 1;
|
|
153
|
+
}
|
|
154
|
+
function concat(chunks, length) {
|
|
155
|
+
const out = new Uint8Array(length);
|
|
156
|
+
let offset = 0;
|
|
157
|
+
for (const chunk of chunks) {
|
|
158
|
+
out.set(chunk, offset);
|
|
159
|
+
offset += chunk.byteLength;
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
const CONTROL_SEQUENCE = "\\u001b\\[[0-?]*[ -/]*[@-~]";
|
|
164
|
+
const OPERATING_SYSTEM_COMMAND = "\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)";
|
|
165
|
+
const TWO_BYTE_ESCAPE = "\\u001b[@-Z\\\\-_]";
|
|
166
|
+
const EIGHT_BIT_CONTROL_SEQUENCE = "\\u009b[0-?]*[ -/]*[@-~]";
|
|
167
|
+
const ANSI_ESCAPE = new RegExp([CONTROL_SEQUENCE, OPERATING_SYSTEM_COMMAND, TWO_BYTE_ESCAPE, EIGHT_BIT_CONTROL_SEQUENCE].join("|"), "gu");
|
|
168
|
+
function text(bytes) {
|
|
169
|
+
const decoded = new TextDecoder("utf-8").decode(bytes).replace(ANSI_ESCAPE, "");
|
|
170
|
+
return decoded.endsWith("\n") ? decoded.slice(0, -1) : decoded;
|
|
171
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BashTool } from "../contract/context.ts";
|
|
2
|
+
import type { ShellToolDeps } from "../contract/deps.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Creates the bash tool. Throws TypeError on an unknown key, a missing runner
|
|
5
|
+
* or env, or a malformed dependency. `limits` and `messages` merge key by key.
|
|
6
|
+
* Every other dependency replaces its default.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createBashTool<THost = undefined>(deps: ShellToolDeps<THost>): BashTool<THost>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolveShellDependencies } from "./deps.js";
|
|
2
|
+
import { isRecord } from "./input.js";
|
|
3
|
+
import { runBash } from "./pipeline.js";
|
|
4
|
+
/**
|
|
5
|
+
* Creates the bash tool. Throws TypeError on an unknown key, a missing runner
|
|
6
|
+
* or env, or a malformed dependency. `limits` and `messages` merge key by key.
|
|
7
|
+
* Every other dependency replaces its default.
|
|
8
|
+
*/
|
|
9
|
+
export function createBashTool(deps) {
|
|
10
|
+
const resolved = resolveShellDependencies(deps);
|
|
11
|
+
const bash = async (input, ctx) => {
|
|
12
|
+
if (ctx !== undefined && !isRecord(ctx))
|
|
13
|
+
throw new TypeError("bash context must be an object");
|
|
14
|
+
const call = ctx ?? {};
|
|
15
|
+
return runBash(resolved, input, call);
|
|
16
|
+
};
|
|
17
|
+
return bash;
|
|
18
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ShellDependencies, ShellToolDeps } from "../contract/deps.ts";
|
|
2
|
+
import type { CommandRunner } from "../contract/runner.ts";
|
|
3
|
+
export declare function resolveShellDependencies<THost>(deps: ShellToolDeps<THost>): ShellDependencies<THost>;
|
|
4
|
+
export declare function isRunner(value: unknown): value is CommandRunner;
|
|
5
|
+
export declare function isAbsolute(value: unknown): value is string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { defaultShellFormatter } from "../formatters/default.js";
|
|
2
|
+
import { isRecord } from "./input.js";
|
|
3
|
+
import { resolveShellLimits } from "./limits.js";
|
|
4
|
+
import { resolveShellMessages } from "./messages.js";
|
|
5
|
+
const KNOWN = new Set([
|
|
6
|
+
"runner",
|
|
7
|
+
"cwd",
|
|
8
|
+
"limits",
|
|
9
|
+
"messages",
|
|
10
|
+
"resolve",
|
|
11
|
+
"authorize",
|
|
12
|
+
"beforeRun",
|
|
13
|
+
"env",
|
|
14
|
+
"spill",
|
|
15
|
+
"afterRun",
|
|
16
|
+
"formatter",
|
|
17
|
+
"digest",
|
|
18
|
+
"clock",
|
|
19
|
+
]);
|
|
20
|
+
export function resolveShellDependencies(deps) {
|
|
21
|
+
if (!isRecord(deps))
|
|
22
|
+
throw new TypeError("bash tool dependencies must be an object");
|
|
23
|
+
for (const key of Object.keys(deps)) {
|
|
24
|
+
if (!KNOWN.has(key))
|
|
25
|
+
throw new TypeError(`Unknown bash tool dependency: ${key}`);
|
|
26
|
+
}
|
|
27
|
+
const { runner } = deps;
|
|
28
|
+
if (typeof runner !== "function" && !isRunner(runner)) {
|
|
29
|
+
throw new TypeError("runner must be a command runner or a function that returns one");
|
|
30
|
+
}
|
|
31
|
+
const cwd = deps.cwd ?? null;
|
|
32
|
+
if (cwd !== null && typeof cwd !== "function" && !isAbsolute(cwd)) {
|
|
33
|
+
throw new TypeError("cwd must be an absolute path, a function that returns one, or null");
|
|
34
|
+
}
|
|
35
|
+
const resolve = deps.resolve ?? null;
|
|
36
|
+
if (resolve !== null && (!isRecord(resolve) || typeof resolve.resolve !== "function")) {
|
|
37
|
+
throw new TypeError("resolve must be a path resolver or null");
|
|
38
|
+
}
|
|
39
|
+
const authorize = deps.authorize ?? null;
|
|
40
|
+
if (authorize !== null && (!isRecord(authorize) || typeof authorize.authorize !== "function")) {
|
|
41
|
+
throw new TypeError("authorize must be an authorizer or null");
|
|
42
|
+
}
|
|
43
|
+
const beforeRun = deps.beforeRun ?? [];
|
|
44
|
+
if (!Array.isArray(beforeRun) || !beforeRun.every(isBeforeRunHook)) {
|
|
45
|
+
throw new TypeError("beforeRun must be an array of hooks with an id and beforeRun");
|
|
46
|
+
}
|
|
47
|
+
const { env } = deps;
|
|
48
|
+
if (env === undefined) {
|
|
49
|
+
throw new TypeError("env is required: pass shellEnv(() => process.env), an allow list such as shellEnv({ PATH }), or shellEnv()");
|
|
50
|
+
}
|
|
51
|
+
if (typeof env !== "function")
|
|
52
|
+
throw new TypeError("env must be a function");
|
|
53
|
+
const spill = deps.spill ?? null;
|
|
54
|
+
if (spill !== null && (!isRecord(spill) || typeof spill.open !== "function")) {
|
|
55
|
+
throw new TypeError("spill must be a spill sink with open, or null");
|
|
56
|
+
}
|
|
57
|
+
const afterRun = deps.afterRun ?? [];
|
|
58
|
+
if (!Array.isArray(afterRun) || !afterRun.every(isAfterRunHook)) {
|
|
59
|
+
throw new TypeError("afterRun must be an array of hooks with an id and afterRun");
|
|
60
|
+
}
|
|
61
|
+
const formatter = deps.formatter ?? defaultShellFormatter();
|
|
62
|
+
if (!isRecord(formatter) || typeof formatter.format !== "function") {
|
|
63
|
+
throw new TypeError("formatter must have a format function");
|
|
64
|
+
}
|
|
65
|
+
const digest = deps.digest ?? null;
|
|
66
|
+
if (digest !== null &&
|
|
67
|
+
(!isRecord(digest) || typeof digest.create !== "function" || typeof digest.hash !== "function")) {
|
|
68
|
+
throw new TypeError("digest must be a digest with create and hash, or null");
|
|
69
|
+
}
|
|
70
|
+
const clock = deps.clock ?? (() => new Date());
|
|
71
|
+
if (typeof clock !== "function")
|
|
72
|
+
throw new TypeError("clock must be a function");
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
runner,
|
|
75
|
+
cwd,
|
|
76
|
+
limits: resolveShellLimits(deps.limits),
|
|
77
|
+
messages: resolveShellMessages(deps.messages),
|
|
78
|
+
resolve,
|
|
79
|
+
authorize,
|
|
80
|
+
beforeRun: Object.freeze([...beforeRun]),
|
|
81
|
+
env,
|
|
82
|
+
spill,
|
|
83
|
+
afterRun: Object.freeze([...afterRun]),
|
|
84
|
+
formatter,
|
|
85
|
+
digest,
|
|
86
|
+
clock,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export function isRunner(value) {
|
|
90
|
+
return (isRecord(value) &&
|
|
91
|
+
typeof value.id === "string" &&
|
|
92
|
+
isAbsolute(value.cwd) &&
|
|
93
|
+
typeof value.run === "function");
|
|
94
|
+
}
|
|
95
|
+
export function isAbsolute(value) {
|
|
96
|
+
return typeof value === "string" && value.startsWith("/");
|
|
97
|
+
}
|
|
98
|
+
function isBeforeRunHook(value) {
|
|
99
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.beforeRun === "function";
|
|
100
|
+
}
|
|
101
|
+
function isAfterRunHook(value) {
|
|
102
|
+
return isRecord(value) && typeof value.id === "string" && typeof value.afterRun === "function";
|
|
103
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type EnvRecord = Readonly<Record<string, string | undefined>>;
|
|
2
|
+
/**
|
|
3
|
+
* Variables shellEnv sets over the base, so a command does not wait on a
|
|
4
|
+
* pager or a prompt and does not print colour codes.
|
|
5
|
+
*/
|
|
6
|
+
export declare const defaultShellEnv: Readonly<Record<string, string>>;
|
|
7
|
+
/**
|
|
8
|
+
* An env dependency for any host: `base` with defaultShellEnv over it.
|
|
9
|
+
* Entries whose value is undefined are left out. A function base is read
|
|
10
|
+
* again on each call, so `shellEnv(() => process.env)` follows changes to
|
|
11
|
+
* process.env.
|
|
12
|
+
*/
|
|
13
|
+
export declare function shellEnv(base?: EnvRecord | (() => EnvRecord)): () => Readonly<Record<string, string>>;
|
|
14
|
+
export {};
|
package/dist/core/env.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variables shellEnv sets over the base, so a command does not wait on a
|
|
3
|
+
* pager or a prompt and does not print colour codes.
|
|
4
|
+
*/
|
|
5
|
+
export const defaultShellEnv = Object.freeze({
|
|
6
|
+
PAGER: "cat",
|
|
7
|
+
GIT_PAGER: "cat",
|
|
8
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
9
|
+
NO_COLOR: "1",
|
|
10
|
+
TERM: "dumb",
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* An env dependency for any host: `base` with defaultShellEnv over it.
|
|
14
|
+
* Entries whose value is undefined are left out. A function base is read
|
|
15
|
+
* again on each call, so `shellEnv(() => process.env)` follows changes to
|
|
16
|
+
* process.env.
|
|
17
|
+
*/
|
|
18
|
+
export function shellEnv(base = {}) {
|
|
19
|
+
if (typeof base === "function")
|
|
20
|
+
return () => merge(base());
|
|
21
|
+
const fixed = merge(base);
|
|
22
|
+
return () => fixed;
|
|
23
|
+
}
|
|
24
|
+
function merge(base) {
|
|
25
|
+
const merged = {};
|
|
26
|
+
for (const [key, value] of Object.entries(base)) {
|
|
27
|
+
if (typeof value === "string")
|
|
28
|
+
merged[key] = value;
|
|
29
|
+
}
|
|
30
|
+
Object.assign(merged, defaultShellEnv);
|
|
31
|
+
return Object.freeze(merged);
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PlannedRun } from "../contract/extensions.ts";
|
|
2
|
+
import type { ShellOutput, ShellRun } from "../contract/result.ts";
|
|
3
|
+
import type { CommandRunner } from "../contract/runner.ts";
|
|
4
|
+
import type { CallScope } from "./stages.ts";
|
|
5
|
+
import { StageStop } from "./stop.ts";
|
|
6
|
+
export interface Executed {
|
|
7
|
+
readonly run: ShellRun;
|
|
8
|
+
readonly output: ShellOutput;
|
|
9
|
+
readonly stop: StageStop | null;
|
|
10
|
+
}
|
|
11
|
+
export declare function execute<THost>(scope: CallScope<THost>, runner: CommandRunner, planned: PlannedRun, env: Readonly<Record<string, string>>): Promise<Executed>;
|