@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,351 @@
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 { MANAGERS, PKGMGR_COMMANDS, withOperand } from "./managers";
39
+ import { aptSaysNeedsRoot, dnfSaysNeedsRoot, pacmanSaysNeedsRoot, parseAptSimulate, parseBrewDeps, parseBrewListVersions, parseDnfTransaction, parsePacmanSimulate, } from "./parse";
40
+ import { run } from "./query";
41
+ import { commandFailureReason, firstLine } from "./unknown";
42
+ /**
43
+ * Decide what the install would do, and ask the manager what else it would
44
+ * drag in.
45
+ *
46
+ * `facts` comes from `queryPackage` — the same function `PackageQuery` calls,
47
+ * so the installed-version this plan reports and the one a caller can read
48
+ * for themselves are produced by one code path, not two.
49
+ */
50
+ export async function resolveInstallPlan(ctx, manager, name, operand, requestedVersion, facts, unknowns) {
51
+ const notes = [];
52
+ const current = facts.installedVersions;
53
+ let action;
54
+ if (facts.status === "unknown") {
55
+ // Could not determine is not "not installed": the plan says `install`
56
+ // because that is what the command would do, and the unknown travels with
57
+ // it so nobody reads the missing current version as "there was none".
58
+ action = "install";
59
+ notes.push("whether this package is already installed could not be determined, so this plan cannot say whether the install would replace a working version");
60
+ }
61
+ else if (facts.status === "not-installed" || current === null || current.length === 0) {
62
+ action = "install";
63
+ }
64
+ else if (requestedVersion !== null && current.includes(requestedVersion)) {
65
+ action = "none";
66
+ }
67
+ else if (requestedVersion !== null) {
68
+ action = "upgrade";
69
+ notes.push(`version ${requestedVersion} was requested and ${current.join(", ")} is installed; the manager decides whether that is an upgrade or a downgrade`);
70
+ }
71
+ else if (facts.outdated === true) {
72
+ action = "upgrade";
73
+ }
74
+ else if (facts.available?.version !== undefined && facts.available.version !== null) {
75
+ action = current.includes(facts.available.version) ? "none" : "upgrade";
76
+ }
77
+ else {
78
+ // Installed, and nothing said a newer version exists. `reinstall` rather
79
+ // than `none`: the manager may still do work, and claiming "nothing would
80
+ // happen" without evidence is the guess this package does not make.
81
+ action = "reinstall";
82
+ notes.push("this package is installed and no available version could be read, so whether the command would change anything is not known");
83
+ }
84
+ const targetVersion = requestedVersion ?? facts.available?.version ?? null;
85
+ if (targetVersion === null) {
86
+ unknowns.add("plan.targetVersion", MANAGERS[manager].label, "the version this install would land on could not be read from the local index");
87
+ }
88
+ const closure = await resolveClosure(ctx, manager, operand, unknowns);
89
+ if (closure.reason !== undefined) {
90
+ unknowns.add("plan.additions", closure.probe, closure.reason);
91
+ }
92
+ return {
93
+ manager,
94
+ name,
95
+ operand,
96
+ requestedVersion,
97
+ action,
98
+ currentVersions: current,
99
+ targetVersion,
100
+ additions: closure.additions,
101
+ removals: closure.removals,
102
+ planSource: closure.source,
103
+ ...(closure.reason !== undefined ? { planUnavailableReason: closure.reason } : {}),
104
+ notes: [...notes, ...closure.notes],
105
+ };
106
+ }
107
+ async function resolveClosure(ctx, manager, operand, unknowns) {
108
+ switch (manager) {
109
+ case "apt":
110
+ return closureApt(ctx, operand);
111
+ case "pacman":
112
+ return closurePacman(ctx, operand);
113
+ case "dnf":
114
+ return closureDnf(ctx, operand);
115
+ case "homebrew":
116
+ return closureBrew(ctx, operand, unknowns);
117
+ // Neither is ever reached: `../index.ts` refuses an install through these
118
+ // two before a plan is resolved. Kept total so the switch has no default.
119
+ case "winget":
120
+ case "choco":
121
+ return {
122
+ additions: null,
123
+ removals: null,
124
+ source: null,
125
+ probe: "not probed",
126
+ reason: MANAGERS[manager].install.supported
127
+ ? "not probed"
128
+ : MANAGERS[manager].install.reason,
129
+ notes: [],
130
+ };
131
+ }
132
+ }
133
+ async function closureApt(ctx, operand) {
134
+ const argv = withOperand(MANAGERS.apt, PKGMGR_COMMANDS.aptSimulate, operand);
135
+ const probe = argv.join(" ");
136
+ const result = await run(ctx, "apt", argv);
137
+ if (result.missing || result.timedOut) {
138
+ return {
139
+ additions: null,
140
+ removals: null,
141
+ source: null,
142
+ probe,
143
+ reason: commandFailureReason(result),
144
+ notes: [],
145
+ };
146
+ }
147
+ if (result.stdoutTruncated === true) {
148
+ // A truncated simulation parses perfectly and is simply MISSING packages.
149
+ // Reporting the prefix as the plan is how an operator approves a
150
+ // transaction they have not seen.
151
+ return {
152
+ additions: null,
153
+ removals: null,
154
+ source: null,
155
+ probe,
156
+ reason: "apt's simulation output was cut at this package's capture ceiling, so the transaction it describes is incomplete and was not used",
157
+ notes: [],
158
+ };
159
+ }
160
+ if (result.code !== 0) {
161
+ return {
162
+ additions: null,
163
+ removals: null,
164
+ source: null,
165
+ probe,
166
+ reason: aptSaysNeedsRoot(`${result.stdout}\n${result.stderr}`)
167
+ ? "apt refused to simulate without root, which is unusual — `apt-get install -s` normally needs none"
168
+ : `${commandFailureReason(result)} (${firstLine(result.stderr) || firstLine(result.stdout)})`,
169
+ notes: [],
170
+ };
171
+ }
172
+ const changes = parseAptSimulate(result.stdout);
173
+ if (changes.length === 0) {
174
+ return {
175
+ additions: [],
176
+ removals: [],
177
+ source: "apt-simulate",
178
+ probe,
179
+ notes: ["apt's simulation lists no package changes, so the requested state already holds"],
180
+ };
181
+ }
182
+ const installs = changes.filter((c) => c.action === "install");
183
+ const removals = changes.filter((c) => c.action === "remove");
184
+ const notes = [];
185
+ if (removals.length > 0) {
186
+ notes.push(`this transaction REMOVES ${removals.length} package(s) — an install that removes something is the case worth reading before approving`);
187
+ }
188
+ return {
189
+ additions: installs.map((c) => ({ name: c.name, version: c.version })),
190
+ removals: removals.map((c) => ({ name: c.name, version: c.version })),
191
+ source: "apt-simulate",
192
+ probe,
193
+ notes,
194
+ };
195
+ }
196
+ async function closurePacman(ctx, operand) {
197
+ const argv = withOperand(MANAGERS.pacman, PKGMGR_COMMANDS.pacmanSimulate, operand);
198
+ const probe = argv.join(" ");
199
+ const result = await run(ctx, "pacman", argv);
200
+ if (result.missing || result.timedOut || result.stdoutTruncated === true) {
201
+ return {
202
+ additions: null,
203
+ removals: null,
204
+ source: null,
205
+ probe,
206
+ reason: result.stdoutTruncated === true
207
+ ? "pacman's target list was cut at this package's capture ceiling, so it is incomplete and was not used"
208
+ : commandFailureReason(result),
209
+ notes: [],
210
+ };
211
+ }
212
+ if (result.code !== 0) {
213
+ return {
214
+ additions: null,
215
+ removals: null,
216
+ source: null,
217
+ probe,
218
+ reason: pacmanSaysNeedsRoot(result.stderr)
219
+ ? "pacman refused to print its targets without root, which is unusual — `pacman -S --print` normally needs none"
220
+ : `${commandFailureReason(result)}`,
221
+ notes: [],
222
+ };
223
+ }
224
+ const targets = parsePacmanSimulate(result.stdout);
225
+ return {
226
+ additions: targets.map((t) => ({ name: t.name, version: t.version, source: t.repo })),
227
+ removals: [],
228
+ source: "pacman-print",
229
+ probe,
230
+ notes: targets.length === 0
231
+ ? ["pacman printed no targets, so the requested state already holds"]
232
+ : [
233
+ "pacman's target list includes the named package itself as well as every dependency it would pull in",
234
+ ],
235
+ };
236
+ }
237
+ async function closureDnf(ctx, operand) {
238
+ const argv = withOperand(MANAGERS.dnf, PKGMGR_COMMANDS.dnfSimulate, operand);
239
+ const probe = argv.join(" ");
240
+ const result = await run(ctx, "dnf", argv);
241
+ const combined = `${result.stdout}\n${result.stderr}`;
242
+ if (result.missing || result.timedOut || result.stdoutTruncated === true) {
243
+ return {
244
+ additions: null,
245
+ removals: null,
246
+ source: null,
247
+ probe,
248
+ reason: result.stdoutTruncated === true
249
+ ? "dnf's transaction table was cut at this package's capture ceiling, so it is incomplete and was not used"
250
+ : commandFailureReason(result),
251
+ notes: [],
252
+ };
253
+ }
254
+ const rows = parseDnfTransaction(result.stdout);
255
+ if (rows.length > 0) {
256
+ // `--assumeno` exits non-zero BY DESIGN after printing the table it then
257
+ // declines, so a non-zero exit here is the success case and the table is
258
+ // read before the exit code is consulted.
259
+ return {
260
+ additions: rows.map((r) => ({ name: r.name, version: r.version, source: r.repo })),
261
+ removals: [],
262
+ source: "dnf-assumeno",
263
+ probe,
264
+ notes: [
265
+ "dnf resolved this transaction and then declined it, which is what --assumeno does; nothing was installed",
266
+ ],
267
+ };
268
+ }
269
+ if (dnfSaysNeedsRoot(combined)) {
270
+ return {
271
+ additions: null,
272
+ removals: null,
273
+ source: null,
274
+ probe,
275
+ reason: "dnf refused to resolve the transaction without root, so what it would install could not be determined — this tool does not acquire root",
276
+ notes: [],
277
+ };
278
+ }
279
+ return {
280
+ additions: null,
281
+ removals: null,
282
+ source: null,
283
+ probe,
284
+ reason: `dnf printed no transaction table this parser could read (${firstLine(combined) || "empty output"})`,
285
+ notes: [],
286
+ };
287
+ }
288
+ /**
289
+ * Homebrew has no dry run, so the closure is DERIVED — and the result says so.
290
+ *
291
+ * `brew deps --formula <name>` prints the transitive runtime dependencies,
292
+ * and `brew list --formula --versions` prints what is already on disk. The
293
+ * additions are the difference. That is the same resolution `brew install`
294
+ * performs, but it is an inference rather than a manager-issued plan, which
295
+ * is why `planSource` is `brew-deps` and not something that sounds official.
296
+ *
297
+ * Two honest gaps, both stated in the notes rather than hidden: build-time
298
+ * dependencies are not listed (they are not installed for a bottle, which is
299
+ * the normal case, but are for a source build), and a dependency's own
300
+ * version is not resolved here.
301
+ */
302
+ async function closureBrew(ctx, operand, unknowns) {
303
+ const depsArgv = withOperand(MANAGERS.homebrew, PKGMGR_COMMANDS.brewDeps, operand);
304
+ const depsProbe = depsArgv.join(" ");
305
+ const deps = await run(ctx, "homebrew", depsArgv);
306
+ if (deps.missing || deps.timedOut || deps.code !== 0 || deps.stdoutTruncated === true) {
307
+ return {
308
+ additions: null,
309
+ removals: null,
310
+ source: null,
311
+ probe: depsProbe,
312
+ reason: deps.stdoutTruncated === true
313
+ ? "brew's dependency list was cut at this package's capture ceiling"
314
+ : commandFailureReason(deps),
315
+ notes: [],
316
+ };
317
+ }
318
+ const wanted = parseBrewDeps(deps.stdout);
319
+ const listArgv = [...PKGMGR_COMMANDS.brewListVersions];
320
+ const listProbe = listArgv.join(" ");
321
+ const list = await run(ctx, "homebrew", listArgv);
322
+ if (list.missing || list.timedOut || list.code !== 0 || list.stdoutTruncated === true) {
323
+ // The dependency list is known and the installed set is not, so the
324
+ // ADDITIONS cannot be computed — reporting every dependency as an
325
+ // addition would overstate the change, which is the wrong direction to
326
+ // be wrong in when an operator is approving it.
327
+ unknowns.add("plan.additions", listProbe, commandFailureReason(list));
328
+ return {
329
+ additions: null,
330
+ removals: null,
331
+ source: null,
332
+ probe: listProbe,
333
+ reason: `brew's dependency list was read but the installed formulae could not be: ${commandFailureReason(list)}`,
334
+ notes: [],
335
+ };
336
+ }
337
+ const installed = parseBrewListVersions(list.stdout);
338
+ const additions = wanted
339
+ .filter((dep) => !installed.has(dep))
340
+ .map((dep) => ({ name: dep, version: null }));
341
+ return {
342
+ additions,
343
+ removals: [],
344
+ source: "brew-deps",
345
+ probe: depsProbe,
346
+ notes: [
347
+ "Homebrew has no dry run for install; these additions are `brew deps` minus what `brew list --versions` reports installed, and their versions are not resolved",
348
+ "build-time dependencies are not listed — a bottle does not install them, a source build does",
349
+ ],
350
+ };
351
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Asking a manager about one package — the code both tools share.
3
+ *
4
+ * `PackageQuery` returns what this produces. `PackageInstall` calls exactly
5
+ * the same function to resolve what it would install, and again afterwards to
6
+ * report what it actually installed. That is not a convenience: house rule 9
7
+ * says a dry run must run the SAME selection code as the real path, and the
8
+ * only way to guarantee it is for there to be one path.
9
+ *
10
+ * ── THREE-VALUED, ALWAYS ──────────────────────────────────────────────────
11
+ *
12
+ * `status` is `"installed"`, `"not-installed"` or `"unknown"`, and the third
13
+ * one is never collapsed into the second. A manager that is not on this host,
14
+ * a command that hit its deadline, output that did not parse, a cache that
15
+ * was empty — every one of those is `"unknown"` with a reason, because a
16
+ * caller that reads "not installed" will install something, and installing
17
+ * because a probe timed out is how a tool breaks a machine.
18
+ *
19
+ * `knownToManager` is separate from `status` for the same reason: "apt has
20
+ * never heard of `curl-x`" and "apt has `curl` and it is not installed" are
21
+ * different answers, and only the second one means an install would work.
22
+ */
23
+ import type { HostPlatform } from "../host";
24
+ import { type RunResult } from "../run";
25
+ import { type ManagerId } from "./managers";
26
+ import { type Unknowns } from "./unknown";
27
+ /** One command this package ran, kept so a result can show its own evidence. */
28
+ export type Probe = {
29
+ readonly argv: readonly string[];
30
+ readonly exitCode: number;
31
+ readonly ok: boolean;
32
+ /** Present when the program itself is not on this host. */
33
+ readonly missing?: boolean;
34
+ readonly timedOut?: boolean;
35
+ };
36
+ export type RunContext = {
37
+ readonly timeoutMs: number;
38
+ readonly signal?: AbortSignal;
39
+ /** Filled in as commands run, in order. */
40
+ readonly probes: Probe[];
41
+ };
42
+ export declare function newContext(timeoutMs?: number, signal?: AbortSignal): RunContext;
43
+ /** Run one command and record it. The only entry point to `../run.ts` here. */
44
+ export declare function run(ctx: RunContext, manager: ManagerId, argv: readonly string[]): Promise<RunResult>;
45
+ export type Detection = {
46
+ readonly kind: "found";
47
+ readonly manager: ManagerId;
48
+ /** Managers looked for before this one, and not found. */
49
+ readonly tried: readonly ManagerId[];
50
+ } | {
51
+ readonly kind: "unknown";
52
+ readonly tried: readonly ManagerId[];
53
+ readonly reason: string;
54
+ };
55
+ /**
56
+ * Find the system package manager, or say that there is none.
57
+ *
58
+ * A caller may name one, in which case only that one is probed and an absence
59
+ * is reported against it by name. Otherwise the platform's list is walked in
60
+ * order (see `managersForPlatform`) and the first one that answers its
61
+ * `--version` probe wins.
62
+ *
63
+ * On a platform with no entry — which includes the hostile `"other"` default
64
+ * a test gets when it forgets to declare a platform — nothing is probed and
65
+ * the answer is `unknown`. That is also the honest answer on a host with no
66
+ * package manager at all, which is what CI is: an unrecognised manager is
67
+ * reported as unknown rather than guessed at.
68
+ */
69
+ export declare function detectManager(ctx: RunContext, platform: HostPlatform, requested?: ManagerId): Promise<Detection>;
70
+ export type Availability = {
71
+ /** The version the local index offers, or null when it could not be read. */
72
+ readonly version: string | null;
73
+ /** Which tap/repository/source it came from, or null. */
74
+ readonly source: string | null;
75
+ };
76
+ export type PackageFacts = {
77
+ readonly manager: ManagerId;
78
+ readonly name: string;
79
+ readonly status: "installed" | "not-installed" | "unknown";
80
+ /**
81
+ * Versions on disk. An ARRAY because Homebrew legitimately keeps several
82
+ * kegs of one formula, and reporting the first as "the" version would be
83
+ * wrong for exactly the formulae people ask about. `null` means could not
84
+ * determine, which is not the same as `[]`.
85
+ */
86
+ readonly installedVersions: readonly string[] | null;
87
+ /**
88
+ * Whether the manager has a record of this name at all. `null` when that
89
+ * could not be determined either.
90
+ */
91
+ readonly knownToManager: boolean | null;
92
+ readonly available: Availability | null;
93
+ /** Homebrew only: a pinned formula will not be upgraded. */
94
+ readonly pinned?: boolean;
95
+ /** The manager's own word for "there is a newer version", where it has one. */
96
+ readonly outdated?: boolean;
97
+ readonly kind?: "formula" | "cask";
98
+ readonly notes: readonly string[];
99
+ };
100
+ /**
101
+ * Ask one manager about one name.
102
+ *
103
+ * `name` must already have passed `checkPackageName`; this function appends it
104
+ * to a frozen argv prefix and never inspects it again.
105
+ */
106
+ export declare function queryPackage(ctx: RunContext, manager: ManagerId, name: string, unknowns: Unknowns, includeAvailable: boolean): Promise<PackageFacts>;