claudeup 4.42.1 → 5.0.1
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/README.md +73 -3
- package/package.json +4 -4
- package/src/__tests__/cli-router.test.ts +46 -0
- package/src/__tests__/dotenv.test.ts +5 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/toolchain.test.ts +31 -0
- package/src/__tests__/update-apply.test.ts +223 -0
- package/src/__tests__/update-plan.test.ts +631 -0
- package/src/cli/bootstrap.ts +140 -0
- package/src/cli/install.ts +65 -83
- package/src/cli/profile.ts +41 -9
- package/src/cli/prompt.ts +42 -0
- package/src/cli/router.ts +23 -9
- package/src/cli/update.ts +459 -71
- package/src/cli/upgrade.ts +89 -0
- package/src/prerunner/index.ts +1 -22
- package/src/services/dotenv.ts +9 -4
- package/src/services/marketplace-sync.ts +37 -6
- package/src/services/plugin-manager.ts +50 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/resolver.ts +1 -1
- package/src/services/toolchain.ts +35 -0
- package/src/services/update-plan.ts +385 -0
- package/src/ui/App.tsx +1 -1
package/README.md
CHANGED
|
@@ -13,9 +13,13 @@ npm install -g claudeup # also works
|
|
|
13
13
|
Updates go through claudeup itself, not the package manager:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
claudeup
|
|
16
|
+
claudeup upgrade
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
`upgrade` advances **claudeup**. `update` advances **your dependencies** — the
|
|
20
|
+
plugins, CLI tools and skills your profile declares. Same split as every package
|
|
21
|
+
manager, so the two are never confused.
|
|
22
|
+
|
|
19
23
|
You get a self-contained binary for your platform (an `optionalDependencies`
|
|
20
24
|
package gated on `os`/`cpu`). On a platform without a prebuilt binary the
|
|
21
25
|
launcher falls back to running from source under Bun, so the install still works.
|
|
@@ -35,23 +39,89 @@ Good for making a teammate's machine match yours. This is the
|
|
|
35
39
|
claudeup Open the interactive TUI
|
|
36
40
|
|
|
37
41
|
install [profile] Install a profile's plugins, binaries, skills and
|
|
38
|
-
env
|
|
42
|
+
env at the manifest's PINNED versions, then activate
|
|
43
|
+
it. No argument = every profile.
|
|
39
44
|
--check Report drift only, write nothing (CI gate)
|
|
40
45
|
--yes, -y Skip the confirmation prompt
|
|
41
46
|
--force Discard unsynced local edits instead of refusing
|
|
47
|
+
update [profile] Bring the ACTIVE profile up to date: install what it
|
|
48
|
+
declares but this machine lacks, and advance every
|
|
49
|
+
"latest" pin to the newest published version.
|
|
50
|
+
--check Report only, write nothing (exits 1 if anything is
|
|
51
|
+
missing or known to be behind)
|
|
52
|
+
--yes, -y Skip the confirmation prompt
|
|
42
53
|
profile list Show every profile; ● marks the active one
|
|
43
54
|
profile show <name> Print a profile's fully resolved closure
|
|
44
55
|
profile switch <name> Repoint the active profile — offline, no reinstall
|
|
56
|
+
profile init Write .claude/profiles.json from this project's setup
|
|
45
57
|
profile sync Promote local edits back into .claude/profiles.json
|
|
46
58
|
doctor [--fix] Check binary deps, profile symlinks, conventions
|
|
47
59
|
|
|
48
60
|
claude [args...] Check for plugin updates (1h cache), then run claude
|
|
49
61
|
--force, -f Force the update check
|
|
50
|
-
|
|
62
|
+
upgrade Update claudeup itself
|
|
51
63
|
--version, -v Version + update check
|
|
52
64
|
--help, -h This list
|
|
53
65
|
```
|
|
54
66
|
|
|
67
|
+
### install vs update
|
|
68
|
+
|
|
69
|
+
They differ in one thing: whether the pins are allowed to move.
|
|
70
|
+
|
|
71
|
+
| | `install` | `update` |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| Versions | exactly what the manifest pins | advances every `"latest"` pin |
|
|
74
|
+
| Scope | every profile (or the one named) | the **active** profile |
|
|
75
|
+
| Also does | materializes + activates the profile | nothing to the manifest |
|
|
76
|
+
|
|
77
|
+
Both install whatever the profile declares and this machine lacks, so `update`
|
|
78
|
+
covers the "I ticked it but never installed it" case on its own.
|
|
79
|
+
|
|
80
|
+
**This is dependency management, not a package manager.** There is no resolver,
|
|
81
|
+
no lockfile and no version negotiation. The job is: everything the profile
|
|
82
|
+
declares is installed, and current.
|
|
83
|
+
|
|
84
|
+
That matters for exact pins. `claude plugin install` takes no version argument —
|
|
85
|
+
it installs whatever the marketplace publishes now — so a pin naming anything
|
|
86
|
+
else cannot be satisfied. `update` plans against the version it can actually
|
|
87
|
+
get, notes the shortfall, and moves on:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
dev@magus = 9.9.9 (manifest pins 4.6.1; the marketplace offers only 9.9.9)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It records what actually landed, never the pin, and it does **not** fail over
|
|
94
|
+
the difference — an unsatisfiable pin is a standing property of the manifest,
|
|
95
|
+
not something a given run did wrong. Pin drift is gated where it always was:
|
|
96
|
+
`claudeup install --check` with `strictVersions` in the manifest.
|
|
97
|
+
|
|
98
|
+
**`update` only ever moves forward.** Nothing here can downgrade — the installer
|
|
99
|
+
takes no version — so an install that is already ahead of what the marketplace
|
|
100
|
+
publishes is left alone rather than dragged back. A plan that tried would emit a
|
|
101
|
+
step that runs, reports success, changes nothing, and reappears on every
|
|
102
|
+
subsequent run.
|
|
103
|
+
|
|
104
|
+
`--check` fails on things that are **missing or known to be behind**. It
|
|
105
|
+
deliberately does not fail on the two items that get re-fetched every run
|
|
106
|
+
regardless: an already-installed skill (a manifest skill ref carries no content
|
|
107
|
+
hash, so there is nothing to compare) and a present unpinned binary (no version
|
|
108
|
+
probe exists, only present/absent). A gate that can never pass gets switched
|
|
109
|
+
off, so it only fires on real drift.
|
|
110
|
+
|
|
111
|
+
Neither command needs a manifest to exist first. In a repo without
|
|
112
|
+
`.claude/profiles.json`, both offer to write one describing the project as it
|
|
113
|
+
stands — that is `claudeup profile init`, which you can also run on its own.
|
|
114
|
+
Adoption reads the project's own `.claude/settings.json`; if that enables
|
|
115
|
+
nothing it falls back to your user-scope settings and says so, because the file
|
|
116
|
+
is meant to be committed and one machine's global set is not a team contract.
|
|
117
|
+
Machine-only keys are stripped, and any settings value naming a path under your
|
|
118
|
+
home directory is flagged for review.
|
|
119
|
+
|
|
120
|
+
`--yes` alone will **not** create a manifest. It means "don't ask me about the
|
|
121
|
+
command I ran", and someone running `claudeup update --yes` in CI did not ask to
|
|
122
|
+
author their team's committed contract. Creating it needs a human at the prompt,
|
|
123
|
+
or `claudeup profile init`.
|
|
124
|
+
|
|
55
125
|
`claudeup doctor` exits non-zero when it finds a problem, so it works as a CI
|
|
56
126
|
check. So does `claudeup install --check`.
|
|
57
127
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.1",
|
|
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": "
|
|
68
|
-
"claudeup-darwin-x64": "
|
|
69
|
-
"claudeup-linux-x64": "
|
|
67
|
+
"claudeup-darwin-arm64": "5.0.1",
|
|
68
|
+
"claudeup-darwin-x64": "5.0.1",
|
|
69
|
+
"claudeup-linux-x64": "5.0.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -63,3 +63,49 @@ describe("route() dispatch", () => {
|
|
|
63
63
|
expect(typeof mod.route).toBe("function");
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
|
+
|
|
67
|
+
describe("update / upgrade split", () => {
|
|
68
|
+
// `update` used to self-update claudeup. It now means what it means in every
|
|
69
|
+
// package manager — bring THIS PROJECT's dependencies up to date — and
|
|
70
|
+
// self-update moved to `upgrade`. Both halves are asserted here because a
|
|
71
|
+
// silent regression either way sends the user to the wrong command.
|
|
72
|
+
|
|
73
|
+
test("`upgrade` is the self-update command", async () => {
|
|
74
|
+
const mod = await import("../cli/upgrade.js");
|
|
75
|
+
expect(typeof mod.runUpgradeCommand).toBe("function");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("`update` is the dependency command and takes args", async () => {
|
|
79
|
+
const mod = await import("../cli/update.js");
|
|
80
|
+
expect(typeof mod.runUpdateCommand).toBe("function");
|
|
81
|
+
// It takes `args`. The old self-update took none, and `Function.length`
|
|
82
|
+
// stops counting at the first defaulted parameter — so `projectPath`
|
|
83
|
+
// does not show up here, but a regression to the no-arg form would.
|
|
84
|
+
expect(mod.runUpdateCommand.length).toBe(1);
|
|
85
|
+
expect(mod.runUpdateCommand.length).toBeGreaterThan(
|
|
86
|
+
(await import("../cli/upgrade.js")).runUpgradeCommand.length,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("--version points at `claudeup upgrade`, not `claudeup update`", async () => {
|
|
91
|
+
await route(["--version"], "1.0.0");
|
|
92
|
+
const out = logs.join("\n");
|
|
93
|
+
// Only assert when an update was actually offered; the check hits the
|
|
94
|
+
// network and may report nothing.
|
|
95
|
+
if (out.includes("Update available")) {
|
|
96
|
+
expect(out).toContain("claudeup upgrade");
|
|
97
|
+
expect(out).not.toContain("Run: claudeup update");
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("help documents both, and describes update as the dependency command", async () => {
|
|
102
|
+
await route(["--help"], "1.0.0");
|
|
103
|
+
const help = logs.join("\n");
|
|
104
|
+
expect(help).toContain("update [profile]");
|
|
105
|
+
expect(help).toContain("upgrade");
|
|
106
|
+
expect(help).toContain("Update claudeup ITSELF");
|
|
107
|
+
expect(help).toContain("profile init");
|
|
108
|
+
// The old wording sent people to `update` for a self-update.
|
|
109
|
+
expect(help).not.toContain("update Update claudeup itself");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -87,6 +87,11 @@ describe("loadProjectDotenv", () => {
|
|
|
87
87
|
expect(r.warnings).toHaveLength(1);
|
|
88
88
|
expect(r.warnings[0]).toContain(".env");
|
|
89
89
|
expect(r.warnings[0]).toContain("named pipe");
|
|
90
|
+
// The warning must give the real reason — the open blocks with no writer.
|
|
91
|
+
// It must NOT claim reading would steal another process's secrets: a
|
|
92
|
+
// 1Password pipe re-serves on every open, so that rationale is false.
|
|
93
|
+
expect(r.warnings[0]).toContain("blocks");
|
|
94
|
+
expect(r.warnings[0]).not.toContain("consume");
|
|
90
95
|
});
|
|
91
96
|
|
|
92
97
|
it("skips a .env that is a FIFO directly, and warns", () => {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildAdoptedProfile,
|
|
4
|
+
findMachineSpecificPaths,
|
|
5
|
+
} from "../services/profile-adopt.js";
|
|
6
|
+
|
|
7
|
+
const NOW = "2026-08-27T00:00:00.000Z";
|
|
8
|
+
|
|
9
|
+
describe("buildAdoptedProfile", () => {
|
|
10
|
+
test("adopts enabled plugins as 'latest', never as the installed version", () => {
|
|
11
|
+
// Pinning whatever a machine drifted to would turn one developer's
|
|
12
|
+
// accident into the team's contract.
|
|
13
|
+
const entry = buildAdoptedProfile({
|
|
14
|
+
enabledPlugins: { "dev@magus": true, "mnemex@magus": true },
|
|
15
|
+
settings: {},
|
|
16
|
+
mcpServers: {},
|
|
17
|
+
now: NOW,
|
|
18
|
+
});
|
|
19
|
+
expect(entry.plugins).toEqual({
|
|
20
|
+
"dev@magus": "latest",
|
|
21
|
+
"mnemex@magus": "latest",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("skips disabled plugins", () => {
|
|
26
|
+
const entry = buildAdoptedProfile({
|
|
27
|
+
enabledPlugins: { "dev@magus": true, "gtd@magus": false },
|
|
28
|
+
settings: {},
|
|
29
|
+
mcpServers: {},
|
|
30
|
+
now: NOW,
|
|
31
|
+
});
|
|
32
|
+
expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("skips an id with no marketplace — it cannot be reinstalled elsewhere", () => {
|
|
36
|
+
const entry = buildAdoptedProfile({
|
|
37
|
+
enabledPlugins: { "dev@magus": true, "some-local-thing": true },
|
|
38
|
+
settings: {},
|
|
39
|
+
mcpServers: {},
|
|
40
|
+
now: NOW,
|
|
41
|
+
});
|
|
42
|
+
expect(Object.keys(entry.plugins ?? {})).toEqual(["dev@magus"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("derives the marketplace each plugin id names", () => {
|
|
46
|
+
const entry = buildAdoptedProfile({
|
|
47
|
+
enabledPlugins: { "dev@magus": true },
|
|
48
|
+
settings: {},
|
|
49
|
+
mcpServers: {},
|
|
50
|
+
now: NOW,
|
|
51
|
+
});
|
|
52
|
+
expect(entry.marketplaces?.magus).toEqual({
|
|
53
|
+
source: "github",
|
|
54
|
+
repo: "MadAppGang/magus",
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("drops enabledPlugins from settings — the plugins map expresses it", () => {
|
|
59
|
+
// Carrying both would let them disagree; materialization regenerates
|
|
60
|
+
// enabledPlugins from the plugins map.
|
|
61
|
+
const entry = buildAdoptedProfile({
|
|
62
|
+
enabledPlugins: { "dev@magus": true },
|
|
63
|
+
settings: {
|
|
64
|
+
enabledPlugins: { "dev@magus": true },
|
|
65
|
+
enableAllProjectMcpServers: true,
|
|
66
|
+
},
|
|
67
|
+
mcpServers: {},
|
|
68
|
+
now: NOW,
|
|
69
|
+
});
|
|
70
|
+
expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("carries MCP servers over verbatim", () => {
|
|
74
|
+
const servers = {
|
|
75
|
+
"chrome-devtools": { command: "npx", args: ["-y", "x"] },
|
|
76
|
+
};
|
|
77
|
+
const entry = buildAdoptedProfile({
|
|
78
|
+
enabledPlugins: { "dev@magus": true },
|
|
79
|
+
settings: {},
|
|
80
|
+
mcpServers: servers,
|
|
81
|
+
now: NOW,
|
|
82
|
+
});
|
|
83
|
+
expect(entry.mcpServers).toEqual(servers);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("omits empty settings and mcpServers rather than writing empty objects", () => {
|
|
87
|
+
const entry = buildAdoptedProfile({
|
|
88
|
+
enabledPlugins: { "dev@magus": true },
|
|
89
|
+
settings: {},
|
|
90
|
+
mcpServers: {},
|
|
91
|
+
now: NOW,
|
|
92
|
+
});
|
|
93
|
+
expect(entry.settings).toBeUndefined();
|
|
94
|
+
expect(entry.mcpServers).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("strips installedPluginVersions — that is machine state, not a contract", () => {
|
|
98
|
+
// Committing it publishes one developer's install state as the team's
|
|
99
|
+
// contract, and it is stale the moment anyone updates.
|
|
100
|
+
const entry = buildAdoptedProfile({
|
|
101
|
+
enabledPlugins: { "dev@magus": true },
|
|
102
|
+
settings: {
|
|
103
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
104
|
+
enableAllProjectMcpServers: true,
|
|
105
|
+
},
|
|
106
|
+
mcpServers: {},
|
|
107
|
+
now: NOW,
|
|
108
|
+
});
|
|
109
|
+
expect(entry.settings).toEqual({ enableAllProjectMcpServers: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("omits settings entirely when only machine keys were present", () => {
|
|
113
|
+
const entry = buildAdoptedProfile({
|
|
114
|
+
enabledPlugins: { "dev@magus": true },
|
|
115
|
+
settings: {
|
|
116
|
+
enabledPlugins: { "dev@magus": true },
|
|
117
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
118
|
+
},
|
|
119
|
+
mcpServers: {},
|
|
120
|
+
now: NOW,
|
|
121
|
+
});
|
|
122
|
+
expect(entry.settings).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not mutate the caller's settings object", () => {
|
|
126
|
+
const settings = {
|
|
127
|
+
installedPluginVersions: { "dev@magus": "4.6.1" },
|
|
128
|
+
other: 1,
|
|
129
|
+
};
|
|
130
|
+
buildAdoptedProfile({
|
|
131
|
+
enabledPlugins: { "dev@magus": true },
|
|
132
|
+
settings,
|
|
133
|
+
mcpServers: {},
|
|
134
|
+
now: NOW,
|
|
135
|
+
});
|
|
136
|
+
expect(settings.installedPluginVersions).toEqual({ "dev@magus": "4.6.1" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("is deterministic when the clock is injected", () => {
|
|
140
|
+
const input = {
|
|
141
|
+
enabledPlugins: { "dev@magus": true },
|
|
142
|
+
settings: {},
|
|
143
|
+
mcpServers: {},
|
|
144
|
+
now: NOW,
|
|
145
|
+
};
|
|
146
|
+
expect(buildAdoptedProfile(input)).toEqual(buildAdoptedProfile(input));
|
|
147
|
+
expect(buildAdoptedProfile(input).createdAt).toBe(NOW);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("findMachineSpecificPaths", () => {
|
|
152
|
+
const HOME = "/Users/someone";
|
|
153
|
+
|
|
154
|
+
test("flags a home-directory path nested in an array", () => {
|
|
155
|
+
// Not stripped — an additionalDirectories list is often genuinely wanted —
|
|
156
|
+
// but surfaced, because it will not resolve on a teammate's machine.
|
|
157
|
+
expect(
|
|
158
|
+
findMachineSpecificPaths(
|
|
159
|
+
{ permissions: { additionalDirectories: ["../sibling", `${HOME}/x`] } },
|
|
160
|
+
HOME,
|
|
161
|
+
),
|
|
162
|
+
).toEqual([`permissions.additionalDirectories[1] = ${HOME}/x`]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("relative paths are not flagged", () => {
|
|
166
|
+
expect(
|
|
167
|
+
findMachineSpecificPaths(
|
|
168
|
+
{ permissions: { additionalDirectories: ["../magus", "./local"] } },
|
|
169
|
+
HOME,
|
|
170
|
+
),
|
|
171
|
+
).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("returns nothing rather than guessing when HOME is unset", () => {
|
|
175
|
+
expect(findMachineSpecificPaths({ a: `${HOME}/x` }, "")).toEqual([]);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
binInstallCommand,
|
|
4
|
+
binUpgradeCommand,
|
|
4
5
|
detectToolchains,
|
|
5
6
|
} from "../services/toolchain.js";
|
|
6
7
|
import type { ResolvedBin } from "../types/index.js";
|
|
@@ -31,6 +32,36 @@ describe("binInstallCommand", () => {
|
|
|
31
32
|
});
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
describe("binUpgradeCommand", () => {
|
|
36
|
+
test("brew and pip do NOT reuse their install command", () => {
|
|
37
|
+
// This is the whole reason the function exists: `brew install` on an
|
|
38
|
+
// installed formula prints "already installed" and changes nothing, and
|
|
39
|
+
// `pip install` without --upgrade does the same. Reusing them would let
|
|
40
|
+
// `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
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
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
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("go installs the module at latest", () => {
|
|
59
|
+
expect(
|
|
60
|
+
binUpgradeCommand({ name: "t", via: "go", module: "github.com/x/t" }),
|
|
61
|
+
).toBe("go install github.com/x/t@latest");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
34
65
|
describe("detectToolchains", () => {
|
|
35
66
|
test("groups bins by installer and reports requiredBy + presence", async () => {
|
|
36
67
|
const bins: ResolvedBin[] = [
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for the `claudeup update` APPLY path.
|
|
3
|
+
*
|
|
4
|
+
* Every defect a multi-model review found in this feature lived here, in the
|
|
5
|
+
* half that had no coverage, while the pure planner beside it had forty tests.
|
|
6
|
+
* These lock the apply path's contract so that gap cannot silently reopen.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
+
import { type PluginApplyDeps, applyPlugins } from "../cli/update.js";
|
|
11
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
12
|
+
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
13
|
+
|
|
14
|
+
const PROJECT = "/tmp/project";
|
|
15
|
+
|
|
16
|
+
/** Records every call, and lets a test dictate what the registry reads back. */
|
|
17
|
+
function stubDeps(
|
|
18
|
+
installedAfter: string | null | ((scope: PluginScope) => string | null),
|
|
19
|
+
) {
|
|
20
|
+
const saved: Array<{ id: string; version: string; scope: PluginScope }> = [];
|
|
21
|
+
const updated: Array<{ id: string; scope: PluginScope }> = [];
|
|
22
|
+
const repaired: Array<{ id: string; scope: PluginScope }> = [];
|
|
23
|
+
|
|
24
|
+
const deps: PluginApplyDeps = {
|
|
25
|
+
update: mock(async (id: string, scope: PluginScope) => {
|
|
26
|
+
updated.push({ id, scope });
|
|
27
|
+
}),
|
|
28
|
+
repair: mock(async (id: string, scope: PluginScope) => {
|
|
29
|
+
repaired.push({ id, scope });
|
|
30
|
+
}),
|
|
31
|
+
readInstalled: mock(async (_id: string, scope: PluginScope) =>
|
|
32
|
+
typeof installedAfter === "function"
|
|
33
|
+
? installedAfter(scope)
|
|
34
|
+
: installedAfter,
|
|
35
|
+
),
|
|
36
|
+
saveInstalled: mock(
|
|
37
|
+
async (id: string, version: string, scope: PluginScope) => {
|
|
38
|
+
saved.push({ id, version, scope });
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
};
|
|
42
|
+
return { deps, saved, updated, repaired };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
|
|
46
|
+
return {
|
|
47
|
+
pluginId: "dev@magus",
|
|
48
|
+
pinned: "latest",
|
|
49
|
+
installed: "1.0.0",
|
|
50
|
+
available: "2.0.0",
|
|
51
|
+
target: "2.0.0",
|
|
52
|
+
action: "update",
|
|
53
|
+
scopes: ["project"],
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let logs: string[];
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
logs = [];
|
|
61
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
62
|
+
console.log = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
64
|
+
console.warn = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("what gets RECORDED is what got installed", () => {
|
|
68
|
+
test("records the version read back, not the version we asked for", async () => {
|
|
69
|
+
// `claude plugin install` accepts no version — it installs the newest.
|
|
70
|
+
// Recording `target` instead of the read-back made the registry claim a
|
|
71
|
+
// version that was never on disk.
|
|
72
|
+
const { deps, saved } = stubDeps("9.9.9");
|
|
73
|
+
await applyPlugins([item({ target: "2.0.0" })], PROJECT, deps);
|
|
74
|
+
expect(saved).toEqual([
|
|
75
|
+
{ id: "dev@magus", version: "9.9.9", scope: "project" },
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("an exact pin the installer could not honour is reported, not hidden", async () => {
|
|
80
|
+
const { deps, saved } = stubDeps("9.9.9");
|
|
81
|
+
const result = await applyPlugins(
|
|
82
|
+
[item({ pinned: "4.6.1", target: "4.6.1" })],
|
|
83
|
+
PROJECT,
|
|
84
|
+
deps,
|
|
85
|
+
);
|
|
86
|
+
// The pin is NOT written as if satisfied...
|
|
87
|
+
expect(saved).toEqual([
|
|
88
|
+
{ id: "dev@magus", version: "9.9.9", scope: "project" },
|
|
89
|
+
]);
|
|
90
|
+
// ...and the shortfall is surfaced to the caller.
|
|
91
|
+
expect(result.mismatches).toEqual([
|
|
92
|
+
{
|
|
93
|
+
pluginId: "dev@magus",
|
|
94
|
+
pinned: "4.6.1",
|
|
95
|
+
actual: "9.9.9",
|
|
96
|
+
scope: "project",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a satisfied exact pin produces no mismatch", async () => {
|
|
102
|
+
const { deps } = stubDeps("4.6.1");
|
|
103
|
+
const result = await applyPlugins(
|
|
104
|
+
[item({ pinned: "4.6.1", target: "4.6.1" })],
|
|
105
|
+
PROJECT,
|
|
106
|
+
deps,
|
|
107
|
+
);
|
|
108
|
+
expect(result.mismatches).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a 'latest' pin never counts as a mismatch", async () => {
|
|
112
|
+
const { deps } = stubDeps("9.9.9");
|
|
113
|
+
const result = await applyPlugins(
|
|
114
|
+
[item({ pinned: "latest", target: "2.0.0" })],
|
|
115
|
+
PROJECT,
|
|
116
|
+
deps,
|
|
117
|
+
);
|
|
118
|
+
expect(result.mismatches).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("nothing is recorded when the registry has no version to read back", async () => {
|
|
122
|
+
// Writing a guess here would be the same fabrication in another costume.
|
|
123
|
+
const { deps, saved } = stubDeps(null);
|
|
124
|
+
await applyPlugins([item({})], PROJECT, deps);
|
|
125
|
+
expect(saved).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("scope handling", () => {
|
|
130
|
+
test("a fresh install goes to project scope only", async () => {
|
|
131
|
+
const { deps, updated } = stubDeps("2.0.0");
|
|
132
|
+
await applyPlugins(
|
|
133
|
+
[item({ action: "install", installed: null, scopes: [] })],
|
|
134
|
+
PROJECT,
|
|
135
|
+
deps,
|
|
136
|
+
);
|
|
137
|
+
expect(updated).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("every outdated scope is updated AND recorded separately", async () => {
|
|
141
|
+
const { deps, updated, saved } = stubDeps((scope) =>
|
|
142
|
+
scope === "user" ? "2.0.0" : "1.5.0",
|
|
143
|
+
);
|
|
144
|
+
await applyPlugins([item({ scopes: ["user", "project"] })], PROJECT, deps);
|
|
145
|
+
expect(updated).toEqual([
|
|
146
|
+
{ id: "dev@magus", scope: "user" },
|
|
147
|
+
{ id: "dev@magus", scope: "project" },
|
|
148
|
+
]);
|
|
149
|
+
// Each scope records ITS OWN read-back, never one shared value.
|
|
150
|
+
expect(saved).toEqual([
|
|
151
|
+
{ id: "dev@magus", version: "2.0.0", scope: "user" },
|
|
152
|
+
{ id: "dev@magus", version: "1.5.0", scope: "project" },
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a repair uses repairPlugin, not a plain install", async () => {
|
|
157
|
+
// A content-stale plugin has no version bump, so `install` is a no-op that
|
|
158
|
+
// reports success — only uninstall+install refreshes the files.
|
|
159
|
+
const { deps, repaired, updated } = stubDeps("1.0.0");
|
|
160
|
+
await applyPlugins([item({ action: "repair" })], PROJECT, deps);
|
|
161
|
+
expect(repaired).toEqual([{ id: "dev@magus", scope: "project" }]);
|
|
162
|
+
expect(updated).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("what is skipped", () => {
|
|
167
|
+
test("'current' and 'unknown' items are never touched", async () => {
|
|
168
|
+
const { deps, updated, saved } = stubDeps("1.0.0");
|
|
169
|
+
await applyPlugins(
|
|
170
|
+
[
|
|
171
|
+
item({ action: "current", scopes: [] }),
|
|
172
|
+
item({ pluginId: "b@magus", action: "unknown", scopes: [] }),
|
|
173
|
+
],
|
|
174
|
+
PROJECT,
|
|
175
|
+
deps,
|
|
176
|
+
);
|
|
177
|
+
expect(updated).toEqual([]);
|
|
178
|
+
expect(saved).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an 'unknown' plugin is not installed on a guess", async () => {
|
|
182
|
+
// It is unknown because the catalog could not be reached. Acting on that
|
|
183
|
+
// would be picking a version at random.
|
|
184
|
+
const { deps, updated } = stubDeps("1.0.0");
|
|
185
|
+
await applyPlugins(
|
|
186
|
+
[item({ action: "unknown", target: null, scopes: ["project"] })],
|
|
187
|
+
PROJECT,
|
|
188
|
+
deps,
|
|
189
|
+
);
|
|
190
|
+
expect(updated).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("failure handling", () => {
|
|
195
|
+
test("a failing plugin is reported, and the rest still run", async () => {
|
|
196
|
+
const { deps, updated } = stubDeps("2.0.0");
|
|
197
|
+
deps.update = mock(async (id: string, scope: PluginScope) => {
|
|
198
|
+
if (id === "bad@magus") throw new Error("network down");
|
|
199
|
+
updated.push({ id, scope });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const result = await applyPlugins(
|
|
203
|
+
[item({ pluginId: "bad@magus" }), item({ pluginId: "good@magus" })],
|
|
204
|
+
PROJECT,
|
|
205
|
+
deps,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.failed).toEqual(["bad@magus"]);
|
|
209
|
+
expect(result.ok).toBe(1);
|
|
210
|
+
expect(updated).toEqual([{ id: "good@magus", scope: "project" }]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a scope that threw records nothing for that plugin", async () => {
|
|
214
|
+
// Half-applied state must not be recorded as if it succeeded.
|
|
215
|
+
const { deps, saved } = stubDeps("2.0.0");
|
|
216
|
+
deps.update = mock(async () => {
|
|
217
|
+
throw new Error("boom");
|
|
218
|
+
});
|
|
219
|
+
const result = await applyPlugins([item({})], PROJECT, deps);
|
|
220
|
+
expect(saved).toEqual([]);
|
|
221
|
+
expect(result.ok).toBe(0);
|
|
222
|
+
});
|
|
223
|
+
});
|