@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/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
+ }