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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
@@ -7,221 +7,76 @@ import { useApp, useModal } from "../state/AppContext.js";
|
|
|
7
7
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
8
8
|
|
|
9
9
|
import { cliTools } from "../../data/cli-tools.js";
|
|
10
|
+
import { loadActiveClosure } from "../../services/active-closure.js";
|
|
11
|
+
import { cliToolUninstall } from "../../services/cli-tool-commands.js";
|
|
10
12
|
import {
|
|
11
|
-
type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
type InstallInfo,
|
|
14
|
+
PROBE_CONCURRENCY,
|
|
15
|
+
REAL_TOOL_PROBE_IO,
|
|
16
|
+
detectInstallMethods,
|
|
17
|
+
probeTools,
|
|
18
|
+
specForCatalogTool,
|
|
19
|
+
} from "../../services/tool-probe.js";
|
|
16
20
|
import type { Command } from "../../utils/command-utils.js";
|
|
17
21
|
import { formatCommand } from "../../utils/command-utils.js";
|
|
22
|
+
import {
|
|
23
|
+
type CliToolRow,
|
|
24
|
+
buildToolRow,
|
|
25
|
+
countToolRows,
|
|
26
|
+
isInstalled,
|
|
27
|
+
} from "../adapters/cliToolsAdapter.js";
|
|
18
28
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
19
29
|
import {
|
|
20
|
-
type CliToolStatus,
|
|
21
30
|
renderCliToolDetail,
|
|
22
31
|
renderCliToolRow,
|
|
23
32
|
} from "../renderers/cliToolRenderers.js";
|
|
24
33
|
import { theme } from "../theme.js";
|
|
25
34
|
|
|
26
|
-
const execAsync = promisify(exec);
|
|
27
35
|
const execFileAsync = promisify(execFile);
|
|
28
36
|
|
|
29
|
-
// ───
|
|
37
|
+
// ─── Session cache ─────────────────────────────────────────────────────────────
|
|
38
|
+
//
|
|
39
|
+
// The probe itself lives in services/tool-probe.ts — the same one `claudeup
|
|
40
|
+
// update` uses — so this screen and the CLI cannot disagree about a tool. It
|
|
41
|
+
// used to carry a private copy: a `/bin/bash` `which`, an `npm view` per tool,
|
|
42
|
+
// its own version comparator, and a string-form `exec` of each check command.
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
let cachedToolStatuses: CliToolStatus[] | null = null;
|
|
44
|
+
let cachedRows: CliToolRow[] | null = null;
|
|
33
45
|
let cacheInitialized = false;
|
|
46
|
+
/** Bumped by every fetch, so a refresh discards the answers of the one before. */
|
|
47
|
+
let fetchGeneration = 0;
|
|
34
48
|
|
|
35
49
|
function clearCliToolsCache(): void {
|
|
36
|
-
|
|
50
|
+
cachedRows = null;
|
|
37
51
|
cacheInitialized = false;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
function methodFromPath(binPath: string): InstallMethod | null {
|
|
46
|
-
if (binPath.includes("/.bun/")) return "bun";
|
|
47
|
-
if (binPath.includes("/homebrew/") || binPath.includes("/Cellar/"))
|
|
48
|
-
return "brew";
|
|
49
|
-
if (
|
|
50
|
-
binPath.includes("/.local/share/claude") ||
|
|
51
|
-
binPath.includes("/.local/bin/claude")
|
|
52
|
-
)
|
|
53
|
-
return "npm";
|
|
54
|
-
if (binPath.includes("/.nvm/") || binPath.includes("/node_modules/"))
|
|
55
|
-
return "npm";
|
|
56
|
-
if (binPath.includes("/.local/share/pnpm") || binPath.includes("/pnpm/"))
|
|
57
|
-
return "pnpm";
|
|
58
|
-
if (binPath.includes("/.yarn/")) return "yarn";
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
interface InstallInfo {
|
|
63
|
-
primary: InstallMethod;
|
|
64
|
-
all: InstallMethod[];
|
|
65
|
-
brewFormula?: string;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Extract brew formula name from a Cellar symlink target like ../Cellar/gemini-cli/0.35.2/bin/gemini */
|
|
69
|
-
function extractBrewFormula(
|
|
70
|
-
binPath: string,
|
|
71
|
-
linkTarget: string,
|
|
72
|
-
): string | undefined {
|
|
73
|
-
// Try symlink target first: ../Cellar/{formula}/{version}/...
|
|
74
|
-
const cellarMatch = linkTarget.match(/Cellar\/([^/]+)\//);
|
|
75
|
-
if (cellarMatch) return cellarMatch[1];
|
|
76
|
-
// Try binary path: /opt/homebrew/Cellar/{formula}/...
|
|
77
|
-
const pathMatch = binPath.match(/Cellar\/([^/]+)\//);
|
|
78
|
-
if (pathMatch) return pathMatch[1];
|
|
79
|
-
return undefined;
|
|
54
|
+
/** Rows before anything is known: the catalogue mapping, checking. */
|
|
55
|
+
function initialRows(): CliToolRow[] {
|
|
56
|
+
return cliTools.map((tool) =>
|
|
57
|
+
buildToolRow(tool, specForCatalogTool(tool, null, undefined), undefined),
|
|
58
|
+
);
|
|
80
59
|
}
|
|
81
60
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.filter((p) => p && !p.includes("aliased"));
|
|
96
|
-
if (paths.length === 0)
|
|
97
|
-
return { primary: fallback as InstallMethod, all: [] };
|
|
98
|
-
|
|
99
|
-
const methods: InstallMethod[] = [];
|
|
100
|
-
let brewFormula: string | undefined;
|
|
101
|
-
|
|
102
|
-
for (const binPath of paths) {
|
|
103
|
-
let method = methodFromPath(binPath);
|
|
104
|
-
let linkTarget = "";
|
|
105
|
-
|
|
106
|
-
// Check symlink target
|
|
107
|
-
try {
|
|
108
|
-
const { stdout: lt } = await execAsync(
|
|
109
|
-
`readlink "${binPath}" 2>/dev/null || true`,
|
|
110
|
-
{ timeout: 2000, shell: "/bin/bash" },
|
|
111
|
-
);
|
|
112
|
-
linkTarget = lt.trim();
|
|
113
|
-
if (!method) method = methodFromPath(linkTarget);
|
|
114
|
-
} catch {
|
|
115
|
-
// ignore
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Detect brew formula name
|
|
119
|
-
if (method === "brew" && !brewFormula) {
|
|
120
|
-
brewFormula = extractBrewFormula(binPath, linkTarget);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (method && !methods.includes(method)) methods.push(method);
|
|
61
|
+
/** `fn` over `items`, at most `limit` at a time, results in input order. */
|
|
62
|
+
async function mapLimited<T, R>(
|
|
63
|
+
items: readonly T[],
|
|
64
|
+
limit: number,
|
|
65
|
+
fn: (item: T) => Promise<R>,
|
|
66
|
+
): Promise<R[]> {
|
|
67
|
+
const out: R[] = new Array(items.length);
|
|
68
|
+
let next = 0;
|
|
69
|
+
const worker = async () => {
|
|
70
|
+
while (next < items.length) {
|
|
71
|
+
const i = next++;
|
|
72
|
+
const item = items[i];
|
|
73
|
+
if (item !== undefined) out[i] = await fn(item);
|
|
124
74
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
} catch {
|
|
131
|
-
return { primary: fallback as InstallMethod, all: [] };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async function getInstalledVersion(
|
|
136
|
-
tool: import("../../data/cli-tools.js").CliTool,
|
|
137
|
-
): Promise<string | undefined> {
|
|
138
|
-
try {
|
|
139
|
-
const { stdout } = await execAsync(tool.checkCommand, { timeout: 5000 });
|
|
140
|
-
return parseVersion(stdout.trim());
|
|
141
|
-
} catch {
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function getLatestNpmVersion(
|
|
147
|
-
packageName: string,
|
|
148
|
-
): Promise<string | undefined> {
|
|
149
|
-
try {
|
|
150
|
-
const { stdout } = await execAsync(
|
|
151
|
-
`npm view ${packageName} version 2>/dev/null`,
|
|
152
|
-
{ timeout: 10000 },
|
|
153
|
-
);
|
|
154
|
-
return stdout.trim() || undefined;
|
|
155
|
-
} catch {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Pick the version out of a PyPI JSON API body.
|
|
162
|
-
*
|
|
163
|
-
* Split out from the fetch so it is testable without a network: the parsing is
|
|
164
|
-
* the part that can be wrong, and the part that must not throw.
|
|
165
|
-
*/
|
|
166
|
-
export function versionFromPypiJson(body: unknown): string | undefined {
|
|
167
|
-
const info = (body as { info?: { version?: unknown } } | null)?.info;
|
|
168
|
-
return typeof info?.version === "string" ? info.version : undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Latest published version of a Python package, straight from PyPI.
|
|
173
|
-
*
|
|
174
|
-
* Was `pip index versions <pkg> | head -1` through a shell, which had three
|
|
175
|
-
* independent problems and only needed one to fail: `pip` is often not an
|
|
176
|
-
* executable at all (the bug this whole change exists for), `pip index` is an
|
|
177
|
-
* experimental subcommand upstream reserves the right to change, and a machine
|
|
178
|
-
* managing its Python tools with uv or pipx may carry no `pip` whatsoever.
|
|
179
|
-
*
|
|
180
|
-
* An HTTP GET needs none of them. Failure mode is unchanged — undefined, so the
|
|
181
|
-
* row simply shows no update badge — because a version check is advisory and
|
|
182
|
-
* must never take the screen down with it.
|
|
183
|
-
*/
|
|
184
|
-
async function getLatestPipVersion(
|
|
185
|
-
packageName: string,
|
|
186
|
-
): Promise<string | undefined> {
|
|
187
|
-
try {
|
|
188
|
-
const res = await fetch(
|
|
189
|
-
`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`,
|
|
190
|
-
{ signal: AbortSignal.timeout(10000) },
|
|
191
|
-
);
|
|
192
|
-
if (!res.ok) return undefined;
|
|
193
|
-
return versionFromPypiJson(await res.json());
|
|
194
|
-
} catch {
|
|
195
|
-
return undefined;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function getLatestVersion(
|
|
200
|
-
tool: import("../../data/cli-tools.js").CliTool,
|
|
201
|
-
): Promise<string | undefined> {
|
|
202
|
-
switch (tool.packageManager) {
|
|
203
|
-
// bun packages are published to the npm registry, so `npm view` resolves them.
|
|
204
|
-
case "bun":
|
|
205
|
-
case "npm":
|
|
206
|
-
return getLatestNpmVersion(tool.packageName);
|
|
207
|
-
case "pip":
|
|
208
|
-
return getLatestPipVersion(tool.packageName);
|
|
209
|
-
// brew/go have no uniform remote version query here — skip the check.
|
|
210
|
-
default:
|
|
211
|
-
return undefined;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function compareVersions(v1: string, v2: string): number {
|
|
216
|
-
const parts1 = v1.split(/[-.]/).map((p) => Number.parseInt(p, 10) || 0);
|
|
217
|
-
const parts2 = v2.split(/[-.]/).map((p) => Number.parseInt(p, 10) || 0);
|
|
218
|
-
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
|
219
|
-
const p1 = parts1[i] || 0;
|
|
220
|
-
const p2 = parts2[i] || 0;
|
|
221
|
-
if (p1 < p2) return -1;
|
|
222
|
-
if (p1 > p2) return 1;
|
|
223
|
-
}
|
|
224
|
-
return 0;
|
|
75
|
+
};
|
|
76
|
+
await Promise.all(
|
|
77
|
+
Array.from({ length: Math.min(limit, items.length) }, worker),
|
|
78
|
+
);
|
|
79
|
+
return out;
|
|
225
80
|
}
|
|
226
81
|
|
|
227
82
|
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
@@ -232,68 +87,80 @@ export function CliToolsScreen() {
|
|
|
232
87
|
const modal = useModal();
|
|
233
88
|
const dimensions = useDimensions();
|
|
234
89
|
|
|
235
|
-
const [
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
cliTools.map((tool) => ({
|
|
239
|
-
tool,
|
|
240
|
-
installed: false,
|
|
241
|
-
installedVersion: undefined,
|
|
242
|
-
checking: true,
|
|
243
|
-
}))
|
|
244
|
-
);
|
|
245
|
-
});
|
|
90
|
+
const [rows, setRows] = useState<CliToolRow[]>(
|
|
91
|
+
() => cachedRows || initialRows(),
|
|
92
|
+
);
|
|
246
93
|
|
|
247
|
-
const
|
|
248
|
-
|
|
94
|
+
const rowsRef = useRef(rows);
|
|
95
|
+
rowsRef.current = rows;
|
|
249
96
|
|
|
250
|
-
const
|
|
251
|
-
(
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return newStatuses;
|
|
97
|
+
const setAndCache = useCallback(
|
|
98
|
+
(update: (prev: CliToolRow[]) => CliToolRow[]) => {
|
|
99
|
+
setRows((prev) => {
|
|
100
|
+
const next = update(prev);
|
|
101
|
+
cachedRows = next;
|
|
102
|
+
return next;
|
|
257
103
|
});
|
|
258
104
|
},
|
|
259
105
|
[],
|
|
260
106
|
);
|
|
261
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Detection first — it decides which installer each tool is probed through
|
|
110
|
+
* (§4.5 of the design: the closure's spec, else the detected installer, else
|
|
111
|
+
* the catalogue) — then one shared probe, rows filling in as tools settle.
|
|
112
|
+
*/
|
|
262
113
|
const fetchVersionInfo = useCallback(async () => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
114
|
+
const generation = ++fetchGeneration;
|
|
115
|
+
const projectPath = state.projectPath || process.cwd();
|
|
116
|
+
const [active, installs] = await Promise.all([
|
|
117
|
+
// Full resolution, not `resolveBins: false`: a catalogue tool may be
|
|
118
|
+
// declared by a PLUGIN's requires.bin, and parity needs the spec the
|
|
119
|
+
// CLI plans with. `resolveBins: false` would still keep the profile's
|
|
120
|
+
// own cliTools, but lose every plugin-declared bin and its pin.
|
|
121
|
+
loadActiveClosure(projectPath),
|
|
122
|
+
mapLimited(cliTools, PROBE_CONCURRENCY, (tool) =>
|
|
123
|
+
detectInstallMethods(tool, REAL_TOOL_PROBE_IO),
|
|
124
|
+
),
|
|
125
|
+
]);
|
|
126
|
+
if (generation !== fetchGeneration) return;
|
|
127
|
+
|
|
128
|
+
const closureBins = active?.closure.bins ?? null;
|
|
129
|
+
const planned = cliTools.map((tool, i) => ({
|
|
130
|
+
tool,
|
|
131
|
+
install: installs[i],
|
|
132
|
+
spec: specForCatalogTool(tool, closureBins, installs[i]),
|
|
133
|
+
}));
|
|
134
|
+
setAndCache(() =>
|
|
135
|
+
planned.map((p) => buildToolRow(p.tool, p.spec, undefined, p.install)),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
await probeTools(
|
|
139
|
+
planned.map((p) => p.spec),
|
|
140
|
+
REAL_TOOL_PROBE_IO,
|
|
141
|
+
{
|
|
142
|
+
onResult: (facts, { index }) => {
|
|
143
|
+
if (generation !== fetchGeneration) return;
|
|
144
|
+
const p = planned[index];
|
|
145
|
+
if (!p) return;
|
|
146
|
+
const row = buildToolRow(p.tool, p.spec, facts, p.install);
|
|
147
|
+
setAndCache((prev) => {
|
|
148
|
+
const next = [...prev];
|
|
149
|
+
next[index] = row;
|
|
150
|
+
return next;
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
}, [setAndCache, state.projectPath]);
|
|
289
156
|
|
|
290
157
|
useEffect(() => {
|
|
291
158
|
if (!cacheInitialized) {
|
|
292
159
|
cacheInitialized = true;
|
|
293
|
-
|
|
294
|
-
fetchVersionInfo();
|
|
160
|
+
cachedRows = rows;
|
|
161
|
+
void fetchVersionInfo();
|
|
295
162
|
}
|
|
296
|
-
}, [fetchVersionInfo,
|
|
163
|
+
}, [fetchVersionInfo, rows]);
|
|
297
164
|
|
|
298
165
|
useKeyboard((event) => {
|
|
299
166
|
if (state.isSearching || state.modal) return;
|
|
@@ -303,7 +170,7 @@ export function CliToolsScreen() {
|
|
|
303
170
|
dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
|
|
304
171
|
} else if (event.name === "down" || event.name === "j") {
|
|
305
172
|
const newIndex = Math.min(
|
|
306
|
-
|
|
173
|
+
rows.length - 1,
|
|
307
174
|
cliToolsState.selectedIndex + 1,
|
|
308
175
|
);
|
|
309
176
|
dispatch({ type: "CLITOOLS_SELECT", index: newIndex });
|
|
@@ -320,16 +187,9 @@ export function CliToolsScreen() {
|
|
|
320
187
|
|
|
321
188
|
const handleRefresh = () => {
|
|
322
189
|
clearCliToolsCache();
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
installed: false,
|
|
327
|
-
installedVersion: undefined,
|
|
328
|
-
checking: true,
|
|
329
|
-
})),
|
|
330
|
-
);
|
|
331
|
-
cacheInitialized = false;
|
|
332
|
-
fetchVersionInfo();
|
|
190
|
+
setRows(initialRows());
|
|
191
|
+
cacheInitialized = true;
|
|
192
|
+
void fetchVersionInfo();
|
|
333
193
|
};
|
|
334
194
|
|
|
335
195
|
/**
|
|
@@ -354,17 +214,22 @@ export function CliToolsScreen() {
|
|
|
354
214
|
};
|
|
355
215
|
|
|
356
216
|
const handleInstall = async () => {
|
|
357
|
-
const
|
|
358
|
-
if (!
|
|
359
|
-
|
|
360
|
-
const { tool,
|
|
217
|
+
const row = rows[cliToolsState.selectedIndex];
|
|
218
|
+
if (!row || row.checking) return;
|
|
219
|
+
|
|
220
|
+
const { tool, status, updateCommand, installCommand } = row;
|
|
221
|
+
const installed = isInstalled(status);
|
|
222
|
+
// Enter on an `unknown` row still offers the detected-method command:
|
|
223
|
+
// an explicit user action, not a plan. The detail panel shows why the
|
|
224
|
+
// row is unverified beside it.
|
|
361
225
|
const action = !installed
|
|
362
226
|
? "Installing"
|
|
363
|
-
:
|
|
227
|
+
: status?.state === "upgrade" || status?.state === "unknown"
|
|
364
228
|
? "Updating"
|
|
365
229
|
: "Reinstalling";
|
|
366
|
-
|
|
367
|
-
|
|
230
|
+
// Both sides come from the row, which built them from the SAME rule its
|
|
231
|
+
// state came from — a declared tool's pin included.
|
|
232
|
+
const command = installed && updateCommand ? updateCommand : installCommand;
|
|
368
233
|
|
|
369
234
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
370
235
|
const result = await runCommand(command);
|
|
@@ -382,14 +247,16 @@ export function CliToolsScreen() {
|
|
|
382
247
|
};
|
|
383
248
|
|
|
384
249
|
const handleResolveConflict = async () => {
|
|
385
|
-
const
|
|
386
|
-
|
|
250
|
+
const row = rows[cliToolsState.selectedIndex];
|
|
251
|
+
const install: InstallInfo | undefined = row?.install;
|
|
252
|
+
if (!row || !install || install.all.length < 2) return;
|
|
387
253
|
|
|
388
|
-
const { tool
|
|
254
|
+
const { tool } = row;
|
|
255
|
+
const allMethods = install.all;
|
|
389
256
|
|
|
390
257
|
// Let user pick which install to keep
|
|
391
258
|
const options = allMethods.map((method) => ({
|
|
392
|
-
label: `Keep ${method}${method ===
|
|
259
|
+
label: `Keep ${method}${method === install.primary ? " (active)" : ""}, remove others`,
|
|
393
260
|
value: method,
|
|
394
261
|
}));
|
|
395
262
|
|
|
@@ -405,7 +272,7 @@ export function CliToolsScreen() {
|
|
|
405
272
|
|
|
406
273
|
const errors: string[] = [];
|
|
407
274
|
for (const method of toRemove) {
|
|
408
|
-
const cmd = cliToolUninstall(tool, method, brewFormula);
|
|
275
|
+
const cmd = cliToolUninstall(tool, method, install.brewFormula);
|
|
409
276
|
if (!cmd) continue;
|
|
410
277
|
const result = await runCommand(cmd);
|
|
411
278
|
if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
|
|
@@ -425,11 +292,14 @@ export function CliToolsScreen() {
|
|
|
425
292
|
};
|
|
426
293
|
|
|
427
294
|
const handleUpdateAll = async () => {
|
|
428
|
-
|
|
295
|
+
// Only rows the shared classifier says are behind. An `unknown` is never
|
|
296
|
+
// swept up in a bulk action — that is exactly the "cannot tell, so run
|
|
297
|
+
// it anyway" the CLI no longer does either.
|
|
298
|
+
const updatable = rows.filter((r) => r.status?.state === "upgrade");
|
|
429
299
|
if (updatable.length === 0) {
|
|
430
300
|
await modal.message(
|
|
431
301
|
"Up to Date",
|
|
432
|
-
"
|
|
302
|
+
"No tool is known to be behind.",
|
|
433
303
|
"info",
|
|
434
304
|
);
|
|
435
305
|
return;
|
|
@@ -437,29 +307,33 @@ export function CliToolsScreen() {
|
|
|
437
307
|
|
|
438
308
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
439
309
|
|
|
440
|
-
for (const
|
|
441
|
-
|
|
442
|
-
await runCommand(command);
|
|
310
|
+
for (const row of updatable) {
|
|
311
|
+
await runCommand(row.updateCommand ?? row.installCommand);
|
|
443
312
|
}
|
|
444
313
|
|
|
445
314
|
modal.hideModal();
|
|
446
315
|
handleRefresh();
|
|
447
316
|
};
|
|
448
317
|
|
|
449
|
-
const
|
|
318
|
+
const selectedRow = rows[cliToolsState.selectedIndex];
|
|
450
319
|
|
|
451
|
-
const
|
|
452
|
-
const updateCount = toolStatuses.filter((s) => s.hasUpdate).length;
|
|
320
|
+
const counts = countToolRows(rows);
|
|
453
321
|
const statusContent = (
|
|
454
322
|
<text fg={theme.colors.text}>
|
|
455
323
|
<span fg={theme.colors.muted}>Installed: </span>
|
|
456
324
|
<span fg={theme.colors.info}>
|
|
457
|
-
{
|
|
325
|
+
{counts.installed}/{rows.length}
|
|
458
326
|
</span>
|
|
459
|
-
{
|
|
327
|
+
{counts.updates > 0 && (
|
|
460
328
|
<>
|
|
461
329
|
<span fg={theme.colors.muted}> │ Updates: </span>
|
|
462
|
-
<span fg={theme.colors.warning}>{
|
|
330
|
+
<span fg={theme.colors.warning}>{counts.updates}</span>
|
|
331
|
+
</>
|
|
332
|
+
)}
|
|
333
|
+
{counts.unverified > 0 && (
|
|
334
|
+
<>
|
|
335
|
+
<span fg={theme.colors.muted}> │ Unverified: </span>
|
|
336
|
+
<span fg={theme.colors.warning}>{counts.unverified}</span>
|
|
463
337
|
</>
|
|
464
338
|
)}
|
|
465
339
|
</text>
|
|
@@ -478,14 +352,14 @@ export function CliToolsScreen() {
|
|
|
478
352
|
]}
|
|
479
353
|
listPanel={
|
|
480
354
|
<ScrollableList
|
|
481
|
-
items={
|
|
355
|
+
items={rows}
|
|
482
356
|
selectedIndex={cliToolsState.selectedIndex}
|
|
483
357
|
renderItem={renderCliToolRow}
|
|
484
358
|
maxHeight={dimensions.listPanelHeight}
|
|
485
|
-
getKey={(
|
|
359
|
+
getKey={(row) => row.tool.name}
|
|
486
360
|
/>
|
|
487
361
|
}
|
|
488
|
-
detailPanel={renderCliToolDetail(
|
|
362
|
+
detailPanel={renderCliToolDetail(selectedRow)}
|
|
489
363
|
/>
|
|
490
364
|
);
|
|
491
365
|
}
|