claudeup 6.0.0 → 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/scripts/test-isolated.ts +7 -1
- package/src/__tests__/catalog-cache-store.test.ts +49 -11
- package/src/__tests__/cli-apply-seams.test.ts +51 -2
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/toolchain.test.ts +176 -31
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +137 -3
- package/src/cli/install.ts +11 -5
- package/src/cli/prompt.ts +0 -9
- package/src/cli/update-view.ts +4 -1
- package/src/cli/update.ts +18 -287
- package/src/data/cli-tools.ts +20 -13
- package/src/services/claude-cli.ts +67 -30
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/doctor-bins.ts +9 -3
- package/src/services/toolchain.ts +235 -33
- package/src/services/update-engine.ts +503 -0
- package/src/ui/renderers/cliToolRenderers.tsx +40 -39
- package/src/ui/screens/CliToolsScreen.tsx +56 -64
- package/src/ui/screens/PluginsScreen.tsx +120 -64
- package/src/utils/command-utils.ts +102 -1
- package/src/utils/run.ts +67 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
2
|
-
import { exec } from "child_process";
|
|
2
|
+
import { exec, execFile } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
4
|
import { useApp, useModal } from "../state/AppContext.js";
|
|
5
5
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
@@ -7,16 +7,24 @@ import { useKeyboard } from "../hooks/useKeyboard.js";
|
|
|
7
7
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
8
8
|
|
|
9
9
|
import { cliTools } from "../../data/cli-tools.js";
|
|
10
|
+
import {
|
|
11
|
+
type InstallMethod,
|
|
12
|
+
cliToolInstall,
|
|
13
|
+
cliToolUninstall,
|
|
14
|
+
cliToolUpdate,
|
|
15
|
+
} from "../../services/cli-tool-commands.js";
|
|
16
|
+
import type { Command } from "../../utils/command-utils.js";
|
|
17
|
+
import { formatCommand } from "../../utils/command-utils.js";
|
|
10
18
|
import {
|
|
11
19
|
renderCliToolRow,
|
|
12
20
|
renderCliToolDetail,
|
|
13
21
|
type CliToolStatus,
|
|
14
|
-
type InstallMethod,
|
|
15
22
|
} from "../renderers/cliToolRenderers.js";
|
|
16
23
|
import { ScrollableList } from "../components/ScrollableList.js";
|
|
17
24
|
import { theme } from "../theme.js";
|
|
18
25
|
|
|
19
26
|
const execAsync = promisify(exec);
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
20
28
|
|
|
21
29
|
// ─── Version helpers ───────────────────────────────────────────────────────────
|
|
22
30
|
|
|
@@ -123,52 +131,6 @@ async function detectInstallMethods(
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
function getUninstallCommand(
|
|
127
|
-
tool: import("../../data/cli-tools.js").CliTool,
|
|
128
|
-
method: InstallMethod,
|
|
129
|
-
brewFormula?: string,
|
|
130
|
-
): string {
|
|
131
|
-
switch (method) {
|
|
132
|
-
case "bun":
|
|
133
|
-
return `bun remove -g ${tool.packageName}`;
|
|
134
|
-
case "npm":
|
|
135
|
-
return `npm uninstall -g ${tool.packageName}`;
|
|
136
|
-
case "pnpm":
|
|
137
|
-
return `pnpm remove -g ${tool.packageName}`;
|
|
138
|
-
case "yarn":
|
|
139
|
-
return `yarn global remove ${tool.packageName}`;
|
|
140
|
-
case "brew":
|
|
141
|
-
return `brew uninstall ${brewFormula || tool.name}`;
|
|
142
|
-
case "pip":
|
|
143
|
-
return `pip uninstall -y ${tool.packageName}`;
|
|
144
|
-
default:
|
|
145
|
-
return "";
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function getUpdateCommand(
|
|
150
|
-
tool: import("../../data/cli-tools.js").CliTool,
|
|
151
|
-
method: InstallMethod,
|
|
152
|
-
brewFormula?: string,
|
|
153
|
-
): string {
|
|
154
|
-
switch (method) {
|
|
155
|
-
case "bun":
|
|
156
|
-
return `bun install -g ${tool.packageName}`;
|
|
157
|
-
case "npm":
|
|
158
|
-
return `npm install -g ${tool.packageName}`;
|
|
159
|
-
case "pnpm":
|
|
160
|
-
return `pnpm install -g ${tool.packageName}`;
|
|
161
|
-
case "yarn":
|
|
162
|
-
return `yarn global add ${tool.packageName}`;
|
|
163
|
-
case "brew":
|
|
164
|
-
return `brew upgrade ${brewFormula || tool.name}`;
|
|
165
|
-
case "pip":
|
|
166
|
-
return tool.installCommand;
|
|
167
|
-
default:
|
|
168
|
-
return tool.installCommand;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
134
|
async function getInstalledVersion(
|
|
173
135
|
tool: import("../../data/cli-tools.js").CliTool,
|
|
174
136
|
): Promise<string | undefined> {
|
|
@@ -194,16 +156,40 @@ async function getLatestNpmVersion(
|
|
|
194
156
|
}
|
|
195
157
|
}
|
|
196
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Pick the version out of a PyPI JSON API body.
|
|
161
|
+
*
|
|
162
|
+
* Split out from the fetch so it is testable without a network: the parsing is
|
|
163
|
+
* the part that can be wrong, and the part that must not throw.
|
|
164
|
+
*/
|
|
165
|
+
export function versionFromPypiJson(body: unknown): string | undefined {
|
|
166
|
+
const info = (body as { info?: { version?: unknown } } | null)?.info;
|
|
167
|
+
return typeof info?.version === "string" ? info.version : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Latest published version of a Python package, straight from PyPI.
|
|
172
|
+
*
|
|
173
|
+
* Was `pip index versions <pkg> | head -1` through a shell, which had three
|
|
174
|
+
* independent problems and only needed one to fail: `pip` is often not an
|
|
175
|
+
* executable at all (the bug this whole change exists for), `pip index` is an
|
|
176
|
+
* experimental subcommand upstream reserves the right to change, and a machine
|
|
177
|
+
* managing its Python tools with uv or pipx may carry no `pip` whatsoever.
|
|
178
|
+
*
|
|
179
|
+
* An HTTP GET needs none of them. Failure mode is unchanged — undefined, so the
|
|
180
|
+
* row simply shows no update badge — because a version check is advisory and
|
|
181
|
+
* must never take the screen down with it.
|
|
182
|
+
*/
|
|
197
183
|
async function getLatestPipVersion(
|
|
198
184
|
packageName: string,
|
|
199
185
|
): Promise<string | undefined> {
|
|
200
186
|
try {
|
|
201
|
-
const
|
|
202
|
-
`
|
|
203
|
-
{
|
|
187
|
+
const res = await fetch(
|
|
188
|
+
`https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`,
|
|
189
|
+
{ signal: AbortSignal.timeout(10000) },
|
|
204
190
|
);
|
|
205
|
-
|
|
206
|
-
return
|
|
191
|
+
if (!res.ok) return undefined;
|
|
192
|
+
return versionFromPypiJson(await res.json());
|
|
207
193
|
} catch {
|
|
208
194
|
return undefined;
|
|
209
195
|
}
|
|
@@ -292,7 +278,7 @@ export function CliToolsScreen() {
|
|
|
292
278
|
installMethod: version ? info.primary : undefined,
|
|
293
279
|
allMethods: version && info.all.length > 1 ? info.all : undefined,
|
|
294
280
|
updateCommand: version
|
|
295
|
-
?
|
|
281
|
+
? (cliToolUpdate(tool, info.primary, info.brewFormula) ?? undefined)
|
|
296
282
|
: undefined,
|
|
297
283
|
brewFormula: info.brewFormula,
|
|
298
284
|
});
|
|
@@ -345,14 +331,20 @@ export function CliToolsScreen() {
|
|
|
345
331
|
fetchVersionInfo();
|
|
346
332
|
};
|
|
347
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Run one installer command and report success.
|
|
336
|
+
*
|
|
337
|
+
* `execFile`, so there is no shell to re-parse a package name — the same
|
|
338
|
+
* reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
|
|
339
|
+
* that one inherits stdio, and a TUI must never hand the terminal to a
|
|
340
|
+
* child. Output is captured and discarded here; the modal is the progress
|
|
341
|
+
* indicator.
|
|
342
|
+
*/
|
|
348
343
|
const runCommand = async (
|
|
349
|
-
command:
|
|
344
|
+
command: Command,
|
|
350
345
|
): Promise<{ ok: boolean; error?: string }> => {
|
|
351
346
|
try {
|
|
352
|
-
await
|
|
353
|
-
shell: "/bin/bash",
|
|
354
|
-
timeout: 60000,
|
|
355
|
-
});
|
|
347
|
+
await execFileAsync(command.cmd, [...command.args], { timeout: 60000 });
|
|
356
348
|
return { ok: true };
|
|
357
349
|
} catch (err) {
|
|
358
350
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -371,7 +363,7 @@ export function CliToolsScreen() {
|
|
|
371
363
|
? "Updating"
|
|
372
364
|
: "Reinstalling";
|
|
373
365
|
const command =
|
|
374
|
-
installed && updateCommand ? updateCommand : tool
|
|
366
|
+
installed && updateCommand ? updateCommand : cliToolInstall(tool);
|
|
375
367
|
|
|
376
368
|
modal.loading(`${action} ${tool.displayName}...`);
|
|
377
369
|
const result = await runCommand(command);
|
|
@@ -382,7 +374,7 @@ export function CliToolsScreen() {
|
|
|
382
374
|
} else {
|
|
383
375
|
await modal.message(
|
|
384
376
|
"Error",
|
|
385
|
-
`Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${command}`,
|
|
377
|
+
`Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
|
|
386
378
|
"error",
|
|
387
379
|
);
|
|
388
380
|
}
|
|
@@ -412,10 +404,10 @@ export function CliToolsScreen() {
|
|
|
412
404
|
|
|
413
405
|
const errors: string[] = [];
|
|
414
406
|
for (const method of toRemove) {
|
|
415
|
-
const cmd =
|
|
407
|
+
const cmd = cliToolUninstall(tool, method, brewFormula);
|
|
416
408
|
if (!cmd) continue;
|
|
417
409
|
const result = await runCommand(cmd);
|
|
418
|
-
if (!result.ok) errors.push(`${method}: ${cmd}`);
|
|
410
|
+
if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
|
|
419
411
|
}
|
|
420
412
|
|
|
421
413
|
modal.hideModal();
|
|
@@ -445,7 +437,7 @@ export function CliToolsScreen() {
|
|
|
445
437
|
modal.loading(`Updating ${updatable.length} tool(s)...`);
|
|
446
438
|
|
|
447
439
|
for (const status of updatable) {
|
|
448
|
-
const command = status.updateCommand || status.tool
|
|
440
|
+
const command = status.updateCommand || cliToolInstall(status.tool);
|
|
449
441
|
await runCommand(command);
|
|
450
442
|
}
|
|
451
443
|
|
|
@@ -7,19 +7,26 @@ import {
|
|
|
7
7
|
import {
|
|
8
8
|
type PluginScope,
|
|
9
9
|
addMarketplace as cliAddMarketplace,
|
|
10
|
-
installPlugin as cliInstallPlugin,
|
|
11
10
|
repairPlugin as cliRepairPlugin,
|
|
12
11
|
uninstallPlugin as cliUninstallPlugin,
|
|
13
|
-
updatePlugin as cliUpdatePlugin,
|
|
14
12
|
} from "../../services/claude-cli.js";
|
|
15
13
|
import {
|
|
16
14
|
getMcpEnvVars,
|
|
17
15
|
readSettings,
|
|
18
|
-
saveGlobalInstalledPluginVersion,
|
|
19
|
-
saveLocalInstalledPluginVersion,
|
|
20
16
|
setMcpEnvVar,
|
|
21
17
|
} from "../../services/claude-settings.js";
|
|
22
18
|
import { clearContentDriftCache } from "../../services/content-drift.js";
|
|
19
|
+
// The one update implementation, shared with `claudeup update`. See the header
|
|
20
|
+
// of update-engine.ts for what drifted while the TUI had its own loop.
|
|
21
|
+
import {
|
|
22
|
+
type ApplyReporter,
|
|
23
|
+
REAL_PLUGIN_DEPS,
|
|
24
|
+
applyPlugins,
|
|
25
|
+
pluginNeedsWork,
|
|
26
|
+
installPluginInScope,
|
|
27
|
+
updatePluginInScope,
|
|
28
|
+
} from "../../services/update-engine.js";
|
|
29
|
+
import { planPluginUpdates } from "../../services/update-plan.js";
|
|
23
30
|
import {
|
|
24
31
|
type MarketplaceFetchFailure,
|
|
25
32
|
type PluginInfo,
|
|
@@ -31,7 +38,6 @@ import {
|
|
|
31
38
|
isInstalledInScope,
|
|
32
39
|
refreshAllMarketplaces,
|
|
33
40
|
resolveScopeAction,
|
|
34
|
-
saveInstalledPluginVersion,
|
|
35
41
|
} from "../../services/plugin-manager.js";
|
|
36
42
|
import {
|
|
37
43
|
getPluginEnvRequirements,
|
|
@@ -516,29 +522,6 @@ export function PluginsScreen() {
|
|
|
516
522
|
|
|
517
523
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
518
524
|
|
|
519
|
-
/**
|
|
520
|
-
* Save the installed plugin version to the correct settings file for the scope.
|
|
521
|
-
* The Claude CLI's `plugin install` does NOT update installedPluginVersions,
|
|
522
|
-
* so claudeup must do it after a successful CLI install/update.
|
|
523
|
-
*/
|
|
524
|
-
const saveVersionForScope = async (
|
|
525
|
-
pluginId: string,
|
|
526
|
-
version: string,
|
|
527
|
-
scope: PluginScope,
|
|
528
|
-
): Promise<void> => {
|
|
529
|
-
if (scope === "user") {
|
|
530
|
-
await saveGlobalInstalledPluginVersion(pluginId, version);
|
|
531
|
-
} else if (scope === "local") {
|
|
532
|
-
await saveLocalInstalledPluginVersion(
|
|
533
|
-
pluginId,
|
|
534
|
-
version,
|
|
535
|
-
state.projectPath,
|
|
536
|
-
);
|
|
537
|
-
} else {
|
|
538
|
-
await saveInstalledPluginVersion(pluginId, version, state.projectPath);
|
|
539
|
-
}
|
|
540
|
-
};
|
|
541
|
-
|
|
542
525
|
/**
|
|
543
526
|
* Check for version mismatch after a plugin update and show the
|
|
544
527
|
* interactive mismatch modal with a "Fix all projects" button.
|
|
@@ -947,14 +930,29 @@ export function PluginsScreen() {
|
|
|
947
930
|
modal.loading(
|
|
948
931
|
`Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
949
932
|
);
|
|
950
|
-
|
|
951
|
-
|
|
933
|
+
// Scope is the user's explicit choice here, so this does NOT go
|
|
934
|
+
// through applyPlugins (which advances every scope behind). It
|
|
935
|
+
// still records the version READ BACK, not `latestVersion`.
|
|
936
|
+
await updatePluginInScope(
|
|
937
|
+
plugin.id,
|
|
938
|
+
scope,
|
|
939
|
+
state.projectPath || process.cwd(),
|
|
940
|
+
);
|
|
952
941
|
} else {
|
|
953
942
|
modal.loading(
|
|
954
943
|
`Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
955
944
|
);
|
|
956
|
-
|
|
957
|
-
|
|
945
|
+
// Same read-back as the update branch above. A fresh install is
|
|
946
|
+
// no more able to guarantee it got `latestVersion` than an
|
|
947
|
+
// update is — the marketplace serves whatever it publishes at
|
|
948
|
+
// the moment of the call — so recording the version we asked
|
|
949
|
+
// for made `install --check` compare that number against a copy
|
|
950
|
+
// of itself and report clean forever.
|
|
951
|
+
await installPluginInScope(
|
|
952
|
+
plugin.id,
|
|
953
|
+
scope,
|
|
954
|
+
state.projectPath || process.cwd(),
|
|
955
|
+
);
|
|
958
956
|
modal.hideModal();
|
|
959
957
|
await collectPluginEnvVars(plugin.name, plugin.marketplace);
|
|
960
958
|
await installPluginSystemDeps(plugin.name, plugin.marketplace);
|
|
@@ -993,15 +991,30 @@ export function PluginsScreen() {
|
|
|
993
991
|
: `Updating ${plugin.name}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
994
992
|
);
|
|
995
993
|
try {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
994
|
+
// One plugin, but the SAME engine — so a single update records the
|
|
995
|
+
// version that landed and touches every scope that is behind, exactly
|
|
996
|
+
// as `claudeup update` does. `repairing` is not re-derived here: the
|
|
997
|
+
// planner already encodes it as the `repair` action.
|
|
998
|
+
const catalog = new Map(
|
|
999
|
+
pluginsState.plugins.status === "success"
|
|
1000
|
+
? pluginsState.plugins.data.map((p) => [p.id, p] as const)
|
|
1001
|
+
: [[plugin.id, plugin] as const],
|
|
1002
|
+
);
|
|
1003
|
+
const items = planPluginUpdates({ [plugin.id]: "latest" }, catalog);
|
|
1004
|
+
const result = await applyPlugins(
|
|
1005
|
+
items,
|
|
1006
|
+
state.projectPath || process.cwd(),
|
|
1007
|
+
REAL_PLUGIN_DEPS,
|
|
1008
|
+
);
|
|
1004
1009
|
modal.hideModal();
|
|
1010
|
+
if (result.failed.length > 0) {
|
|
1011
|
+
await modal.message(
|
|
1012
|
+
"Error",
|
|
1013
|
+
`Failed to update ${plugin.name}`,
|
|
1014
|
+
"error",
|
|
1015
|
+
);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1005
1018
|
await warnIfVersionMismatch(plugin.id);
|
|
1006
1019
|
fetchData();
|
|
1007
1020
|
} catch (error) {
|
|
@@ -1010,6 +1023,18 @@ export function PluginsScreen() {
|
|
|
1010
1023
|
}
|
|
1011
1024
|
};
|
|
1012
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* "Update all" — the SAME planner and the SAME apply engine `claudeup
|
|
1028
|
+
* update` runs. Only the selection differs, and only because a screen and a
|
|
1029
|
+
* profile genuinely mean different sets: here it is "everything listed with
|
|
1030
|
+
* an update", there it is "what the profile declares".
|
|
1031
|
+
*
|
|
1032
|
+
* This used to be a hand-rolled loop, and it had drifted from the CLI in
|
|
1033
|
+
* three ways that were bugs, not choices — it touched one scope derived from
|
|
1034
|
+
* the open tab, it recorded the version it EXPECTED rather than the one that
|
|
1035
|
+
* landed, and it never reported a pin the installer could not honour. All
|
|
1036
|
+
* three are fixed by not having a second implementation.
|
|
1037
|
+
*/
|
|
1013
1038
|
const handleUpdateAll = async () => {
|
|
1014
1039
|
if (pluginsState.plugins.status !== "success") return;
|
|
1015
1040
|
|
|
@@ -1020,30 +1045,45 @@ export function PluginsScreen() {
|
|
|
1020
1045
|
);
|
|
1021
1046
|
if (updatable.length === 0) return;
|
|
1022
1047
|
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
const
|
|
1048
|
+
const projectPath = state.projectPath || process.cwd();
|
|
1049
|
+
// The screen's own list IS the catalog the planner needs.
|
|
1050
|
+
const catalog = new Map(pluginsState.plugins.data.map((p) => [p.id, p]));
|
|
1051
|
+
const items = planPluginUpdates(
|
|
1052
|
+
Object.fromEntries(updatable.map((p) => [p.id, "latest"])),
|
|
1053
|
+
catalog,
|
|
1054
|
+
);
|
|
1055
|
+
const total = items.filter(pluginNeedsWork).length;
|
|
1056
|
+
|
|
1057
|
+
let done = 0;
|
|
1058
|
+
const reporter: ApplyReporter = {
|
|
1059
|
+
begin: (name) => {
|
|
1060
|
+
done++;
|
|
1061
|
+
modal.loading(`Updating ${name} (${done}/${total})…`);
|
|
1062
|
+
},
|
|
1063
|
+
finish: () => {},
|
|
1064
|
+
detach: (work) => work(),
|
|
1065
|
+
};
|
|
1026
1066
|
|
|
1027
1067
|
try {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1068
|
+
const result = await applyPlugins(
|
|
1069
|
+
items,
|
|
1070
|
+
projectPath,
|
|
1071
|
+
REAL_PLUGIN_DEPS,
|
|
1072
|
+
reporter,
|
|
1073
|
+
);
|
|
1074
|
+
const updatedPluginIds = items
|
|
1075
|
+
.filter(pluginNeedsWork)
|
|
1076
|
+
.map((i) => i.pluginId)
|
|
1077
|
+
.filter((id) => !result.failed.includes(id));
|
|
1078
|
+
modal.hideModal();
|
|
1079
|
+
if (result.failed.length > 0) {
|
|
1080
|
+
await modal.message(
|
|
1081
|
+
"Some updates failed",
|
|
1082
|
+
result.failed.join(", "),
|
|
1083
|
+
"error",
|
|
1033
1084
|
);
|
|
1034
|
-
if (repairing) {
|
|
1035
|
-
await cliRepairPlugin(plugin.id, scope, state.projectPath);
|
|
1036
|
-
} else {
|
|
1037
|
-
await cliUpdatePlugin(plugin.id, scope);
|
|
1038
|
-
}
|
|
1039
|
-
if (plugin.version) {
|
|
1040
|
-
await saveVersionForScope(plugin.id, plugin.version, scope);
|
|
1041
|
-
}
|
|
1042
|
-
updatedPluginIds.push(plugin.id);
|
|
1043
1085
|
}
|
|
1044
|
-
modal.hideModal();
|
|
1045
1086
|
// Batch mismatch checks into a single modal
|
|
1046
|
-
const projectPath = state.projectPath || process.cwd();
|
|
1047
1087
|
const allMismatches: VersionMismatchInfo[] = [];
|
|
1048
1088
|
for (const pluginId of updatedPluginIds) {
|
|
1049
1089
|
try {
|
|
@@ -1122,14 +1162,22 @@ export function PluginsScreen() {
|
|
|
1122
1162
|
modal.loading(
|
|
1123
1163
|
`Updating ${plugin.name} in ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1124
1164
|
);
|
|
1125
|
-
|
|
1126
|
-
await
|
|
1165
|
+
// Explicit scope, read-back version — see the note above.
|
|
1166
|
+
await updatePluginInScope(
|
|
1167
|
+
plugin.id,
|
|
1168
|
+
scope,
|
|
1169
|
+
state.projectPath || process.cwd(),
|
|
1170
|
+
);
|
|
1127
1171
|
} else {
|
|
1128
1172
|
modal.loading(
|
|
1129
1173
|
`Installing ${plugin.name} to ${scopeLabel}…\nclaude plugin install ${plugin.id} --scope ${scope}`,
|
|
1130
1174
|
);
|
|
1131
|
-
|
|
1132
|
-
await
|
|
1175
|
+
// Read-back on install too — see the note on the same branch above.
|
|
1176
|
+
await installPluginInScope(
|
|
1177
|
+
plugin.id,
|
|
1178
|
+
scope,
|
|
1179
|
+
state.projectPath || process.cwd(),
|
|
1180
|
+
);
|
|
1133
1181
|
modal.hideModal();
|
|
1134
1182
|
await collectPluginEnvVars(plugin.name, plugin.marketplace);
|
|
1135
1183
|
await installPluginSystemDeps(plugin.name, plugin.marketplace);
|
|
@@ -1206,7 +1254,15 @@ export function PluginsScreen() {
|
|
|
1206
1254
|
modal.loading(
|
|
1207
1255
|
`Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
|
|
1208
1256
|
);
|
|
1209
|
-
|
|
1257
|
+
// The migrated id is a plugin claudeup has never recorded a
|
|
1258
|
+
// version for, so going through the read-back is what stops the
|
|
1259
|
+
// rename leaving it permanently version-less — a plugin with no
|
|
1260
|
+
// recorded version never reports an available update.
|
|
1261
|
+
await installPluginInScope(
|
|
1262
|
+
newId,
|
|
1263
|
+
scope,
|
|
1264
|
+
state.projectPath || process.cwd(),
|
|
1265
|
+
);
|
|
1210
1266
|
}
|
|
1211
1267
|
}
|
|
1212
1268
|
|
|
@@ -1,10 +1,74 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { access, realpath } from "node:fs/promises";
|
|
4
|
-
import { constants } from "node:fs";
|
|
4
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A command as an executable plus its already-separated arguments.
|
|
11
|
+
*
|
|
12
|
+
* The alternative — a single string handed to `/bin/sh` — has cost this
|
|
13
|
+
* codebase twice, in two different ways, and both were shipped rather than
|
|
14
|
+
* hypothetical:
|
|
15
|
+
*
|
|
16
|
+
* 1. **A word the shell could not resolve.** `pip install <pkg>` died with
|
|
17
|
+
* `/bin/sh: pip: command not found` on machines where `pip` is only an
|
|
18
|
+
* interactive-zsh alias. `services/toolchain.ts` fixed *which word* gets
|
|
19
|
+
* emitted; it could not fix that the words are re-parsed at spawn time.
|
|
20
|
+
* 2. **Package names are re-parsed as shell syntax.** The builders are fed
|
|
21
|
+
* `bin.package` / `bin.formula` / `bin.module`, which come from
|
|
22
|
+
* `.claude/profiles.json` and from plugin `requires.bin` — hand-authored,
|
|
23
|
+
* committed by teammates, fetched from marketplaces. Under `shell: true`
|
|
24
|
+
* those go through word-splitting and `$(…)` expansion. Today's catalogue is
|
|
25
|
+
* benign; the surface is not, and nothing checks it.
|
|
26
|
+
*
|
|
27
|
+
* As argv there is no shell, so a package name is one token no matter what
|
|
28
|
+
* characters it contains, and `cmd` must name a real executable rather than
|
|
29
|
+
* anything PATH-plus-a-profile might have meant.
|
|
30
|
+
*
|
|
31
|
+
* `services/plugin-setup.ts` arrived at this same shape independently for the
|
|
32
|
+
* MCP dependency installer. This is the codebase agreeing with itself.
|
|
33
|
+
*/
|
|
34
|
+
export interface Command {
|
|
35
|
+
readonly cmd: string;
|
|
36
|
+
readonly args: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Characters that need no quoting in any POSIX shell.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately conservative, and the reason {@link formatCommand} is display-
|
|
43
|
+
* neutral: every token claudeup actually builds — `@anthropic-ai/claude-code`,
|
|
44
|
+
* `claudish@1.2.0`, `aider==0.1`, `github.com/x/t@latest`, `--upgrade` — is
|
|
45
|
+
* made only of these, so quoting never fires for real input and the rendered
|
|
46
|
+
* string is byte-identical to the one this codebase printed before commands
|
|
47
|
+
* became argv. `doctor-bins.test.ts` is the gate on that claim.
|
|
48
|
+
*/
|
|
49
|
+
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a {@link Command} as the shell line a human would type.
|
|
53
|
+
*
|
|
54
|
+
* FOR DISPLAY ONLY. Nothing may feed this back to a shell — that would undo the
|
|
55
|
+
* whole point of carrying argv. It exists so `doctor` can print "run: brew
|
|
56
|
+
* install tmux" and the updater can show what it is about to do.
|
|
57
|
+
*
|
|
58
|
+
* A token outside {@link SHELL_SAFE} is single-quoted, with `'` closed,
|
|
59
|
+
* escaped and reopened (`'\''`) — the only POSIX-portable escape, since single
|
|
60
|
+
* quotes have no escape character inside them. An empty token quotes too:
|
|
61
|
+
* unquoted it would vanish, which would misrepresent the argv it came from.
|
|
62
|
+
*/
|
|
63
|
+
export function formatCommand(c: Command): string {
|
|
64
|
+
return [c.cmd, ...c.args].map(quoteToken).join(" ");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function quoteToken(token: string): string {
|
|
68
|
+
if (SHELL_SAFE.test(token)) return token;
|
|
69
|
+
return `'${token.replaceAll("'", `'\\''`)}'`;
|
|
70
|
+
}
|
|
71
|
+
|
|
8
72
|
/**
|
|
9
73
|
* Check if a command exists in PATH
|
|
10
74
|
* Uses execFile (not exec) to avoid shell injection vulnerabilities
|
|
@@ -21,6 +85,43 @@ export async function which(command: string): Promise<string | null> {
|
|
|
21
85
|
}
|
|
22
86
|
}
|
|
23
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Synchronous PATH lookup — walks `$PATH` rather than spawning `which(1)`.
|
|
90
|
+
*
|
|
91
|
+
* Synchronous on purpose. The installer-command builders in
|
|
92
|
+
* `services/toolchain.ts` are sync and used by printers; making them async just
|
|
93
|
+
* to answer "does this machine have uv?" would ripple through every caller.
|
|
94
|
+
*
|
|
95
|
+
* Node's `fs` is used rather than `Bun.which` so this needs no `@types/bun`,
|
|
96
|
+
* whose globals collide with `@types/node` in this project.
|
|
97
|
+
*
|
|
98
|
+
* Unlike {@link resolveExecutable} this does NOT follow symlinks to prove the
|
|
99
|
+
* target still exists — use it to pick between candidates, not to prove a
|
|
100
|
+
* binary works.
|
|
101
|
+
*/
|
|
102
|
+
export function whichSync(command: string): string | null {
|
|
103
|
+
// An explicit path is not a PATH lookup; honour it as given.
|
|
104
|
+
if (command.includes("/")) {
|
|
105
|
+
return isExecutableFile(command) ? command : null;
|
|
106
|
+
}
|
|
107
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
108
|
+
if (!dir) continue;
|
|
109
|
+
const candidate = join(dir, command);
|
|
110
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isExecutableFile(p: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
if (!statSync(p).isFile()) return false;
|
|
118
|
+
accessSync(p, constants.X_OK);
|
|
119
|
+
return true;
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
24
125
|
/**
|
|
25
126
|
* Like {@link which}, but verifies the command actually RESOLVES and is
|
|
26
127
|
* executable — a dangling symlink (e.g. a stale npx-cache link) returns null.
|
package/src/utils/run.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running an installer, with the shell split out from the argv.
|
|
3
|
+
*
|
|
4
|
+
* Lives in `utils/` rather than `cli/prompt.ts` because the update engine needs
|
|
5
|
+
* it and a service must not import from the CLI layer — `cli/` and `ui/` are
|
|
6
|
+
* sibling front ends over `services/`, and an arrow from a service back up into
|
|
7
|
+
* one of them is how the two front ends ended up with different update logic in
|
|
8
|
+
* the first place.
|
|
9
|
+
*
|
|
10
|
+
* Both functions live in ONE file on purpose: {@link runShellScript}'s doc only
|
|
11
|
+
* makes sense next to {@link runCommand}, because most of what it has to say is
|
|
12
|
+
* "you almost certainly want the other one".
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import type { Command } from "./command-utils.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run a built command as argv, streaming its output; true on exit 0.
|
|
20
|
+
*
|
|
21
|
+
* **This is the one every installer uses.** No `shell` option, so:
|
|
22
|
+
*
|
|
23
|
+
* - `cmd` is resolved on PATH as an executable. There is no profile to source
|
|
24
|
+
* and therefore no way for a shell alias to appear to work — the reason the
|
|
25
|
+
* shipped `pip install` bug could exist at all was that `/bin/sh` was asked
|
|
26
|
+
* to resolve a word that only interactive zsh knew.
|
|
27
|
+
* - Every argument stays exactly one argument. Package names reach here from
|
|
28
|
+
* `.claude/profiles.json` and plugin `requires.bin` — hand-authored data
|
|
29
|
+
* committed by teammates and fetched from marketplaces — and under a shell a
|
|
30
|
+
* space or a `$(…)` in one of them would have been executed. Nothing
|
|
31
|
+
* validates those names, and after this nothing has to.
|
|
32
|
+
*
|
|
33
|
+
* stdio is inherited: the installer owns the terminal while it runs, which is
|
|
34
|
+
* what makes brew's and bun's own progress output visible. A caller painting an
|
|
35
|
+
* animated region must pause it around this (see `ApplyReporter.detach`).
|
|
36
|
+
*/
|
|
37
|
+
export async function runCommand(c: Command): Promise<boolean> {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const child = spawn(c.cmd, [...c.args], { stdio: "inherit" });
|
|
40
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
41
|
+
child.on("error", () => resolve(false));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a shell SCRIPT through `/bin/sh`, streaming its output; true on exit 0.
|
|
47
|
+
*
|
|
48
|
+
* **Not for installer commands — use {@link runCommand}.** This exists for
|
|
49
|
+
* exactly one input: the values of `TOOLCHAIN_BOOTSTRAP`, which are the
|
|
50
|
+
* upstream-published one-liners for bun and Homebrew. Those genuinely are shell
|
|
51
|
+
* text — one contains a pipe, the other a command substitution — so they cannot
|
|
52
|
+
* be expressed as argv without wrapping them in a fake `/bin/sh -c` at the one
|
|
53
|
+
* place where a shell is actually correct.
|
|
54
|
+
*
|
|
55
|
+
* The distinction is who wrote the string. A bootstrap line is a constant in
|
|
56
|
+
* this repo, copied from bun.sh and brew.sh. An installer command is assembled
|
|
57
|
+
* from names that arrive with a profile. Passing the second kind here is the
|
|
58
|
+
* defect that motivated splitting the two, so
|
|
59
|
+
* `src/__tests__/shell-script-callers.test.ts` pins the caller set to one file.
|
|
60
|
+
*/
|
|
61
|
+
export async function runShellScript(script: string): Promise<boolean> {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const child = spawn(script, { stdio: "inherit", shell: true });
|
|
64
|
+
child.on("exit", (code) => resolve(code === 0));
|
|
65
|
+
child.on("error", () => resolve(false));
|
|
66
|
+
});
|
|
67
|
+
}
|