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.
@@ -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 { stdout } = await execAsync(
202
- `pip index versions ${packageName} 2>/dev/null | head -1`,
203
- { timeout: 10000, shell: "/bin/bash" },
187
+ const res = await fetch(
188
+ `https://pypi.org/pypi/${encodeURIComponent(packageName)}/json`,
189
+ { signal: AbortSignal.timeout(10000) },
204
190
  );
205
- const match = stdout.trim().match(/\(([^)]+)\)/);
206
- return match ? match[1] : undefined;
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
- ? getUpdateCommand(tool, info.primary, info.brewFormula)
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: string,
344
+ command: Command,
350
345
  ): Promise<{ ok: boolean; error?: string }> => {
351
346
  try {
352
- await execAsync(command, {
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.installCommand;
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 = getUninstallCommand(tool, method, brewFormula);
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.installCommand;
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
- await cliUpdatePlugin(plugin.id, scope);
951
- await saveVersionForScope(plugin.id, latestVersion, scope);
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
- await cliInstallPlugin(plugin.id, scope);
957
- await saveVersionForScope(plugin.id, latestVersion, scope);
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
- if (repairing) {
997
- await cliRepairPlugin(plugin.id, scope, state.projectPath);
998
- } else {
999
- await cliUpdatePlugin(plugin.id, scope);
1000
- }
1001
- if (plugin.version) {
1002
- await saveVersionForScope(plugin.id, plugin.version, scope);
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 scope: PluginScope =
1024
- pluginsState.scope === "global" ? "user" : "project";
1025
- const updatedPluginIds: string[] = [];
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
- for (let i = 0; i < updatable.length; i++) {
1029
- const plugin = updatable[i];
1030
- const repairing = !plugin.hasUpdate && plugin.contentStale;
1031
- modal.loading(
1032
- `${repairing ? "Repairing" : "Updating"} ${plugin.name} (${i + 1}/${updatable.length})…\nclaude plugin install ${plugin.id} --scope ${scope}`,
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
- await cliUpdatePlugin(plugin.id, scope);
1126
- await saveVersionForScope(plugin.id, latestVersion, scope);
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
- await cliInstallPlugin(plugin.id, scope);
1132
- await saveVersionForScope(plugin.id, latestVersion, scope);
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
- await cliInstallPlugin(newId, scope);
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.
@@ -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
+ }