claudeup 6.5.1 → 6.7.1
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__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +2 -3
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/models-presets-marketplace.test.ts +141 -0
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/data/models-presets.ts +10 -11
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +2 -174
- package/src/services/community-styles.ts +1 -1
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
|
@@ -0,0 +1,1637 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-probe.ts — the ONE place a CLI tool's state is read and decided.
|
|
3
|
+
*
|
|
4
|
+
* `claudeup update` planned every present, unpinned tool as an UPGRADE on every
|
|
5
|
+
* run, because nothing could tell it what version was installed or whether a
|
|
6
|
+
* newer one existed — only present/absent. The TUI's CLI Tools screen had its
|
|
7
|
+
* own private probe (a shell `which`, an `npm view` per tool, and a third
|
|
8
|
+
* version comparator that read "1.2.3-beta" as "1.2.3"). The two front ends
|
|
9
|
+
* disagreed, and the CLI never converged: the second run planned exactly what
|
|
10
|
+
* the first had just "done".
|
|
11
|
+
*
|
|
12
|
+
* Now both front ends read tools through here, in two halves:
|
|
13
|
+
*
|
|
14
|
+
* - {@link probeTools} gathers FACTS through an injected {@link ToolProbeIO}.
|
|
15
|
+
* Each tool is read through the installer that owns the binary PATH actually
|
|
16
|
+
* runs, and `managed` says whether the upgrade command claudeup would run
|
|
17
|
+
* replaces THAT file. brew and uv answer "is it behind?" themselves; bun, npm
|
|
18
|
+
* and go fall back to a registry lookup.
|
|
19
|
+
* - {@link classifyTool} turns facts into a state, purely. The rules are in
|
|
20
|
+
* order and the vetoes that make an upgrade unsafe come before every rule
|
|
21
|
+
* that can produce one.
|
|
22
|
+
*
|
|
23
|
+
* Asking the installer that will perform the upgrade, about the file that will
|
|
24
|
+
* run, is what makes plan, upgrade and PATH agree — which is what convergence
|
|
25
|
+
* means. A tool whose state cannot be established is `unknown`, and `unknown`
|
|
26
|
+
* is never applied.
|
|
27
|
+
*
|
|
28
|
+
* NOTHING HERE PRINTS, and every subprocess runs through `execFile` (argv, no
|
|
29
|
+
* shell) with its stdio CAPTURED: the probe runs under the CLI's animated live
|
|
30
|
+
* region and inside the TUI, and an inherited stdio would corrupt both.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { execFile } from "node:child_process";
|
|
34
|
+
import { constants } from "node:fs";
|
|
35
|
+
import {
|
|
36
|
+
access as fsAccess,
|
|
37
|
+
readlink as fsReadlink,
|
|
38
|
+
realpath as fsRealpath,
|
|
39
|
+
readFile,
|
|
40
|
+
stat,
|
|
41
|
+
} from "node:fs/promises";
|
|
42
|
+
import os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import semver from "semver";
|
|
45
|
+
import type { CliTool } from "../data/cli-tools.js";
|
|
46
|
+
import type {
|
|
47
|
+
BinInstaller,
|
|
48
|
+
BinRequirement,
|
|
49
|
+
ResolvedBin,
|
|
50
|
+
} from "../types/index.js";
|
|
51
|
+
import type { Command } from "../utils/command-utils.js";
|
|
52
|
+
import type { InstallMethod } from "./cli-tool-commands.js";
|
|
53
|
+
|
|
54
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
export type ToolState = "install" | "current" | "upgrade" | "unknown";
|
|
57
|
+
|
|
58
|
+
export type ToolReason =
|
|
59
|
+
| "absent"
|
|
60
|
+
| "up-to-date"
|
|
61
|
+
| "behind"
|
|
62
|
+
| "at-pin"
|
|
63
|
+
| "behind-pin"
|
|
64
|
+
| "ahead-of-pin"
|
|
65
|
+
| "pin-not-installable"
|
|
66
|
+
| "held-back"
|
|
67
|
+
| "not-managed"
|
|
68
|
+
| "version-unreadable"
|
|
69
|
+
| "latest-unavailable"
|
|
70
|
+
| "unorderable"
|
|
71
|
+
| "not-probed";
|
|
72
|
+
|
|
73
|
+
export type ToolFactSource =
|
|
74
|
+
| "path"
|
|
75
|
+
| "brew"
|
|
76
|
+
| "uv"
|
|
77
|
+
| "uv-receipt"
|
|
78
|
+
/** managed only because uv's `--force` would overwrite a foreign file. */
|
|
79
|
+
| "uv-adopted"
|
|
80
|
+
| "pipx"
|
|
81
|
+
| "bun-global"
|
|
82
|
+
| "npm-global"
|
|
83
|
+
| "go-buildinfo"
|
|
84
|
+
| "pypi"
|
|
85
|
+
| "npm-registry"
|
|
86
|
+
| "go-proxy"
|
|
87
|
+
| "check-command";
|
|
88
|
+
|
|
89
|
+
export interface ToolFacts {
|
|
90
|
+
name: string;
|
|
91
|
+
/**
|
|
92
|
+
* Resolvable, executable, on PATH. Before PATH has been read (a probe that
|
|
93
|
+
* was refused, or one that hit the deadline first) this is TRUE: an
|
|
94
|
+
* unestablished presence must never be planned as an install.
|
|
95
|
+
*/
|
|
96
|
+
present: boolean;
|
|
97
|
+
/** realpath of the PATH binary; null when absent/unresolvable. */
|
|
98
|
+
resolvedPath: string | null;
|
|
99
|
+
/** Version of THAT file, from its owner's record; null = could not be read. */
|
|
100
|
+
installed: string | null;
|
|
101
|
+
/** null = not queried / unavailable. */
|
|
102
|
+
latest: string | null;
|
|
103
|
+
/** The owning installer's own verdict; null = it gave none. */
|
|
104
|
+
behind: boolean | null;
|
|
105
|
+
/**
|
|
106
|
+
* The upgrade command for this spec replaces `resolvedPath` (containment in
|
|
107
|
+
* the package's own directory, or pip adoption). false = another owner;
|
|
108
|
+
* null = the owner directory could not be determined.
|
|
109
|
+
*/
|
|
110
|
+
managed: boolean | null;
|
|
111
|
+
/**
|
|
112
|
+
* Where the installer keeps what it owns, for the human note — e.g.
|
|
113
|
+
* "npm (root /Users/x/.local/lib/node_modules)". Diagnostic only.
|
|
114
|
+
*/
|
|
115
|
+
owner?: string;
|
|
116
|
+
/** e.g. "held by brew pin". */
|
|
117
|
+
heldBack?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Set when `managed` is true only because the upgrade command would
|
|
120
|
+
* OVERWRITE a file another installer put there (today: a binary in uv's bin
|
|
121
|
+
* dir). The adoption is deliberate — `uv tool install --force` replaces the
|
|
122
|
+
* very file PATH runs, so the state converges — but it changes the tool's
|
|
123
|
+
* owner, which a plain `upgrade` row does not say. This is that sentence,
|
|
124
|
+
* carried to both front ends.
|
|
125
|
+
*/
|
|
126
|
+
adopted?: string;
|
|
127
|
+
/** e.g. "timed out after 8s", "registry returned 404". */
|
|
128
|
+
failure?: string;
|
|
129
|
+
/** Where each fact came from, for diagnostics. */
|
|
130
|
+
sources: ToolFactSource[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ToolStatus {
|
|
134
|
+
name: string;
|
|
135
|
+
state: ToolState;
|
|
136
|
+
reason: ToolReason;
|
|
137
|
+
/** Set for every state except plain current/install. */
|
|
138
|
+
note?: string;
|
|
139
|
+
/** {@link ToolFacts.adopted}, carried when this state plans work. */
|
|
140
|
+
adopted?: string;
|
|
141
|
+
installed: string | null;
|
|
142
|
+
latest: string | null;
|
|
143
|
+
target: string | null;
|
|
144
|
+
pin?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Version comparison ───────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** A strict semver string for `v`, padding a 1–2 part numeric version, or null. */
|
|
150
|
+
function toSemver(v: string): string | null {
|
|
151
|
+
const s = v.trim().replace(/^v/i, "");
|
|
152
|
+
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$/.exec(s);
|
|
153
|
+
if (!m) return null;
|
|
154
|
+
const candidate =
|
|
155
|
+
m[3] === undefined ? `${m[1]}.${m[2] ?? "0"}.0${m[4] ?? ""}` : s;
|
|
156
|
+
return semver.valid(candidate);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* -1 | 0 | 1, or null when the pair has no shared ordering.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately STRICTER than the plugin comparator `isBehind`. Plugin opaque
|
|
163
|
+
* ids are catalog-published shas, where "different" means "not what is
|
|
164
|
+
* published". Tool version strings are free-form, and treating two different
|
|
165
|
+
* opaque tool strings as "behind" is how an upgrade loop on a prerelease
|
|
166
|
+
* starts. So: semver when both sides are semver (after stripping a leading `v`
|
|
167
|
+
* and padding "1.2" to "1.2.0"), equal strings are equal, and anything else is
|
|
168
|
+
* unorderable.
|
|
169
|
+
*/
|
|
170
|
+
export function compareToolVersions(a: string, b: string): -1 | 0 | 1 | null {
|
|
171
|
+
const sa = toSemver(a);
|
|
172
|
+
const sb = toSemver(b);
|
|
173
|
+
if (sa && sb) return semver.compare(sa, sb);
|
|
174
|
+
const ra = a.trim().replace(/^v/i, "");
|
|
175
|
+
const rb = b.trim().replace(/^v/i, "");
|
|
176
|
+
return ra === rb ? 0 : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── The classifier ───────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
/** The exact pin, or undefined for an unpinned (or "latest") requirement. */
|
|
182
|
+
function pinOf(spec: BinRequirement): string | undefined {
|
|
183
|
+
const v = spec.version?.trim();
|
|
184
|
+
return v && v !== "latest" ? v : undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function withFailure(text: string, failure: string | undefined): string {
|
|
188
|
+
return failure ? `${text} (${failure})` : text;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* THE tool classifier. Pure. The first matching rule wins, and each branch
|
|
193
|
+
* returns, so reading top to bottom is reading the precedence.
|
|
194
|
+
*
|
|
195
|
+
* The vetoes (`not-managed`, `held-back`) come before every rule that can yield
|
|
196
|
+
* `upgrade`, pinned or not: an upgrade through an installer that does not own
|
|
197
|
+
* the running file installs a second copy PATH never runs, and one through a
|
|
198
|
+
* held formula is a no-op. Both would be replanned forever. The pin rules that
|
|
199
|
+
* plan no work (at-pin, unorderable, ahead-of-pin) come before the vetoes,
|
|
200
|
+
* because ownership cannot make a "nothing to do" answer wrong.
|
|
201
|
+
*/
|
|
202
|
+
export function classifyTool(
|
|
203
|
+
spec: BinRequirement,
|
|
204
|
+
facts: ToolFacts,
|
|
205
|
+
): ToolStatus {
|
|
206
|
+
const pin = pinOf(spec);
|
|
207
|
+
const base = {
|
|
208
|
+
name: spec.name,
|
|
209
|
+
installed: facts.installed,
|
|
210
|
+
latest: facts.latest,
|
|
211
|
+
...(pin ? { pin } : {}),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// 1. Not on PATH.
|
|
215
|
+
if (!facts.present) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
state: "install",
|
|
219
|
+
reason: "absent",
|
|
220
|
+
target: pin ?? facts.latest,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 2. Present, but we could not read what version runs.
|
|
225
|
+
const installed = facts.installed;
|
|
226
|
+
if (installed === null) {
|
|
227
|
+
return {
|
|
228
|
+
...base,
|
|
229
|
+
state: "unknown",
|
|
230
|
+
reason: "version-unreadable",
|
|
231
|
+
target: null,
|
|
232
|
+
note: withFailure(
|
|
233
|
+
`on PATH${facts.resolvedPath ? ` at ${facts.resolvedPath}` : ""}, but its version could not be read`,
|
|
234
|
+
facts.failure,
|
|
235
|
+
),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3–5. Pin rules that plan no work.
|
|
240
|
+
const vsPin = pin ? compareToolVersions(installed, pin) : undefined;
|
|
241
|
+
if (pin && vsPin === 0) {
|
|
242
|
+
return { ...base, state: "current", reason: "at-pin", target: null };
|
|
243
|
+
}
|
|
244
|
+
if (pin && vsPin === null) {
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
state: "unknown",
|
|
248
|
+
reason: "unorderable",
|
|
249
|
+
target: null,
|
|
250
|
+
note: `installed ${installed} cannot be compared with the pin ${pin}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (pin && vsPin === 1) {
|
|
254
|
+
// Forward-only, the same rule as plugins.
|
|
255
|
+
return {
|
|
256
|
+
...base,
|
|
257
|
+
state: "unknown",
|
|
258
|
+
reason: "ahead-of-pin",
|
|
259
|
+
target: null,
|
|
260
|
+
note: `pinned ${pin}; installed ${installed} is newer and claudeup never downgrades`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 6. The veto: the upgrade would not replace the file PATH runs.
|
|
265
|
+
if (facts.managed !== true) {
|
|
266
|
+
return {
|
|
267
|
+
...base,
|
|
268
|
+
state: "unknown",
|
|
269
|
+
reason: "not-managed",
|
|
270
|
+
target: null,
|
|
271
|
+
note:
|
|
272
|
+
facts.managed === false
|
|
273
|
+
? `on PATH at ${facts.resolvedPath ?? "?"}, which ${facts.owner ?? spec.via} does not own — upgrading through ${spec.via} would install a second copy`
|
|
274
|
+
: withFailure(
|
|
275
|
+
`could not tell whether ${spec.via} owns ${facts.resolvedPath ?? spec.name}`,
|
|
276
|
+
facts.failure,
|
|
277
|
+
),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 7. The veto: the installer will refuse to move it.
|
|
282
|
+
if (facts.heldBack) {
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
state: "unknown",
|
|
286
|
+
reason: "held-back",
|
|
287
|
+
target: null,
|
|
288
|
+
note: facts.heldBack,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 8–9. Pinned and behind the pin (every other pinned case returned above).
|
|
293
|
+
// From here on a row can plan work, so an adoption must travel with it.
|
|
294
|
+
const adopted = facts.adopted ? { adopted: facts.adopted } : {};
|
|
295
|
+
if (pin) {
|
|
296
|
+
if (spec.via === "brew") {
|
|
297
|
+
return {
|
|
298
|
+
...base,
|
|
299
|
+
state: "unknown",
|
|
300
|
+
reason: "pin-not-installable",
|
|
301
|
+
target: null,
|
|
302
|
+
note: `pinned ${pin}; installed ${installed} — brew installs only its newest version, so the pin cannot be installed`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
...base,
|
|
307
|
+
...adopted,
|
|
308
|
+
state: "upgrade",
|
|
309
|
+
reason: "behind-pin",
|
|
310
|
+
target: pin,
|
|
311
|
+
note: `installed ${installed} is below the pin ${pin}`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 10–11. The owning installer's own verdict.
|
|
316
|
+
if (facts.behind === true) {
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
...adopted,
|
|
320
|
+
state: "upgrade",
|
|
321
|
+
reason: "behind",
|
|
322
|
+
target: facts.latest,
|
|
323
|
+
note: facts.latest
|
|
324
|
+
? `${facts.latest} is available`
|
|
325
|
+
: "a newer version is available",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (facts.behind === false) {
|
|
329
|
+
return { ...base, state: "current", reason: "up-to-date", target: null };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 12. Nothing to compare against.
|
|
333
|
+
const latest = facts.latest;
|
|
334
|
+
if (latest === null) {
|
|
335
|
+
return {
|
|
336
|
+
...base,
|
|
337
|
+
state: "unknown",
|
|
338
|
+
reason: "latest-unavailable",
|
|
339
|
+
target: null,
|
|
340
|
+
note: withFailure("the latest version could not be read", facts.failure),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 13. Our own comparison.
|
|
345
|
+
const cmp = compareToolVersions(installed, latest);
|
|
346
|
+
if (cmp === null) {
|
|
347
|
+
return {
|
|
348
|
+
...base,
|
|
349
|
+
state: "unknown",
|
|
350
|
+
reason: "unorderable",
|
|
351
|
+
target: null,
|
|
352
|
+
note: `installed ${installed} cannot be compared with the latest ${latest}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
if (cmp < 0) {
|
|
356
|
+
return {
|
|
357
|
+
...base,
|
|
358
|
+
...adopted,
|
|
359
|
+
state: "upgrade",
|
|
360
|
+
reason: "behind",
|
|
361
|
+
target: latest,
|
|
362
|
+
note: `${latest} is available`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { ...base, state: "current", reason: "up-to-date", target: null };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── Parsers (pure; tested against captured real output) ──────────────────────
|
|
369
|
+
|
|
370
|
+
/** `brew list --versions <f>` → the last version token ("tmux 3.7c" → "3.7c"). */
|
|
371
|
+
export function parseBrewListVersions(stdout: string): string | null {
|
|
372
|
+
const line = stdout.split("\n").find((l) => l.trim().length > 0);
|
|
373
|
+
if (!line) return null;
|
|
374
|
+
const tokens = line.trim().split(/\s+/);
|
|
375
|
+
return tokens.length >= 2 ? (tokens[tokens.length - 1] ?? null) : null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* `brew outdated --json=v2 <formula>`. `formulae: []` means not behind. null =
|
|
380
|
+
* unparseable. A tap formula's `name` is its full name ("user/tap/f").
|
|
381
|
+
*/
|
|
382
|
+
export function parseBrewOutdatedJson(
|
|
383
|
+
stdout: string,
|
|
384
|
+
formula: string,
|
|
385
|
+
): {
|
|
386
|
+
behind: boolean;
|
|
387
|
+
installed: string | null;
|
|
388
|
+
latest: string | null;
|
|
389
|
+
pinned: boolean;
|
|
390
|
+
} | null {
|
|
391
|
+
let body: unknown;
|
|
392
|
+
try {
|
|
393
|
+
body = JSON.parse(stdout);
|
|
394
|
+
} catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
const formulae = (body as { formulae?: unknown } | null)?.formulae;
|
|
398
|
+
if (!Array.isArray(formulae)) return null;
|
|
399
|
+
const entry = formulae.find((f: unknown) => {
|
|
400
|
+
const name = (f as { name?: unknown } | null)?.name;
|
|
401
|
+
return (
|
|
402
|
+
typeof name === "string" &&
|
|
403
|
+
(name === formula || name.endsWith(`/${formula}`))
|
|
404
|
+
);
|
|
405
|
+
}) as
|
|
406
|
+
| {
|
|
407
|
+
installed_versions?: unknown;
|
|
408
|
+
current_version?: unknown;
|
|
409
|
+
pinned?: unknown;
|
|
410
|
+
}
|
|
411
|
+
| undefined;
|
|
412
|
+
if (!entry) {
|
|
413
|
+
return { behind: false, installed: null, latest: null, pinned: false };
|
|
414
|
+
}
|
|
415
|
+
const versions = Array.isArray(entry.installed_versions)
|
|
416
|
+
? entry.installed_versions.filter(
|
|
417
|
+
(v: unknown): v is string => typeof v === "string",
|
|
418
|
+
)
|
|
419
|
+
: [];
|
|
420
|
+
return {
|
|
421
|
+
behind: true,
|
|
422
|
+
installed: versions[versions.length - 1] ?? null,
|
|
423
|
+
latest:
|
|
424
|
+
typeof entry.current_version === "string" ? entry.current_version : null,
|
|
425
|
+
pinned: entry.pinned === true,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The keg version from a realpath under `<cellar>/<formula>/<version>/`
|
|
431
|
+
* (".../Cellar/tmux/3.7c/bin/tmux" → "3.7c"). This is the LINKED version, which
|
|
432
|
+
* `brew list --versions` cannot give when several kegs are kept.
|
|
433
|
+
*/
|
|
434
|
+
export function brewKegVersion(
|
|
435
|
+
realPath: string,
|
|
436
|
+
cellar: string,
|
|
437
|
+
formula: string,
|
|
438
|
+
): string | null {
|
|
439
|
+
const prefix = `${path.join(cellar, formula)}${path.sep}`;
|
|
440
|
+
if (!realPath.startsWith(prefix)) return null;
|
|
441
|
+
const segment = realPath.slice(prefix.length).split(path.sep)[0];
|
|
442
|
+
return segment ? segment : null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** PEP 503 normalisation: lowercase, runs of `-_.` collapse to `-`. */
|
|
446
|
+
export function normalizePythonPackageName(name: string): string {
|
|
447
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* `uv tool list` / `uv tool list --outdated`. Package lines are `name vX.Y`,
|
|
452
|
+
* optionally followed by `[latest: Z]`; lines starting `- ` are executables.
|
|
453
|
+
* Keys are PEP 503-normalised. An unrecognised format yields an empty map,
|
|
454
|
+
* which degrades to PyPI, never to an upgrade.
|
|
455
|
+
*/
|
|
456
|
+
export function parseUvToolList(
|
|
457
|
+
stdout: string,
|
|
458
|
+
): Map<string, { installed: string; latest: string | null }> {
|
|
459
|
+
const out = new Map<string, { installed: string; latest: string | null }>();
|
|
460
|
+
for (const raw of stdout.split("\n")) {
|
|
461
|
+
const line = raw.trimEnd();
|
|
462
|
+
if (!line || line.startsWith("-") || line.startsWith(" ")) continue;
|
|
463
|
+
const m = /^(\S+)\s+v(\S+)(.*)$/.exec(line);
|
|
464
|
+
if (!m?.[1] || !m[2]) continue;
|
|
465
|
+
const latest = /\[latest:\s*([^\]\s]+)\s*\]/.exec(m[3] ?? "")?.[1] ?? null;
|
|
466
|
+
out.set(normalizePythonPackageName(m[1]), { installed: m[2], latest });
|
|
467
|
+
}
|
|
468
|
+
return out;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export type UvRequirementSource =
|
|
472
|
+
| "index"
|
|
473
|
+
| "index-constrained"
|
|
474
|
+
| "git"
|
|
475
|
+
| "directory"
|
|
476
|
+
| "path"
|
|
477
|
+
| "url"
|
|
478
|
+
| "editable";
|
|
479
|
+
|
|
480
|
+
function parseToml(text: string): unknown {
|
|
481
|
+
const toml = typeof Bun !== "undefined" ? Bun.TOML : undefined;
|
|
482
|
+
if (!toml || typeof toml.parse !== "function") return null;
|
|
483
|
+
try {
|
|
484
|
+
return toml.parse(text);
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The package's requirement kind from `uv-receipt.toml`; null =
|
|
492
|
+
* unreadable/unparseable/absent.
|
|
493
|
+
*
|
|
494
|
+
* Only a requirement carrying nothing but `name` (and `extras`) is `index`: that
|
|
495
|
+
* is the one shape where uv's silence in `--outdated` may be read as "current".
|
|
496
|
+
*/
|
|
497
|
+
export function parseUvReceipt(
|
|
498
|
+
toml: string,
|
|
499
|
+
pkg: string,
|
|
500
|
+
): UvRequirementSource | null {
|
|
501
|
+
const body = parseToml(toml) as {
|
|
502
|
+
tool?: { requirements?: unknown };
|
|
503
|
+
} | null;
|
|
504
|
+
const reqs = body?.tool?.requirements;
|
|
505
|
+
if (!Array.isArray(reqs)) return null;
|
|
506
|
+
const want = normalizePythonPackageName(pkg);
|
|
507
|
+
const req = reqs.find((r: unknown) => {
|
|
508
|
+
const name = (r as { name?: unknown } | null)?.name;
|
|
509
|
+
return (
|
|
510
|
+
typeof name === "string" && normalizePythonPackageName(name) === want
|
|
511
|
+
);
|
|
512
|
+
}) as Record<string, unknown> | undefined;
|
|
513
|
+
if (!req) return null;
|
|
514
|
+
for (const kind of ["git", "editable", "directory", "path", "url"] as const) {
|
|
515
|
+
if (kind in req) return kind;
|
|
516
|
+
}
|
|
517
|
+
const extra = Object.keys(req).filter((k) => k !== "name" && k !== "extras");
|
|
518
|
+
return extra.length === 0 ? "index" : "index-constrained";
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** `pipx list --json` → normalised package → version. */
|
|
522
|
+
export function parsePipxListJson(body: unknown): Map<string, string> {
|
|
523
|
+
const out = new Map<string, string>();
|
|
524
|
+
const venvs = (body as { venvs?: unknown } | null)?.venvs;
|
|
525
|
+
if (!venvs || typeof venvs !== "object") return out;
|
|
526
|
+
for (const [venv, value] of Object.entries(
|
|
527
|
+
venvs as Record<string, unknown>,
|
|
528
|
+
)) {
|
|
529
|
+
const main = (
|
|
530
|
+
value as {
|
|
531
|
+
metadata?: {
|
|
532
|
+
main_package?: { package?: unknown; package_version?: unknown };
|
|
533
|
+
};
|
|
534
|
+
} | null
|
|
535
|
+
)?.metadata?.main_package;
|
|
536
|
+
if (typeof main?.package_version !== "string") continue;
|
|
537
|
+
const key = typeof main.package === "string" ? main.package : venv;
|
|
538
|
+
out.set(normalizePythonPackageName(key), main.package_version);
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* `go version -m <binary>` → its main module and version, plus the main
|
|
545
|
+
* package path when printed.
|
|
546
|
+
*/
|
|
547
|
+
export function parseGoVersionM(
|
|
548
|
+
stdout: string,
|
|
549
|
+
): { module: string; version: string; path?: string } | null {
|
|
550
|
+
let pkgPath: string | undefined;
|
|
551
|
+
for (const line of stdout.split("\n")) {
|
|
552
|
+
const p = /^\s*path\s+(\S+)/.exec(line);
|
|
553
|
+
if (p?.[1]) pkgPath = p[1];
|
|
554
|
+
const m = /^\s*mod\s+(\S+)\s+(\S+)/.exec(line);
|
|
555
|
+
if (m?.[1] && m[2]) {
|
|
556
|
+
return {
|
|
557
|
+
module: m[1],
|
|
558
|
+
version: m[2],
|
|
559
|
+
...(pkgPath ? { path: pkgPath } : {}),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Pick the version out of a PyPI JSON API body.
|
|
568
|
+
*
|
|
569
|
+
* Split out from the fetch so it is testable without a network: the parsing is
|
|
570
|
+
* the part that can be wrong, and the part that must not throw.
|
|
571
|
+
*/
|
|
572
|
+
export function versionFromPypiJson(body: unknown): string | undefined {
|
|
573
|
+
const info = (body as { info?: { version?: unknown } } | null)?.info;
|
|
574
|
+
return typeof info?.version === "string" ? info.version : undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** An npm registry dist-tags body (`{ latest: "x", … }`) → "x". */
|
|
578
|
+
export function versionFromNpmDistTags(body: unknown): string | undefined {
|
|
579
|
+
const latest = (body as { latest?: unknown } | null)?.latest;
|
|
580
|
+
return typeof latest === "string" ? latest : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The first version-like token in a `--version` output ("tmux 3.7c" → "3.7c",
|
|
585
|
+
* "2.1.268 (Claude Code)" → "2.1.268", "v2.0.0" → "2.0.0"). Needs at least one
|
|
586
|
+
* dot, so a bare number (a year, a count) is not mistaken for a version.
|
|
587
|
+
*/
|
|
588
|
+
export function parseVersionToken(output: string): string | null {
|
|
589
|
+
const m = /(?:^|[^\w.])v?(\d+(?:\.\d+)+[0-9A-Za-z.+-]*)/.exec(output);
|
|
590
|
+
if (!m?.[1]) return null;
|
|
591
|
+
return m[1].replace(/[.+-]+$/, "");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** true when `resolved` lies inside `ownerDir` (compared with a trailing separator). */
|
|
595
|
+
export function isInside(resolved: string, ownerDir: string): boolean {
|
|
596
|
+
const dir = path.resolve(ownerDir);
|
|
597
|
+
const file = path.resolve(resolved);
|
|
598
|
+
return file.startsWith(dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── IO seam ──────────────────────────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
export interface ExecResult {
|
|
604
|
+
/** null when killed. */
|
|
605
|
+
code: number | null;
|
|
606
|
+
stdout: string;
|
|
607
|
+
stderr: string;
|
|
608
|
+
timedOut: boolean;
|
|
609
|
+
/** Executable not found (ENOENT). */
|
|
610
|
+
missing: boolean;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export type FetchJsonResult =
|
|
614
|
+
| { ok: true; body: unknown }
|
|
615
|
+
| { ok: false; reason: string };
|
|
616
|
+
|
|
617
|
+
export interface ToolProbeIO {
|
|
618
|
+
/** argv only, captured stdio, never a shell, never throws. */
|
|
619
|
+
exec(
|
|
620
|
+
command: Command,
|
|
621
|
+
opts: { timeoutMs: number; env?: Readonly<Record<string, string>> },
|
|
622
|
+
): Promise<ExecResult>;
|
|
623
|
+
fetchJson(url: string, opts: { timeoutMs: number }): Promise<FetchJsonResult>;
|
|
624
|
+
/** null when absent/unreadable. */
|
|
625
|
+
readJson(file: string): Promise<unknown | null>;
|
|
626
|
+
/** null when absent/unreadable (uv receipts). */
|
|
627
|
+
readText(file: string): Promise<string | null>;
|
|
628
|
+
readlink(file: string): Promise<string | null>;
|
|
629
|
+
realpath(file: string): Promise<string | null>;
|
|
630
|
+
/**
|
|
631
|
+
* The first executable `name` on PATH, AS FOUND ON PATH (not resolved), or
|
|
632
|
+
* null. A dangling symlink is not executable and does not count.
|
|
633
|
+
*/
|
|
634
|
+
resolveExecutable(name: string): Promise<string | null>;
|
|
635
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
636
|
+
homedir: string;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function isExecutable(file: string): Promise<boolean> {
|
|
640
|
+
try {
|
|
641
|
+
// Both follow symlinks, so a dangling link fails here.
|
|
642
|
+
if (!(await stat(file)).isFile()) return false;
|
|
643
|
+
await fsAccess(file, constants.X_OK);
|
|
644
|
+
return true;
|
|
645
|
+
} catch {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export const REAL_TOOL_PROBE_IO: ToolProbeIO = {
|
|
651
|
+
exec: (command, opts) =>
|
|
652
|
+
new Promise((resolve) => {
|
|
653
|
+
execFile(
|
|
654
|
+
command.cmd,
|
|
655
|
+
[...command.args],
|
|
656
|
+
{
|
|
657
|
+
timeout: opts.timeoutMs,
|
|
658
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
659
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
660
|
+
encoding: "utf8",
|
|
661
|
+
windowsHide: true,
|
|
662
|
+
},
|
|
663
|
+
(error, stdout, stderr) => {
|
|
664
|
+
if (!error) {
|
|
665
|
+
resolve({
|
|
666
|
+
code: 0,
|
|
667
|
+
stdout,
|
|
668
|
+
stderr,
|
|
669
|
+
timedOut: false,
|
|
670
|
+
missing: false,
|
|
671
|
+
});
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const e = error as NodeJS.ErrnoException & {
|
|
675
|
+
code?: unknown;
|
|
676
|
+
killed?: boolean;
|
|
677
|
+
signal?: string | null;
|
|
678
|
+
};
|
|
679
|
+
// A maxBuffer overflow also arrives killed, with a string code.
|
|
680
|
+
// Calling that "timed out" would put a wrong cause in the note
|
|
681
|
+
// of output that was merely too large.
|
|
682
|
+
const overflowed = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
683
|
+
resolve({
|
|
684
|
+
code: typeof e.code === "number" ? e.code : null,
|
|
685
|
+
stdout: stdout ?? "",
|
|
686
|
+
stderr: overflowed
|
|
687
|
+
? `${command.cmd} printed more than 16MB`
|
|
688
|
+
: (stderr ?? ""),
|
|
689
|
+
timedOut: e.killed === true && !overflowed && e.code !== "ENOENT",
|
|
690
|
+
missing: e.code === "ENOENT",
|
|
691
|
+
});
|
|
692
|
+
},
|
|
693
|
+
);
|
|
694
|
+
}),
|
|
695
|
+
fetchJson: async (url, opts) => {
|
|
696
|
+
try {
|
|
697
|
+
// No credentials: a public registry GET, nothing else.
|
|
698
|
+
const res = await fetch(url, {
|
|
699
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
700
|
+
headers: { accept: "application/json" },
|
|
701
|
+
});
|
|
702
|
+
if (!res.ok) return { ok: false, reason: `returned ${res.status}` };
|
|
703
|
+
return { ok: true, body: await res.json() };
|
|
704
|
+
} catch (error) {
|
|
705
|
+
const name = error instanceof Error ? error.name : "";
|
|
706
|
+
return {
|
|
707
|
+
ok: false,
|
|
708
|
+
reason:
|
|
709
|
+
name === "TimeoutError" || name === "AbortError"
|
|
710
|
+
? `timed out after ${Math.round(opts.timeoutMs / 1000)}s`
|
|
711
|
+
: error instanceof Error
|
|
712
|
+
? error.message
|
|
713
|
+
: String(error),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
readJson: async (file) => {
|
|
718
|
+
try {
|
|
719
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
720
|
+
} catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
readText: async (file) => {
|
|
725
|
+
try {
|
|
726
|
+
return await readFile(file, "utf8");
|
|
727
|
+
} catch {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
readlink: async (file) => {
|
|
732
|
+
try {
|
|
733
|
+
return await fsReadlink(file);
|
|
734
|
+
} catch {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
},
|
|
738
|
+
realpath: async (file) => {
|
|
739
|
+
try {
|
|
740
|
+
return await fsRealpath(file);
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
resolveExecutable: async (name) => {
|
|
746
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
747
|
+
if (!dir) continue;
|
|
748
|
+
const candidate = path.join(dir, name);
|
|
749
|
+
if (await isExecutable(candidate)) return candidate;
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
752
|
+
},
|
|
753
|
+
env: process.env,
|
|
754
|
+
homedir: os.homedir(),
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
export const PROBE_TIMEOUT_MS = 8000 as const;
|
|
758
|
+
export const PROBE_DEADLINE_MS = 20000 as const;
|
|
759
|
+
export const PROBE_CONCURRENCY = 4 as const;
|
|
760
|
+
|
|
761
|
+
export interface ProbeProgress {
|
|
762
|
+
index: number;
|
|
763
|
+
settled: number;
|
|
764
|
+
total: number;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export interface ProbeOptions {
|
|
768
|
+
timeoutMs?: number;
|
|
769
|
+
deadlineMs?: number;
|
|
770
|
+
concurrency?: number;
|
|
771
|
+
/**
|
|
772
|
+
* Fires as each tool settles, in completion order. `index` is the spec's
|
|
773
|
+
* index; `settled` is monotonic (1..total) and is what a progress meter
|
|
774
|
+
* must use.
|
|
775
|
+
*/
|
|
776
|
+
onResult?: (facts: ToolFacts, progress: ProbeProgress) => void;
|
|
777
|
+
/**
|
|
778
|
+
* Run a spec's `check` command when no installer record can give the
|
|
779
|
+
* version. Default FALSE, and every planning caller leaves it so: `check`
|
|
780
|
+
* is a string a third-party `plugin.json` supplied, and planning — which
|
|
781
|
+
* includes `--dry-run` and `--check` — executes nothing a catalogue wrote.
|
|
782
|
+
* See {@link versionFromCheck} for what is still bounded when it is on.
|
|
783
|
+
*/
|
|
784
|
+
allowCheckCommand?: boolean;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ── Probe session: memoised batch and location queries ──────────────────────
|
|
788
|
+
|
|
789
|
+
/** Extra env for brew: never mutate brew state, and keep output plain. */
|
|
790
|
+
const BREW_ENV = {
|
|
791
|
+
HOMEBREW_NO_AUTO_UPDATE: "1",
|
|
792
|
+
HOMEBREW_NO_ENV_HINTS: "1",
|
|
793
|
+
NO_COLOR: "1",
|
|
794
|
+
} as const;
|
|
795
|
+
const PLAIN_ENV = { NO_COLOR: "1" } as const;
|
|
796
|
+
|
|
797
|
+
type Located = { dir: string } | { failure: string };
|
|
798
|
+
|
|
799
|
+
type UvOutdated =
|
|
800
|
+
| { ok: true; map: Map<string, { installed: string; latest: string | null }> }
|
|
801
|
+
| { ok: false; unsupported: boolean; failure: string };
|
|
802
|
+
|
|
803
|
+
function firstLine(text: string): string {
|
|
804
|
+
return (
|
|
805
|
+
text
|
|
806
|
+
.split("\n")
|
|
807
|
+
.find((l) => l.trim())
|
|
808
|
+
?.trim() ?? ""
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function execFailure(cmd: string, r: ExecResult, timeoutMs: number): string {
|
|
813
|
+
if (r.missing) return `${cmd} is not installed`;
|
|
814
|
+
if (r.timedOut)
|
|
815
|
+
return `${cmd} timed out after ${Math.round(timeoutMs / 1000)}s`;
|
|
816
|
+
return firstLine(r.stderr) || `${cmd} exited ${r.code ?? "abnormally"}`;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* One per {@link probeTools} call. Each location or batch query is a memoised
|
|
821
|
+
* promise, run only if some spec needs it — N pip tools cost the same four uv
|
|
822
|
+
* subprocesses as one.
|
|
823
|
+
*/
|
|
824
|
+
class ProbeSession {
|
|
825
|
+
private readonly memo = new Map<string, Promise<unknown>>();
|
|
826
|
+
|
|
827
|
+
constructor(
|
|
828
|
+
readonly io: ToolProbeIO,
|
|
829
|
+
readonly timeoutMs: number,
|
|
830
|
+
/** See {@link versionFromCheck}. Off unless the caller opted in. */
|
|
831
|
+
readonly allowCheckCommand: boolean = false,
|
|
832
|
+
) {}
|
|
833
|
+
|
|
834
|
+
once<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
835
|
+
const hit = this.memo.get(key);
|
|
836
|
+
if (hit) return hit as Promise<T>;
|
|
837
|
+
const p = work();
|
|
838
|
+
this.memo.set(key, p);
|
|
839
|
+
return p;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
run(
|
|
843
|
+
cmd: string,
|
|
844
|
+
args: string[],
|
|
845
|
+
env?: Readonly<Record<string, string>>,
|
|
846
|
+
): Promise<ExecResult> {
|
|
847
|
+
return this.io.exec(
|
|
848
|
+
{ cmd, args },
|
|
849
|
+
{ timeoutMs: this.timeoutMs, ...(env ? { env } : {}) },
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** stdout's first line of a location query. */
|
|
854
|
+
private locate(key: string, cmd: string, args: string[]): Promise<Located> {
|
|
855
|
+
return this.once(key, async () => {
|
|
856
|
+
const r = await this.run(cmd, args, PLAIN_ENV);
|
|
857
|
+
const dir = r.code === 0 ? firstLine(r.stdout) : "";
|
|
858
|
+
return dir ? { dir } : { failure: execFailure(cmd, r, this.timeoutMs) };
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
brewPrefix(): Promise<Located> {
|
|
863
|
+
const fromEnv = this.io.env.HOMEBREW_PREFIX;
|
|
864
|
+
if (fromEnv) return Promise.resolve({ dir: fromEnv });
|
|
865
|
+
return this.locate("brew --prefix", "brew", ["--prefix"]);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
uvToolDir(): Promise<Located & { missing?: boolean }> {
|
|
869
|
+
return this.once("uv tool dir", async () => {
|
|
870
|
+
const r = await this.run("uv", ["tool", "dir"], PLAIN_ENV);
|
|
871
|
+
const dir = r.code === 0 ? firstLine(r.stdout) : "";
|
|
872
|
+
if (dir) return { dir };
|
|
873
|
+
return {
|
|
874
|
+
failure: execFailure("uv", r, this.timeoutMs),
|
|
875
|
+
missing: r.missing,
|
|
876
|
+
};
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
uvBinDir(): Promise<Located> {
|
|
881
|
+
return this.locate("uv tool dir --bin", "uv", ["tool", "dir", "--bin"]);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
uvList(): Promise<Map<string, { installed: string; latest: string | null }>> {
|
|
885
|
+
return this.once("uv tool list", async () => {
|
|
886
|
+
const r = await this.run("uv", ["tool", "list"], PLAIN_ENV);
|
|
887
|
+
return r.code === 0 ? parseUvToolList(r.stdout) : new Map();
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
uvOutdated(): Promise<UvOutdated> {
|
|
892
|
+
return this.once("uv tool list --outdated", async () => {
|
|
893
|
+
const r = await this.run("uv", ["tool", "list", "--outdated"], PLAIN_ENV);
|
|
894
|
+
if (r.code === 0) return { ok: true, map: parseUvToolList(r.stdout) };
|
|
895
|
+
return {
|
|
896
|
+
ok: false,
|
|
897
|
+
// Older uv rejects the flag; that is "ask PyPI", not "index down".
|
|
898
|
+
unsupported: /unexpected argument/i.test(r.stderr),
|
|
899
|
+
failure: execFailure("uv", r, this.timeoutMs),
|
|
900
|
+
};
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
pipxList(): Promise<Map<string, string> | null> {
|
|
905
|
+
return this.once("pipx list --json", async () => {
|
|
906
|
+
const r = await this.run("pipx", ["list", "--json"], PLAIN_ENV);
|
|
907
|
+
if (r.code !== 0) return null;
|
|
908
|
+
try {
|
|
909
|
+
return parsePipxListJson(JSON.parse(r.stdout));
|
|
910
|
+
} catch {
|
|
911
|
+
return new Map<string, string>();
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
npmRoot(): Promise<Located> {
|
|
917
|
+
return this.locate("npm root -g", "npm", ["root", "-g"]);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
goBin(): Promise<Located> {
|
|
921
|
+
return this.once("go env GOBIN GOPATH", async () => {
|
|
922
|
+
const r = await this.run("go", ["env", "GOBIN", "GOPATH"], PLAIN_ENV);
|
|
923
|
+
if (r.code !== 0)
|
|
924
|
+
return { failure: execFailure("go", r, this.timeoutMs) };
|
|
925
|
+
const [gobin = "", gopath = ""] = r.stdout
|
|
926
|
+
.split("\n")
|
|
927
|
+
.map((l) => l.trim());
|
|
928
|
+
if (gobin) return { dir: gobin };
|
|
929
|
+
const first = gopath.split(path.delimiter)[0];
|
|
930
|
+
return first
|
|
931
|
+
? { dir: path.join(first, "bin") }
|
|
932
|
+
: { failure: "go reported no GOBIN or GOPATH" };
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ── Probing one tool ─────────────────────────────────────────────────────────
|
|
938
|
+
|
|
939
|
+
/** Characters that make a check command something only a shell could run. */
|
|
940
|
+
const SHELL_META = /[|&;<>()$`\\"'*?{}[\]~!#\n\r]/;
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Refuse a name that could be read as an option, carries a NUL, or (for a
|
|
944
|
+
* value joined into a path) climbs out of its directory. Null when fine.
|
|
945
|
+
*/
|
|
946
|
+
function refusal(spec: BinRequirement): string | null {
|
|
947
|
+
const bad = (v: string | undefined, isPath: boolean): boolean =>
|
|
948
|
+
v !== undefined &&
|
|
949
|
+
(v.startsWith("-") ||
|
|
950
|
+
v.includes("\0") ||
|
|
951
|
+
(isPath && v.split("/").some((s) => s === ".." || s === ".")));
|
|
952
|
+
if (bad(spec.name, true) || spec.name.includes("/") || !spec.name)
|
|
953
|
+
return "refused: name is not a valid name";
|
|
954
|
+
if (bad(spec.package, true)) return "refused: package is not a valid name";
|
|
955
|
+
if (bad(spec.formula, false)) return "refused: formula is not a valid name";
|
|
956
|
+
if (bad(spec.module, false)) return "refused: module is not a valid name";
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A mutable facts record: whatever is known when the deadline hits survives. */
|
|
961
|
+
function blankFacts(name: string): ToolFacts {
|
|
962
|
+
return {
|
|
963
|
+
name,
|
|
964
|
+
present: true,
|
|
965
|
+
resolvedPath: null,
|
|
966
|
+
installed: null,
|
|
967
|
+
latest: null,
|
|
968
|
+
behind: null,
|
|
969
|
+
managed: null,
|
|
970
|
+
sources: [],
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function addSource(f: ToolFacts, s: ToolFactSource): void {
|
|
975
|
+
if (!f.sources.includes(s)) f.sources.push(s);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function noteFailure(f: ToolFacts, failure: string): void {
|
|
979
|
+
if (!f.failure) f.failure = failure;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** The version a real, resolved directory's package.json declares. */
|
|
983
|
+
async function packageJsonVersion(
|
|
984
|
+
io: ToolProbeIO,
|
|
985
|
+
dir: string,
|
|
986
|
+
): Promise<string | null> {
|
|
987
|
+
const pkg = (await io.readJson(path.join(dir, "package.json"))) as {
|
|
988
|
+
version?: unknown;
|
|
989
|
+
} | null;
|
|
990
|
+
return typeof pkg?.version === "string" ? pkg.version : null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Last resort for the installed version: run the tool's own check command and
|
|
995
|
+
* take the first version token.
|
|
996
|
+
*
|
|
997
|
+
* NOT REACHED WHILE PLANNING. `spec.check` is copied verbatim out of a
|
|
998
|
+
* marketplace plugin's `plugin.json` (`plugin-requires.ts`), so it is a string
|
|
999
|
+
* a third party wrote. Before this probe existed nothing in claudeup ever ran
|
|
1000
|
+
* it; running it to PLAN would have meant `claudeup update`, `--dry-run`,
|
|
1001
|
+
* `--check` in CI and merely opening the TUI's Tools screen each executing a
|
|
1002
|
+
* catalogue-supplied argv — and `--dry-run` is documented as writing nothing.
|
|
1003
|
+
* So the planner does not opt in: a version that could only come from a check
|
|
1004
|
+
* command stays null, which classifies as `unknown / version-unreadable`,
|
|
1005
|
+
* never as an upgrade. The one caller that opts in is the post-install
|
|
1006
|
+
* read-back (`update-engine.ts`), where the user has already consented to run
|
|
1007
|
+
* that tool's installer.
|
|
1008
|
+
*
|
|
1009
|
+
* Where it does run it is bounded on every axis:
|
|
1010
|
+
* - argv only, split on whitespace, never a shell (a `SHELL_META` character
|
|
1011
|
+
* refuses the whole string);
|
|
1012
|
+
* - argv[0] must be the tool itself — its name, its path on PATH, or the
|
|
1013
|
+
* realpath behind it. The probe's purpose is "ask the running file its
|
|
1014
|
+
* version", so anything else is refused unrun, and the version reported can
|
|
1015
|
+
* only have come from the binary `resolvedPath` names;
|
|
1016
|
+
* - the session's `timeoutMs` (8s by default), through the injected IO seam,
|
|
1017
|
+
* which captures stdio rather than inheriting the terminal.
|
|
1018
|
+
*/
|
|
1019
|
+
async function versionFromCheck(
|
|
1020
|
+
s: ProbeSession,
|
|
1021
|
+
spec: BinRequirement,
|
|
1022
|
+
f: ToolFacts,
|
|
1023
|
+
onPath: string,
|
|
1024
|
+
): Promise<void> {
|
|
1025
|
+
if (!s.allowCheckCommand) {
|
|
1026
|
+
noteFailure(f, "no installer record, and check commands are not run here");
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const check = spec.check?.trim() || `${spec.name} --version`;
|
|
1030
|
+
if (SHELL_META.test(check)) {
|
|
1031
|
+
noteFailure(f, "check command needs a shell; not run");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const [cmd, ...args] = check.split(/\s+/);
|
|
1035
|
+
if (!cmd) return;
|
|
1036
|
+
if (cmd !== spec.name && cmd !== onPath && cmd !== f.resolvedPath) {
|
|
1037
|
+
noteFailure(f, `check command does not run ${spec.name}; not run`);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const r = await s.run(cmd, args, PLAIN_ENV);
|
|
1041
|
+
if (r.missing || r.timedOut) {
|
|
1042
|
+
noteFailure(f, execFailure(cmd, r, s.timeoutMs));
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const v = parseVersionToken(`${r.stdout}\n${r.stderr}`);
|
|
1046
|
+
if (v) {
|
|
1047
|
+
f.installed = v;
|
|
1048
|
+
addSource(f, "check-command");
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** The npm registry's `latest` dist-tag for a package. */
|
|
1053
|
+
async function npmLatest(
|
|
1054
|
+
s: ProbeSession,
|
|
1055
|
+
pkg: string,
|
|
1056
|
+
f: ToolFacts,
|
|
1057
|
+
): Promise<void> {
|
|
1058
|
+
const registry = (
|
|
1059
|
+
s.io.env.npm_config_registry ?? "https://registry.npmjs.org"
|
|
1060
|
+
).replace(/\/+$/, "");
|
|
1061
|
+
// Keep a scope's `@`, encode its `/` as %2F (verified against the real
|
|
1062
|
+
// registry for @openai/codex).
|
|
1063
|
+
const encoded = encodeURIComponent(pkg).replace(/^%40/, "@");
|
|
1064
|
+
const r = await s.io.fetchJson(`${registry}/-/package/${encoded}/dist-tags`, {
|
|
1065
|
+
timeoutMs: s.timeoutMs,
|
|
1066
|
+
});
|
|
1067
|
+
if (!r.ok) {
|
|
1068
|
+
noteFailure(f, `npm registry ${r.reason}`);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const latest = versionFromNpmDistTags(r.body);
|
|
1072
|
+
if (latest) {
|
|
1073
|
+
f.latest = latest;
|
|
1074
|
+
addSource(f, "npm-registry");
|
|
1075
|
+
} else noteFailure(f, "npm registry published no latest tag");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
async function pypiLatest(
|
|
1079
|
+
s: ProbeSession,
|
|
1080
|
+
pkg: string,
|
|
1081
|
+
f: ToolFacts,
|
|
1082
|
+
): Promise<void> {
|
|
1083
|
+
const r = await s.io.fetchJson(
|
|
1084
|
+
`https://pypi.org/pypi/${encodeURIComponent(pkg)}/json`,
|
|
1085
|
+
{ timeoutMs: s.timeoutMs },
|
|
1086
|
+
);
|
|
1087
|
+
if (!r.ok) {
|
|
1088
|
+
noteFailure(f, `PyPI ${r.reason}`);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const latest = versionFromPypiJson(r.body);
|
|
1092
|
+
if (latest) {
|
|
1093
|
+
f.latest = latest;
|
|
1094
|
+
addSource(f, "pypi");
|
|
1095
|
+
} else noteFailure(f, "PyPI published no version");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Ownership by realpath containment in `ownerDir` (itself realpath'd). */
|
|
1099
|
+
async function containedIn(
|
|
1100
|
+
s: ProbeSession,
|
|
1101
|
+
resolvedPath: string,
|
|
1102
|
+
ownerDir: string,
|
|
1103
|
+
): Promise<{ inside: boolean; real: string | null }> {
|
|
1104
|
+
const real = await s.io.realpath(ownerDir);
|
|
1105
|
+
return { inside: real !== null && isInside(resolvedPath, real), real };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async function probeBrew(
|
|
1109
|
+
s: ProbeSession,
|
|
1110
|
+
spec: BinRequirement,
|
|
1111
|
+
f: ToolFacts,
|
|
1112
|
+
resolvedPath: string,
|
|
1113
|
+
): Promise<void> {
|
|
1114
|
+
const formula = spec.formula ?? spec.name;
|
|
1115
|
+
const prefix = await s.brewPrefix();
|
|
1116
|
+
if ("failure" in prefix) {
|
|
1117
|
+
f.managed = null;
|
|
1118
|
+
noteFailure(f, prefix.failure);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const cellar =
|
|
1122
|
+
(await s.io.realpath(path.join(prefix.dir, "Cellar"))) ??
|
|
1123
|
+
path.join(prefix.dir, "Cellar");
|
|
1124
|
+
const ownerDir = path.join(cellar, formula);
|
|
1125
|
+
f.owner = `brew (Cellar ${ownerDir})`;
|
|
1126
|
+
f.managed = isInside(resolvedPath, ownerDir);
|
|
1127
|
+
if (!f.managed) return;
|
|
1128
|
+
f.installed = brewKegVersion(resolvedPath, cellar, formula);
|
|
1129
|
+
if (f.installed) addSource(f, "brew");
|
|
1130
|
+
|
|
1131
|
+
const r = await s.run("brew", ["outdated", "--json=v2", formula], BREW_ENV);
|
|
1132
|
+
if (r.code !== 0) {
|
|
1133
|
+
noteFailure(f, execFailure("brew", r, s.timeoutMs));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const parsed = parseBrewOutdatedJson(r.stdout, formula);
|
|
1137
|
+
if (!parsed) {
|
|
1138
|
+
noteFailure(f, "brew outdated printed something unreadable");
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
addSource(f, "brew");
|
|
1142
|
+
f.behind = parsed.behind;
|
|
1143
|
+
if (parsed.latest) f.latest = parsed.latest;
|
|
1144
|
+
if (parsed.pinned) f.heldBack = "held by brew pin";
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async function probePip(
|
|
1148
|
+
s: ProbeSession,
|
|
1149
|
+
spec: BinRequirement,
|
|
1150
|
+
f: ToolFacts,
|
|
1151
|
+
onPath: string,
|
|
1152
|
+
resolvedPath: string,
|
|
1153
|
+
pinned: boolean,
|
|
1154
|
+
): Promise<void> {
|
|
1155
|
+
const pkg = spec.package ?? spec.name;
|
|
1156
|
+
const norm = normalizePythonPackageName(pkg);
|
|
1157
|
+
|
|
1158
|
+
const uvDir = await s.uvToolDir();
|
|
1159
|
+
if ("dir" in uvDir) {
|
|
1160
|
+
const ownerDir = path.join(uvDir.dir, norm);
|
|
1161
|
+
f.owner = `uv (tool dir ${uvDir.dir})`;
|
|
1162
|
+
const { inside, real } = await containedIn(s, resolvedPath, ownerDir);
|
|
1163
|
+
// Adoption: `uv tool install --force` overwrites the very file PATH runs
|
|
1164
|
+
// when it sits in uv's bin dir, whoever put it there.
|
|
1165
|
+
const bin = await s.uvBinDir();
|
|
1166
|
+
const adopted =
|
|
1167
|
+
"dir" in bin &&
|
|
1168
|
+
path.resolve(path.dirname(onPath)) === path.resolve(bin.dir);
|
|
1169
|
+
f.managed = inside || adopted;
|
|
1170
|
+
if (adopted && !inside) {
|
|
1171
|
+
// uv did not install this file, but `--force` will overwrite it. The
|
|
1172
|
+
// plan row is an ordinary upgrade, so without this it never says that
|
|
1173
|
+
// the owning installer changes — and pipx, say, goes on claiming a
|
|
1174
|
+
// venv nothing runs until its next `upgrade-all` writes back over it.
|
|
1175
|
+
f.adopted = `installed by another tool in uv's bin dir (${bin.dir}); upgrading adopts it into uv`;
|
|
1176
|
+
addSource(f, "uv-adopted");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (inside) {
|
|
1180
|
+
const listed = (await s.uvList()).get(norm);
|
|
1181
|
+
if (listed) {
|
|
1182
|
+
f.installed = listed.installed;
|
|
1183
|
+
addSource(f, "uv");
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (!f.managed || pinned) return;
|
|
1187
|
+
|
|
1188
|
+
const outdated = await s.uvOutdated();
|
|
1189
|
+
if (outdated.ok) {
|
|
1190
|
+
const hit = outdated.map.get(norm);
|
|
1191
|
+
if (hit) {
|
|
1192
|
+
f.behind = true;
|
|
1193
|
+
f.latest = hit.latest;
|
|
1194
|
+
addSource(f, "uv");
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
const receiptText = await s.io.readText(
|
|
1198
|
+
path.join(real ?? ownerDir, "uv-receipt.toml"),
|
|
1199
|
+
);
|
|
1200
|
+
const kind =
|
|
1201
|
+
inside && receiptText !== null
|
|
1202
|
+
? parseUvReceipt(receiptText, pkg)
|
|
1203
|
+
: null;
|
|
1204
|
+
if (kind) addSource(f, "uv-receipt");
|
|
1205
|
+
if (kind === "index") {
|
|
1206
|
+
f.behind = false;
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
if (
|
|
1210
|
+
kind === "git" ||
|
|
1211
|
+
kind === "directory" ||
|
|
1212
|
+
kind === "path" ||
|
|
1213
|
+
kind === "url" ||
|
|
1214
|
+
kind === "editable"
|
|
1215
|
+
) {
|
|
1216
|
+
f.behind = null;
|
|
1217
|
+
f.latest = null;
|
|
1218
|
+
noteFailure(f, `installed from ${kind}, not a package index`);
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
// Constrained, or a receipt we could not read: ask PyPI.
|
|
1222
|
+
await pypiLatest(s, pkg, f);
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
if (!outdated.unsupported) noteFailure(f, outdated.failure);
|
|
1226
|
+
await pypiLatest(s, pkg, f);
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (!uvDir.missing) {
|
|
1231
|
+
// uv exists but would not say where it keeps tools.
|
|
1232
|
+
f.managed = null;
|
|
1233
|
+
noteFailure(f, uvDir.failure);
|
|
1234
|
+
if (!pinned) await pypiLatest(s, pkg, f);
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const pipx = await s.pipxList();
|
|
1239
|
+
if (pipx !== null) {
|
|
1240
|
+
const home =
|
|
1241
|
+
s.io.env.PIPX_HOME ?? path.join(s.io.homedir, ".local", "pipx");
|
|
1242
|
+
const binDir =
|
|
1243
|
+
s.io.env.PIPX_BIN_DIR ?? path.join(s.io.homedir, ".local", "bin");
|
|
1244
|
+
const ownerDir = path.join(home, "venvs", norm);
|
|
1245
|
+
f.owner = `pipx (venvs ${path.join(home, "venvs")})`;
|
|
1246
|
+
const { inside } = await containedIn(s, resolvedPath, ownerDir);
|
|
1247
|
+
f.managed =
|
|
1248
|
+
inside || path.resolve(path.dirname(onPath)) === path.resolve(binDir);
|
|
1249
|
+
const v = inside ? pipx.get(norm) : undefined;
|
|
1250
|
+
if (v) {
|
|
1251
|
+
f.installed = v;
|
|
1252
|
+
addSource(f, "pipx");
|
|
1253
|
+
}
|
|
1254
|
+
if (f.managed && !pinned) await pypiLatest(s, pkg, f);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// `python3 -m pip --user` only: its location is not queried.
|
|
1259
|
+
f.managed = null;
|
|
1260
|
+
noteFailure(f, "neither uv nor pipx is installed");
|
|
1261
|
+
if (!pinned) await pypiLatest(s, pkg, f);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function probeNodeGlobal(
|
|
1265
|
+
s: ProbeSession,
|
|
1266
|
+
spec: BinRequirement,
|
|
1267
|
+
f: ToolFacts,
|
|
1268
|
+
resolvedPath: string,
|
|
1269
|
+
pinned: boolean,
|
|
1270
|
+
): Promise<void> {
|
|
1271
|
+
const pkg = spec.package ?? spec.name;
|
|
1272
|
+
let root: string;
|
|
1273
|
+
if (spec.via === "bun") {
|
|
1274
|
+
root = path.join(
|
|
1275
|
+
s.io.env.BUN_INSTALL ?? path.join(s.io.homedir, ".bun"),
|
|
1276
|
+
"install",
|
|
1277
|
+
"global",
|
|
1278
|
+
"node_modules",
|
|
1279
|
+
);
|
|
1280
|
+
f.owner = `bun (global ${root})`;
|
|
1281
|
+
} else {
|
|
1282
|
+
const npmRoot = await s.npmRoot();
|
|
1283
|
+
if ("failure" in npmRoot) {
|
|
1284
|
+
f.managed = null;
|
|
1285
|
+
noteFailure(f, npmRoot.failure);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
root = npmRoot.dir;
|
|
1289
|
+
f.owner = `npm (root ${root})`;
|
|
1290
|
+
}
|
|
1291
|
+
const { inside, real } = await containedIn(
|
|
1292
|
+
s,
|
|
1293
|
+
resolvedPath,
|
|
1294
|
+
path.join(root, pkg),
|
|
1295
|
+
);
|
|
1296
|
+
f.managed = inside;
|
|
1297
|
+
if (!inside || !real) return;
|
|
1298
|
+
f.installed = await packageJsonVersion(s.io, real);
|
|
1299
|
+
if (f.installed)
|
|
1300
|
+
addSource(f, spec.via === "bun" ? "bun-global" : "npm-global");
|
|
1301
|
+
if (!pinned) await npmLatest(s, pkg, f);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function probeGo(
|
|
1305
|
+
s: ProbeSession,
|
|
1306
|
+
spec: BinRequirement,
|
|
1307
|
+
f: ToolFacts,
|
|
1308
|
+
resolvedPath: string,
|
|
1309
|
+
pinned: boolean,
|
|
1310
|
+
): Promise<void> {
|
|
1311
|
+
const want = spec.module ?? spec.name;
|
|
1312
|
+
const r = await s.run("go", ["version", "-m", resolvedPath], PLAIN_ENV);
|
|
1313
|
+
const info = r.code === 0 ? parseGoVersionM(r.stdout) : null;
|
|
1314
|
+
if (!info) {
|
|
1315
|
+
f.managed = null;
|
|
1316
|
+
noteFailure(
|
|
1317
|
+
f,
|
|
1318
|
+
r.code === 0
|
|
1319
|
+
? "no Go build info in the binary"
|
|
1320
|
+
: execFailure("go", r, s.timeoutMs),
|
|
1321
|
+
);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
f.installed = info.version;
|
|
1325
|
+
addSource(f, "go-buildinfo");
|
|
1326
|
+
|
|
1327
|
+
const gobin = await s.goBin();
|
|
1328
|
+
if ("failure" in gobin) {
|
|
1329
|
+
f.managed = null;
|
|
1330
|
+
noteFailure(f, gobin.failure);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const realBin = (await s.io.realpath(gobin.dir)) ?? gobin.dir;
|
|
1334
|
+
f.owner = `go (GOBIN ${realBin})`;
|
|
1335
|
+
const sameModule =
|
|
1336
|
+
want === info.module ||
|
|
1337
|
+
want === info.path ||
|
|
1338
|
+
want.startsWith(`${info.module}/`);
|
|
1339
|
+
f.managed =
|
|
1340
|
+
path.resolve(path.dirname(resolvedPath)) === path.resolve(realBin) &&
|
|
1341
|
+
sameModule;
|
|
1342
|
+
if (!f.managed || pinned) return;
|
|
1343
|
+
|
|
1344
|
+
const latest = await s.run(
|
|
1345
|
+
"go",
|
|
1346
|
+
["list", "-m", "-f", "{{.Version}}", `${info.module}@latest`],
|
|
1347
|
+
PLAIN_ENV,
|
|
1348
|
+
);
|
|
1349
|
+
const v = latest.code === 0 ? firstLine(latest.stdout) : "";
|
|
1350
|
+
if (v) {
|
|
1351
|
+
f.latest = v;
|
|
1352
|
+
addSource(f, "go-proxy");
|
|
1353
|
+
} else noteFailure(f, execFailure("go", latest, s.timeoutMs));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Probe one tool into `f`, mutating it as facts arrive. Never throws. */
|
|
1357
|
+
async function probeInto(
|
|
1358
|
+
s: ProbeSession,
|
|
1359
|
+
spec: BinRequirement,
|
|
1360
|
+
f: ToolFacts,
|
|
1361
|
+
): Promise<void> {
|
|
1362
|
+
const refused = refusal(spec);
|
|
1363
|
+
if (refused) {
|
|
1364
|
+
f.failure = refused;
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const onPath = await s.io.resolveExecutable(spec.name);
|
|
1369
|
+
const resolvedPath = onPath ? await s.io.realpath(onPath) : null;
|
|
1370
|
+
addSource(f, "path");
|
|
1371
|
+
if (!onPath || !resolvedPath) {
|
|
1372
|
+
f.present = false;
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
f.present = true;
|
|
1376
|
+
f.resolvedPath = resolvedPath;
|
|
1377
|
+
|
|
1378
|
+
const pinned = pinOf(spec) !== undefined;
|
|
1379
|
+
const via: BinInstaller = spec.via;
|
|
1380
|
+
switch (via) {
|
|
1381
|
+
case "brew":
|
|
1382
|
+
await probeBrew(s, spec, f, resolvedPath);
|
|
1383
|
+
break;
|
|
1384
|
+
case "pip":
|
|
1385
|
+
await probePip(s, spec, f, onPath, resolvedPath, pinned);
|
|
1386
|
+
break;
|
|
1387
|
+
case "bun":
|
|
1388
|
+
case "npm":
|
|
1389
|
+
await probeNodeGlobal(s, spec, f, resolvedPath, pinned);
|
|
1390
|
+
break;
|
|
1391
|
+
case "go":
|
|
1392
|
+
await probeGo(s, spec, f, resolvedPath, pinned);
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// The method's own record was silent: read the running file itself — but
|
|
1397
|
+
// only where the caller opted into running a check command (see there).
|
|
1398
|
+
if (f.installed === null) await versionFromCheck(s, spec, f, onPath);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** A copy, with the verdict fields cleared: a timed-out tool is never "behind". */
|
|
1402
|
+
function timedOut(f: ToolFacts, deadlineMs: number): ToolFacts {
|
|
1403
|
+
return {
|
|
1404
|
+
...f,
|
|
1405
|
+
sources: [...f.sources],
|
|
1406
|
+
latest: null,
|
|
1407
|
+
behind: null,
|
|
1408
|
+
failure: `timed out after ${Math.round(deadlineMs / 1000)}s`,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
async function probeWithDeadline(
|
|
1413
|
+
s: ProbeSession,
|
|
1414
|
+
spec: BinRequirement,
|
|
1415
|
+
deadlineAt: number,
|
|
1416
|
+
deadlineMs: number,
|
|
1417
|
+
): Promise<ToolFacts> {
|
|
1418
|
+
const f = blankFacts(spec.name);
|
|
1419
|
+
const remaining = deadlineAt - Date.now();
|
|
1420
|
+
if (remaining <= 0) return timedOut(f, deadlineMs);
|
|
1421
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1422
|
+
const deadline = new Promise<"deadline">((resolve) => {
|
|
1423
|
+
timer = setTimeout(() => resolve("deadline"), remaining);
|
|
1424
|
+
});
|
|
1425
|
+
try {
|
|
1426
|
+
const outcome = await Promise.race([
|
|
1427
|
+
probeInto(s, spec, f).then(
|
|
1428
|
+
() => "done" as const,
|
|
1429
|
+
(error: unknown) => {
|
|
1430
|
+
noteFailure(
|
|
1431
|
+
f,
|
|
1432
|
+
error instanceof Error ? error.message : String(error),
|
|
1433
|
+
);
|
|
1434
|
+
return "done" as const;
|
|
1435
|
+
},
|
|
1436
|
+
),
|
|
1437
|
+
deadline,
|
|
1438
|
+
]);
|
|
1439
|
+
return outcome === "deadline"
|
|
1440
|
+
? timedOut(f, deadlineMs)
|
|
1441
|
+
: { ...f, sources: [...f.sources] };
|
|
1442
|
+
} finally {
|
|
1443
|
+
if (timer) clearTimeout(timer);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Facts for every spec, in spec order. Never rejects: every failure becomes
|
|
1449
|
+
* facts with `failure` set, which classify as `unknown`, never `upgrade`.
|
|
1450
|
+
*/
|
|
1451
|
+
export async function probeTools(
|
|
1452
|
+
specs: readonly BinRequirement[],
|
|
1453
|
+
io: ToolProbeIO = REAL_TOOL_PROBE_IO,
|
|
1454
|
+
opts: ProbeOptions = {},
|
|
1455
|
+
): Promise<ToolFacts[]> {
|
|
1456
|
+
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
1457
|
+
const deadlineMs = opts.deadlineMs ?? PROBE_DEADLINE_MS;
|
|
1458
|
+
const concurrency = Math.max(1, opts.concurrency ?? PROBE_CONCURRENCY);
|
|
1459
|
+
const session = new ProbeSession(
|
|
1460
|
+
io,
|
|
1461
|
+
timeoutMs,
|
|
1462
|
+
opts.allowCheckCommand === true,
|
|
1463
|
+
);
|
|
1464
|
+
const deadlineAt = Date.now() + deadlineMs;
|
|
1465
|
+
const results: ToolFacts[] = new Array(specs.length);
|
|
1466
|
+
let next = 0;
|
|
1467
|
+
let settled = 0;
|
|
1468
|
+
|
|
1469
|
+
const worker = async (): Promise<void> => {
|
|
1470
|
+
while (next < specs.length) {
|
|
1471
|
+
const index = next++;
|
|
1472
|
+
const spec = specs[index];
|
|
1473
|
+
if (!spec) {
|
|
1474
|
+
// Unreachable from today's callers, and it must stay that way in
|
|
1475
|
+
// the RESULT too: a hole in the array would make
|
|
1476
|
+
// `planBinUpdates`' `new Map(facts.map(…))` throw, instead of
|
|
1477
|
+
// degrading to the `unknown / not-probed` the contract promises.
|
|
1478
|
+
results[index] = { ...blankFacts(""), failure: "not probed" };
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const facts = await probeWithDeadline(
|
|
1482
|
+
session,
|
|
1483
|
+
spec,
|
|
1484
|
+
deadlineAt,
|
|
1485
|
+
deadlineMs,
|
|
1486
|
+
);
|
|
1487
|
+
results[index] = facts;
|
|
1488
|
+
settled++;
|
|
1489
|
+
try {
|
|
1490
|
+
opts.onResult?.(facts, { index, settled, total: specs.length });
|
|
1491
|
+
} catch {
|
|
1492
|
+
// A progress callback must not turn a probe into a rejection.
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1497
|
+
await Promise.all(
|
|
1498
|
+
Array.from({ length: Math.min(concurrency, specs.length) }, worker),
|
|
1499
|
+
);
|
|
1500
|
+
return results;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** One tool, with its own probe session. */
|
|
1504
|
+
export async function probeTool(
|
|
1505
|
+
spec: BinRequirement,
|
|
1506
|
+
io: ToolProbeIO = REAL_TOOL_PROBE_IO,
|
|
1507
|
+
opts: Omit<ProbeOptions, "concurrency" | "onResult"> = {},
|
|
1508
|
+
): Promise<ToolFacts> {
|
|
1509
|
+
const [facts] = await probeTools([spec], io, { ...opts, concurrency: 1 });
|
|
1510
|
+
return facts ?? { ...blankFacts(spec.name), failure: "not probed" };
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ── Catalogue bridge + install-method detection ──────────────────────────────
|
|
1514
|
+
|
|
1515
|
+
export interface InstallInfo {
|
|
1516
|
+
primary: InstallMethod;
|
|
1517
|
+
all: InstallMethod[];
|
|
1518
|
+
brewFormula?: string;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const BIN_INSTALLER_METHODS: ReadonlySet<InstallMethod> = new Set([
|
|
1522
|
+
"npm",
|
|
1523
|
+
"bun",
|
|
1524
|
+
"brew",
|
|
1525
|
+
"pip",
|
|
1526
|
+
]);
|
|
1527
|
+
|
|
1528
|
+
function isBinInstaller(m: InstallMethod): m is BinInstaller & InstallMethod {
|
|
1529
|
+
return BIN_INSTALLER_METHODS.has(m);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* The spec a catalogue tool is probed through, in order:
|
|
1534
|
+
*
|
|
1535
|
+
* 1. the active profile's `ResolvedBin` of the same name — the same pin and
|
|
1536
|
+
* the same installer `claudeup update` uses, so the two agree;
|
|
1537
|
+
* 2. the DETECTED installer, when it is one claudeup models — "how it IS
|
|
1538
|
+
* installed", the rule `cliToolUpdate` follows, so the row's state and the
|
|
1539
|
+
* command Enter offers name the same installer;
|
|
1540
|
+
* 3. the catalogue's own mapping. For a pnpm/yarn install this probes a
|
|
1541
|
+
* manager that does not own the file, which is reported as `not-managed`.
|
|
1542
|
+
*/
|
|
1543
|
+
export function specForCatalogTool(
|
|
1544
|
+
tool: CliTool,
|
|
1545
|
+
closureBins: readonly ResolvedBin[] | null,
|
|
1546
|
+
install: InstallInfo | undefined,
|
|
1547
|
+
): BinRequirement {
|
|
1548
|
+
const declared = closureBins?.find((b) => b.name === tool.name);
|
|
1549
|
+
if (declared) return declared;
|
|
1550
|
+
if (install && isBinInstaller(install.primary)) {
|
|
1551
|
+
return {
|
|
1552
|
+
name: tool.name,
|
|
1553
|
+
via: install.primary,
|
|
1554
|
+
package: tool.packageName,
|
|
1555
|
+
check: tool.checkCommand,
|
|
1556
|
+
...(install.brewFormula ? { formula: install.brewFormula } : {}),
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
return {
|
|
1560
|
+
name: tool.name,
|
|
1561
|
+
via: tool.packageManager,
|
|
1562
|
+
package: tool.packageName,
|
|
1563
|
+
check: tool.checkCommand,
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function methodFromPath(binPath: string): InstallMethod | null {
|
|
1568
|
+
if (binPath.includes("/.bun/")) return "bun";
|
|
1569
|
+
if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
|
|
1570
|
+
return "brew";
|
|
1571
|
+
if (
|
|
1572
|
+
binPath.includes("/.local/share/claude") ||
|
|
1573
|
+
binPath.includes("/.local/bin/claude")
|
|
1574
|
+
)
|
|
1575
|
+
return "npm";
|
|
1576
|
+
if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
|
|
1577
|
+
return "npm";
|
|
1578
|
+
if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
|
|
1579
|
+
return "pnpm";
|
|
1580
|
+
if (binPath.includes("/.yarn/")) return "yarn";
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** The brew formula from a Cellar path like ../Cellar/gemini-cli/0.35.2/bin/gemini. */
|
|
1585
|
+
function extractBrewFormula(
|
|
1586
|
+
binPath: string,
|
|
1587
|
+
linkTarget: string,
|
|
1588
|
+
): string | undefined {
|
|
1589
|
+
const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
|
|
1590
|
+
if (cellarMatch) return cellarMatch[1];
|
|
1591
|
+
const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
|
|
1592
|
+
if (pathMatch) return pathMatch[1];
|
|
1593
|
+
return undefined;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* How a catalogue tool IS installed, from every copy on PATH (`which -a`) and
|
|
1598
|
+
* each one's symlink target. Moved from the CLI Tools screen unchanged in
|
|
1599
|
+
* behaviour, except that `which` and `readlink` now run as argv through the IO
|
|
1600
|
+
* seam instead of a `/bin/bash` pipeline.
|
|
1601
|
+
*/
|
|
1602
|
+
export async function detectInstallMethods(
|
|
1603
|
+
tool: CliTool,
|
|
1604
|
+
io: ToolProbeIO = REAL_TOOL_PROBE_IO,
|
|
1605
|
+
): Promise<InstallInfo> {
|
|
1606
|
+
const fallback: InstallMethod =
|
|
1607
|
+
tool.packageManager === "pip" ? "pip" : "unknown";
|
|
1608
|
+
if (!tool.name || tool.name.startsWith("-") || tool.name.includes("/"))
|
|
1609
|
+
return { primary: fallback, all: [] };
|
|
1610
|
+
const r = await io.exec(
|
|
1611
|
+
{ cmd: "which", args: ["-a", tool.name] },
|
|
1612
|
+
{ timeoutMs: 3000 },
|
|
1613
|
+
);
|
|
1614
|
+
if (r.code !== 0) return { primary: fallback, all: [] };
|
|
1615
|
+
const paths = r.stdout
|
|
1616
|
+
.trim()
|
|
1617
|
+
.split("\n")
|
|
1618
|
+
.map((p) => p.trim())
|
|
1619
|
+
.filter((p) => p && !p.includes("aliased"));
|
|
1620
|
+
if (paths.length === 0) return { primary: fallback, all: [] };
|
|
1621
|
+
|
|
1622
|
+
const methods: InstallMethod[] = [];
|
|
1623
|
+
let brewFormula: string | undefined;
|
|
1624
|
+
for (const binPath of paths) {
|
|
1625
|
+
let method = methodFromPath(binPath);
|
|
1626
|
+
const linkTarget = (await io.readlink(binPath)) ?? "";
|
|
1627
|
+
if (!method && linkTarget) method = methodFromPath(linkTarget);
|
|
1628
|
+
if (method === "brew" && !brewFormula) {
|
|
1629
|
+
brewFormula = extractBrewFormula(binPath, linkTarget);
|
|
1630
|
+
}
|
|
1631
|
+
if (method && !methods.includes(method)) methods.push(method);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const primary = methods[0];
|
|
1635
|
+
if (primary === undefined) return { primary: fallback, all: [] };
|
|
1636
|
+
return { primary, all: methods, ...(brewFormula ? { brewFormula } : {}) };
|
|
1637
|
+
}
|