@crewhaus/tool-proc 0.7.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/dist/index.d.ts +18 -0
- package/dist/index.js +972 -0
- package/dist/lib/backoff.d.ts +26 -0
- package/dist/lib/backoff.js +34 -0
- package/dist/lib/env.d.ts +47 -0
- package/dist/lib/env.js +89 -0
- package/dist/lib/format.d.ts +38 -0
- package/dist/lib/format.js +124 -0
- package/dist/registry.d.ts +60 -0
- package/dist/registry.js +173 -0
- package/dist/safe-path.d.ts +57 -0
- package/dist/safe-path.js +165 -0
- package/dist/spawn.d.ts +28 -0
- package/dist/spawn.js +156 -0
- package/package.json +45 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Is `candidate` the root itself, or something beneath it? Both arguments
|
|
5
|
+
* must already be REAL paths — this is a string containment test, and it is
|
|
6
|
+
* only sound once the symlinks are out of the way.
|
|
7
|
+
*/
|
|
8
|
+
export function isInsideRoot(candidate, root) {
|
|
9
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* True when the NAME exists, whether or not it leads anywhere.
|
|
13
|
+
*
|
|
14
|
+
* `existsSync` follows symlinks, so it answers false for a link whose target
|
|
15
|
+
* is missing — and a missing target is exactly the case that matters here: a
|
|
16
|
+
* dangling link is still a door. A walk that probes with it strides straight
|
|
17
|
+
* past the link, treats it as a plain missing leaf, pushes the name onto the
|
|
18
|
+
* tail and re-appends it to the realpath'd parent, so containment is decided
|
|
19
|
+
* on a path the link does not actually lead to. Probing with `lstat` — a
|
|
20
|
+
* dangling link is a NAME that exists — keeps that name in the part of the
|
|
21
|
+
* path that gets RESOLVED rather than in the "does not exist yet" tail that
|
|
22
|
+
* is appended to the root verbatim.
|
|
23
|
+
*/
|
|
24
|
+
function nameExists(p) {
|
|
25
|
+
try {
|
|
26
|
+
lstatSync(p);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Where `target` would actually land, with every symlink on the way already
|
|
35
|
+
* followed — including one whose own target does not exist yet.
|
|
36
|
+
*
|
|
37
|
+
* `realpathSync` gives up with ENOENT on a dangling link, which would leave
|
|
38
|
+
* that link unresolved and let it stand in for a plain missing file. So the
|
|
39
|
+
* deepest ancestor that exists as a name is resolved, a dangling one is
|
|
40
|
+
* followed a hop by hand, and the components that do not exist are appended.
|
|
41
|
+
* The result is the path an `open` would reach, which is the only path worth
|
|
42
|
+
* checking containment against.
|
|
43
|
+
*
|
|
44
|
+
* Throws if the chain cannot be followed at all; `resolveSafe`'s catch turns
|
|
45
|
+
* that into a refusal, so an unresolvable path fails closed.
|
|
46
|
+
*/
|
|
47
|
+
function resolveLocation(target, depth = 0) {
|
|
48
|
+
if (depth > 40)
|
|
49
|
+
throw new Error(`symlink chain at "${target}" is too long to resolve`);
|
|
50
|
+
let probe = target;
|
|
51
|
+
const tail = [];
|
|
52
|
+
while (!nameExists(probe)) {
|
|
53
|
+
tail.unshift(path.basename(probe));
|
|
54
|
+
const parent = path.dirname(probe);
|
|
55
|
+
if (parent === probe)
|
|
56
|
+
break; // reached the filesystem root
|
|
57
|
+
probe = parent;
|
|
58
|
+
}
|
|
59
|
+
let probeReal;
|
|
60
|
+
try {
|
|
61
|
+
probeReal = realpathSync(probe);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
if (err.code !== "ENOENT")
|
|
65
|
+
throw err;
|
|
66
|
+
// The name is there but `realpath` cannot finish it: a symlink with a
|
|
67
|
+
// missing target. `readlinkSync` throws EINVAL on anything else, which
|
|
68
|
+
// fails closed. Recursing (rather than returning the raw target) is what
|
|
69
|
+
// resolves an absolute target such as /var/folders/... to its real
|
|
70
|
+
// /private/var/... form, so a legitimate in-workspace dangling link is
|
|
71
|
+
// not wrongly refused.
|
|
72
|
+
const link = readlinkSync(probe);
|
|
73
|
+
// A RELATIVE target resolves against the directory that actually CONTAINS
|
|
74
|
+
// the link, which is not the link's lexical parent when that parent is
|
|
75
|
+
// itself reached through a symlink. `<root>/dirlink/x -> ../y` with
|
|
76
|
+
// `dirlink` pointing out of the root lands at `<elsewhere>/y`, but
|
|
77
|
+
// measured from the lexical parent it reads as `<root>/y` and would be
|
|
78
|
+
// admitted; the mirror case wrongly refuses an in-root link. So the
|
|
79
|
+
// parent is made real first. An absolute target ignores the base.
|
|
80
|
+
const base = realpathSync(path.dirname(probe));
|
|
81
|
+
probeReal = resolveLocation(path.resolve(base, link), depth + 1);
|
|
82
|
+
}
|
|
83
|
+
return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Re-check, at the moment of use, that a path resolved earlier still lives
|
|
87
|
+
* inside the root.
|
|
88
|
+
*
|
|
89
|
+
* `resolveSafe` can only resolve the symlinks that exist when it is called.
|
|
90
|
+
* A path that does not exist at all is admitted on the strength of its
|
|
91
|
+
* parent, and a link planted or swapped AFTER that call was never seen. A
|
|
92
|
+
* tool that polls a path for seconds or minutes — `WaitForFile` does exactly
|
|
93
|
+
* that — would happily report on the target once somebody creates it outside
|
|
94
|
+
* the root, so every poll asks again.
|
|
95
|
+
*
|
|
96
|
+
* (An outward DANGLING link is refused up front now, because `resolveSafe`
|
|
97
|
+
* follows it by hand rather than mistaking it for a missing file. This
|
|
98
|
+
* remains the guard for everything that only becomes a symlink later.)
|
|
99
|
+
*
|
|
100
|
+
* Returns `"absent"` when nothing is there yet (which is not an escape — it
|
|
101
|
+
* is the condition `WaitForFile` may be waiting for), `"inside"` when the
|
|
102
|
+
* real path is contained, and `"escaped"` when it is not.
|
|
103
|
+
*/
|
|
104
|
+
export function recheckContainment(target, root = process.cwd()) {
|
|
105
|
+
let rootReal;
|
|
106
|
+
try {
|
|
107
|
+
rootReal = realpathSync(path.resolve(root));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return "escaped";
|
|
111
|
+
}
|
|
112
|
+
let real;
|
|
113
|
+
try {
|
|
114
|
+
real = realpathSync(target);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Nothing (or a dangling link) is there: there is no target to escape
|
|
118
|
+
// to yet, and the caller reads this as "absent".
|
|
119
|
+
return "absent";
|
|
120
|
+
}
|
|
121
|
+
return isInsideRoot(real, rootReal) ? "inside" : "escaped";
|
|
122
|
+
}
|
|
123
|
+
export function resolveSafe(toolName, rel, root = process.cwd()) {
|
|
124
|
+
const refusal = (why) => ({
|
|
125
|
+
ok: false,
|
|
126
|
+
message: `[${toolName} error] refused path "${rel}": ${why}. Paths must stay inside the workspace root.`,
|
|
127
|
+
});
|
|
128
|
+
const rootResolved = path.resolve(root);
|
|
129
|
+
const abs = path.resolve(rootResolved, rel);
|
|
130
|
+
if (!isInsideRoot(abs, rootResolved)) {
|
|
131
|
+
return refusal("it resolves outside the workspace root");
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const rootReal = realpathSync(rootResolved);
|
|
135
|
+
// The leaf may not exist yet (a wait can watch for a file's creation),
|
|
136
|
+
// so `resolveLocation` walks up to the deepest ancestor that exists as a
|
|
137
|
+
// NAME and re-appends the tail. "As a name" rather than "exists": a
|
|
138
|
+
// symlink whose target is missing is still followed by whatever opens
|
|
139
|
+
// this path later, so it is resolved here too.
|
|
140
|
+
const real = resolveLocation(abs);
|
|
141
|
+
if (!isInsideRoot(real, rootReal)) {
|
|
142
|
+
return refusal("a symlink on it leads outside the workspace root");
|
|
143
|
+
}
|
|
144
|
+
return { ok: true, path: real };
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return refusal("it could not be resolved");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Containment for a directory that must already exist (a command's cwd).
|
|
152
|
+
*
|
|
153
|
+
* `existsSync` is the right probe HERE, unlike in the walk above: a cwd has
|
|
154
|
+
* to be a directory the OS can actually chdir into, so a name that leads
|
|
155
|
+
* nowhere is useless and following the link is exactly what is wanted.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveSafeDir(toolName, rel, root) {
|
|
158
|
+
const resolved = resolveSafe(toolName, rel, root);
|
|
159
|
+
if (!resolved.ok)
|
|
160
|
+
return resolved;
|
|
161
|
+
if (!existsSync(resolved.path)) {
|
|
162
|
+
return { ok: false, message: `[${toolName} error] directory "${rel}" does not exist.` };
|
|
163
|
+
}
|
|
164
|
+
return resolved;
|
|
165
|
+
}
|
package/dist/spawn.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type RunOutcome = {
|
|
2
|
+
readonly argv: readonly string[];
|
|
3
|
+
readonly exitCode: number;
|
|
4
|
+
readonly stdout: string;
|
|
5
|
+
readonly stderr: string;
|
|
6
|
+
readonly stdoutTruncated: boolean;
|
|
7
|
+
readonly stderrTruncated: boolean;
|
|
8
|
+
readonly timedOut: boolean;
|
|
9
|
+
readonly durationMs: number;
|
|
10
|
+
/** Set when the command could not be started at all (e.g. no such file). */
|
|
11
|
+
readonly spawnError?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Set when the child outlived even the SIGKILL grace and was left behind:
|
|
14
|
+
* the result is what had been captured by then, and `exitCode` is unknown.
|
|
15
|
+
*/
|
|
16
|
+
readonly abandoned?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type RunOptions = {
|
|
19
|
+
readonly cwd: string;
|
|
20
|
+
readonly env: Record<string, string>;
|
|
21
|
+
readonly stdin?: string;
|
|
22
|
+
readonly timeoutMs: number;
|
|
23
|
+
readonly maxOutputChars: number;
|
|
24
|
+
readonly signal?: AbortSignal;
|
|
25
|
+
};
|
|
26
|
+
export declare function runOnce(argv: readonly string[], options: RunOptions): Promise<RunOutcome>;
|
|
27
|
+
/** A deadline-aware sleep: resolves early (and reports it) when aborted. */
|
|
28
|
+
export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { capText } from "./lib/format";
|
|
2
|
+
/**
|
|
3
|
+
* The one place this package starts a child process.
|
|
4
|
+
*
|
|
5
|
+
* Everything that runs a command — RunCommand, RunPipeline, Retry and the
|
|
6
|
+
* background family — goes through here so a single set of rules holds:
|
|
7
|
+
*
|
|
8
|
+
* - argv is passed to the OS as an ARRAY. There is no shell anywhere in
|
|
9
|
+
* this package, so a filename with a space or a `$(...)` in it is an
|
|
10
|
+
* argument and never a command.
|
|
11
|
+
* - stdin is `ignore` unless the caller supplied it, so a command that
|
|
12
|
+
* reads stdin gets EOF immediately instead of hanging on a terminal
|
|
13
|
+
* that is not there.
|
|
14
|
+
* - every run has a deadline: SIGTERM on expiry, SIGKILL after a short
|
|
15
|
+
* grace period for a child that ignores the first signal.
|
|
16
|
+
* - both streams are capped, so one chatty command cannot fill a context
|
|
17
|
+
* window.
|
|
18
|
+
*/
|
|
19
|
+
/** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
|
|
20
|
+
const KILL_GRACE_MS = 2_000;
|
|
21
|
+
/**
|
|
22
|
+
* A process that forks a grandchild keeps the pipe's write end open after
|
|
23
|
+
* the child itself is gone, so reading to EOF can outlive the process. Give
|
|
24
|
+
* each stream a bounded drain window and move on with what arrived.
|
|
25
|
+
*/
|
|
26
|
+
const DRAIN_GRACE_MS = 500;
|
|
27
|
+
/**
|
|
28
|
+
* After the SIGKILL, how long to keep waiting for the child to be reaped
|
|
29
|
+
* before returning without it.
|
|
30
|
+
*
|
|
31
|
+
* SIGKILL cannot be caught, but it cannot interrupt a process wedged in an
|
|
32
|
+
* uninterruptible kernel wait either — a stalled NFS read, a wayward driver.
|
|
33
|
+
* `proc.exited` then never resolves, and every other bound in this file is
|
|
34
|
+
* downstream of that await, so the whole call hangs with no deadline left to
|
|
35
|
+
* save it. This is the last bound: past it the tool reports what it has and
|
|
36
|
+
* lets go. (There is no portable way to put a process into that state, so
|
|
37
|
+
* this is the one guarantee here with no test behind it.)
|
|
38
|
+
*/
|
|
39
|
+
const REAP_GRACE_MS = 1_000;
|
|
40
|
+
export async function runOnce(argv, options) {
|
|
41
|
+
const started = performance.now();
|
|
42
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
43
|
+
let proc;
|
|
44
|
+
try {
|
|
45
|
+
proc = Bun.spawn([...argv], {
|
|
46
|
+
cwd: options.cwd,
|
|
47
|
+
env: options.env,
|
|
48
|
+
stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
|
|
49
|
+
stdout: "pipe",
|
|
50
|
+
stderr: "pipe",
|
|
51
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
argv,
|
|
57
|
+
exitCode: -1,
|
|
58
|
+
stdout: "",
|
|
59
|
+
stderr: "",
|
|
60
|
+
stdoutTruncated: false,
|
|
61
|
+
stderrTruncated: false,
|
|
62
|
+
timedOut: false,
|
|
63
|
+
durationMs: elapsed(),
|
|
64
|
+
spawnError: err instanceof Error ? err.message : String(err),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let reapGiveUp;
|
|
69
|
+
const term = setTimeout(() => {
|
|
70
|
+
timedOut = true;
|
|
71
|
+
try {
|
|
72
|
+
proc.kill("SIGTERM");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Already gone between the timer firing and the signal.
|
|
76
|
+
}
|
|
77
|
+
}, options.timeoutMs);
|
|
78
|
+
const hardKill = setTimeout(() => {
|
|
79
|
+
try {
|
|
80
|
+
proc.kill("SIGKILL");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Already gone.
|
|
84
|
+
}
|
|
85
|
+
}, options.timeoutMs + KILL_GRACE_MS);
|
|
86
|
+
try {
|
|
87
|
+
// The spread options above widen Bun's stdio inference, so name the
|
|
88
|
+
// piped shape explicitly rather than let the union leak outward.
|
|
89
|
+
// `.catch` is attached HERE, not after the race: the loser of the race
|
|
90
|
+
// below stays pending, and a stream torn down with the killed process
|
|
91
|
+
// would otherwise reject with nobody listening — an unhandled rejection
|
|
92
|
+
// that takes down the harness rather than the command that caused it.
|
|
93
|
+
const stdoutText = new Response(proc.stdout)
|
|
94
|
+
.text()
|
|
95
|
+
.catch(() => "");
|
|
96
|
+
const stderrText = new Response(proc.stderr)
|
|
97
|
+
.text()
|
|
98
|
+
.catch(() => "");
|
|
99
|
+
const abandonedMarker = Symbol("abandoned");
|
|
100
|
+
const reaped = await Promise.race([
|
|
101
|
+
proc.exited,
|
|
102
|
+
new Promise((resolve) => {
|
|
103
|
+
reapGiveUp = setTimeout(() => resolve(abandonedMarker), options.timeoutMs + KILL_GRACE_MS + REAP_GRACE_MS);
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
const abandoned = reaped === abandonedMarker;
|
|
107
|
+
const exitCode = abandoned ? -1 : reaped;
|
|
108
|
+
const drainFallback = () => new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
|
|
109
|
+
const [rawOut, rawErr] = await Promise.all([
|
|
110
|
+
Promise.race([stdoutText, drainFallback()]),
|
|
111
|
+
Promise.race([stderrText, drainFallback()]),
|
|
112
|
+
]);
|
|
113
|
+
const out = capText(rawOut, options.maxOutputChars);
|
|
114
|
+
const err = capText(rawErr, options.maxOutputChars);
|
|
115
|
+
return {
|
|
116
|
+
argv,
|
|
117
|
+
exitCode,
|
|
118
|
+
stdout: out.text,
|
|
119
|
+
stderr: err.text,
|
|
120
|
+
stdoutTruncated: out.truncated,
|
|
121
|
+
stderrTruncated: err.truncated,
|
|
122
|
+
timedOut: timedOut || abandoned,
|
|
123
|
+
durationMs: elapsed(),
|
|
124
|
+
...(abandoned ? { abandoned: true } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
clearTimeout(term);
|
|
129
|
+
clearTimeout(hardKill);
|
|
130
|
+
// Unconditional: left running, this one would hold the event loop open
|
|
131
|
+
// for the whole timeout on every command that finished in a millisecond.
|
|
132
|
+
if (reapGiveUp !== undefined)
|
|
133
|
+
clearTimeout(reapGiveUp);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** A deadline-aware sleep: resolves early (and reports it) when aborted. */
|
|
137
|
+
export function sleep(ms, signal) {
|
|
138
|
+
if (ms <= 0)
|
|
139
|
+
return Promise.resolve();
|
|
140
|
+
// An ALREADY-aborted signal never fires `abort` again, so without this an
|
|
141
|
+
// abort that landed a moment before the call would still cost the full
|
|
142
|
+
// sleep — the one wait in the package that could outlive its own deadline.
|
|
143
|
+
if (signal?.aborted === true)
|
|
144
|
+
return Promise.resolve();
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const timer = setTimeout(() => {
|
|
147
|
+
signal?.removeEventListener("abort", onAbort);
|
|
148
|
+
resolve();
|
|
149
|
+
}, ms);
|
|
150
|
+
function onAbort() {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
resolve();
|
|
153
|
+
}
|
|
154
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
155
|
+
});
|
|
156
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-proc",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic process tools: background processes, log tailing, port and file waits, and retry with backoff",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Max Meier",
|
|
29
|
+
"email": "max@crewhaus.ai",
|
|
30
|
+
"url": "https://crewhaus.ai"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
35
|
+
"directory": "packages/tool-proc"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-proc#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
45
|
+
}
|