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,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for `claudeup update` — the dependency-facing update.
|
|
3
|
+
*
|
|
4
|
+
* `install` makes the machine match the manifest's pins. `update` advances it:
|
|
5
|
+
* anything the active profile declares but the machine lacks gets installed,
|
|
6
|
+
* and anything pinned "latest" moves to the newest version the marketplace
|
|
7
|
+
* catalog offers. Everything here is a pure function of a closure plus captured
|
|
8
|
+
* machine state, so the decisions are unit-testable and the command file only
|
|
9
|
+
* does IO.
|
|
10
|
+
*
|
|
11
|
+
* The one rule worth stating out loud: a catalog fetch that FAILED must never
|
|
12
|
+
* be reported as "up to date". `PluginInfo.updateCheckFailed` exists because a
|
|
13
|
+
* rate-limited fetch once compared the installed version against a stale local
|
|
14
|
+
* clone, found them equal, and rendered the failure as a clean bill of health.
|
|
15
|
+
* That case gets its own action (`unknown`) rather than folding into `current`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ProfileSkillRef, ResolvedBin } from "../types/index.js";
|
|
19
|
+
import type { PluginScope } from "./claude-cli.js";
|
|
20
|
+
import type { BinCheckResult } from "./doctor-bins.js";
|
|
21
|
+
import { type PluginInfo, compareVersions } from "./plugin-manager.js";
|
|
22
|
+
|
|
23
|
+
// ── Plugins ──────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export type PluginUpdateAction =
|
|
26
|
+
/** Declared by the profile, installed nowhere. */
|
|
27
|
+
| "install"
|
|
28
|
+
/** Installed at a version other than the one we should be on. */
|
|
29
|
+
| "update"
|
|
30
|
+
/** Same version, different files — only a reinstall delivers the content. */
|
|
31
|
+
| "repair"
|
|
32
|
+
/** Already on the right version. */
|
|
33
|
+
| "current"
|
|
34
|
+
/** The catalog check failed; we cannot claim either way. */
|
|
35
|
+
| "unknown";
|
|
36
|
+
|
|
37
|
+
export interface PluginUpdateItem {
|
|
38
|
+
pluginId: string;
|
|
39
|
+
/** What the profile pins: an exact version, or "latest". */
|
|
40
|
+
pinned: string;
|
|
41
|
+
/** Highest version installed in any scope, or null when absent. */
|
|
42
|
+
installed: string | null;
|
|
43
|
+
/** Version the marketplace catalog offers, or null when unknown. */
|
|
44
|
+
available: string | null;
|
|
45
|
+
/** Version this item would move to. Null for `current`/`unknown`. */
|
|
46
|
+
target: string | null;
|
|
47
|
+
action: PluginUpdateAction;
|
|
48
|
+
/** Scopes holding an install this action must touch. Empty for a fresh install. */
|
|
49
|
+
scopes: PluginScope[];
|
|
50
|
+
/** Human explanation, set for `unknown` and `repair`. */
|
|
51
|
+
note?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Scope → installed version, for the three scopes a PluginInfo carries. */
|
|
55
|
+
function scopeVersions(
|
|
56
|
+
info: PluginInfo | undefined,
|
|
57
|
+
): Array<[PluginScope, string]> {
|
|
58
|
+
if (!info) return [];
|
|
59
|
+
return (
|
|
60
|
+
[
|
|
61
|
+
["user", info.userScope],
|
|
62
|
+
["project", info.projectScope],
|
|
63
|
+
["local", info.localScope],
|
|
64
|
+
] as const
|
|
65
|
+
)
|
|
66
|
+
.filter(([, status]) => !!status?.version)
|
|
67
|
+
.map(
|
|
68
|
+
([scope, status]) => [scope, status!.version!] as [PluginScope, string],
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The highest version installed across scopes, or null. */
|
|
73
|
+
function highestInstalled(info: PluginInfo | undefined): string | null {
|
|
74
|
+
const versions = scopeVersions(info).map(([, v]) => v);
|
|
75
|
+
if (versions.length === 0) return null;
|
|
76
|
+
return versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Scopes that are strictly BEHIND `target`.
|
|
81
|
+
*
|
|
82
|
+
* Every scope that holds an outdated install is returned, not just "user". The
|
|
83
|
+
* prerunner learned this the expensive way: it detected drift from the current
|
|
84
|
+
* project's resolved version, then "fixed" it at user scope and reported
|
|
85
|
+
* success, leaving the project row untouched so the same drift reappeared on
|
|
86
|
+
* every run.
|
|
87
|
+
*
|
|
88
|
+
* This only ever moves FORWARD, and there is deliberately no option to do
|
|
89
|
+
* otherwise. Nothing here can perform a downgrade: `claude plugin install`
|
|
90
|
+
* installs the newest published version and takes no version argument. So a
|
|
91
|
+
* scope ahead of the target is not a problem to fix — it is a state this tool
|
|
92
|
+
* cannot change, and planning against it produces a move that runs, appears to
|
|
93
|
+
* succeed, lands on the same version as before, and is replanned identically on
|
|
94
|
+
* the next run.
|
|
95
|
+
*
|
|
96
|
+
* A "not equal to target" comparison lived here briefly, on the theory that an
|
|
97
|
+
* explicit pin is absolute and moving down to it is as correct as moving up.
|
|
98
|
+
* Both halves were wrong: the pin is not deliverable, and the downgrade is not
|
|
99
|
+
* performable.
|
|
100
|
+
*/
|
|
101
|
+
export function scopesNeeding(
|
|
102
|
+
info: PluginInfo | undefined,
|
|
103
|
+
target: string,
|
|
104
|
+
): PluginScope[] {
|
|
105
|
+
return scopeVersions(info)
|
|
106
|
+
.filter(([, version]) => compareVersions(target, version) > 0)
|
|
107
|
+
.map(([scope]) => scope);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Decide what to do with every plugin the profile declares.
|
|
112
|
+
*
|
|
113
|
+
* @param pinned closure.plugins — pluginId -> exact version | "latest"
|
|
114
|
+
* @param catalog getAvailablePlugins() output, keyed by plugin id
|
|
115
|
+
*/
|
|
116
|
+
export function planPluginUpdates(
|
|
117
|
+
pinned: Record<string, string>,
|
|
118
|
+
catalog: Map<string, PluginInfo>,
|
|
119
|
+
): PluginUpdateItem[] {
|
|
120
|
+
const items: PluginUpdateItem[] = [];
|
|
121
|
+
|
|
122
|
+
for (const [pluginId, pin] of Object.entries(pinned)) {
|
|
123
|
+
const info = catalog.get(pluginId);
|
|
124
|
+
const installed = highestInstalled(info);
|
|
125
|
+
const available = info?.version ?? null;
|
|
126
|
+
|
|
127
|
+
const base = { pluginId, pinned: pin, installed, available };
|
|
128
|
+
|
|
129
|
+
// Not installed anywhere — the "checked but not installed" case.
|
|
130
|
+
if (!installed) {
|
|
131
|
+
items.push({
|
|
132
|
+
...base,
|
|
133
|
+
target: pin === "latest" ? available : pin,
|
|
134
|
+
action: "install",
|
|
135
|
+
scopes: [],
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// An exact pin is planned against what can ACTUALLY be delivered.
|
|
141
|
+
//
|
|
142
|
+
// This is dependency management, not a package manager: there is no
|
|
143
|
+
// resolver and no version negotiation. `claude plugin install` takes no
|
|
144
|
+
// version — it fetches whatever the marketplace publishes now — so a pin
|
|
145
|
+
// naming anything else is a request nothing can satisfy.
|
|
146
|
+
//
|
|
147
|
+
// Planning toward the pin regardless is an infinite loop with a progress
|
|
148
|
+
// bar: plan 9.9.9 → 4.6.1, install, get 9.9.9 back, replan the same thing
|
|
149
|
+
// next run, and fail `--check` every time in between. Targeting the
|
|
150
|
+
// deliverable version converges, and the note carries the shortfall so it
|
|
151
|
+
// is recorded rather than hidden. `install --check` + `strictVersions`
|
|
152
|
+
// remains the place that GATES on pin drift; this command reports it.
|
|
153
|
+
//
|
|
154
|
+
// `scopesNeeding` only ever moves forward, so a scope already AHEAD of the
|
|
155
|
+
// deliverable version is left alone rather than downgraded — the same rule
|
|
156
|
+
// as the "latest" path below, because in both cases the target is what the
|
|
157
|
+
// marketplace currently publishes.
|
|
158
|
+
//
|
|
159
|
+
// The decision comes from the PER-SCOPE versions, never from `installed`.
|
|
160
|
+
// `installed` is the highest across scopes, so comparing it to the target
|
|
161
|
+
// hides the case that matters most: user scope at the target and project
|
|
162
|
+
// scope behind it reads as "current" while the project — the scope that
|
|
163
|
+
// actually loads for this repo — stays stale.
|
|
164
|
+
if (pin !== "latest") {
|
|
165
|
+
const deliverable = available ?? pin;
|
|
166
|
+
const needing = scopesNeeding(info, deliverable);
|
|
167
|
+
items.push({
|
|
168
|
+
...base,
|
|
169
|
+
target: deliverable,
|
|
170
|
+
action: needing.length === 0 ? "current" : "update",
|
|
171
|
+
scopes: needing,
|
|
172
|
+
...(available !== null && available !== pin
|
|
173
|
+
? {
|
|
174
|
+
note: `manifest pins ${pin}; the marketplace offers only ${available}`,
|
|
175
|
+
}
|
|
176
|
+
: {}),
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// "latest" — and the catalog is the only source for what that means.
|
|
182
|
+
if (info?.updateCheckFailed) {
|
|
183
|
+
items.push({
|
|
184
|
+
...base,
|
|
185
|
+
target: null,
|
|
186
|
+
action: "unknown",
|
|
187
|
+
scopes: [],
|
|
188
|
+
note:
|
|
189
|
+
info.updateCheckFailure?.detail ??
|
|
190
|
+
"catalog fetch failed — cannot tell whether a newer version exists",
|
|
191
|
+
});
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Same version, changed files. There is no version bump to detect, so a
|
|
196
|
+
// plain install is a no-op ("Plugin is already installed") and only
|
|
197
|
+
// uninstall+install refreshes the content.
|
|
198
|
+
if (info?.contentStale) {
|
|
199
|
+
items.push({
|
|
200
|
+
...base,
|
|
201
|
+
target: installed,
|
|
202
|
+
action: "repair",
|
|
203
|
+
scopes: scopeVersions(info).map(([scope]) => scope),
|
|
204
|
+
note: "files changed without a version bump",
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Installed, but no marketplace offers it any more. "current" would be a
|
|
210
|
+
// claim we cannot support — there is nothing to compare against.
|
|
211
|
+
if (!available) {
|
|
212
|
+
items.push({
|
|
213
|
+
...base,
|
|
214
|
+
target: null,
|
|
215
|
+
action: "unknown",
|
|
216
|
+
scopes: [],
|
|
217
|
+
note: "not offered by any configured marketplace",
|
|
218
|
+
});
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Again per-scope, and only ever forward.
|
|
223
|
+
const needing = scopesNeeding(info, available);
|
|
224
|
+
items.push({
|
|
225
|
+
...base,
|
|
226
|
+
target: needing.length === 0 ? installed : available,
|
|
227
|
+
action: needing.length === 0 ? "current" : "update",
|
|
228
|
+
scopes: needing,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return items.sort((a, b) => a.pluginId.localeCompare(b.pluginId));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Binaries ─────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export type BinUpdateAction =
|
|
238
|
+
/** Not on PATH. */
|
|
239
|
+
| "install"
|
|
240
|
+
/** Present and unpinned — re-run the installer to advance it. */
|
|
241
|
+
| "upgrade"
|
|
242
|
+
/** Present and pinned to an exact version; nothing to advance to. */
|
|
243
|
+
| "current";
|
|
244
|
+
|
|
245
|
+
export interface BinUpdateItem {
|
|
246
|
+
name: string;
|
|
247
|
+
action: BinUpdateAction;
|
|
248
|
+
/** The exact pin, when the profile declares one. */
|
|
249
|
+
version?: string;
|
|
250
|
+
sources: string[];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Plan binary work from a presence check.
|
|
255
|
+
*
|
|
256
|
+
* There is no version probe for an arbitrary binary — `checkBinaries` answers
|
|
257
|
+
* present/absent only — so a PINNED bin that is present is left alone rather
|
|
258
|
+
* than reinstalled on a guess. An unpinned one is re-run through its installer,
|
|
259
|
+
* which is what "update" means for a dependency with no declared version.
|
|
260
|
+
*/
|
|
261
|
+
export function planBinUpdates(
|
|
262
|
+
bins: ResolvedBin[],
|
|
263
|
+
checks: BinCheckResult[],
|
|
264
|
+
): BinUpdateItem[] {
|
|
265
|
+
const present = new Map(checks.map((c) => [c.name, c.present]));
|
|
266
|
+
return bins.map((bin) => {
|
|
267
|
+
const item: BinUpdateItem = {
|
|
268
|
+
name: bin.name,
|
|
269
|
+
sources: bin.sources,
|
|
270
|
+
action: !present.get(bin.name)
|
|
271
|
+
? "install"
|
|
272
|
+
: bin.version
|
|
273
|
+
? "current"
|
|
274
|
+
: "upgrade",
|
|
275
|
+
};
|
|
276
|
+
if (bin.version) item.version = bin.version;
|
|
277
|
+
return item;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Skills ───────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export interface SkillUpdateItem {
|
|
284
|
+
name: string;
|
|
285
|
+
ref: ProfileSkillRef;
|
|
286
|
+
action: "install" | "refresh";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Plan skill work.
|
|
291
|
+
*
|
|
292
|
+
* A profile's skill refs carry no content hash, so "is this one behind?" is not
|
|
293
|
+
* answerable without fetching every file. Skills are small and the fetch is the
|
|
294
|
+
* same call either way, so `update` re-fetches the installed ones instead of
|
|
295
|
+
* guessing. The distinction is kept in the REPORT — a user needs to see which
|
|
296
|
+
* skills were newly added — not in the work.
|
|
297
|
+
*/
|
|
298
|
+
export function planSkillUpdates(
|
|
299
|
+
skills: ProfileSkillRef[],
|
|
300
|
+
installedNames: ReadonlySet<string>,
|
|
301
|
+
): SkillUpdateItem[] {
|
|
302
|
+
return skills.map((ref) => ({
|
|
303
|
+
name: ref.name,
|
|
304
|
+
ref,
|
|
305
|
+
action: installedNames.has(ref.name) ? "refresh" : "install",
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ── Summary ──────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
export interface UpdatePlan {
|
|
312
|
+
profileId: string;
|
|
313
|
+
plugins: PluginUpdateItem[];
|
|
314
|
+
bins: BinUpdateItem[];
|
|
315
|
+
skills: SkillUpdateItem[];
|
|
316
|
+
/**
|
|
317
|
+
* The full requirement behind each bin item, by name. The plan items carry
|
|
318
|
+
* only the decision; the installer command needs `via`/`package`/`formula`,
|
|
319
|
+
* and re-deriving those from a name would guess.
|
|
320
|
+
*/
|
|
321
|
+
binSpecs: Map<string, ResolvedBin>;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* True when applying the plan would do any work at all — including work that is
|
|
326
|
+
* only ever best-effort, like re-fetching an already-installed skill.
|
|
327
|
+
*
|
|
328
|
+
* Drives the "nothing to do" early exit. NOT the `--check` gate: see
|
|
329
|
+
* {@link planIsBehind}.
|
|
330
|
+
*/
|
|
331
|
+
export function planHasWork(plan: UpdatePlan): boolean {
|
|
332
|
+
return (
|
|
333
|
+
plan.plugins.some((p) => p.action !== "current") ||
|
|
334
|
+
plan.bins.some((b) => b.action !== "current") ||
|
|
335
|
+
plan.skills.length > 0
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* True when something is actually MISSING or KNOWN TO BE BEHIND. This is what
|
|
341
|
+
* `--check` gates on, and it is deliberately narrower than {@link planHasWork}.
|
|
342
|
+
*
|
|
343
|
+
* The distinction exists because two kinds of item are re-run every time
|
|
344
|
+
* without that meaning anything is wrong:
|
|
345
|
+
* - a skill, whose manifest ref carries no content hash, so it is re-fetched
|
|
346
|
+
* rather than compared;
|
|
347
|
+
* - an unpinned binary, for which there is no version probe at all, only
|
|
348
|
+
* present/absent.
|
|
349
|
+
*
|
|
350
|
+
* Counting those as drift made `--check` exit 1 forever for any profile that
|
|
351
|
+
* declared a single skill — a gate that can never pass tells you nothing, and
|
|
352
|
+
* would have been switched off in CI within a week.
|
|
353
|
+
*
|
|
354
|
+
* `unknown` DOES count: an unverifiable plugin is exactly what a gate must not
|
|
355
|
+
* wave through.
|
|
356
|
+
*/
|
|
357
|
+
export function planIsBehind(plan: UpdatePlan): boolean {
|
|
358
|
+
return (
|
|
359
|
+
plan.plugins.some((p) => p.action !== "current") ||
|
|
360
|
+
plan.bins.some((b) => b.action === "install") ||
|
|
361
|
+
plan.skills.some((s) => s.action === "install")
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Counts for the closing summary line, in a stable order. */
|
|
366
|
+
export function summarizePlan(plan: UpdatePlan): {
|
|
367
|
+
installed: number;
|
|
368
|
+
updated: number;
|
|
369
|
+
repaired: number;
|
|
370
|
+
current: number;
|
|
371
|
+
unknown: number;
|
|
372
|
+
} {
|
|
373
|
+
const count = (a: PluginUpdateAction) =>
|
|
374
|
+
plan.plugins.filter((p) => p.action === a).length;
|
|
375
|
+
return {
|
|
376
|
+
installed:
|
|
377
|
+
count("install") + plan.bins.filter((b) => b.action === "install").length,
|
|
378
|
+
updated:
|
|
379
|
+
count("update") + plan.bins.filter((b) => b.action === "upgrade").length,
|
|
380
|
+
repaired: count("repair"),
|
|
381
|
+
current:
|
|
382
|
+
count("current") + plan.bins.filter((b) => b.action === "current").length,
|
|
383
|
+
unknown: count("unknown"),
|
|
384
|
+
};
|
|
385
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -259,7 +259,7 @@ function UpdateBanner({ result }: { result: VersionCheckResult }) {
|
|
|
259
259
|
v{result.currentVersion} → v{result.latestVersion}
|
|
260
260
|
</text>
|
|
261
261
|
<text fg={theme.colors.muted}> Run: </text>
|
|
262
|
-
<text fg={theme.colors.info}>claudeup
|
|
262
|
+
<text fg={theme.colors.info}>claudeup upgrade</text>
|
|
263
263
|
</box>
|
|
264
264
|
);
|
|
265
265
|
}
|