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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "6.0.0",
68
- "claudeup-darwin-x64": "6.0.0",
69
- "claudeup-linux-x64": "6.0.0"
67
+ "claudeup-darwin-arm64": "6.1.0",
68
+ "claudeup-darwin-x64": "6.1.0",
69
+ "claudeup-linux-x64": "6.1.0"
70
70
  }
71
71
  }
@@ -19,7 +19,13 @@ function findTests(dir: string): string[] {
19
19
  for (const entry of readdirSync(dir)) {
20
20
  const p = join(dir, entry);
21
21
  if (statSync(p).isDirectory()) out.push(...findTests(p));
22
- else if (p.endsWith(".test.ts")) out.push(p);
22
+ // `.tsx` as well as `.ts`. The suffix was written when no `.tsx` test
23
+ // existed, so it silently excluded the only file testing a UI primitive
24
+ // — the class of test most likely to be `.tsx` and least likely to be
25
+ // noticed missing, because a file that is never collected reports
26
+ // nothing at all. The printed `N/N` count below is the guard: it moves
27
+ // when collection changes.
28
+ else if (/\.test\.tsx?$/.test(p)) out.push(p);
23
29
  }
24
30
  return out;
25
31
  }
@@ -15,9 +15,10 @@ import {
15
15
  type ApplyReporter,
16
16
  type PluginApplyDeps,
17
17
  applyPlugins,
18
- parseArgs,
19
18
  pluginNeedsWork,
20
- } from "../cli/update.js";
19
+ installPluginInScope,
20
+ } from "../services/update-engine.js";
21
+ import { parseArgs } from "../cli/update.js";
21
22
  import { checkBinaries } from "../services/doctor-bins.js";
22
23
  import type { PluginScope } from "../services/claude-cli.js";
23
24
  import type { PluginUpdateItem } from "../services/update-plan.js";
@@ -174,6 +175,53 @@ describe("flags", () => {
174
175
  });
175
176
  });
176
177
 
178
+ describe("installPluginInScope — the scope-targeted path", () => {
179
+ test("records the version READ BACK, not the one expected", async () => {
180
+ // This is the bug the TUI's per-scope menu carried after the CLI had
181
+ // fixed it: it wrote the version it *expected*, so `install --check`
182
+ // compared a pin against a copy of itself and reported clean forever.
183
+ const d = deps();
184
+ d.readInstalled = mock(async () => "9.9.9");
185
+ const saved: unknown[] = [];
186
+ d.saveInstalled = mock(async (id, version, scope) => {
187
+ saved.push({ id, version, scope });
188
+ });
189
+
190
+ const actual = await installPluginInScope("dev@magus", "user", PROJECT, d);
191
+
192
+ expect(actual).toBe("9.9.9");
193
+ expect(saved).toEqual([
194
+ { id: "dev@magus", version: "9.9.9", scope: "user" },
195
+ ]);
196
+ });
197
+
198
+ test("honours the caller's scope rather than fanning out", async () => {
199
+ // The whole reason this exists beside applyPlugins: the user picked a
200
+ // scope from a menu, so "every scope that is behind" would override them.
201
+ const d = deps();
202
+ const updated: Array<[string, string]> = [];
203
+ d.update = mock(async (id, scope) => {
204
+ updated.push([id, scope]);
205
+ });
206
+
207
+ await installPluginInScope("dev@magus", "local", PROJECT, d);
208
+
209
+ expect(updated).toEqual([["dev@magus", "local"]]);
210
+ });
211
+
212
+ test("an unreadable version is reported, never guessed", async () => {
213
+ const d = deps();
214
+ d.readInstalled = mock(async () => null);
215
+ const saved: unknown[] = [];
216
+ d.saveInstalled = mock(async () => {
217
+ saved.push(1);
218
+ });
219
+
220
+ expect(await installPluginInScope("x", "user", PROJECT, d)).toBeNull();
221
+ expect(saved).toEqual([]);
222
+ });
223
+ });
224
+
177
225
  describe("checkBinaries onProbe", () => {
178
226
  const bins: ResolvedBin[] = [
179
227
  { name: "tmux", via: "brew", sources: [] } as unknown as ResolvedBin,
@@ -0,0 +1,239 @@
1
+ /**
2
+ * The CLI-tools catalogue can no longer emit a bare `pip`, or shell syntax.
3
+ *
4
+ * Two defects lived in this screen, and both were data problems dressed as code
5
+ * problems:
6
+ *
7
+ * 1. **Bare `pip`.** `getUpdateCommand`'s `pip` and `default` arms returned the
8
+ * catalogue's `installCommand` verbatim, and `getUninstallCommand` emitted
9
+ * `pip uninstall -y`. `pip` is frequently a zsh alias rather than an
10
+ * executable, and a machine that manages Python tools with uv or pipx may
11
+ * have no `pip` at all. `services/toolchain.ts` already knew how to answer
12
+ * "how does Python work here"; this screen was not asking it.
13
+ * 2. **Shell syntax in data.** `aider`'s entry was
14
+ * `pip install aider-install && aider-install` — a `&&` chain in a catalogue
15
+ * field that got handed to `/bin/bash`.
16
+ *
17
+ * Both are now unrepresentable: the field is gone and the command is derived.
18
+ * These tests assert that over the whole catalogue rather than over an example,
19
+ * so a new entry cannot reintroduce either.
20
+ *
21
+ * Pure — no React, no network, no PATH dependency. Every Python installer is
22
+ * pinned explicitly, per the convention `toolchain.test.ts` established: the
23
+ * production default is resolved from PATH, so an unpinned assertion would
24
+ * encode whatever happens to be installed on the machine running the suite.
25
+ */
26
+
27
+ import { describe, expect, test } from "bun:test";
28
+ import { cliTools } from "../data/cli-tools.js";
29
+ import {
30
+ type InstallMethod,
31
+ cliToolInstall,
32
+ cliToolUninstall,
33
+ cliToolUpdate,
34
+ } from "../services/cli-tool-commands.js";
35
+ import {
36
+ PYTHON_CLI_INSTALLERS,
37
+ type PythonCliInstaller,
38
+ resolvePythonCliInstaller,
39
+ } from "../services/toolchain.js";
40
+ import { versionFromPypiJson } from "../ui/screens/CliToolsScreen.js";
41
+ import { type Command, formatCommand } from "../utils/command-utils.js";
42
+
43
+ const byProbe = (probe: string): PythonCliInstaller => {
44
+ const found = PYTHON_CLI_INSTALLERS.find((i) => i.probe === probe);
45
+ if (!found) throw new Error(`no python installer with probe ${probe}`);
46
+ return found;
47
+ };
48
+ const PYTHON_PROBES = PYTHON_CLI_INSTALLERS.map((i) => i.probe);
49
+
50
+ /** The methods the resolver models, i.e. the ones that reach toolchain.ts. */
51
+ const RESOLVED_METHODS: InstallMethod[] = ["npm", "bun", "brew", "pip"];
52
+
53
+ describe("the catalogue is well-formed", () => {
54
+ test("it has entries, and each names a package manager and a package", () => {
55
+ // Guard: every assertion below iterates the catalogue, so an empty or
56
+ // malformed one would make them all pass vacuously.
57
+ expect(cliTools.length).toBeGreaterThan(5);
58
+ for (const tool of cliTools) {
59
+ expect(typeof tool.packageName).toBe("string");
60
+ expect(tool.packageName.length).toBeGreaterThan(0);
61
+ expect(["bun", "npm", "pip", "brew", "go"]).toContain(
62
+ tool.packageManager,
63
+ );
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("no builder ever emits a bare `pip`", () => {
69
+ test("install: cmd is the resolver's probe, never `pip`", () => {
70
+ for (const tool of cliTools) {
71
+ const cmd = cliToolInstall(tool);
72
+ expect(cmd.cmd).not.toBe("pip");
73
+ if (tool.packageManager === "pip") {
74
+ // Whatever the machine running this resolved to, it must be one of
75
+ // the three modelled installers — and `cmd` must BE that probe,
76
+ // since that is the executable the syscall looks up.
77
+ expect(PYTHON_PROBES).toContain(cmd.cmd);
78
+ expect(cmd.cmd).toBe(resolvePythonCliInstaller().probe);
79
+ }
80
+ }
81
+ });
82
+
83
+ test("update and uninstall: cmd is never `pip`, for any method", () => {
84
+ for (const tool of cliTools) {
85
+ for (const method of RESOLVED_METHODS) {
86
+ for (const cmd of [
87
+ cliToolUpdate(tool, method),
88
+ cliToolUninstall(tool, method),
89
+ ]) {
90
+ if (!cmd) continue;
91
+ expect(cmd.cmd).not.toBe("pip");
92
+ }
93
+ }
94
+ }
95
+ });
96
+
97
+ test("each pinned Python installer drives all three of its own commands", () => {
98
+ // Pinned explicitly so this holds on a runner with no uv. The `pip`
99
+ // method must route to the SAME executable for install, update and
100
+ // uninstall — installing with uv and uninstalling with python3 would look
101
+ // like it worked and leave the tool on disk.
102
+ for (const probe of PYTHON_PROBES) {
103
+ const python = byProbe(probe);
104
+ expect(python.install("x").cmd).toBe(probe);
105
+ expect(python.upgrade("x").cmd).toBe(probe);
106
+ expect(python.uninstall("x").cmd).toBe(probe);
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("no builder can emit shell syntax", () => {
112
+ test("no argv element of any command contains &&, | or ;", () => {
113
+ // The catalogue's only `&&` chain was aider's installCommand. With the
114
+ // field gone there is nowhere left for shell syntax to enter, and this
115
+ // asserts that over every entry and every method rather than over that
116
+ // one case.
117
+ const all: Command[] = [];
118
+ for (const tool of cliTools) {
119
+ all.push(cliToolInstall(tool));
120
+ for (const method of [
121
+ ...RESOLVED_METHODS,
122
+ "pnpm" as const,
123
+ "yarn" as const,
124
+ ]) {
125
+ const u = cliToolUpdate(tool, method);
126
+ const r = cliToolUninstall(tool, method);
127
+ if (u) all.push(u);
128
+ if (r) all.push(r);
129
+ }
130
+ }
131
+ expect(all.length).toBeGreaterThan(0);
132
+
133
+ for (const cmd of all) {
134
+ for (const token of [cmd.cmd, ...cmd.args]) {
135
+ expect(token).not.toContain("&&");
136
+ expect(token).not.toContain("|");
137
+ expect(token).not.toContain(";");
138
+ }
139
+ }
140
+ });
141
+ });
142
+
143
+ describe("the detected method is followed, not the catalogue's", () => {
144
+ test("pnpm and yarn keep their own shapes", () => {
145
+ // A tool the catalogue calls npm can still have been installed with
146
+ // pnpm. Updating it as npm would install a second copy beside the first,
147
+ // which is the conflict this screen exists to report.
148
+ const tool = cliTools.find((t) => t.packageManager === "npm")!;
149
+ expect(cliToolUpdate(tool, "pnpm")).toEqual({
150
+ cmd: "pnpm",
151
+ args: ["install", "-g", tool.packageName],
152
+ });
153
+ expect(cliToolUpdate(tool, "yarn")).toEqual({
154
+ cmd: "yarn",
155
+ args: ["global", "add", tool.packageName],
156
+ });
157
+ expect(cliToolUninstall(tool, "pnpm")).toEqual({
158
+ cmd: "pnpm",
159
+ args: ["remove", "-g", tool.packageName],
160
+ });
161
+ expect(cliToolUninstall(tool, "yarn")).toEqual({
162
+ cmd: "yarn",
163
+ args: ["global", "remove", tool.packageName],
164
+ });
165
+ });
166
+
167
+ test("`unknown` returns null rather than guessing", () => {
168
+ // Preserves the observable behaviour: handleInstall reads
169
+ // `installed && updateCommand ? updateCommand : install…`, so null falls
170
+ // back to a plain install — which is what the old `default:` arm did by
171
+ // returning `tool.installCommand`.
172
+ const tool = cliTools[0]!;
173
+ expect(cliToolUpdate(tool, "unknown")).toBeNull();
174
+ expect(cliToolUninstall(tool, "unknown")).toBeNull();
175
+ });
176
+
177
+ test("brew and go arms are pinned, since no catalogue entry exercises them", () => {
178
+ // Every entry today is bun/npm/pip, so these two ship untouched by real
179
+ // data. `go` has no uninstaller — `go install` writes a binary and
180
+ // records nothing — so null is correct rather than an invented `rm`.
181
+ const tool = cliTools[0]!;
182
+ expect(cliToolUpdate(tool, "brew", "some-formula")).toEqual({
183
+ cmd: "brew",
184
+ args: ["upgrade", "some-formula"],
185
+ });
186
+ expect(cliToolUninstall(tool, "brew", "some-formula")).toEqual({
187
+ cmd: "brew",
188
+ args: ["uninstall", "some-formula"],
189
+ });
190
+ });
191
+ });
192
+
193
+ describe("derived commands still read as they did", () => {
194
+ test("claude renders exactly the literal the catalogue used to carry", () => {
195
+ // The entry whose command SHOULD not change. Its old
196
+ // `installCommand: "npm install -g @anthropic-ai/claude-code"` is now
197
+ // derived from packageManager + packageName, and must render identically.
198
+ const claude = cliTools.find((t) => t.name === "claude")!;
199
+ expect(formatCommand(cliToolInstall(claude))).toBe(
200
+ "npm install -g @anthropic-ai/claude-code",
201
+ );
202
+ });
203
+
204
+ test("aider now installs the package its own fields already named", () => {
205
+ // The one deliberate behaviour change. The old command installed
206
+ // `aider-install`, a bootstrapper whose version nothing here ever read,
207
+ // while `packageName`, `checkCommand` and the PyPI query all said
208
+ // `aider-chat`. The installCommand was the field that was out of step.
209
+ const aider = cliTools.find((t) => t.name === "aider")!;
210
+ expect(aider.packageName).toBe("aider-chat");
211
+ expect(cliToolInstall(aider).args).toContain("aider-chat");
212
+ expect(formatCommand(cliToolInstall(aider))).not.toContain("aider-install");
213
+ });
214
+ });
215
+
216
+ describe("versionFromPypiJson", () => {
217
+ // The shape of a real https://pypi.org/pypi/<pkg>/json body, trimmed to the
218
+ // two fields that matter. Captured rather than mocked so the parser is
219
+ // tested against what the endpoint actually returns.
220
+ const body = {
221
+ info: { name: "aider-chat", version: "0.86.1" },
222
+ releases: {},
223
+ };
224
+
225
+ test("reads info.version", () => {
226
+ expect(versionFromPypiJson(body)).toBe("0.86.1");
227
+ });
228
+
229
+ test("anything unexpected yields undefined, never a throw", () => {
230
+ // A version check is advisory: it must degrade to "no update badge",
231
+ // never take the screen down. PyPI returning an error object, a 404 body,
232
+ // or nothing at all all land here.
233
+ expect(versionFromPypiJson({})).toBeUndefined();
234
+ expect(versionFromPypiJson({ info: {} })).toBeUndefined();
235
+ expect(versionFromPypiJson({ info: { version: 42 } })).toBeUndefined();
236
+ expect(versionFromPypiJson(null)).toBeUndefined();
237
+ expect(versionFromPypiJson("not json at all")).toBeUndefined();
238
+ });
239
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `formatCommand` renders a Command for a human, and must not lie.
3
+ *
4
+ * Two properties, and they pull in opposite directions:
5
+ *
6
+ * 1. **Display neutrality.** Every token claudeup actually builds is
7
+ * shell-safe, so the rendered line must be byte-identical to the string
8
+ * these builders produced before commands became argv. That is what lets
9
+ * item 3 land without touching `install`/`doctor` output.
10
+ * `doctor-bins.test.ts` is the end-to-end gate on that; these are the unit.
11
+ * 2. **A dangerous token must LOOK dangerous.** If a package name ever did
12
+ * contain shell syntax, printing it bare would show the reader a command
13
+ * that does something other than what runs. Quoting it makes the
14
+ * one-token-ness visible.
15
+ */
16
+
17
+ import { describe, expect, test } from "bun:test";
18
+ import { type Command, formatCommand } from "../utils/command-utils.js";
19
+
20
+ const c = (cmd: string, ...args: string[]): Command => ({ cmd, args });
21
+
22
+ describe("formatCommand — display neutrality", () => {
23
+ test("every token this codebase really builds renders unquoted", () => {
24
+ // Sampled from the actual builders: npm scopes, version pins in both
25
+ // syntaxes, go module paths, long flags. If quoting ever fires for one of
26
+ // these, some install/doctor output changed.
27
+ expect(formatCommand(c("bun", "install", "-g", "claudish@1.2.0"))).toBe(
28
+ "bun install -g claudish@1.2.0",
29
+ );
30
+ expect(
31
+ formatCommand(c("npm", "install", "-g", "@anthropic-ai/claude-code")),
32
+ ).toBe("npm install -g @anthropic-ai/claude-code");
33
+ expect(formatCommand(c("uv", "tool", "install", "aider==0.1"))).toBe(
34
+ "uv tool install aider==0.1",
35
+ );
36
+ expect(formatCommand(c("go", "install", "github.com/x/t@latest"))).toBe(
37
+ "go install github.com/x/t@latest",
38
+ );
39
+ expect(
40
+ formatCommand(
41
+ c("python3", "-m", "pip", "install", "--user", "--upgrade", "aider"),
42
+ ),
43
+ ).toBe("python3 -m pip install --user --upgrade aider");
44
+ expect(formatCommand(c("brew", "install", "tmux"))).toBe(
45
+ "brew install tmux",
46
+ );
47
+ });
48
+
49
+ test("a command with no arguments renders as just the executable", () => {
50
+ expect(formatCommand(c("brew"))).toBe("brew");
51
+ });
52
+ });
53
+
54
+ describe("formatCommand — a dangerous token is made visible", () => {
55
+ test("shell metacharacters are single-quoted", () => {
56
+ expect(formatCommand(c("bun", "install", "-g", "a b; rm -rf /"))).toBe(
57
+ "bun install -g 'a b; rm -rf /'",
58
+ );
59
+ expect(formatCommand(c("bun", "install", "-g", "$(id)"))).toBe(
60
+ "bun install -g '$(id)'",
61
+ );
62
+ expect(formatCommand(c("bun", "install", "-g", "a|b"))).toBe(
63
+ "bun install -g 'a|b'",
64
+ );
65
+ });
66
+
67
+ test("an embedded single quote closes, escapes and reopens", () => {
68
+ // `'\''` is the only POSIX-portable way: a single-quoted string has no
69
+ // escape character at all, so the quote must be left and re-entered.
70
+ expect(formatCommand(c("x", "it's"))).toBe(`x 'it'\\''s'`);
71
+ });
72
+
73
+ test("an empty argument is quoted so it does not vanish", () => {
74
+ // Unquoted it would render as nothing, and the reader would be shown a
75
+ // command with fewer arguments than the one that runs.
76
+ expect(formatCommand(c("x", "", "y"))).toBe("x '' y");
77
+ });
78
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `runShellScript` has exactly one legitimate caller, and this pins it.
3
+ *
4
+ * The whole of item 3 is the split between two ways of running a child process:
5
+ * argv (`runCommand`), which no shell ever sees, and a script (`runShellScript`),
6
+ * which `/bin/sh` parses. Every installer command belongs to the first, because
7
+ * it is assembled from package names that arrive in `.claude/profiles.json` and
8
+ * plugin `requires.bin` — hand-authored, committed by teammates, fetched from
9
+ * marketplaces, validated by nothing. The second exists only for the two
10
+ * `TOOLCHAIN_BOOTSTRAP` values, which are constants in this repo copied from
11
+ * bun.sh and brew.sh and which genuinely contain a pipe and a command
12
+ * substitution.
13
+ *
14
+ * Nothing in the type system distinguishes them — both are strings — so after
15
+ * the commit lands, the design decision is enforced by this test or by nothing.
16
+ * A branded `ShellScript` type would enforce it at compile time and was
17
+ * considered; for one map, two entries and one call site it is more machinery
18
+ * than the rule is worth, and unlike a brand this is greppable.
19
+ *
20
+ * Source-scanning precedent in this suite: `uppercase-keybindings.test.ts`.
21
+ */
22
+
23
+ import { describe, expect, test } from "bun:test";
24
+ import path from "node:path";
25
+ import fs from "fs-extra";
26
+
27
+ const SRC_DIR = path.join(import.meta.dir, "..");
28
+
29
+ /** Files permitted to call `runShellScript`, relative to `src/`. */
30
+ const ALLOWED = ["cli/install.ts"];
31
+
32
+ /** The definition itself, plus this test, are not callers. */
33
+ const NOT_CALLERS = ["utils/run.ts", "__tests__/shell-script-callers.test.ts"];
34
+
35
+ async function sourceFiles(dir: string): Promise<string[]> {
36
+ const out: string[] = [];
37
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) out.push(...(await sourceFiles(full)));
40
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ const isComment = (line: string) => {
46
+ const t = line.trim();
47
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
48
+ };
49
+
50
+ async function callersOf(pattern: RegExp): Promise<string[]> {
51
+ const found = new Set<string>();
52
+ for (const file of await sourceFiles(SRC_DIR)) {
53
+ const rel = path.relative(SRC_DIR, file);
54
+ if (NOT_CALLERS.includes(rel)) continue;
55
+ const text = await fs.readFile(file, "utf8");
56
+ for (const line of text.split("\n")) {
57
+ if (isComment(line)) continue;
58
+ if (pattern.test(line)) found.add(rel);
59
+ }
60
+ }
61
+ return [...found].sort();
62
+ }
63
+
64
+ describe("only the toolchain bootstrap goes through a shell", () => {
65
+ test("runShellScript is called from exactly the allowed files", async () => {
66
+ expect(await callersOf(/\brunShellScript\(/)).toEqual(ALLOWED);
67
+ });
68
+
69
+ test("nothing spawns with `shell: true` outside utils/run.ts", async () => {
70
+ // Catches someone reaching for `spawn`/`exec` directly rather than
71
+ // through the seam — a second shell path that never names
72
+ // `runShellScript`.
73
+ //
74
+ // Scope, stated honestly: this checks `shell: true` only. Several
75
+ // version-DETECTION helpers in CliToolsScreen still pass
76
+ // `shell: "/bin/bash"` to run pipelines like `which -a x 2>/dev/null`.
77
+ // Those interpolate a catalogue `name`, not an installer command, and
78
+ // were out of scope here — so this assertion would be false if it
79
+ // claimed no shell exists anywhere.
80
+ expect(await callersOf(/shell:\s*true/)).toEqual([]);
81
+ });
82
+
83
+ test("the scan can actually fail", async () => {
84
+ // Negative control. The two assertions above pass trivially if the walk
85
+ // finds nothing or the pattern never matches, and a check that cannot
86
+ // fail is not evidence. `runCommand(` is known to be called from several
87
+ // files, so a working scan must find them.
88
+ const runCommandCallers = await callersOf(/\brunCommand\(/);
89
+ expect(runCommandCallers.length).toBeGreaterThan(0);
90
+ expect(runCommandCallers).toContain("cli/install.ts");
91
+ });
92
+ });