magus-cli 7.10.2 → 7.10.4
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/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +0 -1
- package/src/__tests__/blackbox-parity.test.ts +2 -2
- package/src/__tests__/blackbox-tool-state.test.ts +8 -18
- package/src/__tests__/blind/c14-cli-row-label.test.ts +8 -3
- package/src/__tests__/blind/c15-probe-ownership.test.ts +11 -11
- package/src/__tests__/blind/c16-probe-adopts-node-global.test.ts +16 -12
- package/src/__tests__/blind/c17-enter-label.test.ts +42 -7
- package/src/__tests__/blind/c18-probe-adopts-homebrew-keg.test.ts +36 -19
- package/src/__tests__/blind/c19-probe-owner-version-shadow.test.ts +392 -0
- package/src/__tests__/cli-tool-commands.test.ts +2 -2
- package/src/__tests__/cli-tool-external-row.test.tsx +112 -0
- package/src/__tests__/cli-tools-adapter.test.ts +32 -20
- package/src/__tests__/cli-update-view.test.ts +124 -51
- package/src/__tests__/fixtures/bins.ts +9 -2
- package/src/__tests__/npm-package-name.test.ts +3 -2
- package/src/__tests__/tool-adopt-brew-owner.test.ts +167 -0
- package/src/__tests__/tool-probe.test.ts +231 -144
- package/src/__tests__/tool-rows-followups.test.ts +315 -0
- package/src/__tests__/tool-rows-reproduction.test.ts +630 -0
- package/src/__tests__/tool-versions.test.ts +164 -0
- package/src/__tests__/toolchain.test.ts +0 -2
- package/src/__tests__/update-plan.test.ts +40 -9
- package/src/cli/update-view.ts +182 -68
- package/src/cli/update.ts +44 -37
- package/src/data/cli-tools.ts +5 -10
- package/src/data/tool-versions.ts +137 -0
- package/src/services/cli-tool-commands.ts +1 -2
- package/src/services/cli-tool-version.ts +10 -88
- package/src/services/resolver.ts +9 -15
- package/src/services/tool-probe.ts +557 -424
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -18
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -10
- package/src/ui/state/types.ts +0 -1
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +0 -239
|
@@ -12,18 +12,26 @@
|
|
|
12
12
|
* Now both front ends read tools through here, in two halves:
|
|
13
13
|
*
|
|
14
14
|
* - {@link probeTools} gathers FACTS through an injected {@link ToolProbeIO}.
|
|
15
|
-
* Each tool is read through the installer
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* Each tool is read through the installer the spec names, and `managed`
|
|
16
|
+
* says whether the upgrade command magus would run replaces the file PATH
|
|
17
|
+
* actually runs. brew is ASKED (`brew list`), never second-guessed
|
|
18
|
+
* by rebuilding its install layout; the npm, bun, pip and go probes ask their
|
|
19
|
+
* installer where it keeps globals and test against that answer. brew and uv
|
|
20
|
+
* answer "is it behind?" themselves; bun, npm and go fall back to a registry
|
|
21
|
+
* lookup. A version comes from the installer's record or from magus's own
|
|
22
|
+
* version command (`data/tool-versions.ts`) — never guessed from files, and
|
|
23
|
+
* never from a plugin's `check` string.
|
|
19
24
|
* - {@link classifyTool} turns facts into a state, purely. The rules are in
|
|
20
25
|
* order and the vetoes that make an upgrade unsafe come before every rule
|
|
21
26
|
* that can produce one.
|
|
22
27
|
*
|
|
23
28
|
* Asking the installer that will perform the upgrade, about the file that will
|
|
24
29
|
* run, is what makes plan, upgrade and PATH agree — which is what convergence
|
|
25
|
-
* means.
|
|
26
|
-
*
|
|
30
|
+
* means. When the spec's installer does not have the file PATH runs, the other
|
|
31
|
+
* installers magus drives (bun, npm, Homebrew) are asked the same way, and the
|
|
32
|
+
* one that has it is the owner: the update runs through IT. A tool none of them
|
|
33
|
+
* has is `external`: installed another way, found, and correctly left alone. A
|
|
34
|
+
* tool whose state cannot be established is `unknown`. Neither is ever applied.
|
|
27
35
|
*
|
|
28
36
|
* NOTHING HERE PRINTS, and every subprocess runs through `execFile` (argv, no
|
|
29
37
|
* shell) with its stdio CAPTURED: the probe runs under the CLI's animated live
|
|
@@ -43,14 +51,11 @@ import os from "node:os";
|
|
|
43
51
|
import path from "node:path";
|
|
44
52
|
import semver from "semver";
|
|
45
53
|
import { CLAUDE_CODE_NPM_PACKAGE, type CliTool } from "../data/cli-tools.js";
|
|
54
|
+
import { parseToolVersion, toolVersionCommand } from "../data/tool-versions.js";
|
|
46
55
|
import type { BinInstaller, ManagedBin, ManagedTool } from "../types/index.js";
|
|
47
56
|
import type { Command } from "../utils/command-utils.js";
|
|
48
57
|
import type { InstallMethod } from "./cli-tool-commands.js";
|
|
49
|
-
import {
|
|
50
|
-
readPackageVersionForBin,
|
|
51
|
-
readVersionDirForBin,
|
|
52
|
-
versionFromVersionedPath,
|
|
53
|
-
} from "./cli-tool-version.js";
|
|
58
|
+
import { versionFromVersionedPath } from "./cli-tool-version.js";
|
|
54
59
|
import {
|
|
55
60
|
binPinCommand,
|
|
56
61
|
binUpgradeCommand,
|
|
@@ -61,10 +66,10 @@ import {
|
|
|
61
66
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
62
67
|
|
|
63
68
|
/**
|
|
64
|
-
* `external
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
69
|
+
* `external`: on PATH, and no installer magus drives has it — or the one that
|
|
70
|
+
* does has a different copy than the one PATH runs. Installed another way,
|
|
71
|
+
* found, and left alone. Never work, never a warning, never "could not be
|
|
72
|
+
* checked".
|
|
68
73
|
*/
|
|
69
74
|
export type ToolState =
|
|
70
75
|
| "install"
|
|
@@ -82,7 +87,10 @@ export type ToolReason =
|
|
|
82
87
|
| "ahead-of-pin"
|
|
83
88
|
| "pin-not-installable"
|
|
84
89
|
| "held-back"
|
|
85
|
-
|
|
90
|
+
/** `external`: the spec's installer does not own the file PATH runs. */
|
|
91
|
+
| "installed-elsewhere"
|
|
92
|
+
/** `unknown`: whether the spec's installer owns it could not be determined. */
|
|
93
|
+
| "owner-unknown"
|
|
86
94
|
| "version-unreadable"
|
|
87
95
|
| "latest-unavailable"
|
|
88
96
|
| "unorderable"
|
|
@@ -102,11 +110,12 @@ export type ToolFactSource =
|
|
|
102
110
|
| "pypi"
|
|
103
111
|
| "npm-registry"
|
|
104
112
|
| "go-proxy"
|
|
105
|
-
/** the
|
|
106
|
-
| "package-json"
|
|
107
|
-
/** the file name of a versioned install (`…/versions/2.1.276`). */
|
|
113
|
+
/** the file name of Claude Code's native install (`…/versions/2.1.276`). */
|
|
108
114
|
| "version-dir"
|
|
109
|
-
|
|
115
|
+
/** magus's own version command for the tool (`data/tool-versions.ts`). */
|
|
116
|
+
| "version-command"
|
|
117
|
+
/** a standalone installer's own receipt (`~/.config/uv/uv-receipt.json`). */
|
|
118
|
+
| "installer-receipt";
|
|
110
119
|
|
|
111
120
|
export interface ToolFacts {
|
|
112
121
|
name: string;
|
|
@@ -125,16 +134,14 @@ export interface ToolFacts {
|
|
|
125
134
|
/** The owning installer's own verdict; null = it gave none. */
|
|
126
135
|
behind: boolean | null;
|
|
127
136
|
/**
|
|
128
|
-
* The upgrade command for this spec replaces
|
|
129
|
-
* the
|
|
130
|
-
*
|
|
137
|
+
* The upgrade command for this spec replaces the file PATH runs. For brew:
|
|
138
|
+
* the file PATH runs is one `brew list <formula>` prints, and (when magus
|
|
139
|
+
* can read the tool's version) one of brew's versions is the one PATH runs. For npm/bun/pip/go: containment in
|
|
140
|
+
* the directory the installer reports, or pip adoption. false = the spec's
|
|
141
|
+
* installer does not have it, or has another copy (`external`); null = it
|
|
142
|
+
* could not be determined (the installer timed out or failed).
|
|
131
143
|
*/
|
|
132
144
|
managed: boolean | null;
|
|
133
|
-
/**
|
|
134
|
-
* Where the installer keeps what it owns, for the human note — e.g.
|
|
135
|
-
* "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
|
|
136
|
-
*/
|
|
137
|
-
owner?: string;
|
|
138
145
|
/** e.g. "held by brew pin". */
|
|
139
146
|
heldBack?: string;
|
|
140
147
|
/**
|
|
@@ -168,13 +175,19 @@ export interface ToolFacts {
|
|
|
168
175
|
failure?: string;
|
|
169
176
|
/** Where each fact came from, for diagnostics. */
|
|
170
177
|
sources: ToolFactSource[];
|
|
178
|
+
/**
|
|
179
|
+
* How many OTHER files of this name sit later on PATH, which nothing runs.
|
|
180
|
+
* A count only: telling which copy is whose, or which is newer, would mean
|
|
181
|
+
* reading installers' layouts or running files magus did not choose.
|
|
182
|
+
*/
|
|
183
|
+
otherCopies?: number;
|
|
171
184
|
}
|
|
172
185
|
|
|
173
186
|
export interface ToolStatus {
|
|
174
187
|
name: string;
|
|
175
188
|
state: ToolState;
|
|
176
189
|
reason: ToolReason;
|
|
177
|
-
/** Set for every state except plain current/install
|
|
190
|
+
/** Set for every state except plain current/install and `external`. */
|
|
178
191
|
note?: string;
|
|
179
192
|
/** {@link ToolFacts.adopted}, carried when this state plans work. */
|
|
180
193
|
adopted?: string;
|
|
@@ -228,68 +241,29 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
228
241
|
return failure ? `${text} (${failure})` : text;
|
|
229
242
|
}
|
|
230
243
|
|
|
231
|
-
/** The installer as the user knows it. */
|
|
232
|
-
export function installerWord(via: ManagedTool["via"]): string {
|
|
233
|
-
return via === "brew" ? "Homebrew" : via;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** The directories the operating system itself installs programs into. */
|
|
237
|
-
const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
|
|
238
|
-
|
|
239
|
-
function isSystemPath(resolved: string | null): boolean {
|
|
240
|
-
return (
|
|
241
|
-
resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
247
|
-
* and what magus does about it. Never the path or the installer's directory:
|
|
248
|
-
* the user acts on neither.
|
|
249
|
-
*/
|
|
250
|
-
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
251
|
-
const pin = pinOf(spec);
|
|
252
|
-
if (facts.managed === false && pin) {
|
|
253
|
-
return `installed outside ${installerWord(spec.via)}, so magus cannot move it to the pin ${pin}`;
|
|
254
|
-
}
|
|
255
|
-
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
256
|
-
// installed it.
|
|
257
|
-
return facts.managed === false
|
|
258
|
-
? isSystemPath(facts.resolvedPath)
|
|
259
|
-
? "comes with the operating system"
|
|
260
|
-
: `installed outside ${installerWord(spec.via)}`
|
|
261
|
-
: withFailure(
|
|
262
|
-
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
263
|
-
facts.failure,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Another installer's file is `external`, settled, only while nothing is
|
|
269
|
-
* asked of it. With a pin the profile declares a version magus cannot deliver
|
|
270
|
-
* without a second copy, so that is still a warning: `unknown`.
|
|
271
|
-
*/
|
|
272
|
-
function externalOr(pin: string | undefined): ToolState {
|
|
273
|
-
return pin ? "unknown" : "external";
|
|
274
|
-
}
|
|
275
|
-
|
|
276
244
|
/**
|
|
277
245
|
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
278
246
|
* returns, so reading top to bottom is reading the precedence.
|
|
279
247
|
*
|
|
280
|
-
* The vetoes (
|
|
281
|
-
* `upgrade`, pinned or not: an upgrade through an
|
|
282
|
-
* the running file installs a second copy PATH
|
|
283
|
-
* held formula is a no-op. Both would be
|
|
284
|
-
* plan no work (at-pin, unorderable,
|
|
285
|
-
* because ownership cannot make a
|
|
248
|
+
* The vetoes (installed elsewhere, owner unknown, `held-back`) come before
|
|
249
|
+
* every rule that can yield `upgrade`, pinned or not: an upgrade through an
|
|
250
|
+
* installer that does not own the running file installs a second copy PATH
|
|
251
|
+
* never runs, and one through a held formula is a no-op. Both would be
|
|
252
|
+
* replanned forever. The pin rules that plan no work (at-pin, unorderable,
|
|
253
|
+
* ahead-of-pin) come before the vetoes, because ownership cannot make a
|
|
254
|
+
* "nothing to do" answer wrong.
|
|
286
255
|
*
|
|
287
256
|
* One ownership answer comes earlier still: a file KNOWN to be nobody's the
|
|
288
|
-
* spec's installer reaches (`managed === false`) is `
|
|
289
|
-
* version could be read.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
257
|
+
* spec's installer reaches (`managed === false`) is `external` even when no
|
|
258
|
+
* version could be read. It was found and is correctly left alone, so a
|
|
259
|
+
* missing version is not a finding about it. The pin rules are not displaced by
|
|
260
|
+
* it — they need a version to compare, and there is none.
|
|
261
|
+
*
|
|
262
|
+
* The other answer that needs no version: an unpinned tool its installer owns
|
|
263
|
+
* (`managed === true`) and has judged itself (`behind` not null) — a brew dev
|
|
264
|
+
* build whose output does not parse, or several kegs with no table entry. The
|
|
265
|
+
* veto `held-back` still comes first, and a pinned tool still needs a version:
|
|
266
|
+
* without one it cannot be compared with the pin, so it plans nothing.
|
|
293
267
|
*/
|
|
294
268
|
export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
295
269
|
const pin = pinOf(spec);
|
|
@@ -299,6 +273,29 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
299
273
|
latest: facts.latest,
|
|
300
274
|
...(pin ? { pin } : {}),
|
|
301
275
|
};
|
|
276
|
+
// A row that can plan work carries its adoption with it.
|
|
277
|
+
const adopted = facts.adopted ? { adopted: facts.adopted } : {};
|
|
278
|
+
const heldBack = (note: string): ToolStatus => ({
|
|
279
|
+
...base,
|
|
280
|
+
state: "unknown",
|
|
281
|
+
reason: "held-back",
|
|
282
|
+
target: null,
|
|
283
|
+
note,
|
|
284
|
+
});
|
|
285
|
+
/** The owning installer's own verdict, which needs no version of ours. */
|
|
286
|
+
const ownerVerdict = (behind: boolean): ToolStatus =>
|
|
287
|
+
behind
|
|
288
|
+
? {
|
|
289
|
+
...base,
|
|
290
|
+
...adopted,
|
|
291
|
+
state: "upgrade",
|
|
292
|
+
reason: "behind",
|
|
293
|
+
target: facts.latest,
|
|
294
|
+
note: facts.latest
|
|
295
|
+
? `${facts.latest} is available`
|
|
296
|
+
: "a newer version is available",
|
|
297
|
+
}
|
|
298
|
+
: { ...base, state: "current", reason: "up-to-date", target: null };
|
|
302
299
|
|
|
303
300
|
// 1. Not on PATH.
|
|
304
301
|
if (!facts.present) {
|
|
@@ -310,23 +307,35 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
310
307
|
};
|
|
311
308
|
}
|
|
312
309
|
|
|
313
|
-
// 2. Present,
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
310
|
+
// 2. Present, installed another way — and no version read. The version is
|
|
311
|
+
// unreadable BECAUSE no installer's record covers the file and magus has no
|
|
312
|
+
// version command for it; "could not read its version" would misreport a
|
|
313
|
+
// tool that is simply someone else's. Only a DEFINITE `false`; an
|
|
314
|
+
// undetermined owner (null) is not this answer.
|
|
318
315
|
const installed = facts.installed;
|
|
319
316
|
if (installed === null && facts.managed === false) {
|
|
320
317
|
return {
|
|
321
318
|
...base,
|
|
322
|
-
state:
|
|
323
|
-
reason: "
|
|
319
|
+
state: "external",
|
|
320
|
+
reason: "installed-elsewhere",
|
|
324
321
|
target: null,
|
|
325
|
-
note: notManagedNote(spec, facts),
|
|
326
322
|
};
|
|
327
323
|
}
|
|
328
324
|
|
|
329
|
-
// 3. Present, but
|
|
325
|
+
// 3. Present and owned, no version read, but the owner judged it itself and
|
|
326
|
+
// there is no pin to compare: the owner's verdict, behind the held-back veto.
|
|
327
|
+
if (
|
|
328
|
+
installed === null &&
|
|
329
|
+
facts.managed === true &&
|
|
330
|
+
!pin &&
|
|
331
|
+
facts.behind !== null
|
|
332
|
+
) {
|
|
333
|
+
if (facts.heldBack) return heldBack(facts.heldBack);
|
|
334
|
+
return ownerVerdict(facts.behind);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 4. Present, but we could not read what version runs. Every note states
|
|
338
|
+
// the tool's state, never where it lives or how magus looked.
|
|
330
339
|
if (installed === null) {
|
|
331
340
|
return {
|
|
332
341
|
...base,
|
|
@@ -334,13 +343,13 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
334
343
|
reason: "version-unreadable",
|
|
335
344
|
target: null,
|
|
336
345
|
note: withFailure(
|
|
337
|
-
|
|
346
|
+
"installed, but its version could not be read",
|
|
338
347
|
facts.failure,
|
|
339
348
|
),
|
|
340
349
|
};
|
|
341
350
|
}
|
|
342
351
|
|
|
343
|
-
//
|
|
352
|
+
// 5–7. Pin rules that plan no work.
|
|
344
353
|
const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
|
|
345
354
|
if (pin && vsPin === 0) {
|
|
346
355
|
return { ...base, state: "current", reason: "at-pin", target: null };
|
|
@@ -365,34 +374,38 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
365
374
|
};
|
|
366
375
|
}
|
|
367
376
|
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
if (facts.managed
|
|
377
|
+
// 8. The vetoes: the upgrade would not replace the file PATH runs, or
|
|
378
|
+
// whether it would could not be told.
|
|
379
|
+
if (facts.managed === false) {
|
|
371
380
|
return {
|
|
372
381
|
...base,
|
|
373
|
-
state:
|
|
374
|
-
reason: "
|
|
382
|
+
state: "external",
|
|
383
|
+
reason: "installed-elsewhere",
|
|
375
384
|
target: null,
|
|
376
|
-
note: notManagedNote(spec, facts),
|
|
377
385
|
};
|
|
378
386
|
}
|
|
379
|
-
|
|
380
|
-
// 8. The veto: the installer will refuse to move it.
|
|
381
|
-
if (facts.heldBack) {
|
|
387
|
+
if (facts.managed === null) {
|
|
382
388
|
return {
|
|
383
389
|
...base,
|
|
384
390
|
state: "unknown",
|
|
385
|
-
reason: "
|
|
391
|
+
reason: "owner-unknown",
|
|
386
392
|
target: null,
|
|
387
|
-
note:
|
|
393
|
+
note: withFailure(
|
|
394
|
+
"could not tell which installer owns it",
|
|
395
|
+
facts.failure,
|
|
396
|
+
),
|
|
388
397
|
};
|
|
389
398
|
}
|
|
390
399
|
|
|
391
|
-
// 9
|
|
392
|
-
|
|
393
|
-
|
|
400
|
+
// 9. The veto: the installer will refuse to move it.
|
|
401
|
+
if (facts.heldBack) return heldBack(facts.heldBack);
|
|
402
|
+
|
|
403
|
+
// 10–11. Pinned and behind the pin (every other pinned case returned above).
|
|
394
404
|
if (pin) {
|
|
395
|
-
|
|
405
|
+
// Homebrew cannot install a chosen version — but the spec's installer
|
|
406
|
+
// is not always the owner, and an owner's own command (`uv self update
|
|
407
|
+
// <pin>`, `bun install -g <pkg>@<pin>`) is already aimed at the pin.
|
|
408
|
+
if (spec.via === "brew" && !facts.upgradeCommand) {
|
|
396
409
|
return {
|
|
397
410
|
...base,
|
|
398
411
|
state: "unknown",
|
|
@@ -411,22 +424,8 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
411
424
|
};
|
|
412
425
|
}
|
|
413
426
|
|
|
414
|
-
//
|
|
415
|
-
if (facts.behind
|
|
416
|
-
return {
|
|
417
|
-
...base,
|
|
418
|
-
...adopted,
|
|
419
|
-
state: "upgrade",
|
|
420
|
-
reason: "behind",
|
|
421
|
-
target: facts.latest,
|
|
422
|
-
note: facts.latest
|
|
423
|
-
? `${facts.latest} is available`
|
|
424
|
-
: "a newer version is available",
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
if (facts.behind === false) {
|
|
428
|
-
return { ...base, state: "current", reason: "up-to-date", target: null };
|
|
429
|
-
}
|
|
427
|
+
// 12. The owning installer's own verdict.
|
|
428
|
+
if (facts.behind !== null) return ownerVerdict(facts.behind);
|
|
430
429
|
|
|
431
430
|
// 13. Nothing to compare against.
|
|
432
431
|
const latest = facts.latest;
|
|
@@ -466,12 +465,25 @@ export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
466
465
|
|
|
467
466
|
// ── Parsers (pure; tested against captured real output) ──────────────────────
|
|
468
467
|
|
|
469
|
-
/**
|
|
470
|
-
|
|
468
|
+
/**
|
|
469
|
+
* `brew list --versions <f>` → EVERY version brew still has on disk
|
|
470
|
+
* ("tmux 3.7b 3.7c" → ["3.7b", "3.7c"]). With cleanup off brew keeps old kegs,
|
|
471
|
+
* and the line does not say which one is linked, so none is dropped: the
|
|
472
|
+
* caller matches the version PATH runs against all of them. [] = none listed.
|
|
473
|
+
*/
|
|
474
|
+
export function parseBrewListVersions(stdout: string): string[] {
|
|
471
475
|
const line = stdout.split("\n").find((l) => l.trim().length > 0);
|
|
472
|
-
if (!line) return
|
|
473
|
-
|
|
474
|
-
|
|
476
|
+
if (!line) return [];
|
|
477
|
+
return line.trim().split(/\s+/).slice(1);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* A brew version without its leading `v` or its `_N` rebuild suffix: brew
|
|
482
|
+
* appends `_1` when it rebuilds a formula (`1.8.2_1`), while the tool itself
|
|
483
|
+
* prints `1.8.2`.
|
|
484
|
+
*/
|
|
485
|
+
function stripBrewRevision(v: string): string {
|
|
486
|
+
return v.trim().replace(/^v/i, "").replace(/_\d+$/, "");
|
|
475
487
|
}
|
|
476
488
|
|
|
477
489
|
/**
|
|
@@ -525,22 +537,6 @@ export function parseBrewOutdatedJson(
|
|
|
525
537
|
};
|
|
526
538
|
}
|
|
527
539
|
|
|
528
|
-
/**
|
|
529
|
-
* The keg version from a realpath under `<cellar>/<formula>/<version>/`
|
|
530
|
-
* (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
|
|
531
|
-
* `brew list --versions` cannot give when several kegs are kept.
|
|
532
|
-
*/
|
|
533
|
-
export function brewKegVersion(
|
|
534
|
-
realPath: string,
|
|
535
|
-
cellar: string,
|
|
536
|
-
formula: string,
|
|
537
|
-
): string | null {
|
|
538
|
-
const prefix = `${path.join(cellar, formula)}${path.sep}`;
|
|
539
|
-
if (!realPath.startsWith(prefix)) return null;
|
|
540
|
-
const segment = realPath.slice(prefix.length).split(path.sep)[0];
|
|
541
|
-
return segment ? segment : null;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
540
|
/** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
|
|
545
541
|
export function normalizePythonPackageName(name: string): string {
|
|
546
542
|
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
@@ -679,17 +675,6 @@ export function versionFromNpmDistTags(body: unknown): string | undefined {
|
|
|
679
675
|
return typeof latest === "string" ? latest : undefined;
|
|
680
676
|
}
|
|
681
677
|
|
|
682
|
-
/**
|
|
683
|
-
* The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
|
|
684
|
-
* "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
|
|
685
|
-
* dot, so a bare number (a year, a count) is not mistaken for a version.
|
|
686
|
-
*/
|
|
687
|
-
export function parseVersionToken(output: string): string | null {
|
|
688
|
-
const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
|
|
689
|
-
if (!m?.[1]) return null;
|
|
690
|
-
return m[1].replace(/[.+-]+$/, "");
|
|
691
|
-
}
|
|
692
|
-
|
|
693
678
|
/** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
|
|
694
679
|
export function isInside(resolved: string, ownerDir: string): boolean {
|
|
695
680
|
const dir = path.resolve(ownerDir);
|
|
@@ -758,6 +743,11 @@ export interface ToolProbeIO {
|
|
|
758
743
|
* null. A dangling symlink is not executable and does not count.
|
|
759
744
|
*/
|
|
760
745
|
resolveExecutable(name: string): Promise<string | null>;
|
|
746
|
+
/**
|
|
747
|
+
* Every executable `name` on PATH, in PATH order, as found. Optional: an IO
|
|
748
|
+
* without it reports no other copies.
|
|
749
|
+
*/
|
|
750
|
+
resolveAllExecutables?(name: string): Promise<string[]>;
|
|
761
751
|
env: Readonly<Record<string, string | undefined>>;
|
|
762
752
|
homedir: string;
|
|
763
753
|
}
|
|
@@ -876,6 +866,16 @@ export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
|
|
|
876
866
|
}
|
|
877
867
|
return null;
|
|
878
868
|
},
|
|
869
|
+
resolveAllExecutables: async (name) => {
|
|
870
|
+
const found: string[] = [];
|
|
871
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
872
|
+
if (!dir) continue;
|
|
873
|
+
const candidate = path.join(dir, name);
|
|
874
|
+
if (!found.includes(candidate) && (await isExecutable(candidate)))
|
|
875
|
+
found.push(candidate);
|
|
876
|
+
}
|
|
877
|
+
return found;
|
|
878
|
+
},
|
|
879
879
|
env: process.env,
|
|
880
880
|
homedir: os.homedir(),
|
|
881
881
|
};
|
|
@@ -900,14 +900,6 @@ export interface ProbeOptions {
|
|
|
900
900
|
* must use.
|
|
901
901
|
*/
|
|
902
902
|
onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
|
|
903
|
-
/**
|
|
904
|
-
* Run a spec's `check` command when no installer record can give the
|
|
905
|
-
* version. Default FALSE, and every planning caller leaves it so: `check`
|
|
906
|
-
* is a string a third-party `plugin.json` supplied, and planning — which
|
|
907
|
-
* includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
|
|
908
|
-
* See {@link versionFromCheck} for what is still bounded when it is on.
|
|
909
|
-
*/
|
|
910
|
-
allowCheckCommand?: boolean;
|
|
911
903
|
}
|
|
912
904
|
|
|
913
905
|
// ── Probe session: memoised batch and location queries ──────────────────────
|
|
@@ -935,11 +927,17 @@ function firstLine(text: string): string {
|
|
|
935
927
|
);
|
|
936
928
|
}
|
|
937
929
|
|
|
930
|
+
/**
|
|
931
|
+
* What went wrong, in words. Never the installer's own stderr: brew's names
|
|
932
|
+
* lock files, Cellar folders and its own internal calls, and this text reaches
|
|
933
|
+
* every front end as a note.
|
|
934
|
+
*/
|
|
938
935
|
function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
|
|
939
936
|
if (r.missing) return `${cmd} is not installed`;
|
|
940
937
|
if (r.timedOut)
|
|
941
938
|
return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
942
|
-
|
|
939
|
+
if (r.stderr.trim()) return `${cmd} reported an error`;
|
|
940
|
+
return `${cmd} exited ${r.code ?? "abnormally"}`;
|
|
943
941
|
}
|
|
944
942
|
|
|
945
943
|
/**
|
|
@@ -953,8 +951,6 @@ class ProbeSession {
|
|
|
953
951
|
constructor(
|
|
954
952
|
readonly io: ToolProbeIO,
|
|
955
953
|
readonly timeoutMs: number,
|
|
956
|
-
/** See {@link versionFromCheck}. Off unless the caller opted in. */
|
|
957
|
-
readonly allowCheckCommand: boolean = false,
|
|
958
954
|
) {}
|
|
959
955
|
|
|
960
956
|
once<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
@@ -985,12 +981,6 @@ class ProbeSession {
|
|
|
985
981
|
});
|
|
986
982
|
}
|
|
987
983
|
|
|
988
|
-
brewPrefix(): Promise<Located> {
|
|
989
|
-
const fromEnv = this.io.env.HOMEBREW_PREFIX;
|
|
990
|
-
if (fromEnv) return Promise.resolve({ dir: fromEnv });
|
|
991
|
-
return this.locate("brew --prefix", "brew", ["--prefix"]);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
984
|
uvToolDir(): Promise<Located & { missing?: boolean }> {
|
|
995
985
|
return this.once("uv tool dir", async () => {
|
|
996
986
|
const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
|
|
@@ -1062,9 +1052,6 @@ class ProbeSession {
|
|
|
1062
1052
|
|
|
1063
1053
|
// ── Probing one tool ─────────────────────────────────────────────────────────
|
|
1064
1054
|
|
|
1065
|
-
/** Characters that make a check command something only a shell could run. */
|
|
1066
|
-
const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
|
|
1067
|
-
|
|
1068
1055
|
/**
|
|
1069
1056
|
* Refuse a name that could be read as an option, carries a NUL, or (for a
|
|
1070
1057
|
* value joined into a path) climbs out of its directory. Null when fine.
|
|
@@ -1117,62 +1104,45 @@ async function packageJsonVersion(
|
|
|
1117
1104
|
}
|
|
1118
1105
|
|
|
1119
1106
|
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1107
|
+
* The version of the file PATH runs, from magus's OWN version command for the
|
|
1108
|
+
* tool (`data/tool-versions.ts`), or null — when the table has no entry, the
|
|
1109
|
+
* command could not run, or its first line is not one the pattern anchors.
|
|
1122
1110
|
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
* So the planner does not opt in: a version that could only come from a check
|
|
1130
|
-
* command stays null, which classifies as `unknown / version-unreadable`,
|
|
1131
|
-
* never as an upgrade. The one caller that opts in is the post-install
|
|
1132
|
-
* read-back (`update-engine.ts`), where the user has already consented to run
|
|
1133
|
-
* that tool's installer.
|
|
1111
|
+
* Runs while planning, `--dry-run` and `--check` included, because magus wrote
|
|
1112
|
+
* the argv: argv[0] is the bare `spec.name` (which {@link refusal} has already
|
|
1113
|
+
* vetted), resolved by the same PATH `resolveExecutable` reads, so it asks the
|
|
1114
|
+
* very file `resolvedPath` names. argv only, never a shell, under the session's
|
|
1115
|
+
* timeout, with stdio captured through the IO seam. A plugin's `check` string
|
|
1116
|
+
* is never consulted.
|
|
1134
1117
|
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
* refuses the whole string);
|
|
1138
|
-
* - argv[0] must be the tool itself — its name, its path on PATH, or the
|
|
1139
|
-
* realpath behind it. The probe's purpose is "ask the running file its
|
|
1140
|
-
* version", so anything else is refused unrun, and the version reported can
|
|
1141
|
-
* only have come from the binary `resolvedPath` names;
|
|
1142
|
-
* - the session's `timeoutMs` (8s by default), through the injected IO seam,
|
|
1143
|
-
* which captures stdio rather than inheriting the terminal.
|
|
1118
|
+
* Memoised per tool, so the brew cross-check and the fallback in
|
|
1119
|
+
* {@link probeInto} run it at most once.
|
|
1144
1120
|
*/
|
|
1145
|
-
|
|
1121
|
+
function versionFromTable(
|
|
1146
1122
|
s: ProbeSession,
|
|
1147
1123
|
spec: ManagedTool,
|
|
1148
1124
|
f: ToolFacts,
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (!
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
|
|
1172
|
-
if (v) {
|
|
1173
|
-
f.installed = v;
|
|
1174
|
-
addSource(f, "check-command");
|
|
1175
|
-
}
|
|
1125
|
+
): Promise<string | null> {
|
|
1126
|
+
const cmd = toolVersionCommand(spec.name);
|
|
1127
|
+
if (!cmd) return Promise.resolve(null);
|
|
1128
|
+
return s
|
|
1129
|
+
.once<{ failure: string } | { version: string | null }>(
|
|
1130
|
+
`version ${spec.name}`,
|
|
1131
|
+
async () => {
|
|
1132
|
+
const r = await s.run(spec.name, [...cmd.args], PLAIN_ENV);
|
|
1133
|
+
if (r.missing || r.timedOut)
|
|
1134
|
+
return { failure: execFailure(spec.name, r, s.timeoutMs) };
|
|
1135
|
+
return { version: parseToolVersion(cmd, r.stdout, r.stderr) };
|
|
1136
|
+
},
|
|
1137
|
+
)
|
|
1138
|
+
.then((out) => {
|
|
1139
|
+
if ("failure" in out) {
|
|
1140
|
+
noteFailure(f, out.failure);
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1143
|
+
if (out.version) addSource(f, "version-command");
|
|
1144
|
+
return out.version;
|
|
1145
|
+
});
|
|
1176
1146
|
}
|
|
1177
1147
|
|
|
1178
1148
|
/** The npm registry's `latest` dist-tag for a package. */
|
|
@@ -1231,31 +1201,127 @@ async function containedIn(
|
|
|
1231
1201
|
return { inside: real !== null && isInside(resolvedPath, real), real };
|
|
1232
1202
|
}
|
|
1233
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* brew's own answer about THE FILE: `brew list <formula>` prints the
|
|
1206
|
+
* formula's installed files, one absolute path per line. True when the PATH
|
|
1207
|
+
* entry or its realpath is one of them; false when brew lists neither; null
|
|
1208
|
+
* (with the failure noted) when brew could not answer.
|
|
1209
|
+
*/
|
|
1210
|
+
async function brewListsFile(
|
|
1211
|
+
s: ProbeSession,
|
|
1212
|
+
formula: string,
|
|
1213
|
+
onPath: string,
|
|
1214
|
+
resolvedPath: string,
|
|
1215
|
+
f?: ToolFacts,
|
|
1216
|
+
): Promise<boolean | null> {
|
|
1217
|
+
const r = await s.run("brew", ["list", formula], BREW_ENV);
|
|
1218
|
+
if (r.timedOut || r.missing || r.code !== 0) {
|
|
1219
|
+
if (f) noteFailure(f, execFailure("brew", r, s.timeoutMs));
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
const files = new Set(
|
|
1223
|
+
r.stdout
|
|
1224
|
+
.split("\n")
|
|
1225
|
+
.map((line) => line.trim())
|
|
1226
|
+
.filter(Boolean),
|
|
1227
|
+
);
|
|
1228
|
+
return files.has(resolvedPath) || files.has(onPath);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Ask brew whether it has the formula, and at which versions — never compute
|
|
1233
|
+
* where it installs. magus used to build `<prefix>/Cellar/<formula>` itself and
|
|
1234
|
+
* test the PATH binary's realpath against it, which is wrong for a tap formula
|
|
1235
|
+
* (`madappgang/tap/claudish` lives under `Cellar/claudish`) and reimplements a
|
|
1236
|
+
* layout that is brew's to define.
|
|
1237
|
+
*
|
|
1238
|
+
* - `brew` not installed: brew cannot have it — installed elsewhere. A Mac
|
|
1239
|
+
* without Homebrew must not report every declared tool as "not checked".
|
|
1240
|
+
* - exit 1 with nothing on stdout or stderr: brew does not have it (measured:
|
|
1241
|
+
* `zsh`, `madappgang/tap/claudish` when not installed).
|
|
1242
|
+
* - exit 1 that says anything, `Error: No available formula …` for an
|
|
1243
|
+
* untapped tap among them: brew failed, so the owner could not be
|
|
1244
|
+
* determined. brew exits 1 for almost every error, so silence is the only
|
|
1245
|
+
* exit 1 that means "not installed".
|
|
1246
|
+
* - a timeout or any other exit: the owner could not be determined.
|
|
1247
|
+
*
|
|
1248
|
+
* When brew has it and magus can read the tool's version, the version PATH
|
|
1249
|
+
* runs must be one of brew's versions; otherwise PATH runs another copy, and
|
|
1250
|
+
* upgrading brew's would not move it. A matching version is still not
|
|
1251
|
+
* ownership — a same-version copy elsewhere is another copy too. brew owns the
|
|
1252
|
+
* tool only if the file PATH runs is one brew lists as the formula's: `brew
|
|
1253
|
+
* list <formula>` prints its installed files, one absolute path per line, and
|
|
1254
|
+
* the PATH entry or its realpath must be among them ({@link brewListsFile}).
|
|
1255
|
+
* Comparing a path against the list brew printed asks brew; magus builds no
|
|
1256
|
+
* Cellar path. When the version cannot be read (no table entry, or output the
|
|
1257
|
+
* pattern does not anchor), brew listing the file stands: managed, judged by
|
|
1258
|
+
* brew's own `outdated` verdict, its version shown only when brew lists exactly
|
|
1259
|
+
* one and the table does not know the tool.
|
|
1260
|
+
*/
|
|
1234
1261
|
async function probeBrew(
|
|
1235
1262
|
s: ProbeSession,
|
|
1236
1263
|
spec: ManagedTool,
|
|
1237
1264
|
f: ToolFacts,
|
|
1265
|
+
pathEntry: string,
|
|
1238
1266
|
resolvedPath: string,
|
|
1239
1267
|
): Promise<void> {
|
|
1240
1268
|
const formula = spec.formula ?? spec.name;
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1269
|
+
const list = await s.run("brew", ["list", "--versions", formula], BREW_ENV);
|
|
1270
|
+
if (list.missing) {
|
|
1271
|
+
f.managed = false;
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const silent = !list.stdout.trim() && !list.stderr.trim();
|
|
1275
|
+
if (
|
|
1276
|
+
list.timedOut ||
|
|
1277
|
+
(list.code !== 0 && list.code !== 1) ||
|
|
1278
|
+
(list.code === 1 && !silent)
|
|
1279
|
+
) {
|
|
1246
1280
|
f.managed = null;
|
|
1247
|
-
noteFailure(f,
|
|
1281
|
+
noteFailure(f, execFailure("brew", list, s.timeoutMs));
|
|
1248
1282
|
return;
|
|
1249
1283
|
}
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
f
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1284
|
+
const kegs = list.code === 0 ? parseBrewListVersions(list.stdout) : [];
|
|
1285
|
+
if (kegs.length === 0) {
|
|
1286
|
+
f.managed = false;
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
addSource(f, "brew");
|
|
1290
|
+
|
|
1291
|
+
const inTable = toolVersionCommand(spec.name) !== undefined;
|
|
1292
|
+
const onPath = inTable ? await versionFromTable(s, spec, f) : null;
|
|
1293
|
+
if (onPath !== null) {
|
|
1294
|
+
f.installed = onPath;
|
|
1295
|
+
const hit = kegs.some(
|
|
1296
|
+
(k) => compareToolVersions(stripBrewRevision(k), onPath) === 0,
|
|
1297
|
+
);
|
|
1298
|
+
// A version none of brew's is already another copy: no file list needed.
|
|
1299
|
+
if (!hit) {
|
|
1300
|
+
f.managed = false;
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// A matching version is not ownership; brew listing the file is.
|
|
1306
|
+
const owns = await brewListsFile(s, formula, pathEntry, resolvedPath, f);
|
|
1307
|
+
if (owns !== true) {
|
|
1308
|
+
f.managed = owns;
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if (onPath !== null) {
|
|
1313
|
+
f.managed = true;
|
|
1314
|
+
} else {
|
|
1315
|
+
// brew's answer stands, and its `outdated` verdict below says whether it
|
|
1316
|
+
// is behind. The version is shown only when the table does not know the
|
|
1317
|
+
// tool and brew lists exactly one: with several kegs kept, brew list
|
|
1318
|
+
// cannot say which is linked, and a tool whose own answer does not parse
|
|
1319
|
+
// (`tmux next-3.7` from a `HEAD-…` keg) is not the version brew names.
|
|
1320
|
+
// Never guess.
|
|
1321
|
+
f.managed = true;
|
|
1322
|
+
const only = !inTable && kegs.length === 1 ? kegs[0] : undefined;
|
|
1323
|
+
f.installed = only ? stripBrewRevision(only) : null;
|
|
1324
|
+
}
|
|
1259
1325
|
|
|
1260
1326
|
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
1261
1327
|
if (r.code !== 0) {
|
|
@@ -1287,7 +1353,6 @@ async function probePip(
|
|
|
1287
1353
|
const uvDir = await s.uvToolDir();
|
|
1288
1354
|
if ("dir" in uvDir) {
|
|
1289
1355
|
const ownerDir = path.join(uvDir.dir, norm);
|
|
1290
|
-
f.owner = `uv (tool dir ${uvDir.dir})`;
|
|
1291
1356
|
const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
|
|
1292
1357
|
// Adoption: `uv tool install --force` overwrites the very file PATH runs
|
|
1293
1358
|
// when it sits in uv's bin dir, whoever put it there.
|
|
@@ -1301,7 +1366,7 @@ async function probePip(
|
|
|
1301
1366
|
// plan row is an ordinary upgrade, so without this it never says that
|
|
1302
1367
|
// the owning installer changes — and pipx, say, goes on claiming a
|
|
1303
1368
|
// venv nothing runs until its next `upgrade-all` writes back over it.
|
|
1304
|
-
f.adopted =
|
|
1369
|
+
f.adopted = "installed another way; upgrading through uv takes it over";
|
|
1305
1370
|
addSource(f, "uv-adopted");
|
|
1306
1371
|
}
|
|
1307
1372
|
|
|
@@ -1371,7 +1436,6 @@ async function probePip(
|
|
|
1371
1436
|
const binDir =
|
|
1372
1437
|
s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
|
|
1373
1438
|
const ownerDir = path.join(home, "venvs", norm);
|
|
1374
|
-
f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
|
|
1375
1439
|
const { inside } = await containedIn(s, resolvedPath, ownerDir);
|
|
1376
1440
|
f.managed =
|
|
1377
1441
|
inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
|
|
@@ -1404,129 +1468,6 @@ async function readNodePackage(
|
|
|
1404
1468
|
if (!pinned) await npmLatest(s, pkg, f);
|
|
1405
1469
|
}
|
|
1406
1470
|
|
|
1407
|
-
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1408
|
-
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1409
|
-
return path.join(
|
|
1410
|
-
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1411
|
-
"install",
|
|
1412
|
-
"global",
|
|
1413
|
-
"node_modules",
|
|
1414
|
-
);
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
/** The package a path names, from its segments under `node_modules`. */
|
|
1418
|
-
function packageFromSegments(rel: string): string | null {
|
|
1419
|
-
const [first, second] = rel.split(path.sep);
|
|
1420
|
-
if (!first || first.startsWith(".")) return null;
|
|
1421
|
-
if (!first.startsWith("@")) return first;
|
|
1422
|
-
return second ? `${first}/${second}` : null;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
/**
|
|
1426
|
-
* The bun or npm global package that holds `resolvedPath`, read from the path
|
|
1427
|
-
* alone. Null when the file lies in neither tree (a Homebrew keg included:
|
|
1428
|
-
* `npmGlobalPrefixOf` refuses one).
|
|
1429
|
-
*/
|
|
1430
|
-
async function nodeGlobalOwner(
|
|
1431
|
-
s: ProbeSession,
|
|
1432
|
-
resolvedPath: string,
|
|
1433
|
-
): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
|
|
1434
|
-
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1435
|
-
if (bunRoot && isInside(resolvedPath, bunRoot)) {
|
|
1436
|
-
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1437
|
-
return pkg ? { via: "bun", pkg } : null;
|
|
1438
|
-
}
|
|
1439
|
-
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1440
|
-
const at = resolvedPath.lastIndexOf(marker);
|
|
1441
|
-
if (at < 0) return null;
|
|
1442
|
-
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1443
|
-
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1444
|
-
? { via: "npm", pkg }
|
|
1445
|
-
: null;
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
/**
|
|
1449
|
-
* The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
|
|
1450
|
-
* read from the path alone. `probeBrew` then checks that this Cellar is brew's.
|
|
1451
|
-
*/
|
|
1452
|
-
function kegFormulaOf(resolvedPath: string): string | null {
|
|
1453
|
-
const parts = resolvedPath.split(path.sep);
|
|
1454
|
-
const at = parts.lastIndexOf("Cellar");
|
|
1455
|
-
return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
/**
|
|
1459
|
-
* The spec as the installer that actually holds `resolvedPath` would state it,
|
|
1460
|
-
* or null when that is the spec's own installer or none magus drives.
|
|
1461
|
-
*/
|
|
1462
|
-
async function actualOwnerSpec(
|
|
1463
|
-
s: ProbeSession,
|
|
1464
|
-
spec: ManagedTool,
|
|
1465
|
-
resolvedPath: string,
|
|
1466
|
-
): Promise<ManagedTool | null> {
|
|
1467
|
-
const base = {
|
|
1468
|
-
name: spec.name,
|
|
1469
|
-
...(spec.check ? { check: spec.check } : {}),
|
|
1470
|
-
...(spec.version ? { version: spec.version } : {}),
|
|
1471
|
-
};
|
|
1472
|
-
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1473
|
-
if (node) {
|
|
1474
|
-
return node.via === spec.via
|
|
1475
|
-
? null
|
|
1476
|
-
: { ...base, via: node.via, package: node.pkg };
|
|
1477
|
-
}
|
|
1478
|
-
const formula = kegFormulaOf(resolvedPath);
|
|
1479
|
-
// Homebrew takes no version, so it cannot honour a pin: such a tool stays
|
|
1480
|
-
// "installed outside", which the classifier reports as unknown.
|
|
1481
|
-
if (!formula || spec.via === "brew" || spec.version) return null;
|
|
1482
|
-
return { ...base, via: "brew", formula };
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
/**
|
|
1486
|
-
* The spec's installer does not own the file PATH runs, but bun's or npm's
|
|
1487
|
-
* global tree or a Homebrew keg does: re-read the tool through THAT installer.
|
|
1488
|
-
*
|
|
1489
|
-
* A plugin's `requires` lists its install options in preference order for a
|
|
1490
|
-
* FRESH install, and magus plans through the first. That order says how to
|
|
1491
|
-
* install a missing tool, not which owner an installed one must have. A
|
|
1492
|
-
* mnemex from `bun add -g` or from Homebrew has a version, a registry and an
|
|
1493
|
-
* upgrade command like any other. Reading it as "installed outside" the
|
|
1494
|
-
* first option's installer showed a settled, upgradeable tool as one magus
|
|
1495
|
-
* could not touch.
|
|
1496
|
-
*
|
|
1497
|
-
* The catalogue's tools already follow how a tool IS installed
|
|
1498
|
-
* (`specForCatalogTool`); this gives a plugin's requirements the same rule.
|
|
1499
|
-
* The upgrade command is carried on the facts, so `magus update` and the TUI
|
|
1500
|
-
* run the owner's command rather than the spec's.
|
|
1501
|
-
*/
|
|
1502
|
-
async function adoptActualOwner(
|
|
1503
|
-
s: ProbeSession,
|
|
1504
|
-
spec: ManagedTool,
|
|
1505
|
-
f: ToolFacts,
|
|
1506
|
-
resolvedPath: string,
|
|
1507
|
-
): Promise<void> {
|
|
1508
|
-
const actual = await actualOwnerSpec(s, spec, resolvedPath);
|
|
1509
|
-
if (!actual) return;
|
|
1510
|
-
const read = blankFacts(spec.name);
|
|
1511
|
-
if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
|
|
1512
|
-
else await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1513
|
-
if (read.managed !== true) return;
|
|
1514
|
-
f.managed = true;
|
|
1515
|
-
f.owner = read.owner;
|
|
1516
|
-
f.installed = read.installed;
|
|
1517
|
-
f.latest = read.latest;
|
|
1518
|
-
f.behind = read.behind;
|
|
1519
|
-
// What the spec's installer could not answer is moot once the owner is
|
|
1520
|
-
// known: only the owner's own failure describes this tool.
|
|
1521
|
-
f.failure = read.failure;
|
|
1522
|
-
for (const source of read.sources) addSource(f, source);
|
|
1523
|
-
f.upgradeCommand =
|
|
1524
|
-
read.upgradeCommand ??
|
|
1525
|
-
(spec.version
|
|
1526
|
-
? (binPinCommand(actual) ?? undefined)
|
|
1527
|
-
: binUpgradeCommand(actual));
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
1471
|
async function probeNodeGlobal(
|
|
1531
1472
|
s: ProbeSession,
|
|
1532
1473
|
spec: ManagedTool,
|
|
@@ -1538,7 +1479,6 @@ async function probeNodeGlobal(
|
|
|
1538
1479
|
const pinned = pin !== undefined;
|
|
1539
1480
|
if (spec.via === "bun") {
|
|
1540
1481
|
const root = bunGlobalRoot(s.io);
|
|
1541
|
-
f.owner = `bun (global ${root})`;
|
|
1542
1482
|
const { inside, real } = await containedIn(
|
|
1543
1483
|
s,
|
|
1544
1484
|
resolvedPath,
|
|
@@ -1553,7 +1493,6 @@ async function probeNodeGlobal(
|
|
|
1553
1493
|
// The root the npm first on PATH installs into.
|
|
1554
1494
|
const npmRoot = await s.npmRoot();
|
|
1555
1495
|
if ("dir" in npmRoot) {
|
|
1556
|
-
f.owner = `npm (root ${npmRoot.dir})`;
|
|
1557
1496
|
const { inside, real } = await containedIn(
|
|
1558
1497
|
s,
|
|
1559
1498
|
resolvedPath,
|
|
@@ -1568,7 +1507,7 @@ async function probeNodeGlobal(
|
|
|
1568
1507
|
|
|
1569
1508
|
// Not under that root — but perhaps under ANOTHER npm's. The file's own
|
|
1570
1509
|
// path names the prefix that owns it, and `--prefix` upgrades exactly that
|
|
1571
|
-
// tree, so this is npm-managed, never
|
|
1510
|
+
// tree, so this is npm-managed, never installed elsewhere.
|
|
1572
1511
|
const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
|
|
1573
1512
|
if (prefix === null) {
|
|
1574
1513
|
if ("failure" in npmRoot) {
|
|
@@ -1578,7 +1517,6 @@ async function probeNodeGlobal(
|
|
|
1578
1517
|
return;
|
|
1579
1518
|
}
|
|
1580
1519
|
f.managed = true;
|
|
1581
|
-
f.owner = `npm (prefix ${prefix})`;
|
|
1582
1520
|
// Reached with the default root only when containment could not resolve the
|
|
1583
1521
|
// package directory; the plain command is right then, and --prefix is noise.
|
|
1584
1522
|
if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
|
|
@@ -1652,7 +1590,7 @@ async function isNativeLauncher(
|
|
|
1652
1590
|
* on running the other file at the old version. Every run would find it behind
|
|
1653
1591
|
* and plan the same no-op again. So the check is on `onPath`, the unresolved
|
|
1654
1592
|
* entry, and a copy that is not the launcher falls through to the spec's own
|
|
1655
|
-
* installer — which reports it
|
|
1593
|
+
* installer — which reports it installed elsewhere.
|
|
1656
1594
|
*
|
|
1657
1595
|
* True when it recognised the layout; `f` then holds the whole answer.
|
|
1658
1596
|
*/
|
|
@@ -1685,7 +1623,6 @@ async function probeClaudeNative(
|
|
|
1685
1623
|
|
|
1686
1624
|
const pin = pinOf(spec);
|
|
1687
1625
|
f.managed = true;
|
|
1688
|
-
f.owner = `Claude Code's native installer (${dir})`;
|
|
1689
1626
|
f.installed = version;
|
|
1690
1627
|
addSource(f, "version-dir");
|
|
1691
1628
|
f.upgradeCommand = claudeNativeUpgradeCommand(pin);
|
|
@@ -1723,7 +1660,6 @@ async function probeGo(
|
|
|
1723
1660
|
return;
|
|
1724
1661
|
}
|
|
1725
1662
|
const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
|
|
1726
|
-
f.owner = `go (GOBIN ${realBin})`;
|
|
1727
1663
|
const sameModule =
|
|
1728
1664
|
want === info.module ||
|
|
1729
1665
|
want === info.path ||
|
|
@@ -1776,7 +1712,7 @@ async function probeInto(
|
|
|
1776
1712
|
const via: BinInstaller = spec.via;
|
|
1777
1713
|
switch (via) {
|
|
1778
1714
|
case "brew":
|
|
1779
|
-
await probeBrew(s, spec, f, resolvedPath);
|
|
1715
|
+
await probeBrew(s, spec, f, onPath, resolvedPath);
|
|
1780
1716
|
break;
|
|
1781
1717
|
case "pip":
|
|
1782
1718
|
await probePip(s, spec, f, onPath, resolvedPath, pinned);
|
|
@@ -1790,52 +1726,255 @@ async function probeInto(
|
|
|
1790
1726
|
break;
|
|
1791
1727
|
}
|
|
1792
1728
|
|
|
1793
|
-
// Not the spec's installer's file, or it could not tell
|
|
1794
|
-
//
|
|
1795
|
-
|
|
1796
|
-
|
|
1729
|
+
// Not the spec's installer's file, or it could not tell: perhaps another
|
|
1730
|
+
// installer magus drives has it, and then that installer updates it.
|
|
1731
|
+
if (f.managed !== true)
|
|
1732
|
+
await adoptActualOwner(s, spec, f, onPath, resolvedPath);
|
|
1797
1733
|
|
|
1798
|
-
|
|
1799
|
-
// installed files already say about themselves.
|
|
1800
|
-
if (f.installed === null) versionFromFiles(f, resolvedPath);
|
|
1734
|
+
await countOtherCopies(s, spec.name, f, onPath, resolvedPath);
|
|
1801
1735
|
|
|
1802
|
-
//
|
|
1803
|
-
//
|
|
1804
|
-
|
|
1736
|
+
// The installer's own record was silent — usually because the tool on PATH
|
|
1737
|
+
// came some other way (a pnpm global, a system copy, its own installer).
|
|
1738
|
+
// Ask the running file, through magus's own version command. A tool magus
|
|
1739
|
+
// has no command for shows no version: never a guess.
|
|
1740
|
+
if (f.installed === null) f.installed = await versionFromTable(s, spec, f);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// ── Adoption: the installer that actually has the tool ──────────────────────
|
|
1744
|
+
//
|
|
1745
|
+
// A plugin's `requires` lists install options in preference order for a FRESH
|
|
1746
|
+
// install, and magus plans through the first. That order says how to install a
|
|
1747
|
+
// missing tool, not which installer an installed one must have: a mnemex from
|
|
1748
|
+
// `bun add -g` or from Homebrew has a version, a registry and an upgrade
|
|
1749
|
+
// command like any other. So when the spec's installer does not have the file
|
|
1750
|
+
// PATH runs, magus asks the other installers it can drive, each the same way
|
|
1751
|
+
// its own probe does, and plans the update through the one that has it. Only
|
|
1752
|
+
// a tool none of them has is `external`.
|
|
1753
|
+
|
|
1754
|
+
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1755
|
+
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1756
|
+
return path.join(
|
|
1757
|
+
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1758
|
+
"install",
|
|
1759
|
+
"global",
|
|
1760
|
+
"node_modules",
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
/** The package directory `rel` (relative to a `node_modules`) starts with. */
|
|
1765
|
+
function packageFromSegments(rel: string): string | null {
|
|
1766
|
+
const [first, second] = rel.split(path.sep);
|
|
1767
|
+
if (!first || first.startsWith(".") || first === "..") return null;
|
|
1768
|
+
if (!first.startsWith("@")) return first;
|
|
1769
|
+
return second ? `${first}/${second}` : null;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/** bun's global tree (realpath'd) when `resolvedPath` is inside it, else null. */
|
|
1773
|
+
async function inBunGlobalTree(
|
|
1774
|
+
s: ProbeSession,
|
|
1775
|
+
resolvedPath: string,
|
|
1776
|
+
): Promise<string | null> {
|
|
1777
|
+
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1778
|
+
return bunRoot && isInside(resolvedPath, bunRoot) ? bunRoot : null;
|
|
1805
1779
|
}
|
|
1806
1780
|
|
|
1807
1781
|
/**
|
|
1808
|
-
* The
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
* OWNED before this runs.) Each of those still says what it is. A binary inside
|
|
1815
|
-
* `node_modules` belongs to a package whose `package.json` carries the version;
|
|
1816
|
-
* a versioned install names the file after it. Neither says who OWNS the file,
|
|
1817
|
-
* so `managed` is left as the method found it — the classifier still refuses to
|
|
1818
|
-
* plan an upgrade it cannot land — but the row can show a version instead of
|
|
1819
|
-
* "could not be read".
|
|
1820
|
-
*
|
|
1821
|
-
* Reads the real filesystem rather than the IO seam: `resolvedPath` already
|
|
1822
|
-
* came through the seam's `realpath`, and a path a test invented does not
|
|
1823
|
-
* exist, so this answers null there exactly as a missing record does.
|
|
1782
|
+
* The bun or npm spec whose global tree holds `resolvedPath`, or null. bun's
|
|
1783
|
+
* tree is the one bun installs globals into; an npm prefix is recognised the
|
|
1784
|
+
* way the npm probe recognises one ({@link npmGlobalPrefixOf}, which refuses a
|
|
1785
|
+
* Homebrew keg's private `node_modules`). Either way the package's own
|
|
1786
|
+
* `package.json` is then read by {@link probeNodeGlobal}, as for any bun or
|
|
1787
|
+
* npm spec.
|
|
1824
1788
|
*/
|
|
1825
|
-
function
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
}
|
|
1789
|
+
async function nodeOwnerSpec(
|
|
1790
|
+
s: ProbeSession,
|
|
1791
|
+
spec: ManagedTool,
|
|
1792
|
+
resolvedPath: string,
|
|
1793
|
+
): Promise<ManagedTool | null> {
|
|
1794
|
+
const base = {
|
|
1795
|
+
name: spec.name,
|
|
1796
|
+
...(spec.version ? { version: spec.version } : {}),
|
|
1797
|
+
};
|
|
1798
|
+
const bunRoot = await inBunGlobalTree(s, resolvedPath);
|
|
1799
|
+
if (bunRoot) {
|
|
1800
|
+
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1801
|
+
return pkg && spec.via !== "bun"
|
|
1802
|
+
? { ...base, via: "bun", package: pkg }
|
|
1803
|
+
: null;
|
|
1833
1804
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1805
|
+
if (spec.via === "npm") return null;
|
|
1806
|
+
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1807
|
+
const at = resolvedPath.lastIndexOf(marker);
|
|
1808
|
+
if (at < 0) return null;
|
|
1809
|
+
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1810
|
+
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1811
|
+
? { ...base, via: "npm", package: pkg }
|
|
1812
|
+
: null;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Standalone installers that update their own copy in place, keyed by the app
|
|
1817
|
+
* their receipt names: the arguments after the program, and the PyPI project
|
|
1818
|
+
* that publishes it. uv's own installer writes a receipt and ships
|
|
1819
|
+
* `uv self update`, so a uv from it is as upgradeable as one from Homebrew.
|
|
1820
|
+
*/
|
|
1821
|
+
const SELF_UPDATERS: Readonly<
|
|
1822
|
+
Record<string, { args: readonly string[]; pypi: string }>
|
|
1823
|
+
> = {
|
|
1824
|
+
uv: { args: ["self", "update"], pypi: "uv" },
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
/**
|
|
1828
|
+
* The receipt a standalone installer (cargo-dist's format, which uv's uses)
|
|
1829
|
+
* left for `name`, when it covers `resolvedPath`. The receipt is that
|
|
1830
|
+
* installer's OWN record: it names the folder it installed into
|
|
1831
|
+
* (`install_prefix`) and the programs it put there (`binaries`). It is the
|
|
1832
|
+
* only record such an installer keeps — there is no command to ask — so magus
|
|
1833
|
+
* reads it, and tests the file against the folder the receipt names, exactly
|
|
1834
|
+
* as the npm probe tests against `npm root -g`. A receipt for another folder
|
|
1835
|
+
* is another copy and says nothing about this one.
|
|
1836
|
+
*/
|
|
1837
|
+
async function installerReceipt(
|
|
1838
|
+
s: ProbeSession,
|
|
1839
|
+
name: string,
|
|
1840
|
+
resolvedPath: string,
|
|
1841
|
+
): Promise<{ app: string; version: string } | null> {
|
|
1842
|
+
const config = s.io.env.XDG_CONFIG_HOME || path.join(s.io.homedir, ".config");
|
|
1843
|
+
const body = await s.io.readJson(
|
|
1844
|
+
path.join(config, name, `${name}-receipt.json`),
|
|
1845
|
+
);
|
|
1846
|
+
if (!body || typeof body !== "object") return null;
|
|
1847
|
+
const r = body as {
|
|
1848
|
+
version?: unknown;
|
|
1849
|
+
install_prefix?: unknown;
|
|
1850
|
+
binaries?: unknown;
|
|
1851
|
+
source?: { app_name?: unknown } | null;
|
|
1852
|
+
};
|
|
1853
|
+
if (typeof r.version !== "string" || typeof r.install_prefix !== "string")
|
|
1854
|
+
return null;
|
|
1855
|
+
if (
|
|
1856
|
+
!Array.isArray(r.binaries) ||
|
|
1857
|
+
!r.binaries.includes(path.basename(resolvedPath))
|
|
1858
|
+
)
|
|
1859
|
+
return null;
|
|
1860
|
+
const prefix = (await s.io.realpath(r.install_prefix)) ?? r.install_prefix;
|
|
1861
|
+
if (!isInside(resolvedPath, prefix)) return null;
|
|
1862
|
+
const app = typeof r.source?.app_name === "string" ? r.source.app_name : name;
|
|
1863
|
+
return { app, version: r.version };
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* The file is a standalone installer's: its receipt gives the version, and
|
|
1868
|
+
* when that installer updates itself ({@link SELF_UPDATERS}) it is the owner
|
|
1869
|
+
* `magus update` drives — latest from PyPI, `uv self update [<pin>]`.
|
|
1870
|
+
* Otherwise the tool is installed elsewhere, with the receipt's version.
|
|
1871
|
+
* True when a receipt answered, so no other installer is asked.
|
|
1872
|
+
*/
|
|
1873
|
+
async function adoptOwnInstaller(
|
|
1874
|
+
s: ProbeSession,
|
|
1875
|
+
spec: ManagedTool,
|
|
1876
|
+
f: ToolFacts,
|
|
1877
|
+
resolvedPath: string,
|
|
1878
|
+
): Promise<boolean> {
|
|
1879
|
+
const receipt = await installerReceipt(s, spec.name, resolvedPath);
|
|
1880
|
+
if (!receipt) return false;
|
|
1881
|
+
f.installed = receipt.version;
|
|
1882
|
+
addSource(f, "installer-receipt");
|
|
1883
|
+
const updater = SELF_UPDATERS[receipt.app];
|
|
1884
|
+
if (!updater) {
|
|
1885
|
+
f.managed = false;
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
f.managed = true;
|
|
1889
|
+
f.latest = null;
|
|
1890
|
+
f.behind = null;
|
|
1891
|
+
// What the spec's installer could not answer is moot: the owner is known.
|
|
1892
|
+
f.failure = undefined;
|
|
1893
|
+
const pin = pinOf(spec);
|
|
1894
|
+
f.upgradeCommand = {
|
|
1895
|
+
cmd: spec.name,
|
|
1896
|
+
args: [...updater.args, ...(pin ? [pin] : [])],
|
|
1897
|
+
};
|
|
1898
|
+
if (!pin) await pypiLatest(s, updater.pypi, f);
|
|
1899
|
+
return true;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* How many other files of this name come later on PATH (distinct real files;
|
|
1904
|
+
* a link to the one that runs is the same copy). Only counted: the CLI header
|
|
1905
|
+
* says how many tools have one, and nothing is guessed about whose they are.
|
|
1906
|
+
*/
|
|
1907
|
+
async function countOtherCopies(
|
|
1908
|
+
s: ProbeSession,
|
|
1909
|
+
name: string,
|
|
1910
|
+
f: ToolFacts,
|
|
1911
|
+
onPath: string,
|
|
1912
|
+
resolvedPath: string,
|
|
1913
|
+
): Promise<void> {
|
|
1914
|
+
if (!s.io.resolveAllExecutables) return;
|
|
1915
|
+
const seen = new Set([resolvedPath]);
|
|
1916
|
+
for (const candidate of await s.io.resolveAllExecutables(name)) {
|
|
1917
|
+
if (candidate === onPath) continue;
|
|
1918
|
+
const real = await s.io.realpath(candidate);
|
|
1919
|
+
if (!real || seen.has(real)) continue;
|
|
1920
|
+
seen.add(real);
|
|
1838
1921
|
}
|
|
1922
|
+
if (seen.size > 1) f.otherCopies = seen.size - 1;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Re-read the tool through the installer that has it — bun's or npm's global
|
|
1927
|
+
* tree, else Homebrew — and, when one does, carry its answer and its upgrade
|
|
1928
|
+
* command onto `f`. `magus update` and the TUI then run that installer's
|
|
1929
|
+
* command rather than the spec's.
|
|
1930
|
+
*
|
|
1931
|
+
* Homebrew is ASKED, exactly as for a brew spec ({@link probeBrew}, by the
|
|
1932
|
+
* tool's name): it is credited only when the file PATH runs is one brew lists
|
|
1933
|
+
* as the formula's (`brew list <formula>`) and the version PATH runs, read with
|
|
1934
|
+
* magus's own version command, is one of brew's. A same-version copy brew does
|
|
1935
|
+
* not list is another copy. Not for a pinned spec: brew installs only its
|
|
1936
|
+
* newest version. Not for a file in bun's global tree either: that file is
|
|
1937
|
+
* bun's, whichever package holds it, so brew is never asked about it.
|
|
1938
|
+
*/
|
|
1939
|
+
async function adoptActualOwner(
|
|
1940
|
+
s: ProbeSession,
|
|
1941
|
+
spec: ManagedTool,
|
|
1942
|
+
f: ToolFacts,
|
|
1943
|
+
onPath: string,
|
|
1944
|
+
resolvedPath: string,
|
|
1945
|
+
): Promise<void> {
|
|
1946
|
+
let actual = await nodeOwnerSpec(s, spec, resolvedPath);
|
|
1947
|
+
if (!actual && (await adoptOwnInstaller(s, spec, f, resolvedPath))) return;
|
|
1948
|
+
const read = blankFacts(spec.name);
|
|
1949
|
+
if (actual) {
|
|
1950
|
+
await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1951
|
+
} else if (
|
|
1952
|
+
spec.via !== "brew" &&
|
|
1953
|
+
pinOf(spec) === undefined &&
|
|
1954
|
+
(await inBunGlobalTree(s, resolvedPath)) === null &&
|
|
1955
|
+
// Without a version command nothing could confirm PATH runs brew's
|
|
1956
|
+
// copy, so brew is not even asked.
|
|
1957
|
+
toolVersionCommand(spec.name) !== undefined
|
|
1958
|
+
) {
|
|
1959
|
+
actual = { name: spec.name, via: "brew", formula: spec.name };
|
|
1960
|
+
await probeBrew(s, actual, read, onPath, resolvedPath);
|
|
1961
|
+
if (!read.sources.includes("version-command")) return;
|
|
1962
|
+
}
|
|
1963
|
+
if (!actual || read.managed !== true) return;
|
|
1964
|
+
f.managed = true;
|
|
1965
|
+
f.installed = read.installed;
|
|
1966
|
+
f.latest = read.latest;
|
|
1967
|
+
f.behind = read.behind;
|
|
1968
|
+
if (read.heldBack) f.heldBack = read.heldBack;
|
|
1969
|
+
// What the spec's installer could not answer is moot once the owner is
|
|
1970
|
+
// known: only the owner's own failure describes this tool.
|
|
1971
|
+
f.failure = read.failure;
|
|
1972
|
+
for (const source of read.sources) addSource(f, source);
|
|
1973
|
+
f.upgradeCommand =
|
|
1974
|
+
read.upgradeCommand ??
|
|
1975
|
+
(pinOf(spec)
|
|
1976
|
+
? (binPinCommand(actual) ?? undefined)
|
|
1977
|
+
: binUpgradeCommand(actual));
|
|
1839
1978
|
}
|
|
1840
1979
|
|
|
1841
1980
|
/** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
|
|
@@ -1896,11 +2035,7 @@ export async function probeTools(
|
|
|
1896
2035
|
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
1897
2036
|
const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
|
|
1898
2037
|
const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
|
|
1899
|
-
const session = new ProbeSession(
|
|
1900
|
-
io,
|
|
1901
|
-
timeoutMs,
|
|
1902
|
-
opts.allowCheckCommand === true,
|
|
1903
|
-
);
|
|
2038
|
+
const session = new ProbeSession(io, timeoutMs);
|
|
1904
2039
|
const deadlineAt = Date.now() + deadlineMs;
|
|
1905
2040
|
const results: ToolFacts[] = new Array(specs.length);
|
|
1906
2041
|
let next = 0;
|
|
@@ -1978,7 +2113,7 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
|
|
|
1978
2113
|
* installed", the rule `cliToolUpdate` follows, so the row's state and the
|
|
1979
2114
|
* command Enter offers name the same installer;
|
|
1980
2115
|
* 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
|
|
1981
|
-
* manager that does not own the file, which is reported as `
|
|
2116
|
+
* manager that does not own the file, which is reported as `external`.
|
|
1982
2117
|
*/
|
|
1983
2118
|
export function specForCatalogTool(
|
|
1984
2119
|
tool: CliTool,
|
|
@@ -1992,7 +2127,6 @@ export function specForCatalogTool(
|
|
|
1992
2127
|
name: tool.name,
|
|
1993
2128
|
via: install.primary,
|
|
1994
2129
|
package: tool.packageName,
|
|
1995
|
-
check: tool.checkCommand,
|
|
1996
2130
|
...(install.brewFormula ? { formula: install.brewFormula } : {}),
|
|
1997
2131
|
};
|
|
1998
2132
|
}
|
|
@@ -2000,7 +2134,6 @@ export function specForCatalogTool(
|
|
|
2000
2134
|
name: tool.name,
|
|
2001
2135
|
via: tool.packageManager,
|
|
2002
2136
|
package: tool.packageName,
|
|
2003
|
-
check: tool.checkCommand,
|
|
2004
2137
|
};
|
|
2005
2138
|
}
|
|
2006
2139
|
|