claudeup 4.26.0 → 4.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.26.0",
3
+ "version": "4.27.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": "4.26.0",
68
- "claudeup-darwin-x64": "4.26.0",
69
- "claudeup-linux-x64": "4.26.0"
67
+ "claudeup-darwin-arm64": "4.27.1",
68
+ "claudeup-darwin-x64": "4.27.1",
69
+ "claudeup-linux-x64": "4.27.1"
70
70
  }
71
71
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Guards the update-check refresh fix. checkForUpdates caches within a session
3
+ * (so we don't hammer the registry), but `force` bypasses that cache so a
4
+ * release landing mid-session is actually seen — the 4.25-vs-4.26 banner
5
+ * staleness. (The fetch itself also sets `cache: "no-store"` so a CDN-stale
6
+ * response can't mask a fresh release.)
7
+ */
8
+ import { afterEach, describe, expect, it } from "bun:test";
9
+ import { checkForUpdates } from "../services/version-check.js";
10
+
11
+ describe("checkForUpdates — session cache vs forced re-check", () => {
12
+ const realFetch = globalThis.fetch;
13
+ afterEach(() => {
14
+ globalThis.fetch = realFetch;
15
+ });
16
+
17
+ it("serves the session cache by default and re-fetches only when forced", async () => {
18
+ let calls = 0;
19
+ let latest = "9.9.9";
20
+ globalThis.fetch = (async () => {
21
+ calls++;
22
+ return new Response(JSON.stringify({ version: latest }), {
23
+ status: 200,
24
+ headers: { "content-type": "application/json" },
25
+ });
26
+ }) as typeof fetch;
27
+
28
+ // Force the first call so the test is independent of any cache another
29
+ // test in the same process may have primed.
30
+ const first = await checkForUpdates(true);
31
+ expect(calls).toBe(1);
32
+ expect(first.latestVersion).toBe("9.9.9");
33
+
34
+ // Default call is served from the session cache — no new fetch.
35
+ const cached = await checkForUpdates();
36
+ expect(calls).toBe(1);
37
+ expect(cached.latestVersion).toBe("9.9.9");
38
+
39
+ // A release lands; a forced re-check bypasses the cache and sees it.
40
+ latest = "9.9.10";
41
+ const forced = await checkForUpdates(true);
42
+ expect(calls).toBe(2);
43
+ expect(forced.latestVersion).toBe("9.9.10");
44
+ });
45
+ });
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { diffVersions } from "../services/version-snapshot.js";
2
+ import {
3
+ diffVersions,
4
+ isKnownVersion,
5
+ UNKNOWN_VERSION,
6
+ } from "../services/version-snapshot.js";
3
7
 
4
8
  /**
5
9
  * Plugins get updated by things claudeup does not control — Claude Code's own
@@ -8,20 +12,60 @@ import { diffVersions } from "../services/version-snapshot.js";
8
12
  * source; logging only claudeup's own actions catches none of them.
9
13
  */
10
14
 
11
- describe("diffVersions", () => {
12
- test("reports a genuine version transition", () => {
15
+ describe("isKnownVersion", () => {
16
+ test('"0.0.0" is not a usable version', () => {
17
+ // Claude Code records this for a plugin whose version it cannot determine.
18
+ expect(isKnownVersion(UNKNOWN_VERSION)).toBe(false);
19
+ expect(isKnownVersion(undefined)).toBe(false);
20
+ expect(isKnownVersion("")).toBe(false);
21
+ expect(isKnownVersion("1.0.0")).toBe(true);
22
+ });
23
+ });
24
+
25
+ describe("diffVersions — first run", () => {
26
+ test("no baseline reports nothing at all", () => {
27
+ // Without this, the first launch would flag every installed plugin as new,
28
+ // which is both wrong and the fastest way to train someone to ignore badges.
13
29
  expect(
14
- diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
15
- ).toEqual([{ pluginId: "dev@magus", from: "2.12.0", to: "2.12.1" }]);
30
+ diffVersions(null, { "dev@magus": "2.12.1", "a@mp": UNKNOWN_VERSION }),
31
+ ).toEqual([]);
16
32
  });
17
33
 
18
- test("first run reports nothing otherwise every plugin looks updated", () => {
19
- expect(diffVersions(null, { "dev@magus": "2.12.1" })).toEqual([]);
34
+ test("an empty baseline is NOT the same as no baseline", () => {
35
+ // A real snapshot that happens to be empty means we have looked before and
36
+ // saw nothing, so anything present now is genuinely new.
37
+ const changes = diffVersions({}, { "dev@magus": "2.12.1" });
38
+ expect(changes).toEqual([
39
+ { pluginId: "dev@magus", kind: "installed", to: "2.12.1" },
40
+ ]);
20
41
  });
42
+ });
21
43
 
22
- test("a newly installed plugin is not an update", () => {
23
- // Absent from the snapshot means we have never seen it, not that it moved.
24
- expect(diffVersions({}, { "dev@magus": "2.12.1" })).toEqual([]);
44
+ describe("diffVersions installs", () => {
45
+ test("a plugin absent from the baseline is reported as installed, not updated", () => {
46
+ const changes = diffVersions({ "other@mp": "1.0.0" }, { "new@mp": "3.1.0" });
47
+ expect(changes).toEqual([
48
+ { pluginId: "new@mp", kind: "installed", to: "3.1.0" },
49
+ ]);
50
+ });
51
+
52
+ test("an install with an unknown version is still reported", () => {
53
+ // The version is unusable but the install is real — the UI shows "new"
54
+ // with no version rather than suppressing the plugin entirely.
55
+ const changes = diffVersions({}, { "a@mp": UNKNOWN_VERSION });
56
+ expect(changes).toEqual([
57
+ { pluginId: "a@mp", kind: "installed", to: UNKNOWN_VERSION },
58
+ ]);
59
+ });
60
+ });
61
+
62
+ describe("diffVersions — updates", () => {
63
+ test("reports a genuine version transition", () => {
64
+ expect(
65
+ diffVersions({ "dev@magus": "2.12.0" }, { "dev@magus": "2.12.1" }),
66
+ ).toEqual([
67
+ { pluginId: "dev@magus", kind: "updated", from: "2.12.0", to: "2.12.1" },
68
+ ]);
25
69
  });
26
70
 
27
71
  test("unchanged versions report nothing", () => {
@@ -30,32 +74,38 @@ describe("diffVersions", () => {
30
74
  ).toEqual([]);
31
75
  });
32
76
 
33
- test('"0.0.0" transitions are ignored on both sides', () => {
34
- // 0.0.0 means installed-with-unknown-version (Anthropic official plugins).
35
- // Treating it as a real version produces bogus "0.0.0 x" badges.
36
- expect(
37
- diffVersions({ "a@mp": "0.0.0" }, { "a@mp": "1.0.0" }),
38
- ).toEqual([]);
39
- expect(
40
- diffVersions({ "a@mp": "1.0.0" }, { "a@mp": "0.0.0" }),
41
- ).toEqual([]);
77
+ test("learning a previously unknown version is not an update", () => {
78
+ // This is exactly what v4.22.0's registry overlay caused: versions that
79
+ // could not be resolved suddenly could be. Reporting it would announce
80
+ // claudeup's own bugfix as if the user's plugins had changed.
81
+ expect(diffVersions({ "a@mp": UNKNOWN_VERSION }, { "a@mp": "5.3.0" })).toEqual([]);
42
82
  });
43
83
 
84
+ test("losing a known version is not an update either", () => {
85
+ expect(diffVersions({ "a@mp": "5.3.0" }, { "a@mp": UNKNOWN_VERSION })).toEqual([]);
86
+ });
87
+
88
+ test("a downgrade IS reported", () => {
89
+ // Rolling back is a real change, arguably the most important to surface.
90
+ expect(diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" })).toEqual([
91
+ { pluginId: "a@mp", kind: "updated", from: "2.0.0", to: "1.0.0" },
92
+ ]);
93
+ });
94
+ });
95
+
96
+ describe("diffVersions — removals and mixed sets", () => {
44
97
  test("a removed plugin is not reported", () => {
45
98
  expect(diffVersions({ "gone@mp": "1.0.0" }, {})).toEqual([]);
46
99
  });
47
100
 
48
- test("reports every changed plugin, leaving unchanged ones out", () => {
101
+ test("classifies each plugin independently in one pass", () => {
49
102
  const changes = diffVersions(
50
- { "a@mp": "1.0.0", "b@mp": "2.0.0", "c@mp": "3.0.0" },
51
- { "a@mp": "1.1.0", "b@mp": "2.0.0", "c@mp": "4.0.0" },
103
+ { same: "1.0.0", moved: "2.0.0", learned: UNKNOWN_VERSION },
104
+ { same: "1.0.0", moved: "2.1.0", learned: "9.0.0", fresh: "0.1.0" },
52
105
  );
53
- expect(changes.map((c) => c.pluginId).sort()).toEqual(["a@mp", "c@mp"]);
54
- });
55
-
56
- test("a downgrade is still reported", () => {
57
- // Rolling back is a change the user should see, not silently hidden.
58
- const changes = diffVersions({ "a@mp": "2.0.0" }, { "a@mp": "1.0.0" });
59
- expect(changes).toEqual([{ pluginId: "a@mp", from: "2.0.0", to: "1.0.0" }]);
106
+ expect(changes).toEqual([
107
+ { pluginId: "moved", kind: "updated", from: "2.0.0", to: "2.1.0" },
108
+ { pluginId: "fresh", kind: "installed", to: "0.1.0" },
109
+ ]);
60
110
  });
61
111
  });
@@ -78,6 +78,11 @@ export interface PluginInfo {
78
78
  * rather than by claudeup itself. Drives the "updated" badge.
79
79
  */
80
80
  recentlyUpdatedFrom?: string;
81
+ /**
82
+ * Set when claudeup is seeing this plugin installed for the first time.
83
+ * Never set on the very first run, when there is no baseline to compare to.
84
+ */
85
+ recentlyInstalled?: boolean;
81
86
  }
82
87
 
83
88
  /**
@@ -33,6 +33,9 @@ async function fetchLatestVersion(): Promise<string | null> {
33
33
  signal: controller.signal,
34
34
  headers: {
35
35
  Accept: "application/json",
36
+ // Always revalidate — a CDN/HTTP-cached response serves a stale
37
+ // "latest" right after a release (the 4.25-vs-4.26 flicker).
38
+ "Cache-Control": "no-cache",
36
39
  },
37
40
  });
38
41
 
@@ -70,12 +73,15 @@ function getUpdateType(
70
73
  }
71
74
 
72
75
  /**
73
- * Check if a new version is available
74
- * Returns cached result if already checked this session
76
+ * Check if a new version is available.
77
+ * Returns the session-cached result unless `force` requests a fresh check.
75
78
  */
76
- export async function checkForUpdates(): Promise<VersionCheckResult> {
77
- // Return cached result if available
78
- if (cachedResult) {
79
+ export async function checkForUpdates(
80
+ force = false,
81
+ ): Promise<VersionCheckResult> {
82
+ // Return the session-cached result unless a fresh check is forced — a periodic
83
+ // re-check passes force:true so a release landing mid-session updates the banner.
84
+ if (!force && cachedResult) {
79
85
  return cachedResult;
80
86
  }
81
87
 
@@ -1,32 +1,51 @@
1
1
  /**
2
- * version-snapshot.ts — detect plugin version changes that happened outside claudeup.
2
+ * version-snapshot.ts — detect plugin changes that happened outside claudeup.
3
3
  *
4
4
  * Plugins get updated by things claudeup does not control: Claude Code's own
5
5
  * `plugin install`/`update`, the `claudeup claude` prerunner, or a direct CLI
6
6
  * call. All of them write `installed_plugins.json`; none of them tell the user
7
- * anything the next time claudeup opens, so versions silently move underfoot.
7
+ * anything the next time claudeup opens, so versions move underfoot silently.
8
8
  *
9
9
  * Rather than log what claudeup itself did — which misses every other source —
10
10
  * this records what claudeup last *saw* installed and diffs against reality on
11
11
  * load. Any change is reported, regardless of who made it.
12
12
  *
13
13
  * The snapshot is advisory UI state, kept separate from the update-check cache
14
- * so a TTL expiry or a corrupt cache can never make plugins look updated.
14
+ * so a TTL expiry or a corrupt cache can never make plugins look changed.
15
15
  */
16
16
 
17
17
  import { promises as fs } from "node:fs";
18
18
  import path from "node:path";
19
19
  import os from "node:os";
20
20
 
21
- export interface VersionChange {
21
+ /**
22
+ * Sentinel recorded by Claude Code for a plugin that is installed but whose
23
+ * version is unknown — every plugin in the official Anthropic marketplace uses
24
+ * it. It is not a real version and must never be rendered as one.
25
+ */
26
+ export const UNKNOWN_VERSION = "0.0.0";
27
+
28
+ /** True when a version string carries no usable information. */
29
+ export function isKnownVersion(v: string | undefined): v is string {
30
+ return !!v && v !== UNKNOWN_VERSION;
31
+ }
32
+
33
+ export type PluginChangeKind = "installed" | "updated";
34
+
35
+ export interface PluginChange {
22
36
  pluginId: string;
23
- from: string;
37
+ kind: PluginChangeKind;
38
+ /** Previous version. Only set for "updated", and always a known version. */
39
+ from?: string;
40
+ /** Current version. May be UNKNOWN_VERSION — callers must not render it raw. */
24
41
  to: string;
25
42
  }
26
43
 
27
44
  interface SnapshotFile {
28
45
  /** pluginId -> installedVersion as of the last time claudeup rendered it. */
29
46
  seen: Record<string, string>;
47
+ /** Set on the run that first created the baseline. */
48
+ seededAt: string;
30
49
  updatedAt: string;
31
50
  }
32
51
 
@@ -41,35 +60,56 @@ async function read(): Promise<SnapshotFile | null> {
41
60
  if (!data || typeof data.seen !== "object" || data.seen === null) return null;
42
61
  return data;
43
62
  } catch {
44
- return null; // absent or corrupt — treated as "first run"
63
+ return null; // absent or corrupt — treated as "no baseline yet"
45
64
  }
46
65
  }
47
66
 
48
67
  /**
49
- * Diff the versions currently installed against the previous snapshot.
68
+ * Diff what is installed now against the previous snapshot.
69
+ *
70
+ * Three cases are deliberately silent, because each reflects claudeup's own
71
+ * knowledge changing rather than anything happening to the user's plugins:
72
+ *
73
+ * 1. No baseline (first run). Every plugin would otherwise be reported as
74
+ * newly installed on the very first launch — a guaranteed false flood that
75
+ * trains the user to ignore the badge. The first run seeds and says nothing.
50
76
  *
51
- * Returns only genuine transitions. Deliberately excluded:
52
- * - first run (no snapshot): everything would look "updated"
53
- * - a plugin absent from the snapshot: newly installed, not updated
54
- * - unchanged versions
55
- * - "0.0.0", which means installed-with-unknown-version rather than a real one
77
+ * 2. A version moving to or from UNKNOWN_VERSION. That is claudeup learning
78
+ * (or losing) a version it could not previously resolve — notably after
79
+ * v4.22.0 taught it to read the registry not the plugin changing.
80
+ *
81
+ * 3. A plugin disappearing. Uninstalls are visible elsewhere; nothing to badge.
82
+ *
83
+ * A downgrade IS reported: rolling back is a real change worth surfacing.
56
84
  */
57
85
  export function diffVersions(
58
86
  previous: Record<string, string> | null,
59
87
  current: Record<string, string>,
60
- ): VersionChange[] {
88
+ ): PluginChange[] {
61
89
  if (!previous) return [];
62
- const changes: VersionChange[] = [];
90
+
91
+ const changes: PluginChange[] = [];
63
92
  for (const [pluginId, to] of Object.entries(current)) {
64
93
  const from = previous[pluginId];
65
- if (!from || from === to) continue;
66
- if (from === "0.0.0" || to === "0.0.0") continue;
67
- changes.push({ pluginId, from, to });
94
+
95
+ // Never seen before newly installed, not an update. There is no
96
+ // meaningful "from", and `to` may legitimately be UNKNOWN_VERSION.
97
+ if (from === undefined) {
98
+ changes.push({ pluginId, kind: "installed", to });
99
+ continue;
100
+ }
101
+
102
+ if (from === to) continue;
103
+
104
+ // One side carries no real version: we learned or lost it, nothing moved.
105
+ if (!isKnownVersion(from) || !isKnownVersion(to)) continue;
106
+
107
+ changes.push({ pluginId, kind: "updated", from, to });
68
108
  }
69
109
  return changes;
70
110
  }
71
111
 
72
- /** Load the previous snapshot's version map, or null on first run. */
112
+ /** Load the previous snapshot's version map, or null when no baseline exists. */
73
113
  export async function loadSeenVersions(): Promise<Record<string, string> | null> {
74
114
  const data = await read();
75
115
  return data ? data.seen : null;
@@ -77,7 +117,7 @@ export async function loadSeenVersions(): Promise<Record<string, string> | null>
77
117
 
78
118
  /**
79
119
  * Persist what is installed now, so the same change is not reported twice.
80
- * Best-effort: a failure here must never break the UI, it only means the badge
120
+ * Best-effort: a failure here must never break the UI it only means the badge
81
121
  * shows again next launch.
82
122
  */
83
123
  export async function saveSeenVersions(
@@ -86,9 +126,12 @@ export async function saveSeenVersions(
86
126
  try {
87
127
  const file = snapshotPath();
88
128
  await fs.mkdir(path.dirname(file), { recursive: true });
129
+ const existing = await read();
130
+ const now = new Date().toISOString();
89
131
  const payload: SnapshotFile = {
90
132
  seen: current,
91
- updatedAt: new Date().toISOString(),
133
+ seededAt: existing?.seededAt ?? now,
134
+ updatedAt: now,
92
135
  };
93
136
  await fs.writeFile(file, JSON.stringify(payload, null, 2), "utf-8");
94
137
  } catch {
package/src/ui/App.tsx CHANGED
@@ -348,11 +348,22 @@ function AppContent({ onExit }: AppContentProps) {
348
348
  } | null>(null);
349
349
  const gitignoreModal = useGitignoreModal();
350
350
 
351
- // Check for updates on startup (non-blocking)
351
+ // Check for updates on startup, then re-check periodically (force-bypassing
352
+ // the session cache) so the banner reflects a release that lands while
353
+ // claudeup is left open — otherwise it shows the stale startup value.
352
354
  useEffect(() => {
353
355
  checkForUpdates()
354
356
  .then(setUpdateInfo)
355
357
  .catch(() => {});
358
+ const id = setInterval(
359
+ () => {
360
+ checkForUpdates(true)
361
+ .then(setUpdateInfo)
362
+ .catch(() => {});
363
+ },
364
+ 15 * 60 * 1000, // 15 min
365
+ );
366
+ return () => clearInterval(id);
356
367
  }, []);
357
368
 
358
369
  // Auto-dismiss recovery banner after 5 seconds
@@ -17,6 +17,7 @@ import { theme } from "../theme.js";
17
17
  import { highlightMatches } from "../../utils/fuzzy-search.js";
18
18
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
19
19
  import { isEnabledButNotInstalled } from "../../services/plugin-manager.js";
20
+ import { isKnownVersion } from "../../services/version-snapshot.js";
20
21
 
21
22
  // ─── Category renderers ───────────────────────────────────────────────────────
22
23
 
@@ -125,19 +126,28 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
125
126
  // to render exactly like a healthy install.
126
127
  const notInstalled = isEnabledButNotInstalled(plugin);
127
128
 
128
- // Build version string only show if plugin is installed in at least one scope
129
+ // A known version is one we can actually show. "0.0.0" means installed with an
130
+ // unknown version (every official Anthropic plugin records it) — never render it.
131
+ const shownVersion = isKnownVersion(plugin.installedVersion)
132
+ ? plugin.installedVersion
133
+ : undefined;
134
+
129
135
  let versionStr = "";
130
136
  if (plugin.isOrphaned) {
131
137
  versionStr = " deprecated";
132
138
  } else if (notInstalled) {
133
139
  versionStr = " not installed";
134
- } else if (hasAnyScope && plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
135
- if (plugin.recentlyUpdatedFrom) {
140
+ } else if (hasAnyScope) {
141
+ if (plugin.recentlyUpdatedFrom && shownVersion) {
136
142
  // Show what actually happened, not just where it landed — the update may
137
143
  // have come from Claude Code or the prerunner without ever telling us.
138
- versionStr = ` v${plugin.recentlyUpdatedFrom} → v${plugin.installedVersion} updated`;
139
- } else {
140
- versionStr = ` v${plugin.installedVersion}`;
144
+ versionStr = ` v${plugin.recentlyUpdatedFrom} → v${shownVersion} updated`;
145
+ } else if (plugin.recentlyInstalled) {
146
+ // Newly seen. Version is optional here: a plugin can be genuinely
147
+ // installed with no version we can name.
148
+ versionStr = shownVersion ? ` v${shownVersion} new` : " new";
149
+ } else if (shownVersion) {
150
+ versionStr = ` v${shownVersion}`;
141
151
  if (plugin.hasUpdate && plugin.version) {
142
152
  versionStr += ` → v${plugin.version}`;
143
153
  }
@@ -160,9 +170,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
160
170
 
161
171
  if (plugin.isOrphaned) {
162
172
  const ver =
163
- plugin.installedVersion && plugin.installedVersion !== "0.0.0"
164
- ? ` v${plugin.installedVersion}`
165
- : "";
173
+ isKnownVersion(plugin.installedVersion) ? ` v${plugin.installedVersion}` : "";
166
174
  return (
167
175
  <text>
168
176
  <span fg={theme.colors.danger}> ■■■ </span>
@@ -184,7 +192,7 @@ function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode
184
192
  <MetaText
185
193
  text={versionStr}
186
194
  tone={
187
- plugin.recentlyUpdatedFrom
195
+ plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
188
196
  ? "success"
189
197
  : notInstalled || plugin.hasUpdate
190
198
  ? "warning"
@@ -239,9 +247,9 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
239
247
  components.push(`${Object.keys(plugin.lspServers).length} LSP`);
240
248
  }
241
249
 
242
- const showVersion = plugin.version && plugin.version !== "0.0.0";
250
+ const showVersion = isKnownVersion(plugin.version ?? undefined);
243
251
  const showInstalledVersion =
244
- isInstalled && plugin.installedVersion && plugin.installedVersion !== "0.0.0";
252
+ isInstalled && isKnownVersion(plugin.installedVersion);
245
253
 
246
254
  return (
247
255
  <box flexDirection="column">
@@ -90,7 +90,12 @@ export function PluginsScreen() {
90
90
  const previous = await loadSeenVersions();
91
91
  for (const change of diffVersions(previous, current)) {
92
92
  const target = pluginData.find((p) => p.id === change.pluginId);
93
- if (target) target.recentlyUpdatedFrom = change.from;
93
+ if (!target) continue;
94
+ if (change.kind === "updated") {
95
+ target.recentlyUpdatedFrom = change.from;
96
+ } else {
97
+ target.recentlyInstalled = true;
98
+ }
94
99
  }
95
100
  // Record now so the same change is not reported on the next launch.
96
101
  void saveSeenVersions(current);