@crewhaus/tool-pkgmgr 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/fixtures.d.ts +174 -0
- package/dist/fixtures.js +334 -0
- package/dist/host.d.ts +21 -0
- package/dist/host.js +90 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +434 -0
- package/dist/lib/managers.d.ts +172 -0
- package/dist/lib/managers.js +266 -0
- package/dist/lib/names.d.ts +106 -0
- package/dist/lib/names.js +174 -0
- package/dist/lib/parse.d.ts +308 -0
- package/dist/lib/parse.js +572 -0
- package/dist/lib/plan.d.ts +88 -0
- package/dist/lib/plan.js +351 -0
- package/dist/lib/query.d.ts +106 -0
- package/dist/lib/query.js +552 -0
- package/dist/lib/unknown.d.ts +68 -0
- package/dist/lib/unknown.js +85 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/run.d.ts +115 -0
- package/dist/run.js +295 -0
- package/package.json +46 -0
package/dist/run.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place this package starts a child process, and the seam every test
|
|
3
|
+
* drives instead of the machine.
|
|
4
|
+
*
|
|
5
|
+
* The conventions are `@crewhaus/tool-proc`'s (`src/spawn.ts`) and
|
|
6
|
+
* `@crewhaus/tool-host`'s (`src/run.ts`), copied rather than re-invented —
|
|
7
|
+
* those packages export their tools and not their runners, and reaching past
|
|
8
|
+
* an exports map would be worse than a documented copy:
|
|
9
|
+
*
|
|
10
|
+
* - ARGV IS AN ARRAY. There is no shell in this package. No `sh -c`, no
|
|
11
|
+
* `cmd /c`, no interpolated command line, nothing that could turn a
|
|
12
|
+
* package name into syntax. `assertArgv` refuses anything that would make
|
|
13
|
+
* that untrue before a process exists.
|
|
14
|
+
* - EVERY RUN HAS A DEADLINE. SIGTERM at the deadline, SIGKILL after a
|
|
15
|
+
* grace period, and a bounded drain so a manager that forks (apt-get
|
|
16
|
+
* forks dpkg; brew forks git) cannot hold the pipe open past its death.
|
|
17
|
+
* - THE ENVIRONMENT IS PINNED, AND PINNED NON-INTERACTIVE. Every parser
|
|
18
|
+
* here reads English keywords out of a manager's output, and `apt-cache
|
|
19
|
+
* policy`, `dnf`, `pacman` and `brew` all translate theirs through
|
|
20
|
+
* gettext, so `LC_ALL=C` is what makes the bytes the same on a German
|
|
21
|
+
* laptop and an English CI box. `DEBIAN_FRONTEND=noninteractive` and
|
|
22
|
+
* `NONINTERACTIVE=1` are the other half: a manager that opens a dialog or
|
|
23
|
+
* asks a question on a pipe is a command that never exits, and this
|
|
24
|
+
* package's whole contract is that it does not hang and does not prompt.
|
|
25
|
+
*
|
|
26
|
+
* `_setRunner` replaces all of it. Every parser in this package is fed from
|
|
27
|
+
* output recorded on a real machine, because CI is a Linux container with no
|
|
28
|
+
* package manager at all and this was written on macOS: a parser checked
|
|
29
|
+
* against whatever the test host happens to answer is a parser checked
|
|
30
|
+
* against nothing.
|
|
31
|
+
*/
|
|
32
|
+
/** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
|
|
33
|
+
const KILL_GRACE_MS = 2_000;
|
|
34
|
+
/**
|
|
35
|
+
* A child that forks a grandchild leaves the pipe's write end open after it
|
|
36
|
+
* is gone, so reading to EOF can outlive the process. Bound the drain and
|
|
37
|
+
* return what arrived. `apt-get` and `brew` both do this.
|
|
38
|
+
*/
|
|
39
|
+
const DRAIN_GRACE_MS = 500;
|
|
40
|
+
/**
|
|
41
|
+
* After the SIGKILL, how long to keep waiting for the child to be reaped.
|
|
42
|
+
*
|
|
43
|
+
* tool-proc's `REAP_GRACE_MS`, and here for its reason: SIGKILL cannot be
|
|
44
|
+
* caught, but it cannot interrupt a process wedged in an uninterruptible
|
|
45
|
+
* kernel wait either — and a package manager blocked on a stale NFS mount or
|
|
46
|
+
* a dead network filesystem under `/var/lib` is exactly that shape. Every
|
|
47
|
+
* other bound here is downstream of `await proc.exited`, so without this one
|
|
48
|
+
* a wedged child hangs the tool with no deadline left to save it. (There is
|
|
49
|
+
* no portable way to put a process into that state, so this is the one
|
|
50
|
+
* guarantee here with no test behind it — same as in tool-proc.)
|
|
51
|
+
*/
|
|
52
|
+
const REAP_GRACE_MS = 1_000;
|
|
53
|
+
/**
|
|
54
|
+
* Per-stream cap.
|
|
55
|
+
*
|
|
56
|
+
* `apt-get install -s` on a package with a large dependency closure prints a
|
|
57
|
+
* line per unpacked package, and `brew info --json=v2` for a formula with
|
|
58
|
+
* many bottles is tens of kilobytes. The cap is generous because the flag
|
|
59
|
+
* matters more than the number: a truncated simulation parses perfectly and
|
|
60
|
+
* is simply MISSING packages, so every reader below treats a truncated stream
|
|
61
|
+
* as "could not determine" rather than as the answer.
|
|
62
|
+
*/
|
|
63
|
+
export const MAX_OUTPUT_CHARS = 512_000;
|
|
64
|
+
/** Default per-command deadline. Every command here reads a local index. */
|
|
65
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 20_000;
|
|
66
|
+
/** Ceiling a caller may raise a deadline to. An install is the slow case. */
|
|
67
|
+
export const MAX_COMMAND_TIMEOUT_MS = 600_000;
|
|
68
|
+
/** `PATH` for a child when the harness itself has none. */
|
|
69
|
+
export const FALLBACK_PATH = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
70
|
+
let runnerOverride;
|
|
71
|
+
/**
|
|
72
|
+
* Refuse an argv that could not be handed to `execve` as-is.
|
|
73
|
+
*
|
|
74
|
+
* A NUL byte truncates an argument at the syscall boundary: a string that
|
|
75
|
+
* reads as harmless here arrives at the kernel cut short at the NUL, with
|
|
76
|
+
* whatever followed it gone. An empty argv[0] spawns nothing. Both are caller
|
|
77
|
+
* mistakes and both are refused before a process exists rather than after.
|
|
78
|
+
*
|
|
79
|
+
* The shell check is the belt to `lib/names.ts`'s braces: nothing in this
|
|
80
|
+
* package may run an interpreter, because the moment argv[0] is `sh` the
|
|
81
|
+
* careful argv array below becomes one string again and every escaping
|
|
82
|
+
* guarantee in this package evaporates.
|
|
83
|
+
*/
|
|
84
|
+
const INTERPRETERS = new Set([
|
|
85
|
+
"sh",
|
|
86
|
+
"bash",
|
|
87
|
+
"zsh",
|
|
88
|
+
"dash",
|
|
89
|
+
"ksh",
|
|
90
|
+
"csh",
|
|
91
|
+
"tcsh",
|
|
92
|
+
"fish",
|
|
93
|
+
"cmd",
|
|
94
|
+
"cmd.exe",
|
|
95
|
+
"powershell",
|
|
96
|
+
"powershell.exe",
|
|
97
|
+
"pwsh",
|
|
98
|
+
"pwsh.exe",
|
|
99
|
+
"env",
|
|
100
|
+
"eval",
|
|
101
|
+
"xargs",
|
|
102
|
+
]);
|
|
103
|
+
export function assertArgv(argv) {
|
|
104
|
+
const program = argv[0];
|
|
105
|
+
if (program === undefined || program.trim() === "") {
|
|
106
|
+
return "argv[0] must name a program to run";
|
|
107
|
+
}
|
|
108
|
+
const base = program.split(/[\\/]/).pop() ?? program;
|
|
109
|
+
if (INTERPRETERS.has(base.toLowerCase())) {
|
|
110
|
+
return `argv[0] is "${base}", and this package never runs a shell or an interpreter`;
|
|
111
|
+
}
|
|
112
|
+
for (const arg of argv) {
|
|
113
|
+
if (typeof arg !== "string")
|
|
114
|
+
return "every argv element must be a string";
|
|
115
|
+
if (arg.includes("\0"))
|
|
116
|
+
return "argv may not contain a NUL byte";
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The child's environment: pinned, minimal, and the same on every machine.
|
|
122
|
+
*
|
|
123
|
+
* `PATH` and `HOME` are forwarded because a manager cannot be found without
|
|
124
|
+
* the first and Homebrew cannot find its prefix without the second. Nothing
|
|
125
|
+
* else from the harness is, so the harness's API keys are not in `apt-get`'s
|
|
126
|
+
* environment. The Windows four are forwarded because `winget.exe` and
|
|
127
|
+
* `choco.exe` are not resolvable without them.
|
|
128
|
+
*
|
|
129
|
+
* `LC_ALL`/`LANG`/`TZ` are pinned rather than forwarded — see the header.
|
|
130
|
+
*/
|
|
131
|
+
export function buildEnv(extra = {}, source = process.env) {
|
|
132
|
+
const env = {
|
|
133
|
+
PATH: source["PATH"] !== undefined && source["PATH"] !== "" ? source["PATH"] : FALLBACK_PATH,
|
|
134
|
+
LC_ALL: "C",
|
|
135
|
+
LANG: "C",
|
|
136
|
+
LANGUAGE: "C",
|
|
137
|
+
TZ: "UTC",
|
|
138
|
+
};
|
|
139
|
+
const home = source["HOME"];
|
|
140
|
+
if (home !== undefined && home !== "")
|
|
141
|
+
env["HOME"] = home;
|
|
142
|
+
for (const name of [
|
|
143
|
+
"SystemRoot",
|
|
144
|
+
"windir",
|
|
145
|
+
"PATHEXT",
|
|
146
|
+
"COMSPEC",
|
|
147
|
+
"LOCALAPPDATA",
|
|
148
|
+
"ProgramData",
|
|
149
|
+
]) {
|
|
150
|
+
const value = source[name];
|
|
151
|
+
if (value !== undefined && value !== "")
|
|
152
|
+
env[name] = value;
|
|
153
|
+
}
|
|
154
|
+
for (const [key, value] of Object.entries(extra))
|
|
155
|
+
env[key] = value;
|
|
156
|
+
return env;
|
|
157
|
+
}
|
|
158
|
+
/** Cut a stream at the cap, saying whether anything was dropped. */
|
|
159
|
+
export function capText(text, max) {
|
|
160
|
+
if (text.length <= max)
|
|
161
|
+
return { text, truncated: false };
|
|
162
|
+
return { text: text.slice(0, max), truncated: true };
|
|
163
|
+
}
|
|
164
|
+
/** Run `argv` to completion, or kill it when the deadline passes. */
|
|
165
|
+
export async function runHostCommand(request) {
|
|
166
|
+
// Checked BEFORE the seam, not after: an injected runner must not be able
|
|
167
|
+
// to make an argv acceptable that the real spawn would refuse, or a test
|
|
168
|
+
// would pass on a command the host could never run.
|
|
169
|
+
const bad = assertArgv(request.argv);
|
|
170
|
+
if (bad !== undefined) {
|
|
171
|
+
return { code: -1, stdout: "", stderr: bad, timedOut: false, missing: false };
|
|
172
|
+
}
|
|
173
|
+
if (runnerOverride !== undefined)
|
|
174
|
+
return runnerOverride(request);
|
|
175
|
+
return spawnHostCommand(request);
|
|
176
|
+
}
|
|
177
|
+
async function spawnHostCommand(request) {
|
|
178
|
+
let proc;
|
|
179
|
+
try {
|
|
180
|
+
proc = Bun.spawn([...request.argv], {
|
|
181
|
+
// The workspace root, not a caller-chosen directory: no tool here takes
|
|
182
|
+
// a path, and every command reads the machine rather than a tree.
|
|
183
|
+
cwd: process.cwd(),
|
|
184
|
+
// stdin is `ignore`, so a manager that would read a terminal gets EOF
|
|
185
|
+
// immediately instead of blocking until the deadline. This is the other
|
|
186
|
+
// half of the non-interactive contract: `apt-get` without a tty and
|
|
187
|
+
// with a closed stdin cannot sit on a confirmation prompt.
|
|
188
|
+
stdin: "ignore",
|
|
189
|
+
stdout: "pipe",
|
|
190
|
+
stderr: "pipe",
|
|
191
|
+
env: buildEnv(request.env ?? {}),
|
|
192
|
+
...(request.signal !== undefined ? { signal: request.signal } : {}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
+
// ENOENT means the manager is not installed, which is a DIFFERENT answer
|
|
198
|
+
// from "it ran and told us nothing".
|
|
199
|
+
const missing = /ENOENT|not found|No such file/i.test(message);
|
|
200
|
+
return { code: -1, stdout: "", stderr: message, timedOut: false, missing };
|
|
201
|
+
}
|
|
202
|
+
let timedOut = false;
|
|
203
|
+
let reapGiveUp;
|
|
204
|
+
const term = setTimeout(() => {
|
|
205
|
+
timedOut = true;
|
|
206
|
+
try {
|
|
207
|
+
proc.kill("SIGTERM");
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// Already gone between the timer firing and the signal.
|
|
211
|
+
}
|
|
212
|
+
}, request.timeoutMs);
|
|
213
|
+
const hardKill = setTimeout(() => {
|
|
214
|
+
try {
|
|
215
|
+
proc.kill("SIGKILL");
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Already gone.
|
|
219
|
+
}
|
|
220
|
+
}, request.timeoutMs + KILL_GRACE_MS);
|
|
221
|
+
try {
|
|
222
|
+
// `.catch` is attached HERE and not after the race: the loser stays
|
|
223
|
+
// pending, and a stream torn down with a killed process rejects with
|
|
224
|
+
// nobody listening — an unhandled rejection that takes down the harness
|
|
225
|
+
// rather than the command that caused it.
|
|
226
|
+
const stdoutText = new Response(proc.stdout)
|
|
227
|
+
.text()
|
|
228
|
+
.catch(() => "");
|
|
229
|
+
const stderrText = new Response(proc.stderr)
|
|
230
|
+
.text()
|
|
231
|
+
.catch(() => "");
|
|
232
|
+
const abandonedMarker = Symbol("abandoned");
|
|
233
|
+
const reaped = await Promise.race([
|
|
234
|
+
proc.exited,
|
|
235
|
+
new Promise((resolve) => {
|
|
236
|
+
reapGiveUp = setTimeout(() => resolve(abandonedMarker), request.timeoutMs + KILL_GRACE_MS + REAP_GRACE_MS);
|
|
237
|
+
}),
|
|
238
|
+
]);
|
|
239
|
+
const abandoned = reaped === abandonedMarker;
|
|
240
|
+
const code = abandoned ? -1 : reaped;
|
|
241
|
+
let drainTimer;
|
|
242
|
+
const drainFallback = new Promise((resolve) => {
|
|
243
|
+
drainTimer = setTimeout(() => resolve(""), DRAIN_GRACE_MS);
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
const [rawOut, rawErr] = await Promise.all([
|
|
247
|
+
Promise.race([stdoutText, drainFallback]),
|
|
248
|
+
Promise.race([stderrText, drainFallback]),
|
|
249
|
+
]);
|
|
250
|
+
const cap = request.maxOutputChars ?? MAX_OUTPUT_CHARS;
|
|
251
|
+
const out = capText(rawOut, cap);
|
|
252
|
+
const err = capText(rawErr, cap);
|
|
253
|
+
return {
|
|
254
|
+
code,
|
|
255
|
+
stdout: out.text,
|
|
256
|
+
stderr: err.text,
|
|
257
|
+
timedOut: timedOut || abandoned,
|
|
258
|
+
missing: false,
|
|
259
|
+
...(out.truncated ? { stdoutTruncated: true } : {}),
|
|
260
|
+
...(err.truncated ? { stderrTruncated: true } : {}),
|
|
261
|
+
...(abandoned ? { abandoned: true } : {}),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
// One shared timer, cleared as soon as the race settles: a fresh
|
|
266
|
+
// `setTimeout` per stream that nobody cancels keeps the event loop
|
|
267
|
+
// awake for the full grace period after every command.
|
|
268
|
+
if (drainTimer !== undefined)
|
|
269
|
+
clearTimeout(drainTimer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
clearTimeout(term);
|
|
274
|
+
clearTimeout(hardKill);
|
|
275
|
+
if (reapGiveUp !== undefined)
|
|
276
|
+
clearTimeout(reapGiveUp);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Test seam. Pass a function to answer every command from recorded output;
|
|
281
|
+
* pass `undefined` to go back to spawning for real.
|
|
282
|
+
*/
|
|
283
|
+
export function _setRunner(runner) {
|
|
284
|
+
runnerOverride = runner;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* True when a runner is installed.
|
|
288
|
+
*
|
|
289
|
+
* Read by `host.ts` to decide the platform default, and by the one smoke test
|
|
290
|
+
* that is allowed to touch the real host so it can skip itself. See
|
|
291
|
+
* `hostPlatform()` for why the two seams are tied together.
|
|
292
|
+
*/
|
|
293
|
+
export function _runnerInstalled() {
|
|
294
|
+
return runnerOverride !== undefined;
|
|
295
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-pkgmgr",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic system package-manager queries and installs, with no privilege escalation and a dry run that runs the real selection",
|
|
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
|
+
"@crewhaus/tool-proc": "0.7.0",
|
|
22
|
+
"zod": "^3.23.8"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": {
|
|
29
|
+
"name": "Max Meier",
|
|
30
|
+
"email": "max@crewhaus.ai",
|
|
31
|
+
"url": "https://crewhaus.ai"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
36
|
+
"directory": "packages/tool-pkgmgr"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-pkgmgr#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
46
|
+
}
|