@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,552 @@
|
|
|
1
|
+
import { DEFAULT_COMMAND_TIMEOUT_MS, runHostCommand } from "../run";
|
|
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";
|
|
4
|
+
import { commandFailureReason, firstLine } from "./unknown";
|
|
5
|
+
export function newContext(timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS, signal) {
|
|
6
|
+
return { timeoutMs, ...(signal !== undefined ? { signal } : {}), probes: [] };
|
|
7
|
+
}
|
|
8
|
+
/** Run one command and record it. The only entry point to `../run.ts` here. */
|
|
9
|
+
export async function run(ctx, manager, argv) {
|
|
10
|
+
const result = await runHostCommand({
|
|
11
|
+
argv,
|
|
12
|
+
timeoutMs: ctx.timeoutMs,
|
|
13
|
+
env: MANAGER_ENV[manager],
|
|
14
|
+
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
15
|
+
});
|
|
16
|
+
ctx.probes.push({
|
|
17
|
+
argv,
|
|
18
|
+
exitCode: result.code,
|
|
19
|
+
ok: result.code === 0 && !result.timedOut && !result.missing,
|
|
20
|
+
...(result.missing ? { missing: true } : {}),
|
|
21
|
+
...(result.timedOut ? { timedOut: true } : {}),
|
|
22
|
+
});
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A stream that hit the capture ceiling is not an answer.
|
|
27
|
+
*
|
|
28
|
+
* A truncated `apt-get -s` or `brew list` parses perfectly and is simply
|
|
29
|
+
* MISSING packages, so the tail that did not arrive would be read as "not
|
|
30
|
+
* there". Every reader below checks this before it believes a parse.
|
|
31
|
+
*/
|
|
32
|
+
function truncated(result) {
|
|
33
|
+
return result.stdoutTruncated === true || result.stderrTruncated === true;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Find the system package manager, or say that there is none.
|
|
37
|
+
*
|
|
38
|
+
* A caller may name one, in which case only that one is probed and an absence
|
|
39
|
+
* is reported against it by name. Otherwise the platform's list is walked in
|
|
40
|
+
* order (see `managersForPlatform`) and the first one that answers its
|
|
41
|
+
* `--version` probe wins.
|
|
42
|
+
*
|
|
43
|
+
* On a platform with no entry — which includes the hostile `"other"` default
|
|
44
|
+
* a test gets when it forgets to declare a platform — nothing is probed and
|
|
45
|
+
* the answer is `unknown`. That is also the honest answer on a host with no
|
|
46
|
+
* package manager at all, which is what CI is: an unrecognised manager is
|
|
47
|
+
* reported as unknown rather than guessed at.
|
|
48
|
+
*/
|
|
49
|
+
export async function detectManager(ctx, platform, requested) {
|
|
50
|
+
const candidates = requested !== undefined ? [requested] : managersForPlatform(platform);
|
|
51
|
+
if (candidates.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "unknown",
|
|
54
|
+
tried: [],
|
|
55
|
+
reason: platform === "other"
|
|
56
|
+
? "this host reports a platform this package has no backend for, so no package manager was probed"
|
|
57
|
+
: `no package manager is known for platform "${platform}"`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// A caller-named manager is probed even when the platform table does not
|
|
61
|
+
// list it: Homebrew on Linux is real, and a container can carry a manager
|
|
62
|
+
// its platform does not usually have. The probe decides, not the table.
|
|
63
|
+
const tried = [];
|
|
64
|
+
for (const id of candidates) {
|
|
65
|
+
const result = await run(ctx, id, MANAGERS[id].detect);
|
|
66
|
+
tried.push(id);
|
|
67
|
+
if (result.code === 0 && !result.timedOut && !result.missing) {
|
|
68
|
+
return { kind: "found", manager: id, tried };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const names = tried.map((id) => MANAGERS[id].label).join(", ");
|
|
72
|
+
return {
|
|
73
|
+
kind: "unknown",
|
|
74
|
+
tried,
|
|
75
|
+
reason: requested !== undefined
|
|
76
|
+
? `${MANAGERS[requested].label} did not answer its --version probe on this host`
|
|
77
|
+
: `none of the package managers this package supports answered a --version probe on this ${platform} host (tried ${names})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Ask one manager about one name.
|
|
82
|
+
*
|
|
83
|
+
* `name` must already have passed `checkPackageName`; this function appends it
|
|
84
|
+
* to a frozen argv prefix and never inspects it again.
|
|
85
|
+
*/
|
|
86
|
+
export async function queryPackage(ctx, manager, name, unknowns, includeAvailable) {
|
|
87
|
+
switch (manager) {
|
|
88
|
+
case "homebrew":
|
|
89
|
+
return queryHomebrew(ctx, name, unknowns, includeAvailable);
|
|
90
|
+
case "apt":
|
|
91
|
+
return queryApt(ctx, name, unknowns, includeAvailable);
|
|
92
|
+
case "dnf":
|
|
93
|
+
return queryDnf(ctx, name, unknowns, includeAvailable);
|
|
94
|
+
case "pacman":
|
|
95
|
+
return queryPacman(ctx, name, unknowns, includeAvailable);
|
|
96
|
+
case "winget":
|
|
97
|
+
return queryWinget(ctx, name, unknowns, includeAvailable);
|
|
98
|
+
case "choco":
|
|
99
|
+
return queryChoco(ctx, name, unknowns, includeAvailable);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The shape every branch below returns when it has nothing at all. */
|
|
103
|
+
function unknownFacts(manager, name, notes = []) {
|
|
104
|
+
return {
|
|
105
|
+
manager,
|
|
106
|
+
name,
|
|
107
|
+
status: "unknown",
|
|
108
|
+
installedVersions: null,
|
|
109
|
+
knownToManager: null,
|
|
110
|
+
available: null,
|
|
111
|
+
notes,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// ---- Homebrew -------------------------------------------------------------
|
|
115
|
+
async function queryHomebrew(ctx, name, unknowns, includeAvailable) {
|
|
116
|
+
const spec = MANAGERS.homebrew;
|
|
117
|
+
const argv = withOperand(spec, PKGMGR_COMMANDS.brewInfoJson, name);
|
|
118
|
+
const result = await run(ctx, "homebrew", argv);
|
|
119
|
+
const probe = argv.join(" ");
|
|
120
|
+
if (result.missing || result.timedOut || truncated(result)) {
|
|
121
|
+
const reason = truncated(result)
|
|
122
|
+
? "brew's output was cut at this package's capture ceiling, so the document is incomplete and was not parsed"
|
|
123
|
+
: commandFailureReason(result);
|
|
124
|
+
unknowns.add("status", probe, reason);
|
|
125
|
+
unknowns.add("available.version", probe, reason);
|
|
126
|
+
return unknownFacts("homebrew", name);
|
|
127
|
+
}
|
|
128
|
+
if (result.code !== 0) {
|
|
129
|
+
if (brewSaysNoSuchPackage(result.stderr)) {
|
|
130
|
+
// Homebrew's own not-found sentence — the only thing that turns a
|
|
131
|
+
// non-zero exit into "there is no such package".
|
|
132
|
+
return {
|
|
133
|
+
manager: "homebrew",
|
|
134
|
+
name,
|
|
135
|
+
status: "not-installed",
|
|
136
|
+
installedVersions: [],
|
|
137
|
+
knownToManager: false,
|
|
138
|
+
available: null,
|
|
139
|
+
notes: ["Homebrew has no formula or cask with this name in the taps on this host"],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const reason = commandFailureReason(result);
|
|
143
|
+
unknowns.add("status", probe, reason);
|
|
144
|
+
unknowns.add("available.version", probe, reason);
|
|
145
|
+
return unknownFacts("homebrew", name);
|
|
146
|
+
}
|
|
147
|
+
const info = parseBrewInfoJson(result.stdout);
|
|
148
|
+
if (info === undefined) {
|
|
149
|
+
const reason = `brew exited 0 but did not print a --json=v2 document (${firstLine(result.stdout) || "empty output"})`;
|
|
150
|
+
unknowns.add("status", probe, reason);
|
|
151
|
+
unknowns.add("available.version", probe, reason);
|
|
152
|
+
return unknownFacts("homebrew", name);
|
|
153
|
+
}
|
|
154
|
+
const entry = info.formula ?? info.cask;
|
|
155
|
+
if (entry === undefined) {
|
|
156
|
+
return {
|
|
157
|
+
manager: "homebrew",
|
|
158
|
+
name,
|
|
159
|
+
status: "not-installed",
|
|
160
|
+
installedVersions: [],
|
|
161
|
+
knownToManager: false,
|
|
162
|
+
available: null,
|
|
163
|
+
notes: ["brew returned an empty formula and cask list for this name"],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const notes = [];
|
|
167
|
+
if (info.formula !== undefined && info.cask !== undefined) {
|
|
168
|
+
notes.push("this name matches both a formula and a cask; the formula is reported, and the cask is a different package");
|
|
169
|
+
}
|
|
170
|
+
if (!includeAvailable) {
|
|
171
|
+
unknowns.add("available.version", probe, "the caller asked for installed state only");
|
|
172
|
+
}
|
|
173
|
+
else if (entry.stableVersion === null) {
|
|
174
|
+
unknowns.add("available.version", probe, "brew's document has no stable version for this name (a HEAD-only formula, or a cask without a version)");
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
manager: "homebrew",
|
|
178
|
+
name,
|
|
179
|
+
status: entry.installedVersions.length > 0 ? "installed" : "not-installed",
|
|
180
|
+
installedVersions: [...entry.installedVersions],
|
|
181
|
+
knownToManager: true,
|
|
182
|
+
available: includeAvailable && entry.stableVersion !== null
|
|
183
|
+
? { version: entry.stableVersion, source: entry.tap }
|
|
184
|
+
: null,
|
|
185
|
+
pinned: entry.pinned,
|
|
186
|
+
outdated: entry.outdated,
|
|
187
|
+
kind: entry.kind,
|
|
188
|
+
notes,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// ---- apt / dpkg -----------------------------------------------------------
|
|
192
|
+
async function queryApt(ctx, name, unknowns, includeAvailable) {
|
|
193
|
+
const spec = MANAGERS.apt;
|
|
194
|
+
const dpkgArgv = withOperand(spec, PKGMGR_COMMANDS.dpkgQuery, name);
|
|
195
|
+
const dpkg = await run(ctx, "apt", dpkgArgv);
|
|
196
|
+
const dpkgProbe = dpkgArgv.join(" ");
|
|
197
|
+
let status = "unknown";
|
|
198
|
+
let installedVersions = null;
|
|
199
|
+
let knownToManager = null;
|
|
200
|
+
const notes = [];
|
|
201
|
+
if (dpkg.missing || dpkg.timedOut || truncated(dpkg)) {
|
|
202
|
+
unknowns.add("status", dpkgProbe, truncated(dpkg)
|
|
203
|
+
? "dpkg-query's output was cut at this package's capture ceiling"
|
|
204
|
+
: commandFailureReason(dpkg));
|
|
205
|
+
}
|
|
206
|
+
else if (dpkg.code === 0) {
|
|
207
|
+
const rows = parseDpkgQuery(dpkg.stdout);
|
|
208
|
+
// `-W <name>` can match several binary packages when the name carries an
|
|
209
|
+
// architecture qualifier; the exact name wins, and anything else is a
|
|
210
|
+
// note rather than a silent pick.
|
|
211
|
+
const exact = rows.find((r) => r.name === name || `${r.name}:`.startsWith(`${name}:`));
|
|
212
|
+
const row = exact ?? rows[0];
|
|
213
|
+
if (row === undefined) {
|
|
214
|
+
unknowns.add("status", dpkgProbe, "dpkg-query exited 0 but printed no rows");
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
if (rows.length > 1) {
|
|
218
|
+
notes.push(`dpkg knows ${rows.length} binary packages matching this name; the one reported is "${row.name}"`);
|
|
219
|
+
}
|
|
220
|
+
knownToManager = true;
|
|
221
|
+
if (dpkgStatusIsInstalled(row.statusAbbrev)) {
|
|
222
|
+
status = "installed";
|
|
223
|
+
installedVersions = [row.version];
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
status = "not-installed";
|
|
227
|
+
installedVersions = [];
|
|
228
|
+
if (row.statusAbbrev.startsWith("r")) {
|
|
229
|
+
notes.push(`dpkg status is "${row.statusAbbrev}": the package was removed but its configuration files are still on disk, which is not the same as installed`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else if (dpkgSaysNoSuchPackage(dpkg.stderr)) {
|
|
235
|
+
// dpkg has no record. That is not yet "not known to apt": a package that
|
|
236
|
+
// has never been installed is absent from dpkg's database and still very
|
|
237
|
+
// much available, which the policy call below decides.
|
|
238
|
+
status = "not-installed";
|
|
239
|
+
installedVersions = [];
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
unknowns.add("status", dpkgProbe, commandFailureReason(dpkg));
|
|
243
|
+
}
|
|
244
|
+
let available = null;
|
|
245
|
+
if (!includeAvailable) {
|
|
246
|
+
unknowns.add("available.version", "apt-cache policy", "the caller asked for installed state only");
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const policyArgv = withOperand(spec, PKGMGR_COMMANDS.aptCachePolicy, name);
|
|
250
|
+
const policy = await run(ctx, "apt", policyArgv);
|
|
251
|
+
const policyProbe = policyArgv.join(" ");
|
|
252
|
+
if (policy.missing || policy.timedOut || truncated(policy)) {
|
|
253
|
+
unknowns.add("available.version", policyProbe, commandFailureReason(policy));
|
|
254
|
+
}
|
|
255
|
+
else if (policy.code !== 0 && !aptSaysNoSuchPackage(policy.stderr)) {
|
|
256
|
+
unknowns.add("available.version", policyProbe, commandFailureReason(policy));
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const parsed = parseAptCachePolicy(policy.stdout);
|
|
260
|
+
if (parsed === undefined) {
|
|
261
|
+
if (policy.stdout.trim() === "" || aptSaysNoSuchPackage(policy.stderr)) {
|
|
262
|
+
// apt-cache prints nothing at all for a name it does not know.
|
|
263
|
+
if (knownToManager === null)
|
|
264
|
+
knownToManager = false;
|
|
265
|
+
available = null;
|
|
266
|
+
notes.push("apt has no record of this package in its downloaded package lists");
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
unknowns.add("available.version", policyProbe, "apt-cache policy printed neither an Installed nor a Candidate label — either its output is not English despite the pinned C locale, or the format has changed");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
knownToManager = true;
|
|
274
|
+
available = parsed.candidate === null ? null : { version: parsed.candidate, source: "apt" };
|
|
275
|
+
if (parsed.candidate === null) {
|
|
276
|
+
unknowns.add("available.version", policyProbe, "apt has this package in its lists but no installation candidate, so no version can be installed from the configured sources");
|
|
277
|
+
}
|
|
278
|
+
// The two probes can disagree: a package held back, or a dpkg
|
|
279
|
+
// database read between an apt operation's two halves. Saying so is
|
|
280
|
+
// better than picking one.
|
|
281
|
+
if (status === "installed" &&
|
|
282
|
+
installedVersions?.[0] !== undefined &&
|
|
283
|
+
parsed.installed !== null &&
|
|
284
|
+
parsed.installed !== installedVersions[0]) {
|
|
285
|
+
notes.push(`dpkg reports ${installedVersions[0]} installed and apt-cache reports ${parsed.installed}; the dpkg answer is the one on disk`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
manager: "apt",
|
|
292
|
+
name,
|
|
293
|
+
status,
|
|
294
|
+
installedVersions,
|
|
295
|
+
knownToManager,
|
|
296
|
+
available,
|
|
297
|
+
notes,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// ---- dnf / rpm ------------------------------------------------------------
|
|
301
|
+
async function queryDnf(ctx, name, unknowns, includeAvailable) {
|
|
302
|
+
const spec = MANAGERS.dnf;
|
|
303
|
+
const rpmArgv = withOperand(spec, PKGMGR_COMMANDS.rpmQuery, name);
|
|
304
|
+
const rpm = await run(ctx, "dnf", rpmArgv);
|
|
305
|
+
const rpmProbe = rpmArgv.join(" ");
|
|
306
|
+
let status = "unknown";
|
|
307
|
+
let installedVersions = null;
|
|
308
|
+
let knownToManager = null;
|
|
309
|
+
const notes = [];
|
|
310
|
+
if (rpm.missing || rpm.timedOut || truncated(rpm)) {
|
|
311
|
+
unknowns.add("status", rpmProbe, commandFailureReason(rpm));
|
|
312
|
+
}
|
|
313
|
+
else if (rpm.code === 0) {
|
|
314
|
+
const rows = parseRpmQuery(rpm.stdout);
|
|
315
|
+
if (rows.length === 0) {
|
|
316
|
+
unknowns.add("status", rpmProbe, "rpm exited 0 but printed no rows this parser could read");
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
status = "installed";
|
|
320
|
+
knownToManager = true;
|
|
321
|
+
// Several rows means the same name installed for several
|
|
322
|
+
// architectures — a real and common state on x86_64 with i686 libs.
|
|
323
|
+
installedVersions = rows.map((r) => r.evr);
|
|
324
|
+
if (rows.length > 1) {
|
|
325
|
+
notes.push(`rpm reports ${rows.length} installed packages with this name (architectures: ${rows.map((r) => r.arch).join(", ")})`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
else if (rpmSaysNotInstalled(rpm.stdout)) {
|
|
330
|
+
status = "not-installed";
|
|
331
|
+
installedVersions = [];
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
unknowns.add("status", rpmProbe, commandFailureReason(rpm));
|
|
335
|
+
}
|
|
336
|
+
let available = null;
|
|
337
|
+
if (!includeAvailable) {
|
|
338
|
+
unknowns.add("available.version", "dnf list", "the caller asked for installed state only");
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
const listArgv = withOperand(spec, PKGMGR_COMMANDS.dnfListAvailable, name);
|
|
342
|
+
const list = await run(ctx, "dnf", listArgv);
|
|
343
|
+
const listProbe = listArgv.join(" ");
|
|
344
|
+
const combined = `${list.stdout}\n${list.stderr}`;
|
|
345
|
+
if (list.missing || list.timedOut || truncated(list)) {
|
|
346
|
+
unknowns.add("available.version", listProbe, commandFailureReason(list));
|
|
347
|
+
}
|
|
348
|
+
else if (dnfSaysNoCache(combined)) {
|
|
349
|
+
// The case `--cacheonly` makes common, and the case rule 6 is about:
|
|
350
|
+
// an empty metadata cache is not an absent package.
|
|
351
|
+
unknowns.add("available.version", listProbe, "dnf's repository metadata is not cached on this host and this tool does not fetch it, so what is available could not be determined — run `dnf makecache` to populate it");
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const rows = parseDnfList(list.stdout);
|
|
355
|
+
const row = rows[0];
|
|
356
|
+
if (row !== undefined) {
|
|
357
|
+
knownToManager = true;
|
|
358
|
+
available = { version: row.version, source: row.repo };
|
|
359
|
+
if (rows.length > 1) {
|
|
360
|
+
notes.push(`dnf lists ${rows.length} available builds of this name (architectures: ${rows.map((r) => r.arch).join(", ")}); the first is reported`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else if (dnfSaysNoMatch(combined)) {
|
|
364
|
+
if (knownToManager === null)
|
|
365
|
+
knownToManager = false;
|
|
366
|
+
notes.push("dnf's enabled repositories have no package with this name");
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
unknowns.add("available.version", listProbe, `dnf exited ${list.code} and printed no row this parser could read (${firstLine(combined) || "empty output"})`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { manager: "dnf", name, status, installedVersions, knownToManager, available, notes };
|
|
374
|
+
}
|
|
375
|
+
// ---- pacman ---------------------------------------------------------------
|
|
376
|
+
async function queryPacman(ctx, name, unknowns, includeAvailable) {
|
|
377
|
+
const spec = MANAGERS.pacman;
|
|
378
|
+
const queryArgv = withOperand(spec, PKGMGR_COMMANDS.pacmanQuery, name);
|
|
379
|
+
const query = await run(ctx, "pacman", queryArgv);
|
|
380
|
+
const queryProbe = queryArgv.join(" ");
|
|
381
|
+
let status = "unknown";
|
|
382
|
+
let installedVersions = null;
|
|
383
|
+
let knownToManager = null;
|
|
384
|
+
const notes = [];
|
|
385
|
+
if (query.missing || query.timedOut || truncated(query)) {
|
|
386
|
+
unknowns.add("status", queryProbe, commandFailureReason(query));
|
|
387
|
+
}
|
|
388
|
+
else if (query.code === 0) {
|
|
389
|
+
const rows = parsePacmanQuery(query.stdout);
|
|
390
|
+
const row = rows[0];
|
|
391
|
+
if (row === undefined) {
|
|
392
|
+
unknowns.add("status", queryProbe, "pacman exited 0 but printed no `name version` row");
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
status = "installed";
|
|
396
|
+
knownToManager = true;
|
|
397
|
+
installedVersions = [row.version];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (pacmanSaysNoSuchPackage(query.stderr)) {
|
|
401
|
+
status = "not-installed";
|
|
402
|
+
installedVersions = [];
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
unknowns.add("status", queryProbe, commandFailureReason(query));
|
|
406
|
+
}
|
|
407
|
+
let available = null;
|
|
408
|
+
if (!includeAvailable) {
|
|
409
|
+
unknowns.add("available.version", "pacman -Si", "the caller asked for installed state only");
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
const infoArgv = withOperand(spec, PKGMGR_COMMANDS.pacmanSyncInfo, name);
|
|
413
|
+
const info = await run(ctx, "pacman", infoArgv);
|
|
414
|
+
const infoProbe = infoArgv.join(" ");
|
|
415
|
+
if (info.missing || info.timedOut || truncated(info)) {
|
|
416
|
+
unknowns.add("available.version", infoProbe, commandFailureReason(info));
|
|
417
|
+
}
|
|
418
|
+
else if (info.code === 0) {
|
|
419
|
+
const fields = parsePacmanInfo(info.stdout);
|
|
420
|
+
const version = fields.get("Version");
|
|
421
|
+
const repo = fields.get("Repository") ?? null;
|
|
422
|
+
if (version === undefined) {
|
|
423
|
+
unknowns.add("available.version", infoProbe, "pacman -Si printed a block with no Version field — either its output is not English despite the pinned C locale, or the format has changed");
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
knownToManager = true;
|
|
427
|
+
available = { version, source: repo };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else if (pacmanSaysNoSuchPackage(info.stderr)) {
|
|
431
|
+
if (knownToManager === null)
|
|
432
|
+
knownToManager = false;
|
|
433
|
+
notes.push("pacman's sync databases have no package with this name");
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
unknowns.add("available.version", infoProbe, commandFailureReason(info));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { manager: "pacman", name, status, installedVersions, knownToManager, available, notes };
|
|
440
|
+
}
|
|
441
|
+
// ---- winget ---------------------------------------------------------------
|
|
442
|
+
async function queryWinget(ctx, name, unknowns, _includeAvailable) {
|
|
443
|
+
const spec = MANAGERS.winget;
|
|
444
|
+
const argv = withOperand(spec, PKGMGR_COMMANDS.wingetList, name);
|
|
445
|
+
const result = await run(ctx, "winget", argv);
|
|
446
|
+
const probe = argv.join(" ");
|
|
447
|
+
// winget's available version comes only from its source, over the network,
|
|
448
|
+
// and this package makes no network call. Said once, always.
|
|
449
|
+
unknowns.add("available.version", "not probed", spec.availableReason ?? "not probed");
|
|
450
|
+
if (result.missing || result.timedOut || truncated(result)) {
|
|
451
|
+
unknowns.add("status", probe, commandFailureReason(result));
|
|
452
|
+
return unknownFacts("winget", name);
|
|
453
|
+
}
|
|
454
|
+
if (result.code === WINGET_NO_PACKAGES_FOUND) {
|
|
455
|
+
// The one part of winget's answer that is neither localised nor
|
|
456
|
+
// column-aligned: its documented "no installed package matched" code.
|
|
457
|
+
return {
|
|
458
|
+
manager: "winget",
|
|
459
|
+
name,
|
|
460
|
+
status: "not-installed",
|
|
461
|
+
installedVersions: [],
|
|
462
|
+
knownToManager: null,
|
|
463
|
+
available: null,
|
|
464
|
+
notes: [
|
|
465
|
+
"winget reported no installed package with this Id; whether its source has one was not asked, because that is a network query",
|
|
466
|
+
],
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
if (result.code !== 0) {
|
|
470
|
+
unknowns.add("status", probe, commandFailureReason(result));
|
|
471
|
+
return unknownFacts("winget", name);
|
|
472
|
+
}
|
|
473
|
+
const rows = parseWingetList(result.stdout);
|
|
474
|
+
const row = rows[0];
|
|
475
|
+
if (row === undefined) {
|
|
476
|
+
unknowns.add("status", probe, "winget exited 0 but printed no row this parser could split on column padding, so whether the package is installed could not be determined");
|
|
477
|
+
return unknownFacts("winget", name);
|
|
478
|
+
}
|
|
479
|
+
const notes = [];
|
|
480
|
+
if (rows.length > 1) {
|
|
481
|
+
notes.push(`winget listed ${rows.length} matching packages; the first is reported`);
|
|
482
|
+
}
|
|
483
|
+
if (row.unparsedReason !== undefined) {
|
|
484
|
+
unknowns.add("installedVersions", probe, row.unparsedReason);
|
|
485
|
+
notes.push(`winget's row for "${row.id}" could not be split safely`);
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
manager: "winget",
|
|
489
|
+
name,
|
|
490
|
+
status: "installed",
|
|
491
|
+
installedVersions: row.version === null ? null : [row.version],
|
|
492
|
+
knownToManager: true,
|
|
493
|
+
available: null,
|
|
494
|
+
notes,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
// ---- chocolatey -----------------------------------------------------------
|
|
498
|
+
async function queryChoco(ctx, name, unknowns, _includeAvailable) {
|
|
499
|
+
const spec = MANAGERS.choco;
|
|
500
|
+
unknowns.add("available.version", "not probed", spec.availableReason ?? "not probed");
|
|
501
|
+
const versionResult = await run(ctx, "choco", PKGMGR_COMMANDS.chocoVersion);
|
|
502
|
+
const versionProbe = PKGMGR_COMMANDS.chocoVersion.join(" ");
|
|
503
|
+
if (versionResult.missing || versionResult.timedOut) {
|
|
504
|
+
unknowns.add("status", versionProbe, commandFailureReason(versionResult));
|
|
505
|
+
return unknownFacts("choco", name);
|
|
506
|
+
}
|
|
507
|
+
const major = parseChocoMajorVersion(versionResult.stdout);
|
|
508
|
+
if (major === undefined) {
|
|
509
|
+
// Not a nicety: `--local-only` was removed in Chocolatey 2.0, and on v1
|
|
510
|
+
// `choco list` without it searches the REMOTE feed. Guessing would answer
|
|
511
|
+
// "installed, 8.7.1" for a package that is not installed at all.
|
|
512
|
+
unknowns.add("status", versionProbe, "chocolatey's version could not be read, and which `choco list` flags are legal depends on it (--local-only was removed in 2.0, and without it a v1 `choco list` searches the remote feed instead of the machine)");
|
|
513
|
+
return unknownFacts("choco", name);
|
|
514
|
+
}
|
|
515
|
+
const prefix = major >= 2 ? PKGMGR_COMMANDS.chocoListV2 : PKGMGR_COMMANDS.chocoListV1;
|
|
516
|
+
const argv = withOperand(spec, prefix, name);
|
|
517
|
+
const result = await run(ctx, "choco", argv);
|
|
518
|
+
const probe = argv.join(" ");
|
|
519
|
+
if (result.missing || result.timedOut || truncated(result)) {
|
|
520
|
+
unknowns.add("status", probe, commandFailureReason(result));
|
|
521
|
+
return unknownFacts("choco", name);
|
|
522
|
+
}
|
|
523
|
+
if (result.code !== 0) {
|
|
524
|
+
unknowns.add("status", probe, commandFailureReason(result));
|
|
525
|
+
return unknownFacts("choco", name);
|
|
526
|
+
}
|
|
527
|
+
const rows = parseChocoList(result.stdout);
|
|
528
|
+
// Chocolatey ids are case-insensitive, and `--exact` matches them that way.
|
|
529
|
+
const row = rows.find((r) => r.name.toLowerCase() === name.toLowerCase());
|
|
530
|
+
if (row === undefined) {
|
|
531
|
+
return {
|
|
532
|
+
manager: "choco",
|
|
533
|
+
name,
|
|
534
|
+
status: "not-installed",
|
|
535
|
+
installedVersions: [],
|
|
536
|
+
knownToManager: null,
|
|
537
|
+
available: null,
|
|
538
|
+
notes: [
|
|
539
|
+
`chocolatey ${major >= 2 ? "2.x" : "1.x"} listed no installed package with this id; whether its feed has one was not asked, because that is a network query`,
|
|
540
|
+
],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
manager: "choco",
|
|
545
|
+
name,
|
|
546
|
+
status: "installed",
|
|
547
|
+
installedVersions: [row.version],
|
|
548
|
+
knownToManager: true,
|
|
549
|
+
available: null,
|
|
550
|
+
notes: [],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary for "I could not find out" — `@crewhaus/tool-host`'s
|
|
3
|
+
* `lib/unknown.ts`, reproduced here because that package exports its tools
|
|
4
|
+
* and not its library, and the rule it encodes is the one this package is
|
|
5
|
+
* most likely to break.
|
|
6
|
+
*
|
|
7
|
+
* A caller asks whether `curl` is installed in order to decide something:
|
|
8
|
+
* whether to install it, whether to pick a different strategy, whether to
|
|
9
|
+
* tell an operator the host is not ready. Every one of those decisions is
|
|
10
|
+
* made WRONGLY, and silently, if a probe that could not answer comes back as
|
|
11
|
+
* "no":
|
|
12
|
+
*
|
|
13
|
+
* - `dpkg-query` is not on the host (this is a Fedora box) -> "not installed"
|
|
14
|
+
* - `dnf --cacheonly` has no cached metadata -> "not available"
|
|
15
|
+
* - `apt-cache policy` printed German labels -> "no candidate"
|
|
16
|
+
* - `winget list` printed a row whose name was truncated -> wrong version
|
|
17
|
+
* - the command hit its deadline -> "not installed"
|
|
18
|
+
* - `brew info` exited non-zero for a reason nobody parsed -> "not installed"
|
|
19
|
+
*
|
|
20
|
+
* So: a fact that could not be established is `null` in the result AND
|
|
21
|
+
* carries an entry here naming the field, the probe that was tried, and why
|
|
22
|
+
* it did not answer. A boolean is never defaulted to false, a version is
|
|
23
|
+
* never defaulted to "latest", and a status is `"unknown"` rather than
|
|
24
|
+
* `"not-installed"` unless the manager gave its own documented not-found
|
|
25
|
+
* signal. `every null is explained` in index.test.ts walks each tool's output
|
|
26
|
+
* and fails if any null is missing its entry, so the rule cannot rot.
|
|
27
|
+
*/
|
|
28
|
+
export type UnknownFact = {
|
|
29
|
+
/** Dotted path of the field in the result, e.g. "available.version". */
|
|
30
|
+
readonly field: string;
|
|
31
|
+
/** What was tried: a command, or the reason no command was run. */
|
|
32
|
+
readonly probe: string;
|
|
33
|
+
/** Why it did not answer, in a sentence a caller can act on. */
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
};
|
|
36
|
+
/** Collects unknowns for one tool call. */
|
|
37
|
+
export declare class Unknowns {
|
|
38
|
+
private readonly facts;
|
|
39
|
+
add(field: string, probe: string, reason: string): void;
|
|
40
|
+
has(field: string): boolean;
|
|
41
|
+
get size(): number;
|
|
42
|
+
/**
|
|
43
|
+
* Sorted by field, so two calls against the same machine return the same
|
|
44
|
+
* bytes rather than whatever order the probes happened to fail in.
|
|
45
|
+
*/
|
|
46
|
+
list(): ReadonlyArray<UnknownFact>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Describe a failed command, keeping apart the cases a caller acts on
|
|
50
|
+
* differently: a manager that is NOT INSTALLED (this is the wrong host, or
|
|
51
|
+
* ask a different manager), one that TIMED OUT (the answer is unknown and
|
|
52
|
+
* retrying may work), and one that RAN AND FAILED (its stderr is the lead).
|
|
53
|
+
*/
|
|
54
|
+
export declare function commandFailureReason(result: {
|
|
55
|
+
readonly code: number;
|
|
56
|
+
readonly stderr: string;
|
|
57
|
+
readonly timedOut: boolean;
|
|
58
|
+
readonly missing: boolean;
|
|
59
|
+
readonly abandoned?: boolean;
|
|
60
|
+
}): string;
|
|
61
|
+
/**
|
|
62
|
+
* The first non-empty line, capped.
|
|
63
|
+
*
|
|
64
|
+
* A manager's usage message can be forty lines long — `dnf` prints its whole
|
|
65
|
+
* option grammar on a bad flag — and none of it belongs in a result a model
|
|
66
|
+
* has to read.
|
|
67
|
+
*/
|
|
68
|
+
export declare function firstLine(text: string, max?: number): string;
|