magus-cli 7.10.3 → 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 +38 -35
- 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 -22
- 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 +180 -76
- 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 +537 -691
- package/src/services/toolchain.ts +10 -4
- package/src/services/update-engine.ts +9 -15
- package/src/services/update-plan.ts +49 -21
- package/src/types/index.ts +0 -2
- package/src/ui/adapters/cliToolsAdapter.ts +31 -22
- package/src/ui/renderers/cliToolRenderers.tsx +15 -21
- 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,24 +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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
| "os-version";
|
|
114
|
-
|
|
115
|
-
/** Another copy of the tool on PATH, behind the one that runs. */
|
|
116
|
-
export interface OtherCopy {
|
|
117
|
-
/** The installer as the user knows it: "Homebrew", "uv's own installer". */
|
|
118
|
-
owner: string;
|
|
119
|
-
version: string | null;
|
|
120
|
-
/** The PATH directory it was found in, with the home folder as `~`. */
|
|
121
|
-
dir: string;
|
|
122
|
-
}
|
|
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";
|
|
123
119
|
|
|
124
120
|
export interface ToolFacts {
|
|
125
121
|
name: string;
|
|
@@ -138,26 +134,14 @@ export interface ToolFacts {
|
|
|
138
134
|
/** The owning installer's own verdict; null = it gave none. */
|
|
139
135
|
behind: boolean | null;
|
|
140
136
|
/**
|
|
141
|
-
* The upgrade command for this spec replaces
|
|
142
|
-
* the
|
|
143
|
-
*
|
|
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).
|
|
144
143
|
*/
|
|
145
144
|
managed: boolean | null;
|
|
146
|
-
/**
|
|
147
|
-
* Where the installer keeps what it owns, for the human note — e.g.
|
|
148
|
-
* "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
|
|
149
|
-
*/
|
|
150
|
-
owner?: string;
|
|
151
|
-
/**
|
|
152
|
-
* Who installed the file PATH runs, as the user knows it ("uv's own
|
|
153
|
-
* installer", "a Homebrew cask"), when that is not the spec's installer and
|
|
154
|
-
* magus could name it. Read by the not-managed note.
|
|
155
|
-
*/
|
|
156
|
-
ownerName?: string;
|
|
157
|
-
/** The PATH directory the running copy was found in, home as `~`. */
|
|
158
|
-
pathDir?: string;
|
|
159
|
-
/** Other copies of the tool later on PATH, which nothing runs. */
|
|
160
|
-
otherCopies?: OtherCopy[];
|
|
161
145
|
/** e.g. "held by brew pin". */
|
|
162
146
|
heldBack?: string;
|
|
163
147
|
/**
|
|
@@ -191,22 +175,22 @@ export interface ToolFacts {
|
|
|
191
175
|
failure?: string;
|
|
192
176
|
/** Where each fact came from, for diagnostics. */
|
|
193
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;
|
|
194
184
|
}
|
|
195
185
|
|
|
196
186
|
export interface ToolStatus {
|
|
197
187
|
name: string;
|
|
198
188
|
state: ToolState;
|
|
199
189
|
reason: ToolReason;
|
|
200
|
-
/** Set for every state except plain current/install
|
|
190
|
+
/** Set for every state except plain current/install and `external`. */
|
|
201
191
|
note?: string;
|
|
202
192
|
/** {@link ToolFacts.adopted}, carried when this state plans work. */
|
|
203
193
|
adopted?: string;
|
|
204
|
-
/**
|
|
205
|
-
* Set in every state when a NEWER copy sits later on PATH, unused: e.g.
|
|
206
|
-
* "Homebrew also has 0.13.0, unused: ~/.local/bin comes first on PATH".
|
|
207
|
-
* A warning, because only the user can decide which copy to keep.
|
|
208
|
-
*/
|
|
209
|
-
shadowed?: string;
|
|
210
194
|
installed: string | null;
|
|
211
195
|
latest: string | null;
|
|
212
196
|
target: string | null;
|
|
@@ -257,96 +241,31 @@ function withFailure(text: string, failure: string | undefined): string {
|
|
|
257
241
|
return failure ? `${text} (${failure})` : text;
|
|
258
242
|
}
|
|
259
243
|
|
|
260
|
-
/** The installer as the user knows it. */
|
|
261
|
-
export function installerWord(via: ManagedTool["via"]): string {
|
|
262
|
-
return via === "brew" ? "Homebrew" : via;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** The directories the operating system itself installs programs into. */
|
|
266
|
-
const SYSTEM_DIRS = ["/bin", "/sbin", "/usr/bin", "/usr/sbin", "/System"];
|
|
267
|
-
|
|
268
|
-
function isSystemPath(resolved: string | null): boolean {
|
|
269
|
-
return (
|
|
270
|
-
resolved !== null && SYSTEM_DIRS.some((dir) => isInside(resolved, dir))
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* The `not-managed` sentence, in the user's terms: who did not install it,
|
|
276
|
-
* and what magus does about it. Never the path or the installer's directory:
|
|
277
|
-
* the user acts on neither.
|
|
278
|
-
*/
|
|
279
|
-
function notManagedNote(spec: ManagedTool, facts: ToolFacts): string {
|
|
280
|
-
if (facts.managed !== false) {
|
|
281
|
-
return withFailure(
|
|
282
|
-
`could not tell whether ${installerWord(spec.via)} installed it`,
|
|
283
|
-
facts.failure,
|
|
284
|
-
);
|
|
285
|
-
}
|
|
286
|
-
// Who DID install it. "Outside Homebrew" named the installer magus would
|
|
287
|
-
// have used, which says nothing about the copy the user has.
|
|
288
|
-
const who = isSystemPath(facts.resolvedPath)
|
|
289
|
-
? "comes with the operating system"
|
|
290
|
-
: facts.ownerName
|
|
291
|
-
? `installed by ${facts.ownerName}`
|
|
292
|
-
: "installed by an installer magus does not update";
|
|
293
|
-
const pin = pinOf(spec);
|
|
294
|
-
// The KEPT chip already says magus leaves it as is; the note says only who
|
|
295
|
-
// installed it, and what that means for a pin.
|
|
296
|
-
return pin ? `${who}, so magus cannot move it to the pin ${pin}` : who;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/**
|
|
300
|
-
* The warning for a newer copy later on PATH, or undefined. Only a NEWER one
|
|
301
|
-
* (or any, when the running version is unknown) is worth a line: an older
|
|
302
|
-
* copy changes nothing the user runs.
|
|
303
|
-
*/
|
|
304
|
-
function shadowNote(facts: ToolFacts): string | undefined {
|
|
305
|
-
const running = facts.installed;
|
|
306
|
-
const newer = (facts.otherCopies ?? []).find(
|
|
307
|
-
(copy) =>
|
|
308
|
-
copy.version !== null &&
|
|
309
|
-
(running === null || compareToolVersions(copy.version, running) === 1),
|
|
310
|
-
);
|
|
311
|
-
if (!newer?.version) return undefined;
|
|
312
|
-
const first = facts.pathDir ? `${facts.pathDir} comes first on PATH` : "";
|
|
313
|
-
return `${newer.owner} also has ${newer.version}, unused${first ? `: ${first}` : ""}`;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Another installer's file is `external`, settled, only while nothing is
|
|
318
|
-
* asked of it. With a pin the profile declares a version magus cannot deliver
|
|
319
|
-
* without a second copy, so that is still a warning: `unknown`.
|
|
320
|
-
*/
|
|
321
|
-
function externalOr(pin: string | undefined): ToolState {
|
|
322
|
-
return pin ? "unknown" : "external";
|
|
323
|
-
}
|
|
324
|
-
|
|
325
244
|
/**
|
|
326
245
|
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
327
246
|
* returns, so reading top to bottom is reading the precedence.
|
|
328
247
|
*
|
|
329
|
-
* The vetoes (
|
|
330
|
-
* `upgrade`, pinned or not: an upgrade through an
|
|
331
|
-
* the running file installs a second copy PATH
|
|
332
|
-
* held formula is a no-op. Both would be
|
|
333
|
-
* plan no work (at-pin, unorderable,
|
|
334
|
-
* 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.
|
|
335
255
|
*
|
|
336
256
|
* One ownership answer comes earlier still: a file KNOWN to be nobody's the
|
|
337
|
-
* spec's installer reaches (`managed === false`) is `
|
|
338
|
-
* version could be read.
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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.
|
|
342
267
|
*/
|
|
343
268
|
export function classifyTool(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
344
|
-
const status = classifyState(spec, facts);
|
|
345
|
-
const shadowed = facts.present ? shadowNote(facts) : undefined;
|
|
346
|
-
return shadowed ? { ...status, shadowed } : status;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
350
269
|
const pin = pinOf(spec);
|
|
351
270
|
const base = {
|
|
352
271
|
name: spec.name,
|
|
@@ -354,6 +273,29 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
354
273
|
latest: facts.latest,
|
|
355
274
|
...(pin ? { pin } : {}),
|
|
356
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 };
|
|
357
299
|
|
|
358
300
|
// 1. Not on PATH.
|
|
359
301
|
if (!facts.present) {
|
|
@@ -365,23 +307,35 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
365
307
|
};
|
|
366
308
|
}
|
|
367
309
|
|
|
368
|
-
// 2. Present,
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
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.
|
|
373
315
|
const installed = facts.installed;
|
|
374
316
|
if (installed === null && facts.managed === false) {
|
|
375
317
|
return {
|
|
376
318
|
...base,
|
|
377
|
-
state:
|
|
378
|
-
reason: "
|
|
319
|
+
state: "external",
|
|
320
|
+
reason: "installed-elsewhere",
|
|
379
321
|
target: null,
|
|
380
|
-
note: notManagedNote(spec, facts),
|
|
381
322
|
};
|
|
382
323
|
}
|
|
383
324
|
|
|
384
|
-
// 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.
|
|
385
339
|
if (installed === null) {
|
|
386
340
|
return {
|
|
387
341
|
...base,
|
|
@@ -389,13 +343,13 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
389
343
|
reason: "version-unreadable",
|
|
390
344
|
target: null,
|
|
391
345
|
note: withFailure(
|
|
392
|
-
|
|
346
|
+
"installed, but its version could not be read",
|
|
393
347
|
facts.failure,
|
|
394
348
|
),
|
|
395
349
|
};
|
|
396
350
|
}
|
|
397
351
|
|
|
398
|
-
//
|
|
352
|
+
// 5–7. Pin rules that plan no work.
|
|
399
353
|
const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
|
|
400
354
|
if (pin && vsPin === 0) {
|
|
401
355
|
return { ...base, state: "current", reason: "at-pin", target: null };
|
|
@@ -420,36 +374,37 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
420
374
|
};
|
|
421
375
|
}
|
|
422
376
|
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
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) {
|
|
426
380
|
return {
|
|
427
381
|
...base,
|
|
428
|
-
state:
|
|
429
|
-
reason: "
|
|
382
|
+
state: "external",
|
|
383
|
+
reason: "installed-elsewhere",
|
|
430
384
|
target: null,
|
|
431
|
-
note: notManagedNote(spec, facts),
|
|
432
385
|
};
|
|
433
386
|
}
|
|
434
|
-
|
|
435
|
-
// 8. The veto: the installer will refuse to move it.
|
|
436
|
-
if (facts.heldBack) {
|
|
387
|
+
if (facts.managed === null) {
|
|
437
388
|
return {
|
|
438
389
|
...base,
|
|
439
390
|
state: "unknown",
|
|
440
|
-
reason: "
|
|
391
|
+
reason: "owner-unknown",
|
|
441
392
|
target: null,
|
|
442
|
-
note:
|
|
393
|
+
note: withFailure(
|
|
394
|
+
"could not tell which installer owns it",
|
|
395
|
+
facts.failure,
|
|
396
|
+
),
|
|
443
397
|
};
|
|
444
398
|
}
|
|
445
399
|
|
|
446
|
-
// 9
|
|
447
|
-
|
|
448
|
-
|
|
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).
|
|
449
404
|
if (pin) {
|
|
450
|
-
// Homebrew cannot install a chosen version
|
|
451
|
-
// not always the owner
|
|
452
|
-
// is already aimed at the pin.
|
|
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.
|
|
453
408
|
if (spec.via === "brew" && !facts.upgradeCommand) {
|
|
454
409
|
return {
|
|
455
410
|
...base,
|
|
@@ -469,22 +424,8 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
469
424
|
};
|
|
470
425
|
}
|
|
471
426
|
|
|
472
|
-
//
|
|
473
|
-
if (facts.behind
|
|
474
|
-
return {
|
|
475
|
-
...base,
|
|
476
|
-
...adopted,
|
|
477
|
-
state: "upgrade",
|
|
478
|
-
reason: "behind",
|
|
479
|
-
target: facts.latest,
|
|
480
|
-
note: facts.latest
|
|
481
|
-
? `${facts.latest} is available`
|
|
482
|
-
: "a newer version is available",
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
if (facts.behind === false) {
|
|
486
|
-
return { ...base, state: "current", reason: "up-to-date", target: null };
|
|
487
|
-
}
|
|
427
|
+
// 12. The owning installer's own verdict.
|
|
428
|
+
if (facts.behind !== null) return ownerVerdict(facts.behind);
|
|
488
429
|
|
|
489
430
|
// 13. Nothing to compare against.
|
|
490
431
|
const latest = facts.latest;
|
|
@@ -524,12 +465,25 @@ function classifyState(spec: ManagedTool, facts: ToolFacts): ToolStatus {
|
|
|
524
465
|
|
|
525
466
|
// ── Parsers (pure; tested against captured real output) ──────────────────────
|
|
526
467
|
|
|
527
|
-
/**
|
|
528
|
-
|
|
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[] {
|
|
529
475
|
const line = stdout.split("\n").find((l) => l.trim().length > 0);
|
|
530
|
-
if (!line) return
|
|
531
|
-
|
|
532
|
-
|
|
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+$/, "");
|
|
533
487
|
}
|
|
534
488
|
|
|
535
489
|
/**
|
|
@@ -583,22 +537,6 @@ export function parseBrewOutdatedJson(
|
|
|
583
537
|
};
|
|
584
538
|
}
|
|
585
539
|
|
|
586
|
-
/**
|
|
587
|
-
* The keg version from a realpath under `<cellar>/<formula>/<version>/`
|
|
588
|
-
* (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
|
|
589
|
-
* `brew list --versions` cannot give when several kegs are kept.
|
|
590
|
-
*/
|
|
591
|
-
export function brewKegVersion(
|
|
592
|
-
realPath: string,
|
|
593
|
-
cellar: string,
|
|
594
|
-
formula: string,
|
|
595
|
-
): string | null {
|
|
596
|
-
const prefix = `${path.join(cellar, formula)}${path.sep}`;
|
|
597
|
-
if (!realPath.startsWith(prefix)) return null;
|
|
598
|
-
const segment = realPath.slice(prefix.length).split(path.sep)[0];
|
|
599
|
-
return segment ? segment : null;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
540
|
/** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
|
|
603
541
|
export function normalizePythonPackageName(name: string): string {
|
|
604
542
|
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
@@ -737,17 +675,6 @@ export function versionFromNpmDistTags(body: unknown): string | undefined {
|
|
|
737
675
|
return typeof latest === "string" ? latest : undefined;
|
|
738
676
|
}
|
|
739
677
|
|
|
740
|
-
/**
|
|
741
|
-
* The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
|
|
742
|
-
* "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
|
|
743
|
-
* dot, so a bare number (a year, a count) is not mistaken for a version.
|
|
744
|
-
*/
|
|
745
|
-
export function parseVersionToken(output: string): string | null {
|
|
746
|
-
const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
|
|
747
|
-
if (!m?.[1]) return null;
|
|
748
|
-
return m[1].replace(/[.+-]+$/, "");
|
|
749
|
-
}
|
|
750
|
-
|
|
751
678
|
/** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
|
|
752
679
|
export function isInside(resolved: string, ownerDir: string): boolean {
|
|
753
680
|
const dir = path.resolve(ownerDir);
|
|
@@ -973,14 +900,6 @@ export interface ProbeOptions {
|
|
|
973
900
|
* must use.
|
|
974
901
|
*/
|
|
975
902
|
onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
|
|
976
|
-
/**
|
|
977
|
-
* Run a spec's `check` command when no installer record can give the
|
|
978
|
-
* version. Default FALSE, and every planning caller leaves it so: `check`
|
|
979
|
-
* is a string a third-party `plugin.json` supplied, and planning — which
|
|
980
|
-
* includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
|
|
981
|
-
* See {@link versionFromCheck} for what is still bounded when it is on.
|
|
982
|
-
*/
|
|
983
|
-
allowCheckCommand?: boolean;
|
|
984
903
|
}
|
|
985
904
|
|
|
986
905
|
// ── Probe session: memoised batch and location queries ──────────────────────
|
|
@@ -1008,11 +927,17 @@ function firstLine(text: string): string {
|
|
|
1008
927
|
);
|
|
1009
928
|
}
|
|
1010
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
|
+
*/
|
|
1011
935
|
function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
|
|
1012
936
|
if (r.missing) return `${cmd} is not installed`;
|
|
1013
937
|
if (r.timedOut)
|
|
1014
938
|
return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
1015
|
-
|
|
939
|
+
if (r.stderr.trim()) return `${cmd} reported an error`;
|
|
940
|
+
return `${cmd} exited ${r.code ?? "abnormally"}`;
|
|
1016
941
|
}
|
|
1017
942
|
|
|
1018
943
|
/**
|
|
@@ -1026,8 +951,6 @@ class ProbeSession {
|
|
|
1026
951
|
constructor(
|
|
1027
952
|
readonly io: ToolProbeIO,
|
|
1028
953
|
readonly timeoutMs: number,
|
|
1029
|
-
/** See {@link versionFromCheck}. Off unless the caller opted in. */
|
|
1030
|
-
readonly allowCheckCommand: boolean = false,
|
|
1031
954
|
) {}
|
|
1032
955
|
|
|
1033
956
|
once<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
@@ -1058,12 +981,6 @@ class ProbeSession {
|
|
|
1058
981
|
});
|
|
1059
982
|
}
|
|
1060
983
|
|
|
1061
|
-
brewPrefix(): Promise<Located> {
|
|
1062
|
-
const fromEnv = this.io.env.HOMEBREW_PREFIX;
|
|
1063
|
-
if (fromEnv) return Promise.resolve({ dir: fromEnv });
|
|
1064
|
-
return this.locate("brew --prefix", "brew", ["--prefix"]);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
984
|
uvToolDir(): Promise<Located & { missing?: boolean }> {
|
|
1068
985
|
return this.once("uv tool dir", async () => {
|
|
1069
986
|
const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
|
|
@@ -1135,9 +1052,6 @@ class ProbeSession {
|
|
|
1135
1052
|
|
|
1136
1053
|
// ── Probing one tool ─────────────────────────────────────────────────────────
|
|
1137
1054
|
|
|
1138
|
-
/** Characters that make a check command something only a shell could run. */
|
|
1139
|
-
const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
|
|
1140
|
-
|
|
1141
1055
|
/**
|
|
1142
1056
|
* Refuse a name that could be read as an option, carries a NUL, or (for a
|
|
1143
1057
|
* value joined into a path) climbs out of its directory. Null when fine.
|
|
@@ -1190,62 +1104,45 @@ async function packageJsonVersion(
|
|
|
1190
1104
|
}
|
|
1191
1105
|
|
|
1192
1106
|
/**
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
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.
|
|
1195
1110
|
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1202
|
-
* So the planner does not opt in: a version that could only come from a check
|
|
1203
|
-
* command stays null, which classifies as `unknown / version-unreadable`,
|
|
1204
|
-
* never as an upgrade. The one caller that opts in is the post-install
|
|
1205
|
-
* read-back (`update-engine.ts`), where the user has already consented to run
|
|
1206
|
-
* 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.
|
|
1207
1117
|
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
* refuses the whole string);
|
|
1211
|
-
* - argv[0] must be the tool itself — its name, its path on PATH, or the
|
|
1212
|
-
* realpath behind it. The probe's purpose is "ask the running file its
|
|
1213
|
-
* version", so anything else is refused unrun, and the version reported can
|
|
1214
|
-
* only have come from the binary `resolvedPath` names;
|
|
1215
|
-
* - the session's `timeoutMs` (8s by default), through the injected IO seam,
|
|
1216
|
-
* 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.
|
|
1217
1120
|
*/
|
|
1218
|
-
|
|
1121
|
+
function versionFromTable(
|
|
1219
1122
|
s: ProbeSession,
|
|
1220
1123
|
spec: ManagedTool,
|
|
1221
1124
|
f: ToolFacts,
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
if (!
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1244
|
-
const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
|
|
1245
|
-
if (v) {
|
|
1246
|
-
f.installed = v;
|
|
1247
|
-
addSource(f, "check-command");
|
|
1248
|
-
}
|
|
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
|
+
});
|
|
1249
1146
|
}
|
|
1250
1147
|
|
|
1251
1148
|
/** The npm registry's `latest` dist-tag for a package. */
|
|
@@ -1304,31 +1201,127 @@ async function containedIn(
|
|
|
1304
1201
|
return { inside: real !== null && isInside(resolvedPath, real), real };
|
|
1305
1202
|
}
|
|
1306
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
|
+
*/
|
|
1307
1261
|
async function probeBrew(
|
|
1308
1262
|
s: ProbeSession,
|
|
1309
1263
|
spec: ManagedTool,
|
|
1310
1264
|
f: ToolFacts,
|
|
1265
|
+
pathEntry: string,
|
|
1311
1266
|
resolvedPath: string,
|
|
1312
1267
|
): Promise<void> {
|
|
1313
1268
|
const formula = spec.formula ?? spec.name;
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
+
) {
|
|
1319
1280
|
f.managed = null;
|
|
1320
|
-
noteFailure(f,
|
|
1281
|
+
noteFailure(f, execFailure("brew", list, s.timeoutMs));
|
|
1321
1282
|
return;
|
|
1322
1283
|
}
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
f
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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
|
+
}
|
|
1332
1325
|
|
|
1333
1326
|
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
1334
1327
|
if (r.code !== 0) {
|
|
@@ -1360,7 +1353,6 @@ async function probePip(
|
|
|
1360
1353
|
const uvDir = await s.uvToolDir();
|
|
1361
1354
|
if ("dir" in uvDir) {
|
|
1362
1355
|
const ownerDir = path.join(uvDir.dir, norm);
|
|
1363
|
-
f.owner = `uv (tool dir ${uvDir.dir})`;
|
|
1364
1356
|
const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
|
|
1365
1357
|
// Adoption: `uv tool install --force` overwrites the very file PATH runs
|
|
1366
1358
|
// when it sits in uv's bin dir, whoever put it there.
|
|
@@ -1374,7 +1366,7 @@ async function probePip(
|
|
|
1374
1366
|
// plan row is an ordinary upgrade, so without this it never says that
|
|
1375
1367
|
// the owning installer changes — and pipx, say, goes on claiming a
|
|
1376
1368
|
// venv nothing runs until its next `upgrade-all` writes back over it.
|
|
1377
|
-
f.adopted =
|
|
1369
|
+
f.adopted = "installed another way; upgrading through uv takes it over";
|
|
1378
1370
|
addSource(f, "uv-adopted");
|
|
1379
1371
|
}
|
|
1380
1372
|
|
|
@@ -1444,7 +1436,6 @@ async function probePip(
|
|
|
1444
1436
|
const binDir =
|
|
1445
1437
|
s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
|
|
1446
1438
|
const ownerDir = path.join(home, "venvs", norm);
|
|
1447
|
-
f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
|
|
1448
1439
|
const { inside } = await containedIn(s, resolvedPath, ownerDir);
|
|
1449
1440
|
f.managed =
|
|
1450
1441
|
inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
|
|
@@ -1477,330 +1468,6 @@ async function readNodePackage(
|
|
|
1477
1468
|
if (!pinned) await npmLatest(s, pkg, f);
|
|
1478
1469
|
}
|
|
1479
1470
|
|
|
1480
|
-
/** Where `bun add -g` puts packages: `$BUN_INSTALL/install/global/node_modules`. */
|
|
1481
|
-
function bunGlobalRoot(io: ToolProbeIO): string {
|
|
1482
|
-
return path.join(
|
|
1483
|
-
io.env.BUN_INSTALL ?? path.join(io.homedir, ".bun"),
|
|
1484
|
-
"install",
|
|
1485
|
-
"global",
|
|
1486
|
-
"node_modules",
|
|
1487
|
-
);
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
/** The package a path names, from its segments under `node_modules`. */
|
|
1491
|
-
function packageFromSegments(rel: string): string | null {
|
|
1492
|
-
const [first, second] = rel.split(path.sep);
|
|
1493
|
-
if (!first || first.startsWith(".")) return null;
|
|
1494
|
-
if (!first.startsWith("@")) return first;
|
|
1495
|
-
return second ? `${first}/${second}` : null;
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
/**
|
|
1499
|
-
* The bun or npm global package that holds `resolvedPath`, read from the path
|
|
1500
|
-
* alone. Null when the file lies in neither tree (a Homebrew keg included:
|
|
1501
|
-
* `npmGlobalPrefixOf` refuses one).
|
|
1502
|
-
*/
|
|
1503
|
-
async function nodeGlobalOwner(
|
|
1504
|
-
s: ProbeSession,
|
|
1505
|
-
resolvedPath: string,
|
|
1506
|
-
): Promise<{ via: "bun" | "npm"; pkg: string } | null> {
|
|
1507
|
-
const bunRoot = await s.io.realpath(bunGlobalRoot(s.io));
|
|
1508
|
-
if (bunRoot && isInside(resolvedPath, bunRoot)) {
|
|
1509
|
-
const pkg = packageFromSegments(path.relative(bunRoot, resolvedPath));
|
|
1510
|
-
return pkg ? { via: "bun", pkg } : null;
|
|
1511
|
-
}
|
|
1512
|
-
const marker = `${path.sep}${path.join("lib", "node_modules")}${path.sep}`;
|
|
1513
|
-
const at = resolvedPath.lastIndexOf(marker);
|
|
1514
|
-
if (at < 0) return null;
|
|
1515
|
-
const pkg = packageFromSegments(resolvedPath.slice(at + marker.length));
|
|
1516
|
-
return pkg && npmGlobalPrefixOf(resolvedPath, pkg) !== null
|
|
1517
|
-
? { via: "npm", pkg }
|
|
1518
|
-
: null;
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
/**
|
|
1522
|
-
* The formula whose keg holds `resolvedPath` (`…/Cellar/<formula>/<version>/…`),
|
|
1523
|
-
* read from the path alone. `probeBrew` then checks that this Cellar is brew's.
|
|
1524
|
-
*/
|
|
1525
|
-
function kegFormulaOf(resolvedPath: string): string | null {
|
|
1526
|
-
const parts = resolvedPath.split(path.sep);
|
|
1527
|
-
const at = parts.lastIndexOf("Cellar");
|
|
1528
|
-
return at >= 0 && parts.length > at + 2 ? (parts[at + 1] ?? null) : null;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
/**
|
|
1532
|
-
* The spec as the installer that actually holds `resolvedPath` would state it,
|
|
1533
|
-
* or null when that is the spec's own installer or none magus drives.
|
|
1534
|
-
*/
|
|
1535
|
-
async function actualOwnerSpec(
|
|
1536
|
-
s: ProbeSession,
|
|
1537
|
-
spec: ManagedTool,
|
|
1538
|
-
resolvedPath: string,
|
|
1539
|
-
): Promise<ManagedTool | null> {
|
|
1540
|
-
const base = {
|
|
1541
|
-
name: spec.name,
|
|
1542
|
-
...(spec.check ? { check: spec.check } : {}),
|
|
1543
|
-
...(spec.version ? { version: spec.version } : {}),
|
|
1544
|
-
};
|
|
1545
|
-
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1546
|
-
if (node) {
|
|
1547
|
-
return node.via === spec.via
|
|
1548
|
-
? null
|
|
1549
|
-
: { ...base, via: node.via, package: node.pkg };
|
|
1550
|
-
}
|
|
1551
|
-
const formula = kegFormulaOf(resolvedPath);
|
|
1552
|
-
// Homebrew takes no version, so it cannot honour a pin: such a tool stays
|
|
1553
|
-
// "installed outside", which the classifier reports as unknown.
|
|
1554
|
-
if (!formula || spec.via === "brew" || spec.version) return null;
|
|
1555
|
-
return { ...base, via: "brew", formula };
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
/**
|
|
1559
|
-
* The spec's installer does not own the file PATH runs, but bun's or npm's
|
|
1560
|
-
* global tree or a Homebrew keg does: re-read the tool through THAT installer.
|
|
1561
|
-
*
|
|
1562
|
-
* A plugin's `requires` lists its install options in preference order for a
|
|
1563
|
-
* FRESH install, and magus plans through the first. That order says how to
|
|
1564
|
-
* install a missing tool, not which owner an installed one must have. A
|
|
1565
|
-
* mnemex from `bun add -g` or from Homebrew has a version, a registry and an
|
|
1566
|
-
* upgrade command like any other. Reading it as "installed outside" the
|
|
1567
|
-
* first option's installer showed a settled, upgradeable tool as one magus
|
|
1568
|
-
* could not touch.
|
|
1569
|
-
*
|
|
1570
|
-
* The catalogue's tools already follow how a tool IS installed
|
|
1571
|
-
* (`specForCatalogTool`); this gives a plugin's requirements the same rule.
|
|
1572
|
-
* The upgrade command is carried on the facts, so `magus update` and the TUI
|
|
1573
|
-
* run the owner's command rather than the spec's.
|
|
1574
|
-
*/
|
|
1575
|
-
async function adoptActualOwner(
|
|
1576
|
-
s: ProbeSession,
|
|
1577
|
-
spec: ManagedTool,
|
|
1578
|
-
f: ToolFacts,
|
|
1579
|
-
resolvedPath: string,
|
|
1580
|
-
): Promise<void> {
|
|
1581
|
-
const actual = await actualOwnerSpec(s, spec, resolvedPath);
|
|
1582
|
-
if (!actual) return;
|
|
1583
|
-
const read = blankFacts(spec.name);
|
|
1584
|
-
if (actual.via === "brew") await probeBrew(s, actual, read, resolvedPath);
|
|
1585
|
-
else await probeNodeGlobal(s, actual, read, resolvedPath);
|
|
1586
|
-
if (read.managed !== true) return;
|
|
1587
|
-
f.managed = true;
|
|
1588
|
-
f.owner = read.owner;
|
|
1589
|
-
f.installed = read.installed;
|
|
1590
|
-
f.latest = read.latest;
|
|
1591
|
-
f.behind = read.behind;
|
|
1592
|
-
// What the spec's installer could not answer is moot once the owner is
|
|
1593
|
-
// known: only the owner's own failure describes this tool.
|
|
1594
|
-
f.failure = read.failure;
|
|
1595
|
-
for (const source of read.sources) addSource(f, source);
|
|
1596
|
-
f.upgradeCommand =
|
|
1597
|
-
read.upgradeCommand ??
|
|
1598
|
-
(spec.version
|
|
1599
|
-
? (binPinCommand(actual) ?? undefined)
|
|
1600
|
-
: binUpgradeCommand(actual));
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
/**
|
|
1604
|
-
* Standalone installers that update their own copy in place, keyed by the
|
|
1605
|
-
* app their receipt names: the arguments after the program, and the PyPI
|
|
1606
|
-
* project that publishes it. uv's own installer writes a receipt and ships
|
|
1607
|
-
* `uv self update`, so a uv from it is as upgradeable as one from Homebrew.
|
|
1608
|
-
*/
|
|
1609
|
-
const SELF_UPDATERS: Readonly<
|
|
1610
|
-
Record<string, { args: readonly string[]; pypi: string }>
|
|
1611
|
-
> = {
|
|
1612
|
-
uv: { args: ["self", "update"], pypi: "uv" },
|
|
1613
|
-
};
|
|
1614
|
-
|
|
1615
|
-
interface InstallerReceipt {
|
|
1616
|
-
app: string;
|
|
1617
|
-
version: string;
|
|
1618
|
-
file: string;
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
/**
|
|
1622
|
-
* The receipt a standalone installer (cargo-dist's format, which uv's uses)
|
|
1623
|
-
* left for `name`, when it covers `resolvedPath`:
|
|
1624
|
-
* `$XDG_CONFIG_HOME/<name>/<name>-receipt.json`, whose `install_prefix` holds
|
|
1625
|
-
* the file and whose `binaries` list it. A receipt for another folder is a
|
|
1626
|
-
* different copy and says nothing about this one.
|
|
1627
|
-
*/
|
|
1628
|
-
async function installerReceipt(
|
|
1629
|
-
s: ProbeSession,
|
|
1630
|
-
name: string,
|
|
1631
|
-
resolvedPath: string,
|
|
1632
|
-
): Promise<InstallerReceipt | null> {
|
|
1633
|
-
const config = s.io.env.XDG_CONFIG_HOME || path.join(s.io.homedir, ".config");
|
|
1634
|
-
const file = path.join(config, name, `${name}-receipt.json`);
|
|
1635
|
-
const body = await s.io.readJson(file);
|
|
1636
|
-
if (!body || typeof body !== "object") return null;
|
|
1637
|
-
const r = body as {
|
|
1638
|
-
version?: unknown;
|
|
1639
|
-
install_prefix?: unknown;
|
|
1640
|
-
binaries?: unknown;
|
|
1641
|
-
source?: { app_name?: unknown } | null;
|
|
1642
|
-
};
|
|
1643
|
-
if (typeof r.version !== "string" || typeof r.install_prefix !== "string")
|
|
1644
|
-
return null;
|
|
1645
|
-
if (
|
|
1646
|
-
!Array.isArray(r.binaries) ||
|
|
1647
|
-
!r.binaries.includes(path.basename(resolvedPath))
|
|
1648
|
-
)
|
|
1649
|
-
return null;
|
|
1650
|
-
const prefix = (await s.io.realpath(r.install_prefix)) ?? r.install_prefix;
|
|
1651
|
-
if (!isInside(resolvedPath, prefix)) return null;
|
|
1652
|
-
const app = typeof r.source?.app_name === "string" ? r.source.app_name : name;
|
|
1653
|
-
return { app, version: r.version, file };
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
/**
|
|
1657
|
-
* The file is a standalone installer's: its receipt gives the version, and
|
|
1658
|
-
* when that installer updates itself ({@link SELF_UPDATERS}) it is the owner
|
|
1659
|
-
* `magus update` drives. Otherwise the tool stays KEPT, but its row can say
|
|
1660
|
-
* who installed it and which version runs.
|
|
1661
|
-
*/
|
|
1662
|
-
async function adoptOwnInstaller(
|
|
1663
|
-
s: ProbeSession,
|
|
1664
|
-
spec: ManagedTool,
|
|
1665
|
-
f: ToolFacts,
|
|
1666
|
-
resolvedPath: string,
|
|
1667
|
-
): Promise<void> {
|
|
1668
|
-
const receipt = await installerReceipt(s, spec.name, resolvedPath);
|
|
1669
|
-
if (!receipt) return;
|
|
1670
|
-
const ownerName = `${receipt.app}'s own installer`;
|
|
1671
|
-
f.installed = receipt.version;
|
|
1672
|
-
addSource(f, "installer-receipt");
|
|
1673
|
-
const updater = SELF_UPDATERS[receipt.app];
|
|
1674
|
-
if (!updater) {
|
|
1675
|
-
f.managed = false;
|
|
1676
|
-
f.ownerName = ownerName;
|
|
1677
|
-
return;
|
|
1678
|
-
}
|
|
1679
|
-
f.managed = true;
|
|
1680
|
-
f.owner = `${ownerName} (receipt ${receipt.file})`;
|
|
1681
|
-
f.latest = null;
|
|
1682
|
-
f.behind = null;
|
|
1683
|
-
// What the spec's installer could not answer is moot: the owner is known.
|
|
1684
|
-
f.failure = undefined;
|
|
1685
|
-
const pin = pinOf(spec);
|
|
1686
|
-
f.upgradeCommand = {
|
|
1687
|
-
cmd: spec.name,
|
|
1688
|
-
args: [...updater.args, ...(pin ? [pin] : [])],
|
|
1689
|
-
};
|
|
1690
|
-
if (!pin) await pypiLatest(s, updater.pypi, f);
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
/**
|
|
1694
|
-
* Who installed a file no installer magus drives owns, as the user knows it,
|
|
1695
|
-
* or undefined when the path does not say.
|
|
1696
|
-
*/
|
|
1697
|
-
async function nameOwner(
|
|
1698
|
-
s: ProbeSession,
|
|
1699
|
-
resolvedPath: string,
|
|
1700
|
-
): Promise<string | undefined> {
|
|
1701
|
-
if (resolvedPath.split(path.sep).includes("Caskroom"))
|
|
1702
|
-
return "a Homebrew cask";
|
|
1703
|
-
if (kegFormulaOf(resolvedPath)) return "Homebrew";
|
|
1704
|
-
const node = await nodeGlobalOwner(s, resolvedPath);
|
|
1705
|
-
if (node) return node.via;
|
|
1706
|
-
const cargoHome = s.io.env.CARGO_HOME || path.join(s.io.homedir, ".cargo");
|
|
1707
|
-
if (isInside(resolvedPath, path.join(cargoHome, "bin"))) return "cargo";
|
|
1708
|
-
return undefined;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
/**
|
|
1712
|
-
* Where an operating-system program is safe to run for `--version`. Not
|
|
1713
|
-
* macOS's /usr/bin: several programs there are Xcode stubs that open an
|
|
1714
|
-
* install dialog when run on a machine without the developer tools.
|
|
1715
|
-
*/
|
|
1716
|
-
function osVersionDirs(): string[] {
|
|
1717
|
-
return process.platform === "darwin" ? ["/bin", "/sbin"] : SYSTEM_DIRS;
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
/**
|
|
1721
|
-
* The version of a program the operating system ships, from its own
|
|
1722
|
-
* `--version`. No installer keeps a record of these, and the file is the OS
|
|
1723
|
-
* vendor's, not a string a catalogue wrote, so running it is safe.
|
|
1724
|
-
*/
|
|
1725
|
-
async function versionFromOs(
|
|
1726
|
-
s: ProbeSession,
|
|
1727
|
-
f: ToolFacts,
|
|
1728
|
-
resolvedPath: string,
|
|
1729
|
-
): Promise<void> {
|
|
1730
|
-
if (!osVersionDirs().some((dir) => isInside(resolvedPath, dir))) return;
|
|
1731
|
-
const r = await s.run(resolvedPath, ["--version"], PLAIN_ENV);
|
|
1732
|
-
if (r.missing || r.timedOut) return;
|
|
1733
|
-
const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
|
|
1734
|
-
if (v) {
|
|
1735
|
-
f.installed = v;
|
|
1736
|
-
addSource(f, "os-version");
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
/** `dir` with the home folder written as `~`, for a line the user reads. */
|
|
1741
|
-
function homeShort(io: ToolProbeIO, dir: string): string {
|
|
1742
|
-
return isInside(dir, io.homedir)
|
|
1743
|
-
? path.join("~", path.relative(io.homedir, dir))
|
|
1744
|
-
: dir;
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
/** Owner and version of a copy PATH does not run, read from its path. */
|
|
1748
|
-
async function describeCopy(
|
|
1749
|
-
s: ProbeSession,
|
|
1750
|
-
name: string,
|
|
1751
|
-
real: string,
|
|
1752
|
-
): Promise<{ owner: string; version: string | null }> {
|
|
1753
|
-
const parts = real.split(path.sep);
|
|
1754
|
-
for (const [marker, owner] of [
|
|
1755
|
-
["Cellar", "Homebrew"],
|
|
1756
|
-
["Caskroom", "a Homebrew cask"],
|
|
1757
|
-
] as const) {
|
|
1758
|
-
const at = parts.lastIndexOf(marker);
|
|
1759
|
-
if (at >= 0 && parts.length > at + 2)
|
|
1760
|
-
return { owner, version: parts[at + 2] ?? null };
|
|
1761
|
-
}
|
|
1762
|
-
const receipt = await installerReceipt(s, name, real);
|
|
1763
|
-
if (receipt)
|
|
1764
|
-
return {
|
|
1765
|
-
owner: `${receipt.app}'s own installer`,
|
|
1766
|
-
version: receipt.version,
|
|
1767
|
-
};
|
|
1768
|
-
const node = await nodeGlobalOwner(s, real);
|
|
1769
|
-
if (node) return { owner: node.via, version: readPackageVersionForBin(real) };
|
|
1770
|
-
if (isSystemPath(real))
|
|
1771
|
-
return { owner: "the operating system", version: null };
|
|
1772
|
-
return { owner: "another installer", version: null };
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
/**
|
|
1776
|
-
* The other copies of the tool on PATH. The first one wins, and whatever
|
|
1777
|
-
* comes later is never run: a newer copy there is a surprise only the user
|
|
1778
|
-
* can settle, by removing one or reordering PATH.
|
|
1779
|
-
*/
|
|
1780
|
-
async function findOtherCopies(
|
|
1781
|
-
s: ProbeSession,
|
|
1782
|
-
name: string,
|
|
1783
|
-
f: ToolFacts,
|
|
1784
|
-
onPath: string,
|
|
1785
|
-
resolvedPath: string,
|
|
1786
|
-
): Promise<void> {
|
|
1787
|
-
f.pathDir = homeShort(s.io, path.dirname(onPath));
|
|
1788
|
-
if (!s.io.resolveAllExecutables) return;
|
|
1789
|
-
const seen = new Set([resolvedPath]);
|
|
1790
|
-
const copies: OtherCopy[] = [];
|
|
1791
|
-
for (const candidate of await s.io.resolveAllExecutables(name)) {
|
|
1792
|
-
if (candidate === onPath) continue;
|
|
1793
|
-
const real = await s.io.realpath(candidate);
|
|
1794
|
-
if (!real || seen.has(real)) continue;
|
|
1795
|
-
seen.add(real);
|
|
1796
|
-
copies.push({
|
|
1797
|
-
...(await describeCopy(s, name, real)),
|
|
1798
|
-
dir: homeShort(s.io, path.dirname(candidate)),
|
|
1799
|
-
});
|
|
1800
|
-
}
|
|
1801
|
-
if (copies.length > 0) f.otherCopies = copies;
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
1471
|
async function probeNodeGlobal(
|
|
1805
1472
|
s: ProbeSession,
|
|
1806
1473
|
spec: ManagedTool,
|
|
@@ -1812,7 +1479,6 @@ async function probeNodeGlobal(
|
|
|
1812
1479
|
const pinned = pin !== undefined;
|
|
1813
1480
|
if (spec.via === "bun") {
|
|
1814
1481
|
const root = bunGlobalRoot(s.io);
|
|
1815
|
-
f.owner = `bun (global ${root})`;
|
|
1816
1482
|
const { inside, real } = await containedIn(
|
|
1817
1483
|
s,
|
|
1818
1484
|
resolvedPath,
|
|
@@ -1827,7 +1493,6 @@ async function probeNodeGlobal(
|
|
|
1827
1493
|
// The root the npm first on PATH installs into.
|
|
1828
1494
|
const npmRoot = await s.npmRoot();
|
|
1829
1495
|
if ("dir" in npmRoot) {
|
|
1830
|
-
f.owner = `npm (root ${npmRoot.dir})`;
|
|
1831
1496
|
const { inside, real } = await containedIn(
|
|
1832
1497
|
s,
|
|
1833
1498
|
resolvedPath,
|
|
@@ -1842,7 +1507,7 @@ async function probeNodeGlobal(
|
|
|
1842
1507
|
|
|
1843
1508
|
// Not under that root — but perhaps under ANOTHER npm's. The file's own
|
|
1844
1509
|
// path names the prefix that owns it, and `--prefix` upgrades exactly that
|
|
1845
|
-
// tree, so this is npm-managed, never
|
|
1510
|
+
// tree, so this is npm-managed, never installed elsewhere.
|
|
1846
1511
|
const prefix = npmGlobalPrefixOf(resolvedPath, pkg);
|
|
1847
1512
|
if (prefix === null) {
|
|
1848
1513
|
if ("failure" in npmRoot) {
|
|
@@ -1852,7 +1517,6 @@ async function probeNodeGlobal(
|
|
|
1852
1517
|
return;
|
|
1853
1518
|
}
|
|
1854
1519
|
f.managed = true;
|
|
1855
|
-
f.owner = `npm (prefix ${prefix})`;
|
|
1856
1520
|
// Reached with the default root only when containment could not resolve the
|
|
1857
1521
|
// package directory; the plain command is right then, and --prefix is noise.
|
|
1858
1522
|
if (!(await isNpmDefaultPrefix(s, npmRoot, prefix))) {
|
|
@@ -1926,7 +1590,7 @@ async function isNativeLauncher(
|
|
|
1926
1590
|
* on running the other file at the old version. Every run would find it behind
|
|
1927
1591
|
* and plan the same no-op again. So the check is on `onPath`, the unresolved
|
|
1928
1592
|
* entry, and a copy that is not the launcher falls through to the spec's own
|
|
1929
|
-
* installer — which reports it
|
|
1593
|
+
* installer — which reports it installed elsewhere.
|
|
1930
1594
|
*
|
|
1931
1595
|
* True when it recognised the layout; `f` then holds the whole answer.
|
|
1932
1596
|
*/
|
|
@@ -1959,7 +1623,6 @@ async function probeClaudeNative(
|
|
|
1959
1623
|
|
|
1960
1624
|
const pin = pinOf(spec);
|
|
1961
1625
|
f.managed = true;
|
|
1962
|
-
f.owner = `Claude Code's native installer (${dir})`;
|
|
1963
1626
|
f.installed = version;
|
|
1964
1627
|
addSource(f, "version-dir");
|
|
1965
1628
|
f.upgradeCommand = claudeNativeUpgradeCommand(pin);
|
|
@@ -1997,7 +1660,6 @@ async function probeGo(
|
|
|
1997
1660
|
return;
|
|
1998
1661
|
}
|
|
1999
1662
|
const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
|
|
2000
|
-
f.owner = `go (GOBIN ${realBin})`;
|
|
2001
1663
|
const sameModule =
|
|
2002
1664
|
want === info.module ||
|
|
2003
1665
|
want === info.path ||
|
|
@@ -2050,7 +1712,7 @@ async function probeInto(
|
|
|
2050
1712
|
const via: BinInstaller = spec.via;
|
|
2051
1713
|
switch (via) {
|
|
2052
1714
|
case "brew":
|
|
2053
|
-
await probeBrew(s, spec, f, resolvedPath);
|
|
1715
|
+
await probeBrew(s, spec, f, onPath, resolvedPath);
|
|
2054
1716
|
break;
|
|
2055
1717
|
case "pip":
|
|
2056
1718
|
await probePip(s, spec, f, onPath, resolvedPath, pinned);
|
|
@@ -2064,65 +1726,255 @@ async function probeInto(
|
|
|
2064
1726
|
break;
|
|
2065
1727
|
}
|
|
2066
1728
|
|
|
2067
|
-
// Not the spec's installer's file, or it could not tell
|
|
2068
|
-
//
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
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);
|
|
1733
|
+
|
|
1734
|
+
await countOtherCopies(s, spec.name, f, onPath, resolvedPath);
|
|
1735
|
+
|
|
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`.
|
|
2073
1753
|
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
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
|
+
}
|
|
2077
1763
|
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
+
}
|
|
2080
1771
|
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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;
|
|
1779
|
+
}
|
|
2084
1780
|
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
1781
|
+
/**
|
|
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.
|
|
1788
|
+
*/
|
|
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;
|
|
2089
1804
|
}
|
|
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
|
+
};
|
|
2090
1826
|
|
|
2091
|
-
|
|
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 };
|
|
2092
1864
|
}
|
|
2093
1865
|
|
|
2094
1866
|
/**
|
|
2095
|
-
* The
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2100
|
-
* under another prefix and Claude Code's native installer are recognised as
|
|
2101
|
-
* OWNED before this runs.) Each of those still says what it is. A binary inside
|
|
2102
|
-
* `node_modules` belongs to a package whose `package.json` carries the version;
|
|
2103
|
-
* a versioned install names the file after it. Neither says who OWNS the file,
|
|
2104
|
-
* so `managed` is left as the method found it — the classifier still refuses to
|
|
2105
|
-
* plan an upgrade it cannot land — but the row can show a version instead of
|
|
2106
|
-
* "could not be read".
|
|
2107
|
-
*
|
|
2108
|
-
* Reads the real filesystem rather than the IO seam: `resolvedPath` already
|
|
2109
|
-
* came through the seam's `realpath`, and a path a test invented does not
|
|
2110
|
-
* exist, so this answers null there exactly as a missing record does.
|
|
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.
|
|
2111
1872
|
*/
|
|
2112
|
-
function
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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;
|
|
2120
1887
|
}
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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);
|
|
2125
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));
|
|
2126
1978
|
}
|
|
2127
1979
|
|
|
2128
1980
|
/** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
|
|
@@ -2183,11 +2035,7 @@ export async function probeTools(
|
|
|
2183
2035
|
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
2184
2036
|
const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
|
|
2185
2037
|
const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
|
|
2186
|
-
const session = new ProbeSession(
|
|
2187
|
-
io,
|
|
2188
|
-
timeoutMs,
|
|
2189
|
-
opts.allowCheckCommand === true,
|
|
2190
|
-
);
|
|
2038
|
+
const session = new ProbeSession(io, timeoutMs);
|
|
2191
2039
|
const deadlineAt = Date.now() + deadlineMs;
|
|
2192
2040
|
const results: ToolFacts[] = new Array(specs.length);
|
|
2193
2041
|
let next = 0;
|
|
@@ -2265,7 +2113,7 @@ function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
|
|
|
2265
2113
|
* installed", the rule `cliToolUpdate` follows, so the row's state and the
|
|
2266
2114
|
* command Enter offers name the same installer;
|
|
2267
2115
|
* 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
|
|
2268
|
-
* manager that does not own the file, which is reported as `
|
|
2116
|
+
* manager that does not own the file, which is reported as `external`.
|
|
2269
2117
|
*/
|
|
2270
2118
|
export function specForCatalogTool(
|
|
2271
2119
|
tool: CliTool,
|
|
@@ -2279,7 +2127,6 @@ export function specForCatalogTool(
|
|
|
2279
2127
|
name: tool.name,
|
|
2280
2128
|
via: install.primary,
|
|
2281
2129
|
package: tool.packageName,
|
|
2282
|
-
check: tool.checkCommand,
|
|
2283
2130
|
...(install.brewFormula ? { formula: install.brewFormula } : {}),
|
|
2284
2131
|
};
|
|
2285
2132
|
}
|
|
@@ -2287,7 +2134,6 @@ export function specForCatalogTool(
|
|
|
2287
2134
|
name: tool.name,
|
|
2288
2135
|
via: tool.packageManager,
|
|
2289
2136
|
package: tool.packageName,
|
|
2290
|
-
check: tool.checkCommand,
|
|
2291
2137
|
};
|
|
2292
2138
|
}
|
|
2293
2139
|
|