@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
|
@@ -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>;
|