claudeup 6.0.0 → 6.1.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,34 +1,101 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ PYTHON_CLI_INSTALLERS,
4
+ type PythonCliInstaller,
3
5
  binInstallCommand,
4
6
  binUpgradeCommand,
5
7
  detectToolchains,
8
+ resolvePythonCliInstaller,
6
9
  } from "../services/toolchain.js";
7
10
  import type { ResolvedBin } from "../types/index.js";
11
+ import { formatCommand } from "../utils/command-utils.js";
12
+
13
+ /**
14
+ * Pin a Python installer explicitly in every `via: "pip"` assertion.
15
+ *
16
+ * The production default is resolved from PATH, so asserting a literal command
17
+ * would encode whatever happens to be installed on the machine running the
18
+ * suite — green here, red on a runner without uv.
19
+ */
20
+ const byProbe = (probe: string): PythonCliInstaller => {
21
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
22
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
23
+ return found;
24
+ };
25
+ const UV = byProbe("uv");
26
+ const PIPX = byProbe("pipx");
27
+ const PY = byProbe("python3");
8
28
 
9
29
  describe("binInstallCommand", () => {
10
30
  test("bun with and without a version pin", () => {
11
- expect(binInstallCommand({ name: "claudish", via: "bun", package: "claudish", version: "1.2.0" })).toBe(
12
- "bun install -g claudish@1.2.0",
13
- );
14
- expect(binInstallCommand({ name: "claudish", via: "bun" })).toBe(
15
- "bun install -g claudish",
16
- );
31
+ expect(
32
+ formatCommand(
33
+ binInstallCommand({
34
+ name: "claudish",
35
+ via: "bun",
36
+ package: "claudish",
37
+ version: "1.2.0",
38
+ }),
39
+ ),
40
+ ).toBe("bun install -g claudish@1.2.0");
41
+ expect(
42
+ formatCommand(binInstallCommand({ name: "claudish", via: "bun" })),
43
+ ).toBe("bun install -g claudish");
44
+ });
45
+
46
+ test("the package name is ONE argv token, whatever is in it", () => {
47
+ // `bin.package` is hand-authored data — it arrives from
48
+ // `.claude/profiles.json` and from plugin `requires.bin`, committed by
49
+ // teammates and fetched from marketplaces, and nothing validates it.
50
+ // While commands were strings handed to `/bin/sh`, this string was four
51
+ // words and a command separator. As argv it cannot be anything but a
52
+ // (nonexistent) package name, so the install fails instead of running.
53
+ const evil = "a b; rm -rf /";
54
+ const cmd = binInstallCommand({ name: "x", via: "bun", package: evil });
55
+ expect(cmd.cmd).toBe("bun");
56
+ expect(cmd.args).toEqual(["install", "-g", evil]);
57
+ expect(cmd.args.at(-1)).toBe(evil); // unsplit: still exactly one token
58
+ // ...and rendering it for display quotes it rather than emitting syntax.
59
+ expect(formatCommand(cmd)).toBe("bun install -g 'a b; rm -rf /'");
60
+ });
61
+
62
+ test("a version pin joins the package as one token, not two", () => {
63
+ // Splitting `pkg@version` would ask the installer for two packages, the
64
+ // second of which is a version number.
65
+ const cmd = binInstallCommand({
66
+ name: "claudish",
67
+ via: "bun",
68
+ version: "1.2.0",
69
+ });
70
+ expect(cmd.args.at(-1)).toBe("claudish@1.2.0");
17
71
  });
18
72
 
19
73
  test("npm, pip, brew, go each render correctly", () => {
20
- expect(binInstallCommand({ name: "x", via: "npm", package: "x" })).toBe(
21
- "npm install -g x",
22
- );
23
- expect(binInstallCommand({ name: "aider", via: "pip", package: "aider", version: "0.1" })).toBe(
24
- "pip install aider==0.1",
25
- );
26
- expect(binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
27
- "brew install tmux",
28
- );
29
- expect(binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" })).toBe(
30
- "go install github.com/x/t@latest",
31
- );
74
+ // Rendered, not raw, because these literals are also the display-
75
+ // neutrality claim: every token here is shell-safe, so `formatCommand`
76
+ // quotes nothing and the printed line is byte-identical to the string
77
+ // these builders returned before they returned argv.
78
+ expect(
79
+ formatCommand(binInstallCommand({ name: "x", via: "npm", package: "x" })),
80
+ ).toBe("npm install -g x");
81
+ expect(
82
+ formatCommand(
83
+ binInstallCommand(
84
+ { name: "aider", via: "pip", package: "aider", version: "0.1" },
85
+ UV,
86
+ ),
87
+ ),
88
+ ).toBe("uv tool install aider==0.1");
89
+ expect(
90
+ formatCommand(
91
+ binInstallCommand({ name: "tmux", via: "brew", formula: "tmux" }),
92
+ ),
93
+ ).toBe("brew install tmux");
94
+ expect(
95
+ formatCommand(
96
+ binInstallCommand({ name: "t", via: "go", module: "github.com/x/t" }),
97
+ ),
98
+ ).toBe("go install github.com/x/t@latest");
32
99
  });
33
100
  });
34
101
 
@@ -38,26 +105,104 @@ describe("binUpgradeCommand", () => {
38
105
  // installed formula prints "already installed" and changes nothing, and
39
106
  // `pip install` without --upgrade does the same. Reusing them would let
40
107
  // `claudeup update` report success while advancing nothing.
41
- expect(binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" })).toBe(
42
- "brew upgrade tmux",
43
- );
44
- expect(binUpgradeCommand({ name: "aider", via: "pip", package: "aider" })).toBe(
45
- "pip install --upgrade aider",
46
- );
108
+ expect(
109
+ formatCommand(
110
+ binUpgradeCommand({ name: "tmux", via: "brew", formula: "tmux" }),
111
+ ),
112
+ ).toBe("brew upgrade tmux");
113
+ expect(
114
+ formatCommand(
115
+ binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, PY),
116
+ ),
117
+ ).toBe("python3 -m pip install --user --upgrade aider");
118
+ expect(
119
+ formatCommand(
120
+ binUpgradeCommand({ name: "aider", via: "pip", package: "aider" }, UV),
121
+ ),
122
+ ).toBe("uv tool install --force --upgrade aider");
123
+ });
124
+
125
+ test("every Python installer distinguishes install from upgrade", () => {
126
+ // The same trap as brew: `uv tool install` on an already-installed tool
127
+ // is a no-op, so reusing the install command would report success while
128
+ // advancing nothing.
129
+ for (const python of PYTHON_CLI_INSTALLERS) {
130
+ const bin = { name: "aider", via: "pip" as const, package: "aider" };
131
+ // Compared RENDERED. Two `Command` objects are never `toBe`-equal —
132
+ // they are distinct references — so comparing them directly would be
133
+ // an assertion that cannot fail.
134
+ expect(formatCommand(binUpgradeCommand(bin, python))).not.toBe(
135
+ formatCommand(binInstallCommand(bin, python)),
136
+ );
137
+ }
138
+ });
139
+ });
140
+
141
+ describe("resolvePythonCliInstaller", () => {
142
+ test("never emits a bare `pip`", () => {
143
+ // The bug this exists for: `pip` is frequently a zsh ALIAS for pip3 and
144
+ // no such executable exists. Commands were spawned through /bin/sh, which
145
+ // sources no profile, so this died with `/bin/sh: pip: command not found`
146
+ // on a machine where typing `pip` at a prompt works.
147
+ //
148
+ // Now that a command is argv, `cmd` is the exact executable that gets
149
+ // looked up on PATH — so asserting on it is asserting on the syscall,
150
+ // not on a string a shell might later re-read.
151
+ for (const python of PYTHON_CLI_INSTALLERS) {
152
+ for (const cmd of [python.install("x"), python.upgrade("x")]) {
153
+ expect(cmd.cmd).not.toBe("pip");
154
+ expect(cmd.cmd).toBe(python.probe);
155
+ }
156
+ }
157
+ });
158
+
159
+ test("prefers an isolated-venv installer over the system interpreter", () => {
160
+ // `pip install` into a Homebrew interpreter is refused outright by PEP
161
+ // 668 (`externally-managed-environment`), so uv and pipx must outrank it.
162
+ const order = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
163
+ expect(order.indexOf("uv")).toBeLessThan(order.indexOf("python3"));
164
+ expect(order.indexOf("pipx")).toBeLessThan(order.indexOf("python3"));
165
+ });
166
+
167
+ test("always resolves to something, and to one of the known options", () => {
168
+ // Returning null would force every caller to handle "no installer",
169
+ // which in practice means printing nothing and silently skipping.
170
+ const resolved = resolvePythonCliInstaller();
171
+ expect(PYTHON_CLI_INSTALLERS).toContain(resolved);
172
+ });
173
+
174
+ test("a version pin is expressed in the installer's own syntax", () => {
175
+ expect(
176
+ formatCommand(
177
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV),
178
+ ),
179
+ ).toBe("uv tool install a==1.2");
180
+ expect(
181
+ formatCommand(
182
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, PIPX),
183
+ ),
184
+ ).toBe("pipx install a==1.2");
185
+ // `a==1.2` is one token. Two would ask uv to install a package called
186
+ // "1.2".
187
+ expect(
188
+ binInstallCommand({ name: "a", via: "pip", version: "1.2" }, UV).args,
189
+ ).toEqual(["tool", "install", "a==1.2"]);
47
190
  });
48
191
 
49
192
  test("bun and npm state @latest explicitly, so the printed command reads true", () => {
50
- expect(binUpgradeCommand({ name: "claudish", via: "bun" })).toBe(
51
- "bun install -g claudish@latest",
52
- );
53
- expect(binUpgradeCommand({ name: "x", via: "npm", package: "x" })).toBe(
54
- "npm install -g x@latest",
55
- );
193
+ expect(
194
+ formatCommand(binUpgradeCommand({ name: "claudish", via: "bun" })),
195
+ ).toBe("bun install -g claudish@latest");
196
+ expect(
197
+ formatCommand(binUpgradeCommand({ name: "x", via: "npm", package: "x" })),
198
+ ).toBe("npm install -g x@latest");
56
199
  });
57
200
 
58
201
  test("go installs the module at latest", () => {
59
202
  expect(
60
- binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
203
+ formatCommand(
204
+ binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
205
+ ),
61
206
  ).toBe("go install github.com/x/t@latest");
62
207
  });
63
208
  });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The UI records a plugin version only through a function that read it back.
3
+ *
4
+ * `installedPluginVersions` is claudeup's own bookkeeping — the Claude CLI does
5
+ * not maintain it — and `install --check` reads it to decide whether a machine
6
+ * has drifted from its profile. So a version written from an EXPECTATION rather
7
+ * than from disk is not a cosmetic inaccuracy: the drift gate ends up comparing
8
+ * a number against a copy of itself and reporting clean forever. That is what
9
+ * PluginsScreen's install branches did, via a local `saveVersionForScope`
10
+ * helper that duplicated `plugin-manager.ts`'s scope dispatcher and took the
11
+ * version as a parameter.
12
+ *
13
+ * Deleting the helper removed the UI's *ability* to make that mistake. This
14
+ * test is what keeps it removed: a screen may still install and update, but
15
+ * only through `installPluginInScope`, which reads the version off disk and
16
+ * writes nothing when it cannot.
17
+ *
18
+ * A source scan rather than a behavioural test because these are `.tsx` screens
19
+ * this suite cannot render. It encodes the rule directly instead of the symptom
20
+ * — the precedent for source-scanning guards here is
21
+ * `uppercase-keybindings.test.ts`.
22
+ */
23
+
24
+ import { describe, expect, test } from "bun:test";
25
+ import path from "node:path";
26
+ import fs from "fs-extra";
27
+
28
+ const UI_DIR = path.join(import.meta.dir, "..", "ui");
29
+
30
+ /** The version writers that take the number on trust from their caller. */
31
+ const RAW_WRITERS = [
32
+ "saveGlobalInstalledPluginVersion",
33
+ "saveLocalInstalledPluginVersion",
34
+ "saveInstalledPluginVersion",
35
+ "saveInstalledPluginVersionForScope",
36
+ ];
37
+
38
+ async function uiSourceFiles(dir: string): Promise<string[]> {
39
+ const out: string[] = [];
40
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
41
+ const full = path.join(dir, entry.name);
42
+ if (entry.isDirectory()) out.push(...(await uiSourceFiles(full)));
43
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ const isComment = (line: string) => {
49
+ const t = line.trim();
50
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
51
+ };
52
+
53
+ describe("no UI file writes a plugin version it did not read back", () => {
54
+ test("the raw version writers are absent from src/ui", async () => {
55
+ const files = await uiSourceFiles(UI_DIR);
56
+ expect(files.length).toBeGreaterThan(10); // guard: the walk found real files
57
+
58
+ const offenders: string[] = [];
59
+ for (const file of files) {
60
+ const text = await fs.readFile(file, "utf8");
61
+ text.split("\n").forEach((line, i) => {
62
+ if (isComment(line)) return;
63
+ for (const writer of RAW_WRITERS) {
64
+ // Word-bounded: `saveInstalledPluginVersion` is a prefix of
65
+ // `saveInstalledPluginVersionForScope`, and both are banned, so a
66
+ // substring match would just double-report the same line.
67
+ if (new RegExp(`\\b${writer}\\b`).test(line)) {
68
+ offenders.push(
69
+ `${path.relative(UI_DIR, file)}:${i + 1} ${line.trim()}`,
70
+ );
71
+ }
72
+ }
73
+ });
74
+ }
75
+
76
+ expect(offenders).toEqual([]);
77
+ });
78
+
79
+ test("the guard can actually fail", () => {
80
+ // Negative control. The assertion above passes trivially if the walk or
81
+ // the pattern is broken, and a guard that cannot fail is not evidence.
82
+ const line = "\t\t\tawait saveGlobalInstalledPluginVersion(id, version);";
83
+ expect(isComment(line)).toBe(false);
84
+ expect(RAW_WRITERS.some((w) => new RegExp(`\\b${w}\\b`).test(line))).toBe(
85
+ true,
86
+ );
87
+ });
88
+ });
@@ -7,7 +7,11 @@
7
7
  */
8
8
 
9
9
  import { beforeEach, describe, expect, mock, test } from "bun:test";
10
- import { type PluginApplyDeps, applyPlugins } from "../cli/update.js";
10
+ import {
11
+ type PluginApplyDeps,
12
+ applyPlugins,
13
+ installPluginInScope,
14
+ } from "../services/update-engine.js";
11
15
  import type { PluginScope } from "../services/claude-cli.js";
12
16
  import type { PluginUpdateItem } from "../services/update-plan.js";
13
17
 
@@ -221,3 +225,44 @@ describe("failure handling", () => {
221
225
  expect(result.ok).toBe(0);
222
226
  });
223
227
  });
228
+
229
+ describe("installPluginInScope — a fresh install records what landed", () => {
230
+ // The TUI's install branches called the CLI directly and then wrote the
231
+ // version they had ASKED for. A marketplace serves whatever it publishes at
232
+ // the moment of the call, so that number was a guess — and `install --check`
233
+ // reads back the same field, so it compared the guess against a copy of
234
+ // itself and reported clean forever. Routing installs through the read-back
235
+ // the update branches already used is what closes it.
236
+ //
237
+ // The read-back semantics are pinned in cli-apply-seams.test.ts
238
+ // ("installPluginInScope — the scope-targeted path"). What these add is the
239
+ // property that makes the whole class of bug unrepresentable rather than
240
+ // merely absent right now.
241
+
242
+ test("takes no version parameter, so a caller cannot supply a wrong one", () => {
243
+ // Arity, not behaviour — and that is the point. Re-adding a `version`
244
+ // argument would reopen the defect without failing any behavioural test,
245
+ // because the new parameter would simply be believed.
246
+ // (pluginId, scope, projectPath, deps = REAL_PLUGIN_DEPS) — the defaulted
247
+ // parameter is not counted by Function.length.
248
+ expect(installPluginInScope.length).toBe(3);
249
+ });
250
+
251
+ test("saves the version read back, not one the caller chose", async () => {
252
+ const { deps, saved } = stubDeps("9.9.9");
253
+ await installPluginInScope("dev@magus", "user", PROJECT, deps);
254
+ expect(saved).toEqual([
255
+ { id: "dev@magus", version: "9.9.9", scope: "user" },
256
+ ]);
257
+ });
258
+
259
+ test("an unreadable version writes nothing at all", async () => {
260
+ // Never guess. A missing record is recoverable; a wrong one is not,
261
+ // because nothing downstream can tell it from a true one.
262
+ const { deps, saved } = stubDeps(null);
263
+ expect(
264
+ await installPluginInScope("dev@magus", "user", PROJECT, deps),
265
+ ).toBeNull();
266
+ expect(saved).toEqual([]);
267
+ });
268
+ });
@@ -47,9 +47,10 @@ import type {
47
47
  ResolvedClosure,
48
48
  SkillInfo,
49
49
  } from "../types/index.js";
50
- import { resolveExecutable } from "../utils/command-utils.js";
50
+ import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
51
51
  import { ensureManifest } from "./bootstrap.js";
52
- import { confirm, promptValue, runShell } from "./prompt.js";
52
+ import { confirm, promptValue } from "./prompt.js";
53
+ import { runCommand, runShellScript } from "../utils/run.js";
53
54
 
54
55
  interface InstallFlags {
55
56
  check: boolean;
@@ -125,7 +126,10 @@ async function ensureToolchains(
125
126
  ));
126
127
  if (ok) {
127
128
  console.log(`Installing ${tc.name}…`);
128
- if (!(await runShell(bootstrap))) {
129
+ // A bootstrap line IS shell text — bun's contains a pipe, Homebrew's a
130
+ // command substitution — and it is a constant in this repo, not
131
+ // profile data. This is the only legitimate caller.
132
+ if (!(await runShellScript(bootstrap))) {
129
133
  console.warn(`⚠ Failed to install ${tc.name}. Continuing.`);
130
134
  }
131
135
  }
@@ -163,8 +167,10 @@ async function installBins(bins: ResolvedBin[]): Promise<void> {
163
167
  continue; // already installed and runnable (not a dangling link)
164
168
  }
165
169
  const cmd = binInstallCommand(bin);
166
- console.log(`+ ${bin.name} (${cmd})`);
167
- if (!(await runShell(cmd))) {
170
+ // Printed rendered, executed as argv. The package name in `cmd.args` came
171
+ // from a profile or a plugin manifest, so it must never reach a shell.
172
+ console.log(`+ ${bin.name} (${formatCommand(cmd)})`);
173
+ if (!(await runCommand(cmd))) {
168
174
  console.warn(`⚠ failed to install ${bin.name} via ${bin.via}`);
169
175
  }
170
176
  }
package/src/cli/prompt.ts CHANGED
@@ -31,12 +31,3 @@ export async function promptValue(question: string): Promise<string> {
31
31
  }
32
32
  }
33
33
 
34
- /** Run a shell command, streaming output; resolve true on exit 0. */
35
- export async function runShell(command: string): Promise<boolean> {
36
- const { spawn } = await import("node:child_process");
37
- return new Promise((resolve) => {
38
- const child = spawn(command, { stdio: "inherit", shell: true });
39
- child.on("exit", (code) => resolve(code === 0));
40
- child.on("error", () => resolve(false));
41
- });
42
- }
@@ -32,6 +32,7 @@ import type {
32
32
  } from "../services/update-plan.js";
33
33
  import { binInstallCommand, binUpgradeCommand } from "../services/toolchain.js";
34
34
  import { brand } from "../ui/theme.js";
35
+ import { formatCommand } from "../utils/command-utils.js";
35
36
  import {
36
37
  badge,
37
38
  bold,
@@ -256,7 +257,9 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
256
257
  item.action === "install"
257
258
  ? binInstallCommand(spec)
258
259
  : binUpgradeCommand(spec);
259
- return dim(cmd);
260
+ // Rendered for the reader's eyes only — this row says what WILL run, while
261
+ // applyBins runs the argv itself.
262
+ return dim(formatCommand(cmd));
260
263
  }
261
264
 
262
265
  /**