@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/index.js ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * `@crewhaus/tool-pkgmgr` — asking the SYSTEM package manager what is on this
3
+ * machine, and installing from it when that can be done honestly.
4
+ *
5
+ * Two tools. `PackageQuery` reads: is this package installed, at what
6
+ * version, and what does the manager's already-downloaded index say is
7
+ * available. `PackageInstall` writes, and spends most of its code deciding
8
+ * not to.
9
+ *
10
+ * FIVE PROPERTIES HOLD ACROSS BOTH.
11
+ *
12
+ * 1. **THE SCOPE IS NARROWER THAN THE CATALOGUE IMPLIES, AND SAYS SO.**
13
+ * Reading works on six managers: Homebrew, apt/dpkg, dnf/rpm, pacman,
14
+ * winget and chocolatey. Installing works on ONE of them unattended —
15
+ * Homebrew, which needs no root and is designed for a user-owned prefix.
16
+ * apt, dnf and pacman need root, so this package installs through them
17
+ * only when the process it is already running in IS root, and otherwise
18
+ * refuses and prints the command the operator would run. winget and
19
+ * chocolatey are not installed through at all, because both end in an
20
+ * elevation prompt. A narrow tool that says what it did is worth more than
21
+ * a broad one that guesses.
22
+ *
23
+ * 2. **NO PRIVILEGE ESCALATION, EVER.** Nothing here runs `sudo`, `doas`,
24
+ * `runas` or `pkexec`, nothing raises a UAC prompt, and no schema in this
25
+ * package accepts a password. `no argv ever names an escalation program`
26
+ * in index.test.ts asserts that over every recorded call and over both
27
+ * schemas, the way tool-onchain asserts that no schema accepts a private
28
+ * key. Being root and BECOMING root are different things: this package
29
+ * will use what the process already has and will never ask for more.
30
+ *
31
+ * 3. **ARGV IS AN ARRAY, AND ALMOST ALL OF IT IS A CONSTANT.** There is no
32
+ * shell here — no `sh -c`, no `cmd /c`, no interpolated command line. Every
33
+ * command is a frozen literal in `./lib/managers.ts`, and the only value
34
+ * appended to one is a package name that `./lib/names.ts` has accepted. A
35
+ * name beginning with `-` is a FLAG, not a package, so it is refused before
36
+ * an argv exists rather than defended with a `--` that two of these six
37
+ * managers do not document.
38
+ *
39
+ * 4. **A DESTRUCTIVE TOOL RESOLVES ITS PLAN THE SAME WAY EVERY TIME.**
40
+ * `PackageInstall` declares `destructive: true` because an install can
41
+ * replace a working version, and it reports the version it would replace.
42
+ * `dryRun` does not take a different path: the plan is resolved by the same
43
+ * function either way, and `dryRun` only decides whether the install
44
+ * command afterwards runs.
45
+ *
46
+ * 5. **"COULD NOT DETERMINE" IS NOT "NO".** A manager that is not on this
47
+ * host, a probe that timed out, an empty dnf metadata cache, a winget row
48
+ * whose columns could not be split — every one is `unknown` with a named
49
+ * reason, never `not-installed`. A caller that reads "not installed" will
50
+ * install something, and installing because a probe timed out is how a
51
+ * tool breaks a machine.
52
+ *
53
+ * The seams are `_setRunner` (every child process), `_setPlatform` and
54
+ * `_setUid`. The whole suite drives them: CI is a Linux container with no
55
+ * package manager at all, development is macOS, and a parser checked against
56
+ * whatever the test host happens to answer is a parser checked against
57
+ * nothing. The un-injected platform is deliberately hostile — see `./host.ts`.
58
+ */
59
+ import { buildTool } from "@crewhaus/tool-builder";
60
+ import { z } from "zod";
61
+ import { hostPlatform, isRoot } from "./host";
62
+ import { MANAGERS, MANAGER_IDS, PKGMGR_COMMANDS, buildOperand, withOperand, } from "./lib/managers";
63
+ import { MAX_NAME_LENGTH, checkPackageName, checkVersionSyntax } from "./lib/names";
64
+ import { resolveInstallPlan } from "./lib/plan";
65
+ import { detectManager, newContext, queryPackage, run, } from "./lib/query";
66
+ import { Unknowns, commandFailureReason, firstLine } from "./lib/unknown";
67
+ import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS } from "./run";
68
+ export { _setRunner } from "./run";
69
+ export { _setPlatform, _setUid, _resetHostSeams } from "./host";
70
+ export { ToolPermissionError } from "./paths";
71
+ /** Compact JSON — the reader is a model, and every byte is context. */
72
+ const json = (value) => JSON.stringify(value);
73
+ const managerSchema = z
74
+ .enum(MANAGER_IDS)
75
+ .optional()
76
+ .describe("which package manager to use; omitted means detect it from the platform (macOS: Homebrew; Linux: apt, then dnf, then pacman, then Linuxbrew; Windows: winget, then chocolatey)");
77
+ const nameSchema = z
78
+ .string()
79
+ .min(1)
80
+ .max(MAX_NAME_LENGTH)
81
+ .describe("the package name as the manager spells it — a formula or cask for Homebrew, a binary package for apt, an rpm name or full NEVRA for dnf, a package Id for winget");
82
+ const timeoutSchema = z
83
+ .number()
84
+ .int()
85
+ .min(100)
86
+ .max(MAX_COMMAND_TIMEOUT_MS)
87
+ .optional()
88
+ .describe(`milliseconds any single manager command may take before it is SIGTERMed (default ${DEFAULT_COMMAND_TIMEOUT_MS})`);
89
+ /**
90
+ * The probe trail, trimmed for a result.
91
+ *
92
+ * The argv is included verbatim and on purpose: a caller reading "unknown"
93
+ * needs to see what was actually asked, and it is the evidence behind the
94
+ * claim that nothing here builds a command out of anything but a constant and
95
+ * a validated name.
96
+ */
97
+ function probeJson(probes) {
98
+ return probes.map((p) => ({
99
+ argv: p.argv,
100
+ exitCode: p.exitCode,
101
+ ...(p.missing === true ? { notInstalled: true } : {}),
102
+ ...(p.timedOut === true ? { timedOut: true } : {}),
103
+ }));
104
+ }
105
+ function factsJson(facts) {
106
+ return {
107
+ manager: facts.manager,
108
+ name: facts.name,
109
+ status: facts.status,
110
+ installedVersions: facts.installedVersions,
111
+ knownToManager: facts.knownToManager,
112
+ available: facts.available,
113
+ ...(facts.kind !== undefined ? { kind: facts.kind } : {}),
114
+ ...(facts.pinned !== undefined ? { pinned: facts.pinned } : {}),
115
+ ...(facts.outdated !== undefined ? { outdated: facts.outdated } : {}),
116
+ ...(facts.notes.length > 0 ? { notes: facts.notes } : {}),
117
+ };
118
+ }
119
+ // ---------------------------------------------------------------------------
120
+ // PackageQuery
121
+ // ---------------------------------------------------------------------------
122
+ export const packageQuery = buildTool({
123
+ name: "PackageQuery",
124
+ description: 'Ask the system package manager whether a package is installed, at what version, and what its already-downloaded index says is available. Supports Homebrew, apt/dpkg, dnf/rpm, pacman, winget and chocolatey, detecting the manager from the platform unless you name one; a host with none of them is reported as unknown rather than guessed at. It makes NO network call: every answer comes from a local index, which is why winget and chocolatey report installed state only — their available version lives on a remote source. Nothing is ever defaulted: a manager that is not on this host, a probe that timed out, an empty dnf metadata cache or a winget row whose columns could not be split all come back as status "unknown" with the reason and the exact command that was run, never as "not installed". Read-only: it runs only query commands and changes nothing.',
125
+ inputSchema: z.object({
126
+ name: nameSchema,
127
+ manager: managerSchema,
128
+ includeAvailable: z
129
+ .boolean()
130
+ .optional()
131
+ .describe("also read what the local index offers, not just what is installed (default true; ignored for winget and chocolatey, whose available version is a network query)"),
132
+ timeoutMs: timeoutSchema,
133
+ }),
134
+ readOnly: true,
135
+ concurrencySafe: true,
136
+ scope: "external",
137
+ ioCapability: "process",
138
+ execute: async (input, context) => {
139
+ const platform = hostPlatform();
140
+ const ctx = newContext(input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, context?.signal);
141
+ const unknowns = new Unknowns();
142
+ const includeAvailable = input.includeAvailable ?? true;
143
+ const detection = await detectManager(ctx, platform, input.manager);
144
+ if (detection.kind === "unknown") {
145
+ // Rule 5: a host with no package manager — which is what CI is — gets a
146
+ // typed answer naming what was looked for, not a crash and not a false.
147
+ return json({
148
+ tool: "PackageQuery",
149
+ platform,
150
+ manager: null,
151
+ status: "unknown",
152
+ available: false,
153
+ reason: detection.reason,
154
+ managersProbed: detection.tried,
155
+ probes: probeJson(ctx.probes),
156
+ });
157
+ }
158
+ const manager = detection.manager;
159
+ const nameRefusal = checkPackageName(manager, input.name);
160
+ if (nameRefusal !== undefined) {
161
+ return json({
162
+ tool: "PackageQuery",
163
+ platform,
164
+ manager,
165
+ status: "refused",
166
+ reason: nameRefusal,
167
+ // Nothing was spawned with this name; the only probes are the
168
+ // manager detection ones above.
169
+ probes: probeJson(ctx.probes),
170
+ });
171
+ }
172
+ const facts = await queryPackage(ctx, manager, input.name, unknowns, includeAvailable);
173
+ return json({
174
+ tool: "PackageQuery",
175
+ platform,
176
+ ...factsJson(facts),
177
+ availableSource: MANAGERS[manager].availableSource,
178
+ ...(MANAGERS[manager].availableReason !== undefined
179
+ ? { availableNotProbedBecause: MANAGERS[manager].availableReason }
180
+ : {}),
181
+ ...(unknowns.size > 0 ? { unknown: unknowns.list() } : {}),
182
+ probes: probeJson(ctx.probes),
183
+ });
184
+ },
185
+ });
186
+ // ---------------------------------------------------------------------------
187
+ // PackageInstall
188
+ // ---------------------------------------------------------------------------
189
+ /**
190
+ * A refusal, in the one shape every one of them uses.
191
+ *
192
+ * `operatorCommand` is the point of the refusal rather than an apology for
193
+ * it: this package will not acquire privilege, so the useful thing it can do
194
+ * is hand back the exact line a human would type.
195
+ */
196
+ function refusal(fields) {
197
+ return json({
198
+ tool: "PackageInstall",
199
+ platform: fields.platform,
200
+ manager: fields.manager,
201
+ dryRun: fields.dryRun,
202
+ status: "refused",
203
+ installed: false,
204
+ reason: fields.reason,
205
+ ...(fields.operatorCommand !== undefined
206
+ ? {
207
+ operatorCommand: fields.operatorCommand,
208
+ operatorCommandNote: "this tool does not run that command and does not acquire the privilege it needs; run it yourself if you want this change",
209
+ }
210
+ : {}),
211
+ ...(fields.plan !== undefined ? { plan: fields.plan } : {}),
212
+ probes: probeJson(fields.probes),
213
+ });
214
+ }
215
+ export const packageInstall = buildTool({
216
+ name: "PackageInstall",
217
+ description: "Install a package through the system package manager, or report exactly what installing it would do. DESTRUCTIVE: an install can replace a version that is already working, so the plan names the versions currently on disk before anything runs. Pass dryRun to get that plan and install nothing — it is resolved by the same code the real install uses, and includes the transitive packages the manager says it would add (complete from apt and pacman, resolved-then-declined from dnf, derived from `brew deps` for Homebrew, which has no dry run). THIS TOOL NEVER ACQUIRES PRIVILEGE. It runs no sudo, doas, runas or pkexec and raises no UAC prompt. Homebrew needs no root and is the case that genuinely works unattended; apt, dnf and pacman need root, so they work only when this process is ALREADY root and are otherwise refused with the exact command an operator would run themselves; winget and chocolatey installs are refused outright, because both end in an elevation prompt. A version can only be pinned where the manager can express one — apt can, and every other manager here is refused with the reason rather than quietly installing latest. A package name beginning with '-' is refused, because a manager would read it as a flag.",
218
+ inputSchema: z.object({
219
+ name: nameSchema,
220
+ manager: managerSchema,
221
+ version: z
222
+ .string()
223
+ .min(1)
224
+ .optional()
225
+ .describe("an exact version to install. Only apt can express one (name=version); Homebrew, dnf, pacman, winget and chocolatey are refused with the reason rather than installing latest"),
226
+ dryRun: z
227
+ .boolean()
228
+ .optional()
229
+ .describe("resolve and report the plan, and install nothing (default false). The plan is produced by the same code the real install runs"),
230
+ timeoutMs: timeoutSchema,
231
+ }),
232
+ destructive: true,
233
+ requireJustification: true,
234
+ scope: "external",
235
+ ioCapability: "process",
236
+ execute: async (input, context) => {
237
+ const platform = hostPlatform();
238
+ const dryRun = input.dryRun === true;
239
+ const ctx = newContext(input.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS, context?.signal);
240
+ const unknowns = new Unknowns();
241
+ const detection = await detectManager(ctx, platform, input.manager);
242
+ if (detection.kind === "unknown") {
243
+ return json({
244
+ tool: "PackageInstall",
245
+ platform,
246
+ manager: null,
247
+ dryRun,
248
+ status: "unavailable",
249
+ installed: false,
250
+ reason: detection.reason,
251
+ managersProbed: detection.tried,
252
+ probes: probeJson(ctx.probes),
253
+ });
254
+ }
255
+ const manager = detection.manager;
256
+ const spec = MANAGERS[manager];
257
+ // ---- refusals, in the order that gives the most useful message --------
258
+ const nameRefusal = checkPackageName(manager, input.name);
259
+ if (nameRefusal !== undefined) {
260
+ return refusal({ platform, manager, reason: nameRefusal, probes: ctx.probes, dryRun });
261
+ }
262
+ if (!spec.install.supported) {
263
+ return refusal({
264
+ platform,
265
+ manager,
266
+ reason: spec.install.reason,
267
+ operatorCommand: spec.operatorCommand(input.name),
268
+ probes: ctx.probes,
269
+ dryRun,
270
+ });
271
+ }
272
+ let requestedVersion = null;
273
+ if (input.version !== undefined) {
274
+ if (!spec.versionPin.supported) {
275
+ // Refusing rather than installing latest is the whole point: a caller
276
+ // who asked for 1.2.3 and silently got 2.0.0 has been lied to by a
277
+ // tool they will trust again.
278
+ return refusal({
279
+ platform,
280
+ manager,
281
+ reason: `${spec.label} cannot express an exact version: ${spec.versionPin.reason}`,
282
+ operatorCommand: spec.operatorCommand(input.name),
283
+ probes: ctx.probes,
284
+ dryRun,
285
+ });
286
+ }
287
+ const versionRefusal = checkVersionSyntax(input.version);
288
+ if (versionRefusal !== undefined) {
289
+ return refusal({ platform, manager, reason: versionRefusal, probes: ctx.probes, dryRun });
290
+ }
291
+ requestedVersion = input.version;
292
+ }
293
+ const operand = buildOperand(manager, input.name, requestedVersion ?? undefined);
294
+ // ---- resolution: the same code for both paths -------------------------
295
+ const facts = await queryPackage(ctx, manager, input.name, unknowns, true);
296
+ const plan = await resolveInstallPlan(ctx, manager, input.name, operand, requestedVersion, facts, unknowns);
297
+ if (dryRun) {
298
+ return json({
299
+ tool: "PackageInstall",
300
+ platform,
301
+ manager,
302
+ dryRun: true,
303
+ status: "would-install",
304
+ installed: false,
305
+ plan,
306
+ before: factsJson(facts),
307
+ // Stated on the dry run too, so a caller learns BEFORE it tries that
308
+ // the real call would be refused on this host.
309
+ ...(spec.install.needsRoot && !isRoot()
310
+ ? {
311
+ wouldBeRefused: true,
312
+ wouldBeRefusedReason: `${spec.label} needs root to install and this process is not root; this tool does not acquire privilege`,
313
+ operatorCommand: spec.operatorCommand(operand),
314
+ }
315
+ : {}),
316
+ ...(unknowns.size > 0 ? { unknown: unknowns.list() } : {}),
317
+ probes: probeJson(ctx.probes),
318
+ });
319
+ }
320
+ // ---- the privilege gate ----------------------------------------------
321
+ if (spec.install.needsRoot && !isRoot()) {
322
+ return refusal({
323
+ platform,
324
+ manager,
325
+ reason: `${spec.label} needs root to install a package, and this tool does not acquire privilege — it runs no sudo, doas, runas or pkexec, and takes no password`,
326
+ operatorCommand: spec.operatorCommand(operand),
327
+ plan,
328
+ probes: ctx.probes,
329
+ dryRun: false,
330
+ });
331
+ }
332
+ if (plan.action === "none") {
333
+ // Nothing to do, and saying so beats running an install to find out.
334
+ return json({
335
+ tool: "PackageInstall",
336
+ platform,
337
+ manager,
338
+ dryRun: false,
339
+ status: "already-installed",
340
+ installed: false,
341
+ plan,
342
+ before: factsJson(facts),
343
+ ...(unknowns.size > 0 ? { unknown: unknowns.list() } : {}),
344
+ probes: probeJson(ctx.probes),
345
+ });
346
+ }
347
+ // ---- the one line that differs from the dry run ------------------------
348
+ const installArgv = installCommand(manager, operand);
349
+ const result = await run(ctx, manager, installArgv);
350
+ if (result.missing || result.timedOut) {
351
+ return json({
352
+ tool: "PackageInstall",
353
+ platform,
354
+ manager,
355
+ dryRun: false,
356
+ // An install that timed out may have done PART of its work, which is
357
+ // neither "installed" nor "not installed". The verification below is
358
+ // what turns that into a fact.
359
+ status: "unknown",
360
+ installed: false,
361
+ reason: commandFailureReason(result),
362
+ plan,
363
+ probes: probeJson(ctx.probes),
364
+ });
365
+ }
366
+ const failedRoot = result.code !== 0 && /you (are not|must be) root|permission denied/i.test(result.stderr);
367
+ const after = await queryPackage(ctx, manager, input.name, unknowns, true);
368
+ const installed = after.status === "installed" &&
369
+ (facts.status !== "installed" ||
370
+ JSON.stringify(after.installedVersions) !== JSON.stringify(facts.installedVersions));
371
+ return json({
372
+ tool: "PackageInstall",
373
+ platform,
374
+ manager,
375
+ dryRun: false,
376
+ status: result.code === 0
377
+ ? after.status === "installed"
378
+ ? "installed"
379
+ : "unknown"
380
+ : failedRoot
381
+ ? "refused"
382
+ : "failed",
383
+ installed: result.code === 0 && after.status === "installed",
384
+ // Stated separately from `installed`: a reinstall of the same version
385
+ // leaves the machine changed in ways the version does not show, and a
386
+ // caller deciding whether to restart something needs the difference.
387
+ versionChanged: installed,
388
+ exitCode: result.code,
389
+ ...(result.code !== 0
390
+ ? { reason: firstLine(result.stderr) || firstLine(result.stdout) }
391
+ : {}),
392
+ plan,
393
+ before: factsJson(facts),
394
+ after: factsJson(after),
395
+ ...(after.status === "unknown"
396
+ ? {
397
+ verificationNote: "the install command's exit code was read, but re-querying the manager afterwards did not confirm the result, so what is on disk now is not known from this call",
398
+ }
399
+ : {}),
400
+ ...(unknowns.size > 0 ? { unknown: unknowns.list() } : {}),
401
+ probes: probeJson(ctx.probes),
402
+ });
403
+ },
404
+ });
405
+ /**
406
+ * The install argv for a manager.
407
+ *
408
+ * A separate function so the test that asserts no argv names an escalation
409
+ * program can enumerate every one of them, including the ones only reached
410
+ * when the process is already root.
411
+ */
412
+ export function installCommand(manager, operand) {
413
+ const spec = MANAGERS[manager];
414
+ switch (manager) {
415
+ case "homebrew":
416
+ return withOperand(spec, PKGMGR_COMMANDS.brewInstall, operand);
417
+ case "apt":
418
+ return withOperand(spec, PKGMGR_COMMANDS.aptInstall, operand);
419
+ case "dnf":
420
+ return withOperand(spec, PKGMGR_COMMANDS.dnfInstall, operand);
421
+ case "pacman":
422
+ return withOperand(spec, PKGMGR_COMMANDS.pacmanInstall, operand);
423
+ // Unreachable: both are refused above before a plan exists. The throw is
424
+ // the guarantee — a future edit that removes the refusal fails loudly
425
+ // here instead of quietly starting an elevated installer.
426
+ case "winget":
427
+ case "choco":
428
+ throw new Error(`${spec.label} installs are refused by this package and have no argv`);
429
+ }
430
+ }
431
+ export const PKGMGR_TOOLS = Object.freeze([
432
+ packageQuery,
433
+ packageInstall,
434
+ ]);
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The six managers, what each one can actually do, and every argv this
3
+ * package is allowed to run.
4
+ *
5
+ * ── ARGV IS A TABLE OF CONSTANTS ──────────────────────────────────────────
6
+ *
7
+ * `tool-host` established the rule and it is kept here: every command is a
8
+ * frozen constant, and the ONLY value this package appends to one is a
9
+ * package name (or a `name=version` operand) that `../lib/names.ts` has
10
+ * already accepted. Nothing read out of one command's output is ever fed into
11
+ * another command's argv — a dependency name parsed from `apt-get -s` output
12
+ * is reported, never re-spawned. `argv is a constant plus the validated
13
+ * operand` in index.test.ts asserts that over every recorded call, including
14
+ * the adversarial ones.
15
+ *
16
+ * ── THE SCOPE IS NARROWER THAN THE CATALOGUE IMPLIES, ON PURPOSE ──────────
17
+ *
18
+ * Reading is broad: all six managers answer "is this installed, at what
19
+ * version", and four of them also answer "what does the local index say is
20
+ * available" without touching the network.
21
+ *
22
+ * WRITING is narrow, and the table says why per manager rather than pretending
23
+ * otherwise. Homebrew on macOS is the case that genuinely works unattended —
24
+ * it needs no root, it is designed for a writable prefix owned by the user,
25
+ * and `NONINTERACTIVE=1` is its own documented switch for exactly this. The
26
+ * three Linux managers need root, so this package installs through them only
27
+ * when the process it is already running in IS root, and otherwise refuses
28
+ * with the command an operator would run. The two Windows managers are not
29
+ * installed through at all, because both end in an elevation prompt this
30
+ * package will not raise.
31
+ */
32
+ import type { HostPlatform } from "../host";
33
+ import type { ManagerId } from "./names";
34
+ export type { ManagerId };
35
+ export declare const MANAGER_IDS: readonly ManagerId[];
36
+ /**
37
+ * Every command this package may run, as literals.
38
+ *
39
+ * Flag notes that matter to the parsers downstream:
40
+ *
41
+ * - `brew info --json=v2` is the ONE brew call that answers both halves of
42
+ * a query: `installed[]` for what is on disk and `versions.stable` for
43
+ * what the tap has. It also covers casks in the same document, so a name
44
+ * does not have to be classified before it is looked up.
45
+ * - `dpkg-query -W -f=...` is named-field output, not a table. The fields
46
+ * are `${db:Status-Abbrev}` rather than `${Status}` because the abbrev is
47
+ * three fixed characters (`ii `, `un `, `rc `) that mean the same thing in
48
+ * every locale, while `${Status}` is three English words.
49
+ * - `apt-cache policy` IS localised, which is why `LC_ALL=C` is pinned in
50
+ * `../run.ts`. Without that pin the `Installed:`/`Candidate:` labels come
51
+ * back in the operator's language and the parser silently finds nothing —
52
+ * the single most common way a package-manager parser fails in the field.
53
+ * - `rpm -q --queryformat` rather than `dnf list --installed`: rpm reads the
54
+ * local database, needs no repository metadata, and its format string is
55
+ * not localised. `dnf` is only asked what is AVAILABLE.
56
+ * - `dnf --cacheonly` so the query cannot reach the network. The cost is
57
+ * that a host with no cached metadata answers "no matching packages",
58
+ * which is indistinguishable from "not in the repos" — so the parser
59
+ * looks for dnf's own "no cache" complaint and reports unknown instead of
60
+ * "not available". "Could not determine" is not "no" (house rule 6).
61
+ * - `pacman -S --print --print-format '%r/%n %v'` is pacman's dry run. It
62
+ * prints every target INCLUDING dependencies, with versions, and needs no
63
+ * root — the best dry run of the six.
64
+ * - `winget list --exact --query` with `--disable-interactivity` and
65
+ * `--accept-source-agreements`: without those two, winget can sit waiting
66
+ * for a keypress or for an agreement to be accepted, which on a pipe is a
67
+ * command that never exits.
68
+ * - `choco list --limit-output` is chocolatey's documented machine-readable
69
+ * mode: `name|version`, one per line, no headers, no colour. The v1/v2
70
+ * split is real — `--local-only` was removed in Chocolatey 2.0 — so the
71
+ * version is probed first and the right form is chosen.
72
+ */
73
+ export declare const PKGMGR_COMMANDS: Readonly<{
74
+ brewVersion: readonly string[];
75
+ brewInfoJson: readonly string[];
76
+ brewListVersions: readonly string[];
77
+ brewDeps: readonly string[];
78
+ brewInstall: readonly string[];
79
+ dpkgVersion: readonly string[];
80
+ dpkgQuery: readonly string[];
81
+ aptCachePolicy: readonly string[];
82
+ aptSimulate: readonly string[];
83
+ aptInstall: readonly string[];
84
+ dnfVersion: readonly string[];
85
+ rpmQuery: readonly string[];
86
+ dnfListAvailable: readonly string[];
87
+ dnfSimulate: readonly string[];
88
+ dnfInstall: readonly string[];
89
+ pacmanVersion: readonly string[];
90
+ pacmanQuery: readonly string[];
91
+ pacmanSyncInfo: readonly string[];
92
+ pacmanSimulate: readonly string[];
93
+ pacmanInstall: readonly string[];
94
+ wingetVersion: readonly string[];
95
+ wingetList: readonly string[];
96
+ chocoVersion: readonly string[];
97
+ /** Chocolatey 2.x: `choco list` is local by default. */
98
+ chocoListV2: readonly string[];
99
+ /** Chocolatey 1.x and 0.x: `list` searches the remote unless told otherwise. */
100
+ chocoListV1: readonly string[];
101
+ }>;
102
+ /** Flat view of the table, for the test that proves no argv is built. */
103
+ export declare const ALL_PKGMGR_COMMANDS: ReadonlyArray<ReadonlyArray<string>>;
104
+ /**
105
+ * The environment each manager needs on top of the pinned base in `../run.ts`.
106
+ *
107
+ * Every entry here exists to stop the manager doing something this package
108
+ * does not do: reach the network, write analytics, colour its output, or ask
109
+ * a question on a pipe that nothing will ever answer.
110
+ */
111
+ export declare const MANAGER_ENV: Readonly<Record<ManagerId, Readonly<Record<string, string>>>>;
112
+ export type InstallSupport = {
113
+ readonly supported: true;
114
+ readonly needsRoot: boolean;
115
+ } | {
116
+ readonly supported: false;
117
+ readonly reason: string;
118
+ };
119
+ export type VersionPinSupport = {
120
+ readonly supported: true;
121
+ readonly form: string;
122
+ } | {
123
+ readonly supported: false;
124
+ readonly reason: string;
125
+ };
126
+ export type ManagerSpec = {
127
+ readonly id: ManagerId;
128
+ readonly label: string;
129
+ /** Platforms this manager is looked for on, in detection order. */
130
+ readonly platforms: readonly HostPlatform[];
131
+ /** The `--version` probe that decides whether it is installed. */
132
+ readonly detect: readonly string[];
133
+ /** Whether "what is available" can be answered without the network. */
134
+ readonly availableSource: "local-index" | "network-only";
135
+ /** Why the network-only ones are not asked. */
136
+ readonly availableReason?: string;
137
+ readonly install: InstallSupport;
138
+ readonly versionPin: VersionPinSupport;
139
+ /**
140
+ * Whether `--` is appended before the operand.
141
+ *
142
+ * Documented per manager rather than assumed. It is defence in depth only:
143
+ * a leading dash is refused in `names.ts` before this matters, precisely
144
+ * because the four `false` entries below cannot be defended this way.
145
+ */
146
+ readonly doubleDash: boolean;
147
+ /** The command the operator runs themselves when this package refuses. */
148
+ readonly operatorCommand: (operand: string) => string;
149
+ };
150
+ export declare const MANAGERS: Readonly<Record<ManagerId, ManagerSpec>>;
151
+ /**
152
+ * Which managers to look for, in order, on a platform.
153
+ *
154
+ * Homebrew comes LAST on Linux: a Linuxbrew installation sits alongside the
155
+ * distro's manager rather than replacing it, and on a Debian box the answer
156
+ * to "is curl installed" that an operator means is dpkg's.
157
+ *
158
+ * `"other"` is empty, and that is what the hostile default in `../host.ts`
159
+ * produces for any test that forgets to declare a platform — every backend
160
+ * refuses, identically, on every machine.
161
+ */
162
+ export declare function managersForPlatform(platform: HostPlatform): readonly ManagerId[];
163
+ /**
164
+ * Build the operand a manager receives for a name (optionally version-pinned).
165
+ *
166
+ * Kept in one place because it is the ONLY value this package ever appends to
167
+ * an argv, and the join is manager-specific: `name=version` is apt's grammar
168
+ * and nobody else's.
169
+ */
170
+ export declare function buildOperand(manager: ManagerId, name: string, version?: string): string;
171
+ /** Append the operand to a frozen prefix, with `--` where the manager takes it. */
172
+ export declare function withOperand(spec: ManagerSpec, prefix: readonly string[], operand: string): string[];