@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.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Every parser in this package, and nothing else.
3
+ *
4
+ * Each one takes a string that a real manager printed — recorded in
5
+ * `../fixtures.ts` — and returns a shape, or `undefined` when the bytes are
6
+ * not what it was told to expect. None of them reads the host, none of them
7
+ * spawns anything, and none of them guesses: a parser that cannot find what
8
+ * it is looking for says so, and the caller turns that into an `unknown` with
9
+ * a reason rather than into a `false`.
10
+ *
11
+ * Two traps recur across the six managers and are worth stating once.
12
+ *
13
+ * LOCALISATION. `apt-cache policy`, `dnf`, `pacman -Si` and `brew` all
14
+ * translate their labels through gettext. Every parser below reads ENGLISH
15
+ * keywords, which is sound only because `../run.ts` pins `LC_ALL=C` on every
16
+ * child. If that pin is ever removed these parsers do not fail loudly — they
17
+ * find nothing, and "nothing" reads as "not installed". The suite includes a
18
+ * German `apt-cache policy` capture precisely so that failure has a name.
19
+ *
20
+ * COLUMN OUTPUT. `winget list` and `dnf list` print aligned columns, and a
21
+ * column layout is not a data format. winget truncates a wide field with an
22
+ * ellipsis (U+2026) and pads with spaces indistinguishable from the
23
+ * separator, so the parser below refuses a row it cannot split unambiguously
24
+ * rather than returning a version that is a prefix of the real one.
25
+ */
26
+ /** Split into lines, tolerating CRLF from a Windows manager. */
27
+ export function lines(text) {
28
+ return text.split(/\r?\n/);
29
+ }
30
+ /**
31
+ * `brew info --json=v2` — the one call that answers both halves of a query.
32
+ *
33
+ * The document always has both arrays, and a name resolves into exactly one
34
+ * of them, so a caller does not have to know whether it is asking about a
35
+ * formula or a cask before it asks. A cask spells everything differently
36
+ * (`token` not `name`, a scalar `version` not a `versions` object, a scalar
37
+ * `installed` not an array), which is why the two are normalised here rather
38
+ * than at the call site.
39
+ *
40
+ * Returns `undefined` when the bytes are not a v2 document at all — which is
41
+ * what a failed `brew` prints, and must not be read as "no such package".
42
+ */
43
+ export function parseBrewInfoJson(stdout) {
44
+ let doc;
45
+ try {
46
+ doc = JSON.parse(stdout);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ if (typeof doc !== "object" || doc === null)
52
+ return undefined;
53
+ const record = doc;
54
+ const formulae = Array.isArray(record["formulae"]) ? record["formulae"] : undefined;
55
+ const casks = Array.isArray(record["casks"]) ? record["casks"] : undefined;
56
+ // Both keys absent means this is not a v2 info document. Both present and
57
+ // empty is a legitimate answer (brew printed an empty result), and is not
58
+ // the same thing.
59
+ if (formulae === undefined && casks === undefined)
60
+ return undefined;
61
+ const result = {};
62
+ const firstFormula = formulae?.[0];
63
+ if (typeof firstFormula === "object" && firstFormula !== null) {
64
+ const f = firstFormula;
65
+ const versions = asRecord(f["versions"]);
66
+ const installed = Array.isArray(f["installed"]) ? f["installed"] : [];
67
+ result.formula = {
68
+ name: asString(f["full_name"]) ?? asString(f["name"]) ?? "",
69
+ kind: "formula",
70
+ installedVersions: installed
71
+ .map((entry) => asString(asRecord(entry)?.["version"]))
72
+ .filter((v) => v !== undefined),
73
+ stableVersion: asString(versions?.["stable"]) ?? null,
74
+ pinned: f["pinned"] === true,
75
+ outdated: f["outdated"] === true,
76
+ dependencies: Array.isArray(f["dependencies"])
77
+ ? f["dependencies"].filter((d) => typeof d === "string")
78
+ : [],
79
+ tap: asString(f["tap"]) ?? null,
80
+ };
81
+ }
82
+ const firstCask = casks?.[0];
83
+ if (typeof firstCask === "object" && firstCask !== null) {
84
+ const c = firstCask;
85
+ // A cask's `installed` is a scalar version string, or null when absent.
86
+ const installedVersion = asString(c["installed"]);
87
+ result.cask = {
88
+ name: asString(c["full_token"]) ?? asString(c["token"]) ?? "",
89
+ kind: "cask",
90
+ installedVersions: installedVersion === undefined ? [] : [installedVersion],
91
+ stableVersion: asString(c["version"]) ?? null,
92
+ // A cask cannot be pinned; brew reports outdated the same way.
93
+ pinned: false,
94
+ outdated: c["outdated"] === true,
95
+ dependencies: [],
96
+ tap: asString(c["tap"]) ?? null,
97
+ };
98
+ }
99
+ return result;
100
+ }
101
+ function asRecord(value) {
102
+ return typeof value === "object" && value !== null
103
+ ? value
104
+ : undefined;
105
+ }
106
+ function asString(value) {
107
+ return typeof value === "string" && value !== "" ? value : undefined;
108
+ }
109
+ /**
110
+ * `brew list --formula --versions` — `name version [version…]`, one per line.
111
+ *
112
+ * A keg-only formula with several versions kegged prints all of them, which
113
+ * is why the value is an array: reporting the first as "the" installed
114
+ * version would be wrong for exactly the formulae (python@3.x, openssl@3) a
115
+ * caller is most likely to ask about.
116
+ */
117
+ export function parseBrewListVersions(stdout) {
118
+ const out = new Map();
119
+ for (const line of lines(stdout)) {
120
+ const parts = line.trim().split(/\s+/).filter(Boolean);
121
+ const name = parts[0];
122
+ if (name === undefined || parts.length < 2)
123
+ continue;
124
+ out.set(name, parts.slice(1));
125
+ }
126
+ return out;
127
+ }
128
+ /** `brew deps --formula <name>` — one dependency per line, already transitive. */
129
+ export function parseBrewDeps(stdout) {
130
+ return lines(stdout)
131
+ .map((l) => l.trim())
132
+ .filter((l) => l !== "");
133
+ }
134
+ /**
135
+ * Homebrew's own "no such package" signal.
136
+ *
137
+ * Recorded verbatim: `brew info --json=v2 nope-nope` exits 1 and prints
138
+ * `Error: No available formula with the name "nope-nope".` on stderr.
139
+ * Matching that specific sentence rather than "exit code 1" is what keeps a
140
+ * tap that failed to load from being reported as a missing package.
141
+ */
142
+ export function brewSaysNoSuchPackage(stderr) {
143
+ return /No available (formula|cask)( or cask)? with the name/i.test(stderr);
144
+ }
145
+ /**
146
+ * `dpkg-query -W -f='${binary:Package}\t${Version}\t${db:Status-Abbrev}\n'`.
147
+ *
148
+ * Tab-separated because a Debian version can contain almost anything except a
149
+ * tab, and named fields because the abbrev's first character is the fact that
150
+ * matters: `i` means the files are on disk, while `u` (unknown) and `r`
151
+ * (removed, config files left) are packages dpkg KNOWS ABOUT but does not
152
+ * have installed. An `rc` package read as installed is the classic dpkg
153
+ * mistake — the binary is gone and only `/etc` remains.
154
+ */
155
+ export function parseDpkgQuery(stdout) {
156
+ const rows = [];
157
+ for (const line of lines(stdout)) {
158
+ if (line.trim() === "")
159
+ continue;
160
+ const parts = line.split("\t");
161
+ const name = parts[0];
162
+ if (name === undefined || name === "")
163
+ continue;
164
+ rows.push({ name, version: parts[1] ?? "", statusAbbrev: (parts[2] ?? "").trim() });
165
+ }
166
+ return rows;
167
+ }
168
+ /** True when dpkg's status abbreviation means the package is actually on disk. */
169
+ export function dpkgStatusIsInstalled(abbrev) {
170
+ // `ii` = installed; `iU`/`iF` = installed but unconfigured, which is still
171
+ // "the files are there". Anything starting with u/r/p/h/n is not installed.
172
+ return abbrev.startsWith("i");
173
+ }
174
+ /** dpkg's own "I have never heard of this package" signal, on stderr. */
175
+ export function dpkgSaysNoSuchPackage(stderr) {
176
+ return /no packages found matching/i.test(stderr);
177
+ }
178
+ /**
179
+ * `apt-cache policy <name>` under `LC_ALL=C`.
180
+ *
181
+ * curl:
182
+ * Installed: 7.81.0-1ubuntu1.15
183
+ * Candidate: 7.81.0-1ubuntu1.16
184
+ * Version table: …
185
+ *
186
+ * `(none)` is apt's word for "not installed" and becomes `null` here — which
187
+ * is a DIFFERENT null from "the label was not found at all", and the caller
188
+ * tells them apart by whether the parse returned undefined.
189
+ *
190
+ * An unknown package produces an empty document (apt-cache prints nothing and
191
+ * exits 0), which parses to `undefined` rather than to two nulls, so "apt has
192
+ * never heard of this" cannot be confused with "apt has it, not installed".
193
+ */
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
+ }
207
+ }
208
+ if (installed === undefined && candidate === undefined)
209
+ return undefined;
210
+ return { installed: installed ?? null, candidate: candidate ?? null };
211
+ }
212
+ /**
213
+ * `apt-get install -s` — the best dry run of the six managers.
214
+ *
215
+ * It resolves the whole transaction without root and prints a line per
216
+ * package:
217
+ *
218
+ * Inst libnghttp2-14 (1.43.0-1 Debian:12 [amd64])
219
+ * Inst curl [7.88.1-10] (7.88.1-10+deb12u5 Debian-Security:12 [amd64])
220
+ * Conf curl (7.88.1-10+deb12u5 Debian-Security:12 [amd64])
221
+ * Remv oldthing [1.0-1]
222
+ *
223
+ * `Conf` lines are dropped: they name the same packages as `Inst`, and
224
+ * counting both would double every transitive addition. `Remv` is kept,
225
+ * because a transaction that REMOVES something is exactly the case an
226
+ * operator has to see before approving an install. The optional `[old]`
227
+ * bracket between the name and the parenthesis is what an UPGRADE looks like,
228
+ * and is skipped so the version reported is the one being installed.
229
+ */
230
+ export function parseAptSimulate(stdout) {
231
+ const changes = [];
232
+ for (const line of lines(stdout)) {
233
+ const inst = line.match(/^Inst\s+(\S+)\s+(?:\[[^\]]*\]\s+)?\(([^\s)]+)/);
234
+ if (inst?.[1] !== undefined) {
235
+ changes.push({ action: "install", name: inst[1], version: inst[2] ?? null });
236
+ continue;
237
+ }
238
+ const remv = line.match(/^Remv\s+(\S+)(?:\s+\[([^\]]*)\])?/);
239
+ if (remv?.[1] !== undefined) {
240
+ changes.push({ action: "remove", name: remv[1], version: remv[2] ?? null });
241
+ }
242
+ }
243
+ return changes;
244
+ }
245
+ /** apt's "there is no such package" signal, which it prints on stderr. */
246
+ export function aptSaysNoSuchPackage(stderr) {
247
+ return /Unable to locate package|has no installation candidate|Couldn't find any package/i.test(stderr);
248
+ }
249
+ /** apt's "you are not root" signal. */
250
+ export function aptSaysNeedsRoot(text) {
251
+ return /are you root|Permission denied|Unable to acquire the dpkg frontend lock|Could not open lock file/i.test(text);
252
+ }
253
+ /**
254
+ * `rpm -q --queryformat '%{NAME}\t%{EVR}\t%{ARCH}\n' <name>`.
255
+ *
256
+ * rpm prints "package foo is not installed" on STDOUT (not stderr) and exits
257
+ * 1, so that sentence has to be filtered out of the rows rather than detected
258
+ * by exit code alone — a query for several names prints some rows and some of
259
+ * those sentences in the same stream.
260
+ */
261
+ export function parseRpmQuery(stdout) {
262
+ const rows = [];
263
+ for (const line of lines(stdout)) {
264
+ const trimmed = line.trim();
265
+ if (trimmed === "")
266
+ continue;
267
+ if (/ is not installed$/.test(trimmed))
268
+ continue;
269
+ const parts = line.split("\t");
270
+ const name = parts[0];
271
+ if (name === undefined || name === "")
272
+ continue;
273
+ rows.push({ name, evr: parts[1] ?? "", arch: (parts[2] ?? "").trim() });
274
+ }
275
+ return rows;
276
+ }
277
+ /** rpm's own not-installed sentence, which it prints on stdout. */
278
+ export function rpmSaysNotInstalled(stdout) {
279
+ return /package .+ is not installed/i.test(stdout);
280
+ }
281
+ /**
282
+ * `dnf list --available <name>` — three columns, `name.arch version repo`.
283
+ *
284
+ * Header lines ("Available Packages", "Last metadata expiration check: …")
285
+ * are skipped by SHAPE rather than by text: a line qualifies only when it
286
+ * splits into exactly three whitespace-separated fields, the first contains a
287
+ * `.` separating a name from an architecture, and the second starts with a
288
+ * digit. Matching on the header text would break the moment dnf renames it,
289
+ * and dnf5 did exactly that.
290
+ */
291
+ export function parseDnfList(stdout) {
292
+ const rows = [];
293
+ for (const line of lines(stdout)) {
294
+ const parts = line.trim().split(/\s+/).filter(Boolean);
295
+ if (parts.length !== 3)
296
+ continue;
297
+ const nameArch = parts[0];
298
+ const version = parts[1];
299
+ const repo = parts[2];
300
+ if (nameArch === undefined || version === undefined || repo === undefined)
301
+ continue;
302
+ const dot = nameArch.lastIndexOf(".");
303
+ if (dot <= 0)
304
+ continue;
305
+ if (!/^[0-9]/.test(version))
306
+ continue;
307
+ rows.push({ name: nameArch.slice(0, dot), arch: nameArch.slice(dot + 1), version, repo });
308
+ }
309
+ return rows;
310
+ }
311
+ /**
312
+ * dnf's "the cache is empty and I was told not to fetch" complaint.
313
+ *
314
+ * This is the difference between "not in the repositories" and "I do not
315
+ * know", and `--cacheonly` makes the second common on a fresh container.
316
+ */
317
+ export function dnfSaysNoCache(text) {
318
+ return /Cache-only enabled but no cache|no cache for|Failed to (download|synchronize) metadata|There are no enabled repositories/i.test(text);
319
+ }
320
+ /** dnf's own "no such package" signal. */
321
+ export function dnfSaysNoMatch(text) {
322
+ return /No matching Packages to list|No match for argument|Unable to find a match/i.test(text);
323
+ }
324
+ /** dnf's "you are not root" signal — checked before a transaction is read. */
325
+ export function dnfSaysNeedsRoot(text) {
326
+ return /has to be run with superuser privileges|You need to be root|Operation not permitted|Insufficient permissions/i.test(text);
327
+ }
328
+ /**
329
+ * The transaction table `dnf install --assumeno` prints before it declines.
330
+ *
331
+ * Installing:
332
+ * tree x86_64 1.8.0-10.el9 baseos 55 k
333
+ * Installing dependencies:
334
+ * libfoo x86_64 1.2-3 baseos 10 k
335
+ *
336
+ * Section headers are matched on the English words, which is sound under the
337
+ * pinned `LC_ALL=C` and nowhere else. A row must have at least five fields;
338
+ * the size is two of them ("55 k") and is dropped, since a caller who wants a
339
+ * download total has dnf's own summary line.
340
+ */
341
+ export function parseDnfTransaction(stdout) {
342
+ const rows = [];
343
+ let inSection = false;
344
+ for (const line of lines(stdout)) {
345
+ if (/^(Installing|Upgrading|Downgrading|Reinstalling)(\s+(weak\s+)?dependencies)?:\s*$/.test(line)) {
346
+ inSection = true;
347
+ continue;
348
+ }
349
+ if (/^\S/.test(line)) {
350
+ // Any unindented line ends the section — "Transaction Summary", the
351
+ // rule of `=` characters, or the next heading.
352
+ inSection = false;
353
+ continue;
354
+ }
355
+ if (!inSection)
356
+ continue;
357
+ const parts = line.trim().split(/\s+/).filter(Boolean);
358
+ if (parts.length < 5)
359
+ continue;
360
+ const [name, arch, version, repo] = parts;
361
+ if (name === undefined || arch === undefined || version === undefined || repo === undefined) {
362
+ continue;
363
+ }
364
+ rows.push({ name, arch, version, repo });
365
+ }
366
+ return rows;
367
+ }
368
+ /** `pacman -Q <name>` — `name version`, one per line. */
369
+ export function parsePacmanQuery(stdout) {
370
+ const rows = [];
371
+ for (const line of lines(stdout)) {
372
+ const parts = line.trim().split(/\s+/).filter(Boolean);
373
+ const name = parts[0];
374
+ const version = parts[1];
375
+ if (name === undefined || version === undefined)
376
+ continue;
377
+ rows.push({ name, version });
378
+ }
379
+ return rows;
380
+ }
381
+ /**
382
+ * `pacman -Si <name>` — a `Key : Value` block with indented continuations.
383
+ *
384
+ * Only the FIRST block is read: a name that matches in two repositories
385
+ * prints two blocks, and merging them would report `extra`'s version against
386
+ * `core`'s repository. The caller reports the first block and says the name
387
+ * was ambiguous.
388
+ */
389
+ export function parsePacmanInfo(stdout) {
390
+ const fields = new Map();
391
+ let lastKey;
392
+ for (const line of lines(stdout)) {
393
+ if (line.trim() === "") {
394
+ // A blank line ends the first block; stop rather than merge the second.
395
+ if (fields.size > 0)
396
+ break;
397
+ continue;
398
+ }
399
+ const match = line.match(/^(\S(?:[^:]*\S)?)\s*:\s?(.*)$/);
400
+ if (match?.[1] !== undefined && !/^\s/.test(line)) {
401
+ lastKey = match[1].trim();
402
+ fields.set(lastKey, (match[2] ?? "").trim());
403
+ continue;
404
+ }
405
+ if (lastKey !== undefined) {
406
+ fields.set(lastKey, `${fields.get(lastKey) ?? ""} ${line.trim()}`.trim());
407
+ }
408
+ }
409
+ return fields;
410
+ }
411
+ /**
412
+ * `pacman -S --print --print-format '%r/%n %v'` — every target, dependencies
413
+ * included, with versions, no root and no transaction.
414
+ *
415
+ * A package coming from a local file rather than a repository prints an empty
416
+ * repo, which becomes `null` rather than a made-up repository name.
417
+ */
418
+ export function parsePacmanSimulate(stdout) {
419
+ const targets = [];
420
+ for (const line of lines(stdout)) {
421
+ const trimmed = line.trim();
422
+ if (trimmed === "")
423
+ continue;
424
+ const parts = trimmed.split(/\s+/).filter(Boolean);
425
+ const qualified = parts[0];
426
+ const version = parts[1];
427
+ if (qualified === undefined || version === undefined)
428
+ continue;
429
+ const slash = qualified.indexOf("/");
430
+ const repo = slash > 0 ? qualified.slice(0, slash) : null;
431
+ const name = slash >= 0 ? qualified.slice(slash + 1) : qualified;
432
+ if (name === "")
433
+ continue;
434
+ targets.push({ repo, name, version });
435
+ }
436
+ return targets;
437
+ }
438
+ /** pacman's own "no such package" signal. */
439
+ export function pacmanSaysNoSuchPackage(stderr) {
440
+ return /was not found|target not found/i.test(stderr);
441
+ }
442
+ /** pacman's "you are not root" signal. */
443
+ export function pacmanSaysNeedsRoot(text) {
444
+ return /you cannot perform this operation unless you are root|insufficient permission/i.test(text);
445
+ }
446
+ /** The ellipsis winget uses to mark a field it had to cut. */
447
+ const WINGET_ELLIPSIS = "…";
448
+ /**
449
+ * `winget list --exact --query <name>` — an aligned table, which is not a
450
+ * data format.
451
+ *
452
+ * winget has no JSON output for `list`. The survey sketch proposed splitting
453
+ * by the header's character offsets; that is wrong, and this is the improved
454
+ * version. Offsets break on two things winget does routinely: it TRUNCATES a
455
+ * field that does not fit and marks the cut with `…`, and it pads with
456
+ * ordinary spaces, so a CJK or emoji display name — double-width in the
457
+ * console but one or two UTF-16 code units in the string — shifts every
458
+ * offset after it. A version read from a shifted offset is not an error, it
459
+ * is a WRONG VERSION, which is the worst answer a query tool can give.
460
+ *
461
+ * So rows are split on runs of TWO OR MORE spaces (one space is inside a
462
+ * display name; two is padding), and a row is refused rather than guessed at
463
+ * when it does not split cleanly:
464
+ *
465
+ * - any field containing `…` was truncated, so nothing in the row is
466
+ * trusted;
467
+ * - fewer than three fields is not a `Name Id Version` row at all;
468
+ * - the column ORDER is fixed across locales (`Name Id Version [Available]
469
+ * Source`) even though the header TEXT is localised, so position 2 is the
470
+ * installed version — but position 3 is ambiguous: a four-field row is
471
+ * either `…Version Source` or `…Version Available`, with no way to tell.
472
+ * Four fields therefore yields a null `available` with a reason.
473
+ */
474
+ export function parseWingetList(stdout) {
475
+ const rows = [];
476
+ for (const raw of lines(stdout)) {
477
+ const line = raw.replace(/\s+$/, "");
478
+ if (line.trim() === "")
479
+ continue;
480
+ // The rule winget draws under the header, and the spinner characters it
481
+ // leaves behind when its progress display is written to a pipe.
482
+ if (/^[-—─\\|/]+$/.test(line.trim()))
483
+ continue;
484
+ const fields = line
485
+ .split(/ {2,}/)
486
+ .map((f) => f.trim())
487
+ .filter((f) => f !== "");
488
+ if (fields.length < 3)
489
+ continue;
490
+ const name = fields[0];
491
+ const id = fields[1];
492
+ const version = fields[2];
493
+ if (name === undefined || id === undefined || version === undefined)
494
+ continue;
495
+ // The localised header row splits the same way a data row does; it is
496
+ // recognised by the pair of labels rather than by either one alone.
497
+ if (/^(Name|Id|Version)$/i.test(name) && /^(Id|Version|Source)$/i.test(id))
498
+ continue;
499
+ if (fields.some((f) => f.includes(WINGET_ELLIPSIS))) {
500
+ rows.push({
501
+ name,
502
+ id,
503
+ version: null,
504
+ available: null,
505
+ unparsedReason: "winget cut at least one field of this row with an ellipsis, so the version in it is a prefix rather than a version",
506
+ });
507
+ continue;
508
+ }
509
+ rows.push({
510
+ name,
511
+ id,
512
+ version,
513
+ available: fields.length >= 5 ? (fields[3] ?? null) : null,
514
+ ...(fields.length === 4
515
+ ? {
516
+ unparsedReason: "this row has four columns, which is either Name/Id/Version/Source or Name/Id/Version/Available — winget's headers are localised, so the fourth column cannot be identified and no available version is reported from it",
517
+ }
518
+ : {}),
519
+ });
520
+ }
521
+ return rows;
522
+ }
523
+ /**
524
+ * winget's "no installed package matched" exit code, 0x8A15002B as a signed
525
+ * 32-bit integer.
526
+ *
527
+ * `winget list` exits with this rather than 0-and-empty, which makes presence
528
+ * decidable from the EXIT CODE alone — the one part of winget's answer that
529
+ * is neither localised nor column-aligned.
530
+ */
531
+ export const WINGET_NO_PACKAGES_FOUND = -1978335189;
532
+ // ---------------------------------------------------------------------------
533
+ // chocolatey
534
+ // ---------------------------------------------------------------------------
535
+ /**
536
+ * `choco list --limit-output` — `name|version`, one per line, no header.
537
+ *
538
+ * `--limit-output` is chocolatey's documented machine-readable mode and the
539
+ * reason choco is the easiest of the six to read. A trailing summary line
540
+ * ("2 packages installed.") does not appear in this mode, and is dropped by
541
+ * shape anyway in case an older build prints one.
542
+ */
543
+ export function parseChocoList(stdout) {
544
+ const rows = [];
545
+ for (const line of lines(stdout)) {
546
+ const trimmed = line.trim();
547
+ if (trimmed === "" || !trimmed.includes("|"))
548
+ continue;
549
+ const [name, version] = trimmed.split("|");
550
+ if (name === undefined || name === "" || version === undefined || version === "")
551
+ continue;
552
+ rows.push({ name, version });
553
+ }
554
+ return rows;
555
+ }
556
+ /**
557
+ * The major version of `choco --version`.
558
+ *
559
+ * Chocolatey 2.0 removed `--local-only` from `choco list`, so which flags are
560
+ * legal depends on this number. Guessing would mean either an unknown-option
561
+ * error on v2 or a REMOTE search on v1 — and a remote search answering "yes,
562
+ * 8.7.1" for a package that is not installed at all is precisely the wrong
563
+ * answer this package exists to avoid.
564
+ */
565
+ export function parseChocoMajorVersion(stdout) {
566
+ const match = stdout.trim().match(/(\d+)\.(\d+)/);
567
+ const major = match?.[1];
568
+ if (major === undefined)
569
+ return undefined;
570
+ const value = Number.parseInt(major, 10);
571
+ return Number.isFinite(value) ? value : undefined;
572
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * What an install WOULD do — resolved once, by one function, for both the dry
3
+ * run and the real thing.
4
+ *
5
+ * House rule 9 exists because a preview that re-derives the answer separately
6
+ * eventually describes something the real call does not do; TrashPath drifted
7
+ * exactly that way and predicted a destination the real call never used. The
8
+ * defence here is structural rather than disciplinary: `resolveInstallPlan`
9
+ * is the ONLY code that decides what gets installed, `dryRun` does not change
10
+ * which commands run to resolve it, and the single `if` that separates the
11
+ * two paths is in `../index.ts` AFTER this returns — it decides whether the
12
+ * install command runs, and nothing else.
13
+ *
14
+ * The cost is one extra command on the real path: a real install resolves its
15
+ * plan first, exactly as a dry run does. That is the point. The result then
16
+ * reports what it EXPECTED to install next to what it did, and a caller can
17
+ * see the difference rather than trust that there was none.
18
+ *
19
+ * ── WHAT "TRANSITIVE ADDITIONS" ACTUALLY COSTS, PER MANAGER ───────────────
20
+ *
21
+ * The catalogue implies every manager can preview a transaction. Four can,
22
+ * and they differ:
23
+ *
24
+ * apt `apt-get install -s` resolves the whole transaction WITHOUT
25
+ * root and prints every package with its version, including
26
+ * removals. This is the complete answer.
27
+ * pacman `pacman -S --print --print-format` prints every target with its
28
+ * version, dependencies included, without root. Also complete.
29
+ * dnf `dnf install --assumeno` prints the transaction table and then
30
+ * declines — but dnf may refuse before resolving anything when it
31
+ * is not root. Both outcomes are reported for what they are.
32
+ * brew has no dry run for `install` at all. The additions are derived
33
+ * from `brew deps` minus `brew list --versions`, which is the same
34
+ * resolution the real install performs, and the derivation is
35
+ * named in the result as `brew-deps` rather than passed off as a
36
+ * manager-provided plan.
37
+ */
38
+ import { type ManagerId } from "./managers";
39
+ import { type PackageFacts, type RunContext } from "./query";
40
+ import { type Unknowns } from "./unknown";
41
+ export type PlannedPackage = {
42
+ readonly name: string;
43
+ readonly version: string | null;
44
+ /** Present when the manager said which repository it comes from. */
45
+ readonly source?: string | null;
46
+ };
47
+ export type PlanSource = "apt-simulate" | "pacman-print" | "dnf-assumeno" | "brew-deps";
48
+ export type InstallPlan = {
49
+ readonly manager: ManagerId;
50
+ readonly name: string;
51
+ /** Exactly what would be appended to the manager's argv. */
52
+ readonly operand: string;
53
+ /** The version the caller pinned, when the manager can express one. */
54
+ readonly requestedVersion: string | null;
55
+ /**
56
+ * What the call would do. `none` means the requested state already holds —
57
+ * the one case where the honest answer is to run nothing at all.
58
+ */
59
+ readonly action: "install" | "upgrade" | "reinstall" | "none";
60
+ /**
61
+ * Versions ALREADY on disk. This is the field that makes `destructive:true`
62
+ * meaningful: an install can replace a working version, and an operator
63
+ * approving one should see the version they are about to lose.
64
+ */
65
+ readonly currentVersions: readonly string[] | null;
66
+ /** The version the plan expects to end up with, where it could be read. */
67
+ readonly targetVersion: string | null;
68
+ /**
69
+ * Packages the manager would add BESIDES the named one — the transitive
70
+ * closure, where the manager will tell us. `null` means it would not.
71
+ */
72
+ readonly additions: readonly PlannedPackage[] | null;
73
+ /** Packages the manager would REMOVE. apt is the only one that says. */
74
+ readonly removals: readonly PlannedPackage[] | null;
75
+ readonly planSource: PlanSource | null;
76
+ /** Why `additions` is null, when it is. */
77
+ readonly planUnavailableReason?: string;
78
+ readonly notes: readonly string[];
79
+ };
80
+ /**
81
+ * Decide what the install would do, and ask the manager what else it would
82
+ * drag in.
83
+ *
84
+ * `facts` comes from `queryPackage` — the same function `PackageQuery` calls,
85
+ * so the installed-version this plan reports and the one a caller can read
86
+ * for themselves are produced by one code path, not two.
87
+ */
88
+ export declare function resolveInstallPlan(ctx: RunContext, manager: ManagerId, name: string, operand: string, requestedVersion: string | null, facts: PackageFacts, unknowns: Unknowns): Promise<InstallPlan>;