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
|
@@ -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
|
+
});
|
|
@@ -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(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
51
|
-
|
|
52
|
-
);
|
|
53
|
-
expect(
|
|
54
|
-
"npm
|
|
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
|
-
|
|
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,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type PluginApplyDeps,
|
|
12
|
+
applyPlugins,
|
|
13
|
+
installPluginInScope,
|
|
14
|
+
updatePluginInScope,
|
|
15
|
+
} from "../services/update-engine.js";
|
|
11
16
|
import type { PluginScope } from "../services/claude-cli.js";
|
|
12
17
|
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
13
18
|
|
|
@@ -18,10 +23,14 @@ function stubDeps(
|
|
|
18
23
|
installedAfter: string | null | ((scope: PluginScope) => string | null),
|
|
19
24
|
) {
|
|
20
25
|
const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
|
|
26
|
+
const installed: Array<{ id: string; scope: PluginScope }> = [];
|
|
21
27
|
const updated: Array<{ id: string; scope: PluginScope }> = [];
|
|
22
28
|
const repaired: Array<{ id: string; scope: PluginScope }> = [];
|
|
23
29
|
|
|
24
30
|
const deps: PluginApplyDeps = {
|
|
31
|
+
install: mock(async (id: string, scope: PluginScope) => {
|
|
32
|
+
installed.push({ id, scope });
|
|
33
|
+
}),
|
|
25
34
|
update: mock(async (id: string, scope: PluginScope) => {
|
|
26
35
|
updated.push({ id, scope });
|
|
27
36
|
}),
|
|
@@ -39,7 +48,7 @@ function stubDeps(
|
|
|
39
48
|
},
|
|
40
49
|
),
|
|
41
50
|
};
|
|
42
|
-
return { deps, saved, updated, repaired };
|
|
51
|
+
return { deps, saved, installed, updated, repaired };
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
|
|
@@ -128,13 +137,77 @@ describe("what gets RECORDED is what got installed", () => {
|
|
|
128
137
|
|
|
129
138
|
describe("scope handling", () => {
|
|
130
139
|
test("a fresh install goes to project scope only", async () => {
|
|
131
|
-
const { deps,
|
|
140
|
+
const { deps, installed } = stubDeps("2.0.0");
|
|
132
141
|
await applyPlugins(
|
|
133
142
|
[item({ action: "install", installed: null, scopes: [] })],
|
|
134
143
|
PROJECT,
|
|
135
144
|
deps,
|
|
136
145
|
);
|
|
146
|
+
expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Three actions, three Claude Code commands, each a silent no-op in the
|
|
150
|
+
// other two cases. Folding `install` and `update` together is what made
|
|
151
|
+
// every claudeup update path exit 0 having changed nothing: measured on
|
|
152
|
+
// Claude Code 2.1.252, `claude plugin install designer@magus --scope
|
|
153
|
+
// project` answers "already installed" and leaves 0.6.0 in place while
|
|
154
|
+
// 0.6.1 is published, and `claude plugin update` is the command that moves
|
|
155
|
+
// it. The reverse holds too — `update` on a plugin absent from the scope
|
|
156
|
+
// answers "already at the latest version" and installs nothing — so this
|
|
157
|
+
// asserts BOTH directions, not just the one that was broken.
|
|
158
|
+
|
|
159
|
+
test("an update runs `update`, never `install`", async () => {
|
|
160
|
+
const { deps, installed, updated } = stubDeps("2.0.0");
|
|
161
|
+
await applyPlugins([item({ action: "update" })], PROJECT, deps);
|
|
137
162
|
expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
163
|
+
expect(installed).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an update with NO row for this project installs instead", async () => {
|
|
167
|
+
// `claude plugin update --scope project` resolves the project from the
|
|
168
|
+
// working directory, and with no row there it updates some OTHER
|
|
169
|
+
// project's row instead. Measured on 2.1.252 from a directory holding no
|
|
170
|
+
// madbench row, it moved /Users/jack/mag/tmux-mcp — a project nobody
|
|
171
|
+
// named. Live for git worktrees, which inherit the parent repo's version
|
|
172
|
+
// while owning no row of their own.
|
|
173
|
+
const { deps, installed, updated } = stubDeps(null);
|
|
174
|
+
await applyPlugins([item({ action: "update" })], PROJECT, deps);
|
|
175
|
+
expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
176
|
+
expect(updated).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("the row check is per scope, not per plugin", async () => {
|
|
180
|
+
// user scope holds a row, project scope does not: one must update and
|
|
181
|
+
// the other install, in the same pass.
|
|
182
|
+
const { deps, installed, updated } = stubDeps((scope) =>
|
|
183
|
+
scope === "user" ? "1.5.0" : null,
|
|
184
|
+
);
|
|
185
|
+
await applyPlugins([item({ scopes: ["user", "project"] })], PROJECT, deps);
|
|
186
|
+
expect(updated).toEqual([{ id: "dev@magus", scope: "user" }]);
|
|
187
|
+
expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a fresh install runs `install`, never `update`", async () => {
|
|
191
|
+
const { deps, installed, updated } = stubDeps("2.0.0");
|
|
192
|
+
await applyPlugins(
|
|
193
|
+
[item({ action: "install", installed: null, scopes: [] })],
|
|
194
|
+
PROJECT,
|
|
195
|
+
deps,
|
|
196
|
+
);
|
|
197
|
+
expect(installed).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
198
|
+
expect(updated).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a repair runs neither — content drift needs uninstall+install", async () => {
|
|
202
|
+
const { deps, installed, updated, repaired } = stubDeps("1.0.0");
|
|
203
|
+
await applyPlugins(
|
|
204
|
+
[item({ action: "repair", target: "1.0.0", scopes: ["project"] })],
|
|
205
|
+
PROJECT,
|
|
206
|
+
deps,
|
|
207
|
+
);
|
|
208
|
+
expect(repaired).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
209
|
+
expect(installed).toEqual([]);
|
|
210
|
+
expect(updated).toEqual([]);
|
|
138
211
|
});
|
|
139
212
|
|
|
140
213
|
test("every outdated scope is updated AND recorded separately", async () => {
|
|
@@ -221,3 +294,64 @@ describe("failure handling", () => {
|
|
|
221
294
|
expect(result.ok).toBe(0);
|
|
222
295
|
});
|
|
223
296
|
});
|
|
297
|
+
|
|
298
|
+
describe("installPluginInScope — a fresh install records what landed", () => {
|
|
299
|
+
// The TUI's install branches called the CLI directly and then wrote the
|
|
300
|
+
// version they had ASKED for. A marketplace serves whatever it publishes at
|
|
301
|
+
// the moment of the call, so that number was a guess — and `install --check`
|
|
302
|
+
// reads back the same field, so it compared the guess against a copy of
|
|
303
|
+
// itself and reported clean forever. Routing installs through the read-back
|
|
304
|
+
// the update branches already used is what closes it.
|
|
305
|
+
//
|
|
306
|
+
// The read-back semantics are pinned in cli-apply-seams.test.ts
|
|
307
|
+
// ("installPluginInScope — the scope-targeted path"). What these add is the
|
|
308
|
+
// property that makes the whole class of bug unrepresentable rather than
|
|
309
|
+
// merely absent right now.
|
|
310
|
+
|
|
311
|
+
test("takes no version parameter, so a caller cannot supply a wrong one", () => {
|
|
312
|
+
// Arity, not behaviour — and that is the point. Re-adding a `version`
|
|
313
|
+
// argument would reopen the defect without failing any behavioural test,
|
|
314
|
+
// because the new parameter would simply be believed.
|
|
315
|
+
// (pluginId, scope, projectPath, deps = REAL_PLUGIN_DEPS) — the defaulted
|
|
316
|
+
// parameter is not counted by Function.length.
|
|
317
|
+
expect(installPluginInScope.length).toBe(3);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("saves the version read back, not one the caller chose", async () => {
|
|
321
|
+
const { deps, saved } = stubDeps("9.9.9");
|
|
322
|
+
await installPluginInScope("dev@magus", "user", PROJECT, deps);
|
|
323
|
+
expect(saved).toEqual([
|
|
324
|
+
{ id: "dev@magus", version: "9.9.9", scope: "user" },
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("an unreadable version writes nothing at all", async () => {
|
|
329
|
+
// Never guess. A missing record is recoverable; a wrong one is not,
|
|
330
|
+
// because nothing downstream can tell it from a true one.
|
|
331
|
+
const { deps, saved } = stubDeps(null);
|
|
332
|
+
expect(
|
|
333
|
+
await installPluginInScope("dev@magus", "user", PROJECT, deps),
|
|
334
|
+
).toBeNull();
|
|
335
|
+
expect(saved).toEqual([]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// The scope menu's update branch went through the install helper for as
|
|
339
|
+
// long as both issued `claude plugin install`. They no longer do, so the
|
|
340
|
+
// two helpers must reach different commands or the menu silently no-ops
|
|
341
|
+
// exactly the way "update all" did.
|
|
342
|
+
test("the install helper runs `install`, the update helper runs `update`", async () => {
|
|
343
|
+
const a = stubDeps("2.0.0");
|
|
344
|
+
await installPluginInScope("dev@magus", "user", PROJECT, a.deps);
|
|
345
|
+
expect(a.installed).toEqual([{ id: "dev@magus", scope: "user" }]);
|
|
346
|
+
expect(a.updated).toEqual([]);
|
|
347
|
+
|
|
348
|
+
const b = stubDeps("2.0.0");
|
|
349
|
+
await updatePluginInScope("dev@magus", "user", PROJECT, b.deps);
|
|
350
|
+
expect(b.updated).toEqual([{ id: "dev@magus", scope: "user" }]);
|
|
351
|
+
expect(b.installed).toEqual([]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("the update helper takes no version parameter either", () => {
|
|
355
|
+
expect(updatePluginInScope.length).toBe(3);
|
|
356
|
+
});
|
|
357
|
+
});
|
package/src/cli/install.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|