claudeup 4.42.0 → 5.0.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/README.md +73 -3
- package/bin/claudeup.js +16 -3
- package/package.json +4 -4
- package/scripts/build-binaries.ts +12 -1
- package/src/__tests__/cli-router.test.ts +46 -0
- package/src/__tests__/dotenv.test.ts +190 -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/main.tsx +18 -0
- package/src/prerunner/index.ts +1 -22
- package/src/services/dotenv.ts +176 -0
- 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
|
@@ -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
|
+
});
|