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