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.
@@ -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 update</text>
262
+ <text fg={theme.colors.info}>claudeup upgrade</text>
263
263
  </box>
264
264
  );
265
265
  }