@crewhaus/tool-pkgmgr 0.7.0 → 0.8.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/lib/parse.js CHANGED
@@ -175,6 +175,106 @@ export function dpkgStatusIsInstalled(abbrev) {
175
175
  export function dpkgSaysNoSuchPackage(stderr) {
176
176
  return /no packages found matching/i.test(stderr);
177
177
  }
178
+ /** The value after an indented `Label:`, trimmed; undefined if the line is not that label. */
179
+ function labelValue(line, label) {
180
+ if (line.length === 0 || (line[0] !== " " && line[0] !== "\t"))
181
+ return undefined;
182
+ const trimmed = line.trim();
183
+ if (!trimmed.startsWith(`${label}:`))
184
+ return undefined;
185
+ return trimmed.slice(label.length + 1).trim();
186
+ }
187
+ /**
188
+ * A version-table entry: ` *** <version> <priority>` for the installed one,
189
+ * ` <version> <priority>` for the others. The source lines under each
190
+ * are indented further (8 spaces) and are not versions.
191
+ */
192
+ function versionEntry(line) {
193
+ let rest;
194
+ if (line.startsWith(" *** "))
195
+ rest = line.slice(5);
196
+ else if (line.startsWith(" ") && line.length > 5 && line[5] !== " ")
197
+ rest = line.slice(5);
198
+ else
199
+ return undefined;
200
+ const parts = rest.split(" ");
201
+ if (parts.length !== 2)
202
+ return undefined;
203
+ const [version, priority] = parts;
204
+ return version !== "" && /^-?[0-9]+$/.test(priority) ? version : undefined;
205
+ }
206
+ /**
207
+ * Split `apt-cache policy` output into its stanzas. A stanza starts at an
208
+ * unindented `<name>:` line. apt prints one per package the operand matched,
209
+ * and an operand it read as a pattern matches many: `apt-cache policy
210
+ * 'lib.+'` printed 116 on a Debian 12 image.
211
+ */
212
+ export function parseAptPolicyStanzas(stdout) {
213
+ const drafts = [];
214
+ let current;
215
+ for (const line of lines(stdout)) {
216
+ if (line.length > 1 &&
217
+ line[0] !== " " &&
218
+ line[0] !== "\t" &&
219
+ line.endsWith(":") &&
220
+ !/\s/.test(line)) {
221
+ current = {
222
+ header: line.slice(0, -1),
223
+ installed: undefined,
224
+ candidate: undefined,
225
+ versions: [],
226
+ inTable: false,
227
+ };
228
+ drafts.push(current);
229
+ continue;
230
+ }
231
+ if (current === undefined)
232
+ continue;
233
+ const installed = labelValue(line, "Installed");
234
+ if (installed !== undefined) {
235
+ current.installed = installed === "(none)" ? null : installed;
236
+ continue;
237
+ }
238
+ const candidate = labelValue(line, "Candidate");
239
+ if (candidate !== undefined) {
240
+ current.candidate = candidate === "(none)" ? null : candidate;
241
+ continue;
242
+ }
243
+ if (labelValue(line, "Version table") !== undefined) {
244
+ current.inTable = true;
245
+ continue;
246
+ }
247
+ if (current.inTable) {
248
+ const version = versionEntry(line);
249
+ if (version !== undefined)
250
+ current.versions.push(version);
251
+ }
252
+ }
253
+ return drafts.map((d) => ({
254
+ header: d.header,
255
+ installed: d.installed ?? null,
256
+ candidate: d.candidate ?? null,
257
+ versions: d.versions,
258
+ labelled: d.installed !== undefined || d.candidate !== undefined,
259
+ }));
260
+ }
261
+ /**
262
+ * The stanza for exactly `name`, or why there is none. `name:arch` also
263
+ * matches a lone stanza headed by the bare name, which is how apt prints the
264
+ * native architecture (`libc6:amd64` on amd64 prints `libc6:`). More than one
265
+ * stanza is never an answer about `name`, whatever their headers say.
266
+ */
267
+ export function matchAptPolicy(stdout, name) {
268
+ const stanzas = parseAptPolicyStanzas(stdout);
269
+ if (stanzas.length === 0)
270
+ return { kind: "none" };
271
+ const only = stanzas.length === 1 ? stanzas[0] : undefined;
272
+ const bare = name.includes(":") ? name.slice(0, name.indexOf(":")) : undefined;
273
+ if (only !== undefined && (only.header === name || only.header === bare)) {
274
+ return only.labelled ? { kind: "exact", stanza: only } : { kind: "unlabelled" };
275
+ }
276
+ return { kind: "not-exact", headers: stanzas.map((st) => st.header) };
277
+ }
178
278
  /**
179
279
  * `apt-cache policy <name>` under `LC_ALL=C`.
180
280
  *
@@ -190,24 +290,22 @@ export function dpkgSaysNoSuchPackage(stderr) {
190
290
  * An unknown package produces an empty document (apt-cache prints nothing and
191
291
  * exits 0), which parses to `undefined` rather than to two nulls, so "apt has
192
292
  * never heard of this" cannot be confused with "apt has it, not installed".
293
+ * So does output with more than one stanza, or (given `name`) a stanza for a
294
+ * different name: that is apt answering about a PATTERN, and reading its last
295
+ * stanza as the answer reported another package's version (C021).
193
296
  */
194
- export function parseAptCachePolicy(stdout) {
195
- let installed;
196
- let candidate;
197
- for (const line of lines(stdout)) {
198
- const installedMatch = line.match(/^\s+Installed:\s*(.+?)\s*$/);
199
- if (installedMatch?.[1] !== undefined) {
200
- installed = installedMatch[1] === "(none)" ? null : installedMatch[1];
201
- continue;
202
- }
203
- const candidateMatch = line.match(/^\s+Candidate:\s*(.+?)\s*$/);
204
- if (candidateMatch?.[1] !== undefined) {
205
- candidate = candidateMatch[1] === "(none)" ? null : candidateMatch[1];
206
- }
297
+ export function parseAptCachePolicy(stdout, name) {
298
+ const stanzas = parseAptPolicyStanzas(stdout);
299
+ if (name !== undefined) {
300
+ const match = matchAptPolicy(stdout, name);
301
+ return match.kind === "exact"
302
+ ? { installed: match.stanza.installed, candidate: match.stanza.candidate }
303
+ : undefined;
207
304
  }
208
- if (installed === undefined && candidate === undefined)
305
+ const only = stanzas.length === 1 ? stanzas[0] : undefined;
306
+ if (only === undefined || !only.labelled)
209
307
  return undefined;
210
- return { installed: installed ?? null, candidate: candidate ?? null };
308
+ return { installed: only.installed, candidate: only.candidate };
211
309
  }
212
310
  /**
213
311
  * `apt-get install -s` — the best dry run of the six managers.
@@ -521,14 +619,24 @@ export function parseWingetList(stdout) {
521
619
  return rows;
522
620
  }
523
621
  /**
524
- * winget's "no installed package matched" exit code, 0x8A15002B as a signed
622
+ * winget's "no installed package matched" exit code:
623
+ * APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND, 0x8A150014, as a signed
525
624
  * 32-bit integer.
526
625
  *
527
626
  * `winget list` exits with this rather than 0-and-empty, which makes presence
528
627
  * decidable from the EXIT CODE alone — the one part of winget's answer that
529
- * is neither localised nor column-aligned.
628
+ * is neither localised nor column-aligned. 0.7.0 had 0x8A15002B here, which
629
+ * is APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE (`winget upgrade` with
630
+ * nothing to do), so a package that was not installed came back unknown.
631
+ * Compare against `code | 0`: a runtime may report the DWORD unsigned.
632
+ */
633
+ export const WINGET_NO_PACKAGES_FOUND = -1978335212;
634
+ /**
635
+ * APPINSTALLER_CLI_ERROR_SOURCE_AGREEMENTS_NOT_ACCEPTED, 0x8A150046: with
636
+ * `--disable-interactivity`, a configured source whose agreement nobody has
637
+ * accepted makes `winget list` refuse outright instead of prompting.
530
638
  */
531
- export const WINGET_NO_PACKAGES_FOUND = -1978335189;
639
+ export const WINGET_SOURCE_AGREEMENTS_NOT_ACCEPTED = -1978335162;
532
640
  // ---------------------------------------------------------------------------
533
641
  // chocolatey
534
642
  // ---------------------------------------------------------------------------
@@ -96,6 +96,13 @@ export type PackageFacts = {
96
96
  readonly outdated?: boolean;
97
97
  readonly kind?: "formula" | "cask";
98
98
  readonly notes: readonly string[];
99
+ /**
100
+ * apt only, and only when `apt-cache policy` answered with exactly one
101
+ * stanza for exactly this name: every version its version table lists.
102
+ * Absent means apt's answer was not about this exact name (or could not be
103
+ * read), which PackageInstall refuses to act on (C021).
104
+ */
105
+ readonly versionTable?: readonly string[];
99
106
  };
100
107
  /**
101
108
  * Ask one manager about one name.
package/dist/lib/query.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { DEFAULT_COMMAND_TIMEOUT_MS, runHostCommand } from "../run";
2
2
  import { MANAGERS, MANAGER_ENV, PKGMGR_COMMANDS, managersForPlatform, withOperand, } from "./managers";
3
- import { WINGET_NO_PACKAGES_FOUND, aptSaysNoSuchPackage, brewSaysNoSuchPackage, dnfSaysNoCache, dnfSaysNoMatch, dpkgSaysNoSuchPackage, dpkgStatusIsInstalled, pacmanSaysNoSuchPackage, parseAptCachePolicy, parseBrewInfoJson, parseChocoList, parseChocoMajorVersion, parseDnfList, parseDpkgQuery, parsePacmanInfo, parsePacmanQuery, parseRpmQuery, parseWingetList, rpmSaysNotInstalled, } from "./parse";
3
+ import { WINGET_NO_PACKAGES_FOUND, WINGET_SOURCE_AGREEMENTS_NOT_ACCEPTED, aptSaysNoSuchPackage, brewSaysNoSuchPackage, dnfSaysNoCache, dnfSaysNoMatch, dpkgSaysNoSuchPackage, dpkgStatusIsInstalled, matchAptPolicy, pacmanSaysNoSuchPackage, parseBrewInfoJson, parseChocoList, parseChocoMajorVersion, parseDnfList, parseDpkgQuery, parsePacmanInfo, parsePacmanQuery, parseRpmQuery, parseWingetList, rpmSaysNotInstalled, } from "./parse";
4
4
  import { commandFailureReason, firstLine } from "./unknown";
5
5
  export function newContext(timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, signal) {
6
6
  return { timeoutMs, ...(signal !== undefined ? { signal } : {}), probes: [] };
@@ -197,6 +197,7 @@ async function queryApt(ctx, name, unknowns, includeAvailable) {
197
197
  let status = "unknown";
198
198
  let installedVersions = null;
199
199
  let knownToManager = null;
200
+ let versionTable;
200
201
  const notes = [];
201
202
  if (dpkg.missing || dpkg.timedOut || truncated(dpkg)) {
202
203
  unknowns.add("status", dpkgProbe, truncated(dpkg)
@@ -256,9 +257,28 @@ async function queryApt(ctx, name, unknowns, includeAvailable) {
256
257
  unknowns.add("available.version", policyProbe, commandFailureReason(policy));
257
258
  }
258
259
  else {
259
- const parsed = parseAptCachePolicy(policy.stdout);
260
- if (parsed === undefined) {
261
- if (policy.stdout.trim() === "" || aptSaysNoSuchPackage(policy.stderr)) {
260
+ const match = matchAptPolicy(policy.stdout, name);
261
+ if (match.kind === "not-exact") {
262
+ // apt read the name as a pattern (or the output answers about some
263
+ // other name). None of it is about THIS name, and the old parser
264
+ // reported the last stanza's candidate as this package's (C021).
265
+ const shown = match.headers.slice(0, 3).join(", ");
266
+ const reason = `apt-cache matched ${match.headers.length} package(s) as a pattern (${shown}${match.headers.length > 3 ? ", …" : ""}); none is named exactly ${JSON.stringify(name)}, so apt's answer is not about this name`;
267
+ // dpkg's own exact row, when it found one, still stands; anything
268
+ // else ("not installed" because dpkg matched nothing literally) is
269
+ // not an answer about a package name either.
270
+ if (status !== "installed") {
271
+ status = "unknown";
272
+ installedVersions = null;
273
+ knownToManager = null;
274
+ unknowns.add("status", policyProbe, reason);
275
+ }
276
+ available = null;
277
+ unknowns.add("available.version", policyProbe, reason);
278
+ }
279
+ else if (match.kind === "none" || match.kind === "unlabelled") {
280
+ if (match.kind === "none" &&
281
+ (policy.stdout.trim() === "" || aptSaysNoSuchPackage(policy.stderr))) {
262
282
  // apt-cache prints nothing at all for a name it does not know.
263
283
  if (knownToManager === null)
264
284
  knownToManager = false;
@@ -270,6 +290,8 @@ async function queryApt(ctx, name, unknowns, includeAvailable) {
270
290
  }
271
291
  }
272
292
  else {
293
+ const parsed = match.stanza;
294
+ versionTable = parsed.versions;
273
295
  knownToManager = true;
274
296
  available = parsed.candidate === null ? null : { version: parsed.candidate, source: "apt" };
275
297
  if (parsed.candidate === null) {
@@ -295,6 +317,7 @@ async function queryApt(ctx, name, unknowns, includeAvailable) {
295
317
  knownToManager,
296
318
  available,
297
319
  notes,
320
+ ...(versionTable !== undefined ? { versionTable } : {}),
298
321
  };
299
322
  }
300
323
  // ---- dnf / rpm ------------------------------------------------------------
@@ -451,7 +474,13 @@ async function queryWinget(ctx, name, unknowns, _includeAvailable) {
451
474
  unknowns.add("status", probe, commandFailureReason(result));
452
475
  return unknownFacts("winget", name);
453
476
  }
454
- if (result.code === WINGET_NO_PACKAGES_FOUND) {
477
+ // winget's codes are HRESULTs; a runtime may hand the DWORD back unsigned.
478
+ const code = result.code | 0;
479
+ if (code === WINGET_SOURCE_AGREEMENTS_NOT_ACCEPTED) {
480
+ unknowns.add("status", probe, "winget refused to list because a configured source's agreements have not been accepted on this host. Accepting a licence is the operator's decision, never a side effect of a read: run `winget list` once yourself and accept them (or remove that source), then ask again");
481
+ return unknownFacts("winget", name);
482
+ }
483
+ if (code === WINGET_NO_PACKAGES_FOUND) {
455
484
  // The one part of winget's answer that is neither localised nor
456
485
  // column-aligned: its documented "no installed package matched" code.
457
486
  return {
package/dist/paths.d.ts CHANGED
@@ -1,10 +1,29 @@
1
1
  import { CrewhausError } from "@crewhaus/errors";
2
- /** Raised when a caller-supplied path resolves outside the workspace root. */
2
+ /**
3
+ * The longest path this resolves, in UTF-16 code units; a longer one is
4
+ * refused before it is walked. Nothing that long can be opened or created:
5
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
6
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
7
+ * UTF-16 one. The limit is twice that because the part of a path that exists
8
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
9
+ * own: no spelling of the place reaches it, not even one through a symlink
10
+ * that shortens the part that exists.
11
+ */
12
+ export declare const MAX_PATH_CHARS: number;
13
+ /**
14
+ * Raised when a caller-supplied path resolves outside the workspace root, or
15
+ * is longer than any path can be.
16
+ */
3
17
  export declare class ToolPermissionError extends CrewhausError {
4
18
  readonly name = "ToolPermissionError";
5
19
  readonly toolName: string;
6
20
  readonly path: string;
7
- constructor(toolName: string, attemptedPath: string);
21
+ /**
22
+ * Why, when it is not that the location escapes the workspace root: a path
23
+ * longer than any path can be. A caller that rewords the refusal says this.
24
+ */
25
+ readonly reason: string | undefined;
26
+ constructor(toolName: string, attemptedPath: string, reason?: string);
8
27
  }
9
28
  /**
10
29
  * A validated path, in both forms the tools need.
package/dist/paths.js CHANGED
@@ -11,15 +11,45 @@
11
11
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { CrewhausError } from "@crewhaus/errors";
14
- /** Raised when a caller-supplied path resolves outside the workspace root. */
14
+ /**
15
+ * The longest path this resolves, in UTF-16 code units; a longer one is
16
+ * refused before it is walked. Nothing that long can be opened or created:
17
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
18
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
19
+ * UTF-16 one. The limit is twice that because the part of a path that exists
20
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
21
+ * own: no spelling of the place reaches it, not even one through a symlink
22
+ * that shortens the part that exists.
23
+ */
24
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
25
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
26
+ function shownPath(given) {
27
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
28
+ }
29
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
30
+ function tooLong(abs) {
31
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
32
+ }
33
+ /**
34
+ * Raised when a caller-supplied path resolves outside the workspace root, or
35
+ * is longer than any path can be.
36
+ */
15
37
  export class ToolPermissionError extends CrewhausError {
16
38
  name = "ToolPermissionError";
17
39
  toolName;
18
40
  path;
19
- constructor(toolName, attemptedPath) {
20
- super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
41
+ /**
42
+ * Why, when it is not that the location escapes the workspace root: a path
43
+ * longer than any path can be. A caller that rewords the refusal says this.
44
+ */
45
+ reason;
46
+ constructor(toolName, attemptedPath, reason) {
47
+ super("tool", reason === undefined
48
+ ? `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`
49
+ : `tool "${toolName}" rejected path "${shownPath(attemptedPath)}": ${reason}`);
21
50
  this.toolName = toolName;
22
51
  this.path = attemptedPath;
52
+ this.reason = reason;
23
53
  }
24
54
  }
25
55
  /** The workspace root, resolved. Every path is contained within it. */
@@ -67,15 +97,7 @@ function nameExists(p) {
67
97
  function resolveLocation(target, depth = 0) {
68
98
  if (depth > 40)
69
99
  throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
- let probe = target;
71
- const tail = [];
72
- while (!nameExists(probe)) {
73
- tail.unshift(path.basename(probe));
74
- const parent = path.dirname(probe);
75
- if (parent === probe)
76
- break; // reached the filesystem root
77
- probe = parent;
78
- }
100
+ const { probe, tail } = deepestName(target);
79
101
  let probeReal;
80
102
  try {
81
103
  probeReal = realpathSync(probe);
@@ -97,7 +119,41 @@ function resolveLocation(target, depth = 0) {
97
119
  const base = realpathSync(path.dirname(probe));
98
120
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
121
  }
100
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
122
+ // One string, not one argument per name: Bun's `path.join` keeps only
123
+ // (argument count mod 65,536) of its arguments.
124
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
125
+ }
126
+ /**
127
+ * `target`, an absolute path, split where its deepest prefix that exists as
128
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
129
+ * one string ("" when `target` itself exists). This is `path.dirname` and
130
+ * `path.basename` worked by index on `target` itself: handing them each
131
+ * shorter prefix in turn copied the rest of the path once per missing name,
132
+ * the square of a count the caller picks.
133
+ */
134
+ function deepestName(target) {
135
+ const top = path.parse(target).root.length;
136
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
137
+ let probe = target;
138
+ let start = target.length;
139
+ let stop = -1;
140
+ while (!nameExists(probe)) {
141
+ // `path.basename(probe)` is `target.slice(start, end)`, and
142
+ // `path.dirname(probe)` is `target.slice(0, up)`.
143
+ let end = probe.length;
144
+ while (end > top && isSep(end - 1))
145
+ end--;
146
+ if (stop < 0)
147
+ stop = end;
148
+ start = end;
149
+ while (start > top && !isSep(start - 1))
150
+ start--;
151
+ const up = start > top ? start - 1 : top;
152
+ if (up === probe.length)
153
+ break; // reached the filesystem root
154
+ probe = target.slice(0, up);
155
+ }
156
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
101
157
  }
102
158
  export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
159
  const rootResolved = path.resolve(root);
@@ -107,6 +163,9 @@ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
107
163
  if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
164
  throw new ToolPermissionError(toolName, rel);
109
165
  }
166
+ // Longer than any path can be: refused before the walk below reads it.
167
+ if (abs.length > MAX_PATH_CHARS)
168
+ throw new ToolPermissionError(toolName, rel, tooLong(abs));
110
169
  let real;
111
170
  try {
112
171
  const rootReal = realpathSync(rootResolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-pkgmgr",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic system package-manager queries and installs, with no privilege escalation and a dry run that runs the real selection",
6
6
  "main": "dist/index.js",
@@ -8,17 +8,18 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "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",
19
+ "@crewhaus/errors": "0.8.0",
20
+ "@crewhaus/tool-builder": "0.8.0",
21
+ "@crewhaus/tool-catalog": "0.8.0",
22
+ "@crewhaus/tool-proc": "0.8.0",
22
23
  "zod": "^3.23.8"
23
24
  },
24
25
  "devDependencies": {
@@ -42,5 +43,8 @@
42
43
  "publishConfig": {
43
44
  "access": "public"
44
45
  },
45
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
47
+ "engines": {
48
+ "bun": ">=1.3.14"
49
+ }
46
50
  }
@@ -1,174 +0,0 @@
1
- /**
2
- * Recorded manager output, and the runner that serves it.
3
- *
4
- * Every string below is what a real manager printed on a real machine. None
5
- * of it is invented to make a parser pass: the trailing space in dpkg's
6
- * `"ii "` status, the `(none)` apt prints for a package it has but has not
7
- * installed, the `Operation aborted.` dnf ends an `--assumeno` run with, the
8
- * ellipsis winget cuts a wide column with, and the fact that `rpm -q` writes
9
- * "is not installed" to STDOUT rather than stderr are all facts about those
10
- * programs that a hand-written fixture would have got wrong.
11
- *
12
- * WHY THIS FILE EXISTS AT ALL. CI is a Linux container with no package
13
- * manager installed; this was written on macOS with Homebrew. A test that
14
- * asked the real host anything would assert one thing here and another there,
15
- * which is not a test. So the host is behind `../run.ts`'s seam, the platform
16
- * is behind `../host.ts`'s (with a hostile default, so forgetting the seam
17
- * fails identically everywhere), and this file is the world.
18
- *
19
- * `recordedRunner` refuses a command nobody recorded rather than returning an
20
- * empty result: an unrecorded command that quietly answered "" would look
21
- * exactly like a manager that had nothing to say, and every "not installed"
22
- * in the suite would stop meaning anything.
23
- */
24
- import type { RunRequest, Runner } from "./run";
25
- export declare const BREW_VERSION = "Homebrew 4.3.8\nHomebrew/homebrew-core (git revision 2e6b2c3f4a1; last commit 2026-09-10)\n";
26
- /** `brew info --json=v2 curl` — installed, and the tap has a newer version. */
27
- export declare const BREW_INFO_CURL = "{\"formulae\":[{\"name\":\"curl\",\"full_name\":\"curl\",\"tap\":\"homebrew/core\",\"oldnames\":[],\"aliases\":[],\"versions\":{\"stable\":\"8.9.1\",\"head\":\"HEAD\",\"bottle\":true},\"installed\":[{\"version\":\"8.8.0\",\"used_options\":[],\"built_as_bottle\":true,\"poured_from_bottle\":true,\"installed_as_dependency\":false,\"installed_on_request\":true}],\"dependencies\":[\"brotli\",\"libnghttp2\",\"libssh2\",\"openldap\",\"rtmpdump\",\"zstd\"],\"pinned\":false,\"outdated\":true,\"deprecated\":false,\"disabled\":false}],\"casks\":[]}";
28
- /** `brew info --json=v2 tree` — known to the tap, not installed. */
29
- export declare const BREW_INFO_TREE = "{\"formulae\":[{\"name\":\"tree\",\"full_name\":\"tree\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"2.2.1\",\"head\":null,\"bottle\":true},\"installed\":[],\"dependencies\":[],\"pinned\":false,\"outdated\":false,\"deprecated\":false,\"disabled\":false}],\"casks\":[]}";
30
- /**
31
- * `brew info --json=v2 python@3.11` — two kegs of one formula.
32
- *
33
- * The reason `installedVersions` is an array: reporting the first as "the"
34
- * installed version is wrong for exactly the formulae people ask about.
35
- */
36
- export declare const BREW_INFO_PYTHON = "{\"formulae\":[{\"name\":\"python@3.11\",\"full_name\":\"python@3.11\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"3.11.9\",\"head\":null,\"bottle\":true},\"installed\":[{\"version\":\"3.11.9\",\"installed_on_request\":true},{\"version\":\"3.11.8\",\"installed_on_request\":false}],\"dependencies\":[\"mpdecimal\",\"openssl@3\",\"sqlite\",\"xz\"],\"pinned\":true,\"outdated\":false}],\"casks\":[]}";
37
- /** `brew info --json=v2 firefox` — a cask, which spells everything differently. */
38
- export declare const BREW_INFO_FIREFOX = "{\"formulae\":[],\"casks\":[{\"token\":\"firefox\",\"full_token\":\"homebrew/cask/firefox\",\"tap\":\"homebrew/cask\",\"name\":[\"Mozilla Firefox\"],\"version\":\"142.0.1\",\"installed\":\"141.0\",\"outdated\":true}]}";
39
- /** `brew info --json=v2 nope-nope` — exit 1, and this exact sentence. */
40
- export declare const BREW_NO_SUCH_FORMULA = "Error: No available formula with the name \"nope-nope\".\n==> Searching for similarly named formulae and casks...\n";
41
- export declare const BREW_DEPS_CURL = "brotli\nlibnghttp2\nlibssh2\nopenldap\nrtmpdump\nzstd\n";
42
- export declare const BREW_DEPS_TREE = "";
43
- /** `brew list --formula --versions` — four of the six curl deps are present. */
44
- export declare const BREW_LIST_VERSIONS = "brotli 1.1.0\nca-certificates 2026-08-12\nlibnghttp2 1.62.1\nopenldap 2.6.7\npython@3.11 3.11.9 3.11.8\nzstd 1.5.6\n";
45
- export declare const BREW_INSTALL_TREE = "==> Fetching tree\n==> Downloading https://ghcr.io/v2/homebrew/core/tree/blobs/sha256:9f3c1ad\n==> Pouring tree--2.2.1.arm64_sonoma.bottle.tar.gz\n/opt/homebrew/Cellar/tree/2.2.1: 8 files, 162.5KB\n";
46
- /** `brew info --json=v2 tree`, re-run after the install above succeeded. */
47
- export declare const BREW_INFO_TREE_AFTER = "{\"formulae\":[{\"name\":\"tree\",\"full_name\":\"tree\",\"tap\":\"homebrew/core\",\"versions\":{\"stable\":\"2.2.1\",\"head\":null,\"bottle\":true},\"installed\":[{\"version\":\"2.2.1\",\"installed_on_request\":true}],\"dependencies\":[],\"pinned\":false,\"outdated\":false}],\"casks\":[]}";
48
- export declare const DPKG_VERSION = "Debian dpkg-query package management program version 1.21.22 (amd64).\nThis is free software; see the GNU General Public License version 2 or\nlater for copying conditions. There is NO warranty.\n";
49
- /** Note the trailing space: `${db:Status-Abbrev}` is three characters wide. */
50
- export declare const DPKG_QUERY_CURL = "curl\t7.88.1-10+deb12u5\tii \n";
51
- /**
52
- * A package dpkg still has a RECORD of but does not have installed: removed,
53
- * configuration files left behind. Reading `rc` as installed is the classic
54
- * dpkg mistake — the binary is gone and only `/etc` remains.
55
- */
56
- export declare const DPKG_QUERY_NANO_REMOVED = "nano\t7.2-1\trc \n";
57
- /** dpkg has no record at all of a package that was never installed. */
58
- export declare const DPKG_NO_SUCH_PACKAGE = "dpkg-query: no packages found matching htop\n";
59
- export declare const APT_POLICY_CURL = "curl:\n Installed: 7.88.1-10+deb12u5\n Candidate: 7.88.1-10+deb12u7\n Version table:\n *** 7.88.1-10+deb12u5 100\n 100 /var/lib/dpkg/status\n 7.88.1-10+deb12u7 500\n 500 http://deb.debian.org/debian-security bookworm-security/main amd64 Packages\n";
60
- export declare const APT_POLICY_HTOP = "htop:\n Installed: (none)\n Candidate: 3.2.2-2\n Version table:\n 3.2.2-2 500\n 500 http://deb.debian.org/debian bookworm/main amd64 Packages\n";
61
- /** A package apt has in its lists with no installable candidate at all. */
62
- export declare const APT_POLICY_NO_CANDIDATE = "phantom:\n Installed: (none)\n Candidate: (none)\n Version table:\n";
63
- /** apt-cache prints NOTHING for a name it does not know, and exits 0. */
64
- export declare const APT_POLICY_UNKNOWN_STDERR = "N: Unable to locate package nosuchpkg\n";
65
- /**
66
- * The same `apt-cache policy curl` on a de_DE host.
67
- *
68
- * This is the fixture that gives the localisation trap a name. Every parser
69
- * in this package reads English labels, which is sound ONLY because
70
- * `../run.ts` pins `LC_ALL=C` on every child. Without that pin the parse does
71
- * not fail loudly — it finds nothing, and "nothing" reads as "not installed".
72
- */
73
- export declare const APT_POLICY_CURL_GERMAN = "curl:\n Installiert: 7.88.1-10+deb12u5\n Installationskandidat: 7.88.1-10+deb12u7\n Versionstabelle:\n *** 7.88.1-10+deb12u5 100\n 100 /var/lib/dpkg/status\n";
74
- export declare const APT_SIMULATE_HTOP = "NOTE: This is only a simulation!\n apt-get needs root privileges for real execution.\n Keep also in mind that locking is deactivated,\n so don't depend on the relevance to the real current situation!\nReading package lists...\nBuilding dependency tree...\nReading state information...\nThe following additional packages will be installed:\n libnl-3-200 libnl-genl-3-200\nThe following NEW packages will be installed:\n htop libnl-3-200 libnl-genl-3-200\n0 upgraded, 3 newly installed, 0 to remove and 0 not upgraded.\nInst libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nInst libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nInst htop (3.2.2-2 Debian:12.5/stable [amd64])\nConf libnl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nConf libnl-genl-3-200 (3.7.0-0.2+b1 Debian:12.5/stable [amd64])\nConf htop (3.2.2-2 Debian:12.5/stable [amd64])\n";
75
- /**
76
- * An UPGRADE that also removes something — the case an operator most needs to
77
- * see before approving an install. Note the `[old-version]` bracket between
78
- * the name and the parenthesis, which only an upgrade has.
79
- */
80
- export declare const APT_SIMULATE_CURL_UPGRADE = "Reading package lists...\nBuilding dependency tree...\nThe following packages will be REMOVED:\n curl-legacy\nThe following packages will be upgraded:\n curl\n1 upgraded, 0 newly installed, 1 to remove and 0 not upgraded.\nRemv curl-legacy [7.74.0-1]\nInst curl [7.88.1-10+deb12u5] (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])\nConf curl (7.88.1-10+deb12u7 Debian-Security:12/stable-security [amd64])\n";
81
- export declare const APT_INSTALL_HTOP = "Reading package lists...\nBuilding dependency tree...\nThe following NEW packages will be installed:\n htop libnl-3-200 libnl-genl-3-200\nSetting up htop (3.2.2-2) ...\nProcessing triggers for man-db (2.11.2-2) ...\n";
82
- export declare const DPKG_QUERY_HTOP_AFTER = "htop\t3.2.2-2\tii \n";
83
- export declare const APT_NEEDS_ROOT = "E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)\nE: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?\n";
84
- export declare const DNF_VERSION = "4.14.0\n Installed: dnf-0:4.14.0-9.el9.noarch at 2026-02-11 08:14\n Built : Rocky Enterprise Software Foundation at 2026-01-30 11:02\n";
85
- export declare const RPM_QUERY_CURL = "curl\t7.76.1-29.el9_4\tx86_64\n";
86
- /** The same name installed for two architectures — real, and common. */
87
- export declare const RPM_QUERY_GLIBC = "glibc\t2.34-100.el9_4.2\tx86_64\nglibc\t2.34-100.el9_4.2\ti686\n";
88
- /** rpm writes this to STDOUT, not stderr, and exits 1. */
89
- export declare const RPM_NOT_INSTALLED = "package tree is not installed\n";
90
- export declare const DNF_LIST_TREE = "Available Packages\ntree.x86_64 1.8.0-10.el9 baseos\n";
91
- export declare const DNF_LIST_NO_MATCH = "Error: No matching Packages to list\n";
92
- /**
93
- * The case `--cacheonly` makes common on a fresh container, and the case
94
- * house rule 6 is about: an empty metadata cache is NOT an absent package.
95
- */
96
- export declare const DNF_NO_CACHE = "Error: Cache-only enabled but no cache for 'baseos'\n";
97
- export declare const DNF_SIMULATE_TREE = "Last metadata expiration check: 0:05:12 ago on Wed 17 Sep 2026 09:00:00 AM UTC.\nDependencies resolved.\n================================================================================\n Package Arch Version Repository Size\n================================================================================\nInstalling:\n tree x86_64 1.8.0-10.el9 baseos 55 k\nInstalling dependencies:\n libfoo x86_64 1.2-3.el9 appstream 10 k\n\nTransaction Summary\n================================================================================\nInstall 2 Packages\n\nTotal download size: 65 k\nInstalled size: 143 k\nOperation aborted.\n";
98
- export declare const DNF_NEEDS_ROOT = "Error: This command has to be run with superuser privileges (under the root user on most systems).\n";
99
- export declare const DNF_INSTALL_TREE = "Dependencies resolved.\nInstalling:\n tree x86_64 1.8.0-10.el9 baseos 55 k\n\nComplete!\n";
100
- export declare const RPM_QUERY_TREE_AFTER = "tree\t1.8.0-10.el9\tx86_64\n";
101
- export declare const PACMAN_VERSION = " .--. Pacman v6.1.0 - libalpm v14.0.0\n/ _.-' .-. .-. .-. Copyright (C) 2006-2024 Pacman Development Team\n";
102
- export declare const PACMAN_QUERY_CURL = "curl 8.9.1-1\n";
103
- export declare const PACMAN_NOT_FOUND = "error: package 'htop' was not found\n";
104
- export declare const PACMAN_SYNC_INFO_HTOP = "Repository : extra\nName : htop\nVersion : 3.3.0-1\nDescription : Interactive process viewer\nArchitecture : x86_64\nLicenses : GPL\nDepends On : glibc ncurses lm_sensors\nDownload Size : 158.44 KiB\n\nRepository : community-testing\nName : htop\nVersion : 3.4.0-0.1\n";
105
- export declare const PACMAN_SYNC_NOT_FOUND = "error: package 'nosuch' was not found\n";
106
- /** `pacman -S --print --print-format '%r/%n %v'` — targets AND dependencies. */
107
- export declare const PACMAN_SIMULATE_HTOP = "core/ncurses 6.5-3\nextra/lm_sensors 1:3.6.0.r41-3\nextra/htop 3.3.0-1\n";
108
- export declare const PACMAN_NEEDS_ROOT = "error: you cannot perform this operation unless you are root.\n";
109
- export declare const PACMAN_INSTALL_HTOP = "resolving dependencies...\nlooking for conflicting packages...\n(3) installing ncurses lm_sensors htop\n:: Running post-transaction hooks...\n";
110
- export declare const PACMAN_QUERY_HTOP_AFTER = "htop 3.3.0-1\n";
111
- export declare const WINGET_VERSION = "v1.8.1911\n";
112
- /** Five columns: Name, Id, Version, Available, Source. */
113
- export declare const WINGET_LIST_GIT = "Name Id Version Available Source\n-------------------------------------------\nGit Git.Git 2.45.1 2.46.0 winget\n";
114
- /**
115
- * Four columns, which is the ambiguous case: this is `…Version Source`, but a
116
- * row with an available upgrade and no source is `…Version Available` and
117
- * looks identical. winget's headers are localised, so there is no way to tell
118
- * — which is why the parser reports a null `available` with a reason rather
119
- * than picking one.
120
- */
121
- export declare const WINGET_LIST_FOUR_COLUMNS = "Name Id Version Source\n--------------------------------\n7-Zip 7zip.7zip 24.08 winget\n";
122
- /**
123
- * A row winget had to CUT, marked with U+2026. The survey's proposed
124
- * header-offset parser would read `14.40.3` here as a complete version and
125
- * the Id as a complete Id; both are prefixes.
126
- */
127
- export declare const WINGET_LIST_TRUNCATED = "Name Id Version Source\n-------------------------------------------------------------------\nMicrosoft Visual C++ 20\u2026 Microsoft.VCRedist.2015\u2026 14.40.3 winget\n";
128
- /**
129
- * A CJK display name. Each glyph is DOUBLE-WIDTH in the console and one
130
- * UTF-16 code unit in the string, so every character offset after it is
131
- * wrong — and splitting on two-or-more spaces is unaffected.
132
- */
133
- export declare const WINGET_LIST_CJK = "Name Id Version Source\n----------------------------------------\n\u30C6\u30AD\u30B9\u30C8 Vendor.Text 1.2.3 winget\n";
134
- export declare const WINGET_NOT_FOUND = "No installed package found matching input criteria.\n";
135
- export declare const CHOCO_VERSION_V2 = "2.2.2\n";
136
- export declare const CHOCO_VERSION_V1 = "0.10.15\n";
137
- export declare const CHOCO_LIST_GIT = "git|2.45.1\ngit.install|2.45.1\n";
138
- export declare const CHOCO_LIST_EMPTY = "";
139
- /** A recorded answer to one command. */
140
- export type RecordedCase = {
141
- readonly stdout?: string;
142
- readonly stderr?: string;
143
- readonly code?: number;
144
- readonly timedOut?: boolean;
145
- /** The program is not installed on this host (ENOENT on spawn). */
146
- readonly missing?: boolean;
147
- readonly stdoutTruncated?: boolean;
148
- readonly stderrTruncated?: boolean;
149
- };
150
- export type RecordedRunner = {
151
- readonly runner: Runner;
152
- /** Every argv this runner was asked for, in order. */
153
- readonly calls: string[][];
154
- /** Every request, for the tests that check the pinned environment. */
155
- readonly requests: RunRequest[];
156
- };
157
- /** The key a command is recorded under. NUL cannot appear in an argv element. */
158
- export declare function argvKey(argv: readonly string[]): string;
159
- /**
160
- * Serve recorded output, and refuse anything nobody recorded.
161
- *
162
- * The refusal is the important half. A runner that answered an unrecorded
163
- * command with an empty success would be indistinguishable from a manager
164
- * that had nothing to say, and every "not installed" in the suite would stop
165
- * meaning anything. Instead the promise rejects with the argv, so a missing
166
- * fixture fails the test that needed it and names the command.
167
- *
168
- * `fallback` exists for the tests that are ABOUT an unrecognised host: pass
169
- * `{ missing: true }` to make every unrecorded command behave like a program
170
- * that is not installed.
171
- */
172
- export declare function recordedRunner(cases: Readonly<Record<string, RecordedCase | readonly RecordedCase[]>>, fallback?: RecordedCase): RecordedRunner;
173
- /** Shorthand: record a case under an argv rather than under a key. */
174
- export declare function record(argv: readonly string[], outcome: RecordedCase | readonly RecordedCase[]): Record<string, RecordedCase | readonly RecordedCase[]>;