claudeup 6.5.1 → 6.6.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/package.json +4 -4
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cli-apply-seams.test.ts +2 -0
- package/src/__tests__/cli-tool-commands.test.ts +1 -1
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +142 -2
- package/src/__tests__/content-drift.test.ts +60 -0
- package/src/__tests__/enabled-not-installed.test.ts +1 -2
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/{gitignore-prerun.test.ts → gitignore-check.test.ts} +8 -6
- package/src/__tests__/plugin-cli-argv.test.ts +1 -1
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/scope-action.test.ts +69 -19
- package/src/__tests__/shell-script-callers.test.ts +8 -8
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/update-apply.test.ts +283 -1
- package/src/__tests__/update-plan.test.ts +773 -31
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/cli/router.ts +0 -5
- package/src/cli/update-view.ts +92 -12
- package/src/cli/update.ts +114 -38
- package/src/services/active-closure.ts +117 -0
- package/src/services/catalog-cache-store.ts +6 -2
- package/src/services/claude-settings.ts +1 -173
- package/src/services/{gitignore-prerun.ts → gitignore-check.ts} +14 -10
- package/src/services/gitignore-fixer.ts +1 -1
- package/src/services/marketplace-refresh.ts +30 -0
- package/src/services/plugin-manager.ts +200 -283
- package/src/services/resolver.ts +7 -2
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +97 -0
- package/src/services/update-engine.ts +220 -32
- package/src/services/update-plan.ts +640 -160
- package/src/services/version-snapshot.ts +1 -1
- package/src/types/bun.d.ts +6 -0
- package/src/ui/App.tsx +2 -27
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/pluginsAdapter.ts +57 -0
- package/src/ui/components/modals/ModalContainer.tsx +0 -26
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +2 -3
- package/src/ui/renderers/cliToolRenderers.tsx +108 -67
- package/src/ui/renderers/pluginRenderers.tsx +157 -98
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/screens/CliToolsScreen.tsx +154 -280
- package/src/ui/screens/PluginsScreen.tsx +156 -140
- package/src/ui/state/types.ts +0 -7
- package/src/__tests__/dual-write-prevention.test.ts +0 -380
- package/src/__tests__/gap-fill-versions.test.ts +0 -389
- package/src/__tests__/plugin-version-check.test.ts +0 -762
- package/src/cli/claude.ts +0 -21
- package/src/prerunner/index.ts +0 -500
- package/src/services/plugin-version-check.ts +0 -360
- package/src/services/update-cache.ts +0 -78
- package/src/ui/components/modals/VersionMismatchModal.tsx +0 -138
- package/src/ui/hooks/useMismatchModal.ts +0 -89
|
@@ -173,9 +173,27 @@ function tap(fn: (() => void) | undefined): void {
|
|
|
173
173
|
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
174
174
|
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
175
175
|
*/
|
|
176
|
+
/**
|
|
177
|
+
* Select and report, writing nothing.
|
|
178
|
+
*
|
|
179
|
+
* Everything above the pull loop is a read — the marketplace registry and each
|
|
180
|
+
* entry's `autoUpdate` flag — so a read-only caller can have the full selection
|
|
181
|
+
* verdict for free. `--dry-run` and `--check` promise to write nothing and
|
|
182
|
+
* therefore cannot pull, but they must still be able to say "your magus clone
|
|
183
|
+
* has autoUpdate off, so its catalog will never advance". Gating the whole call
|
|
184
|
+
* off silently dropped that warning, which is the same "no signal reads as no
|
|
185
|
+
* problem" failure the flag exists to prevent.
|
|
186
|
+
*/
|
|
187
|
+
export async function reportRegisteredMarketplaces(
|
|
188
|
+
skip: Iterable<string> = [],
|
|
189
|
+
): Promise<RefreshResult> {
|
|
190
|
+
return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
|
|
191
|
+
}
|
|
192
|
+
|
|
176
193
|
export async function refreshRegisteredMarketplaces(
|
|
177
194
|
skip: Iterable<string> = [],
|
|
178
195
|
progress: RefreshProgress = {},
|
|
196
|
+
options: { reportOnly?: boolean } = {},
|
|
179
197
|
): Promise<RefreshResult> {
|
|
180
198
|
const refreshed: string[] = [];
|
|
181
199
|
const failed: string[] = [];
|
|
@@ -200,6 +218,18 @@ export async function refreshRegisteredMarketplaces(
|
|
|
200
218
|
eligible.push(name);
|
|
201
219
|
}
|
|
202
220
|
|
|
221
|
+
// A read-only caller stops here, with the selection verdict intact and not
|
|
222
|
+
// one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
|
|
223
|
+
// claiming otherwise would be the false "all current" this module guards.
|
|
224
|
+
if (options.reportOnly) {
|
|
225
|
+
return {
|
|
226
|
+
refreshed: [],
|
|
227
|
+
failed: [],
|
|
228
|
+
skipped: [...skipped, ...eligible],
|
|
229
|
+
autoUpdateDisabled,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
203
233
|
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
204
234
|
// serialize the rest onto the launch path. fastForwardCloneIfPresent never
|
|
205
235
|
// throws, so the batch always resolves.
|
|
@@ -72,6 +72,19 @@ export interface PluginInfo {
|
|
|
72
72
|
marketplaceDisplay: string;
|
|
73
73
|
enabled: boolean;
|
|
74
74
|
installedVersion?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Provably behind the catalog, on ONE version pair.
|
|
77
|
+
*
|
|
78
|
+
* @deprecated Derive plugin state from `classifyPlugin` (update-plan.ts),
|
|
79
|
+
* which answers per scope and distinguishes "cannot tell" from "current".
|
|
80
|
+
* Nothing in `src/` reads this any more — the content-drift gate was its
|
|
81
|
+
* last reader, and it now asks the version pair directly. It is still
|
|
82
|
+
* computed because `blackbox-parity.test.ts` reproduces the pre-classifier
|
|
83
|
+
* rule from it, which is what proves the fixture discriminates.
|
|
84
|
+
*
|
|
85
|
+
* Its sibling `updateUnknown` was written in six places and read in none,
|
|
86
|
+
* and is gone.
|
|
87
|
+
*/
|
|
75
88
|
hasUpdate?: boolean;
|
|
76
89
|
// Per-scope installation status
|
|
77
90
|
userScope?: ScopeStatus;
|
|
@@ -120,7 +133,7 @@ export interface PluginInfo {
|
|
|
120
133
|
movedTo?: string;
|
|
121
134
|
/**
|
|
122
135
|
* Set when this plugin's installed version changed since claudeup last
|
|
123
|
-
* rendered it — including updates made by Claude Code or
|
|
136
|
+
* rendered it — including updates made by Claude Code or a direct CLI call
|
|
124
137
|
* rather than by claudeup itself. Drives the "updated" badge.
|
|
125
138
|
*/
|
|
126
139
|
recentlyUpdatedFrom?: string;
|
|
@@ -160,7 +173,7 @@ export interface PluginInfo {
|
|
|
160
173
|
*
|
|
161
174
|
* This is a broken state, not a healthy one: Claude Code will not load the
|
|
162
175
|
* plugin, and because the update check is `installedVersion && compare(...)`,
|
|
163
|
-
* it is also invisible to
|
|
176
|
+
* it is also invisible to every update path.
|
|
164
177
|
* It previously rendered identically to a working install.
|
|
165
178
|
*
|
|
166
179
|
* `isOrphaned` is a different failure — installed, but no longer offered by any
|
|
@@ -196,7 +209,38 @@ export function isEnabledButNotInstalled(plugin: PluginInfo): boolean {
|
|
|
196
209
|
* record exactly that — which is why this tests presence, not `isKnownVersion`.
|
|
197
210
|
*/
|
|
198
211
|
export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
199
|
-
|
|
212
|
+
// Presence of files, NOT permission to load them. `enabled` was in this
|
|
213
|
+
// test until it was measured: a plugin with a real registry row that the
|
|
214
|
+
// user had simply switched off reported "not installed", so the row offered
|
|
215
|
+
// to install what was already on disk. Installation is a registry fact;
|
|
216
|
+
// enablement is a separate one, and `isEnabledButNotInstalled` is where the
|
|
217
|
+
// broken combination of the two is named.
|
|
218
|
+
return !!scope?.version;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One scope's status, from the two facts that define it.
|
|
223
|
+
*
|
|
224
|
+
* Exported and taken as arguments so this is testable and has exactly one
|
|
225
|
+
* implementation. It previously lived inline in `getAvailablePlugins` and its
|
|
226
|
+
* global twin — two copies, and both keyed existence off the settings flag:
|
|
227
|
+
*
|
|
228
|
+
* enabledPlugins[id] !== undefined ? {...} : undefined
|
|
229
|
+
*
|
|
230
|
+
* so a plugin with a registry row but no settings key produced NO scope at all
|
|
231
|
+
* and rendered exactly like one that was never installed. Existence is now
|
|
232
|
+
* "either fact is present", and `enabled` defaults to false rather than
|
|
233
|
+
* deciding whether the scope exists.
|
|
234
|
+
*/
|
|
235
|
+
export function buildScopeStatus(
|
|
236
|
+
enabledPlugins: Record<string, boolean>,
|
|
237
|
+
installedVersions: Record<string, string>,
|
|
238
|
+
pluginId: string,
|
|
239
|
+
): ScopeStatus | undefined {
|
|
240
|
+
const enabled = enabledPlugins[pluginId];
|
|
241
|
+
const version = installedVersions[pluginId];
|
|
242
|
+
if (enabled === undefined && version === undefined) return undefined;
|
|
243
|
+
return { enabled: enabled === true, version };
|
|
200
244
|
}
|
|
201
245
|
|
|
202
246
|
/**
|
|
@@ -208,16 +252,31 @@ export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
|
208
252
|
* `claude plugin install dev@magus --scope project` installed it normally and
|
|
209
253
|
* wrote the registry entry. It does not report "already installed", so this
|
|
210
254
|
* needs no uninstall-first dance (unlike content drift, see repairPlugin).
|
|
255
|
+
*
|
|
256
|
+
* `behind` is the answer to "is this scope provably behind?", and it comes from
|
|
257
|
+
* the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
|
|
258
|
+
* comparison made here. This used to take the catalog version and compare raw
|
|
259
|
+
* strings — a fourth "is it behind?" rule, which offered `update` for a scope
|
|
260
|
+
* AHEAD of the catalog or one whose version cannot be ordered at all, while the
|
|
261
|
+
* row beside it said something else.
|
|
211
262
|
*/
|
|
212
263
|
export function resolveScopeAction(
|
|
213
264
|
scope: ScopeStatus | undefined,
|
|
214
|
-
|
|
265
|
+
behind: boolean,
|
|
215
266
|
): "install" | "update" | "uninstall" {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
267
|
+
// `enabled` is deliberately part of THIS decision even though it is no longer
|
|
268
|
+
// part of `isInstalledInScope`. The two ask different questions:
|
|
269
|
+
// "is it on disk" (installation) versus "what should this key do next".
|
|
270
|
+
//
|
|
271
|
+
// Installed-but-disabled must NOT resolve to uninstall. The scope key runs
|
|
272
|
+
// `cliUninstallPlugin` with no confirmation, so that combination would delete
|
|
273
|
+
// a plugin the user had merely switched off — with the row beside it showing
|
|
274
|
+
// no scope square, which reads as "not installed" and invites the press.
|
|
275
|
+
// `install` re-installs and re-enables, which is what the key meant before
|
|
276
|
+
// installation and enablement were separated, and is the only non-destructive
|
|
277
|
+
// answer here.
|
|
278
|
+
if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
|
|
279
|
+
return behind ? "update" : "uninstall";
|
|
221
280
|
}
|
|
222
281
|
|
|
223
282
|
export async function getAvailablePlugins(
|
|
@@ -241,28 +300,25 @@ export async function getAvailablePlugins(
|
|
|
241
300
|
const seenPluginIds = new Set<string>();
|
|
242
301
|
|
|
243
302
|
// Helper to build scope status
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
version: localInstalledVersions[pluginId],
|
|
264
|
-
}
|
|
265
|
-
: undefined,
|
|
303
|
+
// One implementation, shared with the global view and unit-tested. Both
|
|
304
|
+
// copies used to key scope existence off the settings flag, so an installed
|
|
305
|
+
// plugin with no settings key produced no scope at all.
|
|
306
|
+
const scopeStatusFor = (pluginId: string) => ({
|
|
307
|
+
userScope: buildScopeStatus(
|
|
308
|
+
userEnabledPlugins,
|
|
309
|
+
userInstalledVersions,
|
|
310
|
+
pluginId,
|
|
311
|
+
),
|
|
312
|
+
projectScope: buildScopeStatus(
|
|
313
|
+
projectEnabledPlugins,
|
|
314
|
+
projectInstalledVersions,
|
|
315
|
+
pluginId,
|
|
316
|
+
),
|
|
317
|
+
localScope: buildScopeStatus(
|
|
318
|
+
localEnabledPlugins,
|
|
319
|
+
localInstalledVersions,
|
|
320
|
+
pluginId,
|
|
321
|
+
),
|
|
266
322
|
});
|
|
267
323
|
|
|
268
324
|
// Get all marketplace names (configured + official/featured defaults)
|
|
@@ -325,7 +381,7 @@ export async function getAvailablePlugins(
|
|
|
325
381
|
const pluginId = `${plugin.name}@${mpName}`;
|
|
326
382
|
const installedVersion = installedVersions[pluginId];
|
|
327
383
|
const isEnabled = enabledPlugins[pluginId] === true;
|
|
328
|
-
const scopeStatus =
|
|
384
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
329
385
|
|
|
330
386
|
seenPluginIds.add(pluginId);
|
|
331
387
|
plugins.push({
|
|
@@ -337,10 +393,7 @@ export async function getAvailablePlugins(
|
|
|
337
393
|
marketplaceDisplay: marketplace.displayName,
|
|
338
394
|
enabled: isEnabled,
|
|
339
395
|
installedVersion: installedVersion,
|
|
340
|
-
hasUpdate:
|
|
341
|
-
installedVersion && plugin.version
|
|
342
|
-
? compareVersions(plugin.version, installedVersion) > 0
|
|
343
|
-
: false,
|
|
396
|
+
hasUpdate: isBehind(plugin.version, installedVersion) === true,
|
|
344
397
|
catalogSource: resolution.source,
|
|
345
398
|
updateCheckFailed: unverified,
|
|
346
399
|
updateCheckFailure: resolution.failure,
|
|
@@ -367,7 +420,7 @@ export async function getAvailablePlugins(
|
|
|
367
420
|
|
|
368
421
|
const installedVersion = installedVersions[pluginId];
|
|
369
422
|
const isEnabled = enabledPlugins[pluginId] === true;
|
|
370
|
-
const scopeStatus =
|
|
423
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
371
424
|
|
|
372
425
|
seenPluginIds.add(pluginId);
|
|
373
426
|
plugins.push({
|
|
@@ -379,9 +432,7 @@ export async function getAvailablePlugins(
|
|
|
379
432
|
marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
|
|
380
433
|
enabled: isEnabled,
|
|
381
434
|
installedVersion: installedVersion,
|
|
382
|
-
hasUpdate: installedVersion
|
|
383
|
-
? compareVersions(localPlugin.version, installedVersion) > 0
|
|
384
|
-
: false,
|
|
435
|
+
hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
|
|
385
436
|
...scopeStatus,
|
|
386
437
|
category: localPlugin.category,
|
|
387
438
|
author: localPlugin.author,
|
|
@@ -411,7 +462,7 @@ export async function getAvailablePlugins(
|
|
|
411
462
|
const { pluginName, marketplace: mpName } = parsed;
|
|
412
463
|
const installedVersion = installedVersions[pluginId];
|
|
413
464
|
const isEnabled = enabledPlugins[pluginId] === true;
|
|
414
|
-
const scopeStatus =
|
|
465
|
+
const scopeStatus = scopeStatusFor(pluginId);
|
|
415
466
|
|
|
416
467
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
417
468
|
const localMp = localMarketplaces.get(mpName);
|
|
@@ -422,10 +473,7 @@ export async function getAvailablePlugins(
|
|
|
422
473
|
|
|
423
474
|
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
424
475
|
const description = localPlugin?.description || "Installed plugin";
|
|
425
|
-
const hasUpdate =
|
|
426
|
-
installedVersion && localPlugin?.version
|
|
427
|
-
? compareVersions(localPlugin.version, installedVersion) > 0
|
|
428
|
-
: false;
|
|
476
|
+
const hasUpdate = isBehind(localPlugin?.version, installedVersion) === true;
|
|
429
477
|
|
|
430
478
|
plugins.push({
|
|
431
479
|
id: pluginId,
|
|
@@ -447,230 +495,6 @@ export async function getAvailablePlugins(
|
|
|
447
495
|
return plugins;
|
|
448
496
|
}
|
|
449
497
|
|
|
450
|
-
export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
|
|
451
|
-
const configuredMarketplaces = await getGlobalConfiguredMarketplaces();
|
|
452
|
-
const enabledPlugins = await getGlobalEnabledPlugins();
|
|
453
|
-
const installedVersions = await getGlobalInstalledPluginVersions();
|
|
454
|
-
|
|
455
|
-
// Fetch all scopes for per-scope status display
|
|
456
|
-
const userEnabledPlugins = await getGlobalEnabledPlugins();
|
|
457
|
-
const userInstalledVersions = await getGlobalInstalledPluginVersions();
|
|
458
|
-
// Also fetch project and local scope for complete status display
|
|
459
|
-
const projectEnabledPlugins = await getEnabledPlugins();
|
|
460
|
-
const projectInstalledVersions = await getInstalledPluginVersions();
|
|
461
|
-
const localEnabledPlugins = await getLocalEnabledPlugins();
|
|
462
|
-
const localInstalledVersions = await getLocalInstalledPluginVersions();
|
|
463
|
-
|
|
464
|
-
const plugins: PluginInfo[] = [];
|
|
465
|
-
const seenPluginIds = new Set<string>();
|
|
466
|
-
|
|
467
|
-
// Helper to build scope status (show all scopes)
|
|
468
|
-
const buildScopeStatus = (pluginId: string) => ({
|
|
469
|
-
userScope:
|
|
470
|
-
userEnabledPlugins[pluginId] !== undefined
|
|
471
|
-
? {
|
|
472
|
-
enabled: userEnabledPlugins[pluginId],
|
|
473
|
-
version: userInstalledVersions[pluginId],
|
|
474
|
-
}
|
|
475
|
-
: undefined,
|
|
476
|
-
projectScope:
|
|
477
|
-
projectEnabledPlugins[pluginId] !== undefined
|
|
478
|
-
? {
|
|
479
|
-
enabled: projectEnabledPlugins[pluginId],
|
|
480
|
-
version: projectInstalledVersions[pluginId],
|
|
481
|
-
}
|
|
482
|
-
: undefined,
|
|
483
|
-
localScope:
|
|
484
|
-
localEnabledPlugins[pluginId] !== undefined
|
|
485
|
-
? {
|
|
486
|
-
enabled: localEnabledPlugins[pluginId],
|
|
487
|
-
version: localInstalledVersions[pluginId],
|
|
488
|
-
}
|
|
489
|
-
: undefined,
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
// Get all marketplace names (configured + official/featured defaults)
|
|
493
|
-
// Always include official and featured marketplaces so users can browse them
|
|
494
|
-
const marketplaceNames = new Set<string>();
|
|
495
|
-
for (const mp of defaultMarketplaces) {
|
|
496
|
-
if (
|
|
497
|
-
configuredMarketplaces[mp.name] ||
|
|
498
|
-
mp.official ||
|
|
499
|
-
mp.featured ||
|
|
500
|
-
mp.owned
|
|
501
|
-
) {
|
|
502
|
-
marketplaceNames.add(mp.name);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Fetch local marketplace caches up front so we can detect stale caches
|
|
507
|
-
let localMarketplaces = await getLocalMarketplaces();
|
|
508
|
-
|
|
509
|
-
// Resolve every marketplace concurrently. These are independent HTTP calls
|
|
510
|
-
// with a 10s timeout each; awaited in sequence, one unreachable marketplace
|
|
511
|
-
// added its full timeout to the "Loading..." the user stares at. Measured on
|
|
512
|
-
// six marketplaces with four unreachable: 44.9s sequential.
|
|
513
|
-
const resolutions = await Promise.all(
|
|
514
|
-
[...marketplaceNames].map(async (mpName) => {
|
|
515
|
-
const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
|
|
516
|
-
if (!marketplace) return null;
|
|
517
|
-
return {
|
|
518
|
-
mpName,
|
|
519
|
-
marketplace,
|
|
520
|
-
resolution: await resolveMarketplacePlugins(
|
|
521
|
-
mpName,
|
|
522
|
-
marketplace.source.repo,
|
|
523
|
-
localMarketplaces,
|
|
524
|
-
),
|
|
525
|
-
};
|
|
526
|
-
}),
|
|
527
|
-
);
|
|
528
|
-
|
|
529
|
-
for (const entry of resolutions) {
|
|
530
|
-
if (!entry) continue;
|
|
531
|
-
const { mpName, marketplace, resolution } = entry;
|
|
532
|
-
const marketplacePlugins = resolution.plugins;
|
|
533
|
-
|
|
534
|
-
// Auto-sync local cache if remote has plugins the local cache doesn't
|
|
535
|
-
localMarketplaces = await autoSyncIfStale(
|
|
536
|
-
mpName,
|
|
537
|
-
marketplacePlugins.map((p) => p.name),
|
|
538
|
-
localMarketplaces,
|
|
539
|
-
);
|
|
540
|
-
|
|
541
|
-
// The catalog that answered was not the authoritative one, so every
|
|
542
|
-
// version compare below is a guess, not a check.
|
|
543
|
-
// `remote-git` counts as verified: it is upstream's own catalog file, read
|
|
544
|
-
// through a git remote ref rather than over HTTP. Only the pinned working
|
|
545
|
-
// tree (`local-clone`) is untrustworthy for versions.
|
|
546
|
-
const unverified = !isAuthoritative(resolution.source);
|
|
547
|
-
|
|
548
|
-
for (const plugin of marketplacePlugins) {
|
|
549
|
-
const pluginId = `${plugin.name}@${mpName}`;
|
|
550
|
-
const installedVersion = installedVersions[pluginId];
|
|
551
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
552
|
-
const scopeStatus = buildScopeStatus(pluginId);
|
|
553
|
-
|
|
554
|
-
seenPluginIds.add(pluginId);
|
|
555
|
-
plugins.push({
|
|
556
|
-
id: pluginId,
|
|
557
|
-
name: plugin.name,
|
|
558
|
-
version: plugin.version,
|
|
559
|
-
description: plugin.description,
|
|
560
|
-
marketplace: mpName,
|
|
561
|
-
marketplaceDisplay: marketplace.displayName,
|
|
562
|
-
enabled: isEnabled,
|
|
563
|
-
installedVersion: installedVersion,
|
|
564
|
-
hasUpdate:
|
|
565
|
-
installedVersion && plugin.version
|
|
566
|
-
? compareVersions(plugin.version, installedVersion) > 0
|
|
567
|
-
: false,
|
|
568
|
-
catalogSource: resolution.source,
|
|
569
|
-
updateCheckFailed: unverified,
|
|
570
|
-
updateCheckFailure: resolution.failure,
|
|
571
|
-
...scopeStatus,
|
|
572
|
-
category: plugin.category,
|
|
573
|
-
author: plugin.author,
|
|
574
|
-
homepage: plugin.homepage,
|
|
575
|
-
tags: plugin.tags,
|
|
576
|
-
releases: plugin.releases,
|
|
577
|
-
source: plugin.source,
|
|
578
|
-
sourceRepo: plugin.sourceRepo,
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
for (const [mpName, localMp] of localMarketplaces) {
|
|
584
|
-
// Skip if already fetched from defaults
|
|
585
|
-
if (marketplaceNames.has(mpName)) continue;
|
|
586
|
-
|
|
587
|
-
// Add ALL plugins from this local marketplace cache
|
|
588
|
-
for (const localPlugin of localMp.plugins) {
|
|
589
|
-
const pluginId = `${localPlugin.name}@${mpName}`;
|
|
590
|
-
if (seenPluginIds.has(pluginId)) continue;
|
|
591
|
-
|
|
592
|
-
const installedVersion = installedVersions[pluginId];
|
|
593
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
594
|
-
const scopeStatus = buildScopeStatus(pluginId);
|
|
595
|
-
|
|
596
|
-
seenPluginIds.add(pluginId);
|
|
597
|
-
plugins.push({
|
|
598
|
-
id: pluginId,
|
|
599
|
-
name: localPlugin.name,
|
|
600
|
-
version: localPlugin.version,
|
|
601
|
-
description: localPlugin.description || "",
|
|
602
|
-
marketplace: mpName,
|
|
603
|
-
marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
|
|
604
|
-
enabled: isEnabled,
|
|
605
|
-
installedVersion: installedVersion,
|
|
606
|
-
hasUpdate: installedVersion
|
|
607
|
-
? compareVersions(localPlugin.version, installedVersion) > 0
|
|
608
|
-
: false,
|
|
609
|
-
...scopeStatus,
|
|
610
|
-
category: localPlugin.category,
|
|
611
|
-
author: localPlugin.author,
|
|
612
|
-
agents: localPlugin.agents,
|
|
613
|
-
commands: localPlugin.commands,
|
|
614
|
-
skills: localPlugin.skills,
|
|
615
|
-
source: localPlugin.source,
|
|
616
|
-
mcpServers: localPlugin.mcpServers,
|
|
617
|
-
lspServers: localPlugin.lspServers,
|
|
618
|
-
releases: localPlugin.releases,
|
|
619
|
-
});
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Add orphaned plugins (enabled/installed but not in any cache)
|
|
624
|
-
const allPluginIds = new Set([
|
|
625
|
-
...Object.keys(enabledPlugins),
|
|
626
|
-
...Object.keys(installedVersions),
|
|
627
|
-
]);
|
|
628
|
-
|
|
629
|
-
for (const pluginId of allPluginIds) {
|
|
630
|
-
if (seenPluginIds.has(pluginId)) continue;
|
|
631
|
-
|
|
632
|
-
const parsed = parsePluginId(pluginId);
|
|
633
|
-
if (!parsed) continue;
|
|
634
|
-
|
|
635
|
-
const { pluginName, marketplace: mpName } = parsed;
|
|
636
|
-
const installedVersion = installedVersions[pluginId];
|
|
637
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
638
|
-
const scopeStatus = buildScopeStatus(pluginId);
|
|
639
|
-
|
|
640
|
-
// Try to get plugin info from local marketplace cache (fallback)
|
|
641
|
-
const localMp = localMarketplaces.get(mpName);
|
|
642
|
-
const localPlugin = localMp?.plugins.find((p) => p.name === pluginName);
|
|
643
|
-
const movedTo = localPlugin
|
|
644
|
-
? undefined
|
|
645
|
-
: findPluginInOtherMarketplace(pluginName, mpName, localMarketplaces);
|
|
646
|
-
|
|
647
|
-
const latestVersion = localPlugin?.version || installedVersion || "unknown";
|
|
648
|
-
const description = localPlugin?.description || "Installed plugin";
|
|
649
|
-
const hasUpdate =
|
|
650
|
-
installedVersion && localPlugin?.version
|
|
651
|
-
? compareVersions(localPlugin.version, installedVersion) > 0
|
|
652
|
-
: false;
|
|
653
|
-
|
|
654
|
-
plugins.push({
|
|
655
|
-
id: pluginId,
|
|
656
|
-
name: pluginName,
|
|
657
|
-
version: latestVersion,
|
|
658
|
-
description,
|
|
659
|
-
marketplace: mpName,
|
|
660
|
-
marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
|
|
661
|
-
enabled: isEnabled,
|
|
662
|
-
...scopeStatus,
|
|
663
|
-
installedVersion: installedVersion,
|
|
664
|
-
hasUpdate,
|
|
665
|
-
isOrphaned: true,
|
|
666
|
-
movedTo,
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
await annotateContentDrift(plugins, "user");
|
|
671
|
-
return plugins;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
498
|
/**
|
|
675
499
|
* Find another configured marketplace that still publishes this plugin name.
|
|
676
500
|
*
|
|
@@ -689,13 +513,39 @@ function findPluginInOtherMarketplace(
|
|
|
689
513
|
return undefined;
|
|
690
514
|
}
|
|
691
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Is this plugin one whose files could have moved while its version did not?
|
|
518
|
+
*
|
|
519
|
+
* Drift is defined as "same version, different files", so it is only a
|
|
520
|
+
* question at all when the catalog version and the installed version are
|
|
521
|
+
* PROVABLY the same — `compareVersions` returning exactly 0, never null, which
|
|
522
|
+
* would be "cannot tell". A pending version bump is not drift: `hasContentDrift`
|
|
523
|
+
* compares the installed commit's subtree against the marketplace clone's HEAD,
|
|
524
|
+
* and for a plugin that is simply behind, that subtree differs BECAUSE of the
|
|
525
|
+
* newer version. Flagging it would turn an `update` into a `repair`, and a
|
|
526
|
+
* repair reinstalls whatever the clone holds — past a pin.
|
|
527
|
+
*
|
|
528
|
+
* This used to open with `if (plugin.hasUpdate || …)`, the pre-classifier
|
|
529
|
+
* boolean. It decided nothing the version compare below does not (`isBehind`
|
|
530
|
+
* cannot be true for a pair that compares 0), and gating the input the shared
|
|
531
|
+
* classifier reads on the rule it replaced is how the two drift apart again.
|
|
532
|
+
* An orphan is still excluded: it has no marketplace entry to diff against.
|
|
533
|
+
*
|
|
534
|
+
* Exported to be tested directly; `annotateContentDrift` itself needs the real
|
|
535
|
+
* registry.
|
|
536
|
+
*/
|
|
537
|
+
export function contentDriftCandidate(plugin: PluginInfo): boolean {
|
|
538
|
+
if (plugin.isOrphaned) return false;
|
|
539
|
+
if (!plugin.installedVersion || !plugin.version) return false;
|
|
540
|
+
return compareVersions(plugin.version, plugin.installedVersion) === 0;
|
|
541
|
+
}
|
|
542
|
+
|
|
692
543
|
/**
|
|
693
544
|
* Flag plugins whose files moved without their version moving.
|
|
694
545
|
*
|
|
695
546
|
* Runs after the plugin list is assembled so every code path that builds a
|
|
696
|
-
* PluginInfo gets the same treatment.
|
|
697
|
-
*
|
|
698
|
-
* reinstalled by that update anyway.
|
|
547
|
+
* PluginInfo gets the same treatment. Which plugins are worth asking about is
|
|
548
|
+
* {@link contentDriftCandidate}.
|
|
699
549
|
*
|
|
700
550
|
* Best-effort throughout: a plugin whose drift cannot be determined is left
|
|
701
551
|
* exactly as it is today.
|
|
@@ -721,10 +571,7 @@ async function annotateContentDrift(
|
|
|
721
571
|
|
|
722
572
|
await Promise.all(
|
|
723
573
|
plugins.map(async (plugin) => {
|
|
724
|
-
if (plugin
|
|
725
|
-
if (!plugin.installedVersion || !plugin.version) return;
|
|
726
|
-
if (compareVersions(plugin.version, plugin.installedVersion) !== 0)
|
|
727
|
-
return;
|
|
574
|
+
if (!contentDriftCandidate(plugin)) return;
|
|
728
575
|
|
|
729
576
|
const entry = pickRegistryEntry(
|
|
730
577
|
registry.plugins[plugin.id],
|
|
@@ -748,24 +595,94 @@ async function annotateContentDrift(
|
|
|
748
595
|
);
|
|
749
596
|
}
|
|
750
597
|
|
|
751
|
-
|
|
598
|
+
/** A dotted run of digits, optionally `v`-prefixed. Anything else is opaque. */
|
|
599
|
+
const SEMVER_ISH = /^\d+(\.\d+)*$/;
|
|
600
|
+
|
|
601
|
+
const normalize = (v: string) => v.replace(/^v/, "");
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Version comparison: 1 if a > b, -1 if a < b, 0 if equal, **null if the pair
|
|
605
|
+
* carries no ordering at all**.
|
|
606
|
+
*
|
|
607
|
+
* `null` is the whole point. This used to return 0 for every unorderable pair,
|
|
608
|
+
* and 0 means "equal" to every caller — so "I cannot tell" was indistinguishable
|
|
609
|
+
* from "they match". Measured against real registry values before the fix:
|
|
610
|
+
*
|
|
611
|
+
* compareVersions("517b2fcd1b60", "b819188d2eea") = 0 ← two DIFFERENT commits
|
|
612
|
+
* compareVersions("7.2.0", "517b2fcd1b60") = 1 ← a fabricated ordering
|
|
613
|
+
*
|
|
614
|
+
* Anthropic's official plugins record a commit sha as their version, so the
|
|
615
|
+
* first line made every one of them permanently un-updatable, and the second
|
|
616
|
+
* invented an update whenever a sha met a semver.
|
|
617
|
+
*
|
|
618
|
+
* Three cases, and only the first has an answer:
|
|
619
|
+
* - both dotted-numeric → compare numerically, shorter side zero-padded
|
|
620
|
+
* - both opaque → equal strings are 0; different strings are `null`,
|
|
621
|
+
* because two commit ids carry no order between them
|
|
622
|
+
* - one of each → `null`; there is no shared scale
|
|
623
|
+
*
|
|
624
|
+
* A missing side is `null` too. It used to be 0, which claimed an absent
|
|
625
|
+
* version matched a present one.
|
|
626
|
+
*
|
|
627
|
+
* Callers asking "should this be updated?" want {@link isBehind}, not this.
|
|
628
|
+
*/
|
|
752
629
|
export function compareVersions(
|
|
753
630
|
a: string | null | undefined,
|
|
754
631
|
b: string | null | undefined,
|
|
755
|
-
): number {
|
|
756
|
-
if (!a || !b) return
|
|
757
|
-
const
|
|
758
|
-
const
|
|
632
|
+
): number | null {
|
|
633
|
+
if (!a || !b) return null;
|
|
634
|
+
const A = normalize(a);
|
|
635
|
+
const B = normalize(b);
|
|
759
636
|
|
|
637
|
+
const aNumeric = SEMVER_ISH.test(A);
|
|
638
|
+
const bNumeric = SEMVER_ISH.test(B);
|
|
639
|
+
|
|
640
|
+
if (aNumeric !== bNumeric) return null; // no shared scale
|
|
641
|
+
if (!aNumeric) return A === B ? 0 : null; // two opaque ids: equal, or unordered
|
|
642
|
+
|
|
643
|
+
const partsA = A.split(".").map(Number);
|
|
644
|
+
const partsB = B.split(".").map(Number);
|
|
760
645
|
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
761
|
-
const numA = partsA[i]
|
|
762
|
-
const numB = partsB[i]
|
|
646
|
+
const numA = partsA[i] ?? 0;
|
|
647
|
+
const numB = partsB[i] ?? 0;
|
|
763
648
|
if (numA > numB) return 1;
|
|
764
649
|
if (numA < numB) return -1;
|
|
765
650
|
}
|
|
766
651
|
return 0;
|
|
767
652
|
}
|
|
768
653
|
|
|
654
|
+
/**
|
|
655
|
+
* Is `installed` out of date against what the catalog publishes as `latest`?
|
|
656
|
+
*
|
|
657
|
+
* `true` / `false` / `null`, where **null means unknown** — never a silent
|
|
658
|
+
* `false`. This is the question every update path actually asks, and keeping it
|
|
659
|
+
* separate from {@link compareVersions} is what lets the two opaque-id cases
|
|
660
|
+
* differ:
|
|
661
|
+
*
|
|
662
|
+
* - Ordering two commit shas is impossible, so `compareVersions` says `null`.
|
|
663
|
+
* - But the catalog is authoritative for what is published *now*, so an
|
|
664
|
+
* installed sha that differs from it is **not current** — `isBehind` says
|
|
665
|
+
* `true`. That is what makes a sha-versioned plugin updatable at all.
|
|
666
|
+
*
|
|
667
|
+
* Not installed is `null`, not `false`. The old inline check was
|
|
668
|
+
* `installedVersion && compare(...) > 0`, so a plugin with no install at all
|
|
669
|
+
* evaluated to `false` and was reported — and counted — as up to date.
|
|
670
|
+
*/
|
|
671
|
+
export function isBehind(
|
|
672
|
+
latest: string | null | undefined,
|
|
673
|
+
installed: string | null | undefined,
|
|
674
|
+
): boolean | null {
|
|
675
|
+
if (!latest || !installed) return null;
|
|
676
|
+
const cmp = compareVersions(latest, installed);
|
|
677
|
+
if (cmp !== null) return cmp > 0;
|
|
678
|
+
|
|
679
|
+
// Unorderable. Two opaque ids that differ still mean "not what is published".
|
|
680
|
+
const bothOpaque =
|
|
681
|
+
!SEMVER_ISH.test(normalize(latest)) &&
|
|
682
|
+
!SEMVER_ISH.test(normalize(installed));
|
|
683
|
+
return bothOpaque ? true : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
769
686
|
// Get installed plugin versions from settings.json (shared, not secret)
|
|
770
687
|
async function getInstalledPluginVersions(
|
|
771
688
|
projectPath?: string,
|
package/src/services/resolver.ts
CHANGED
|
@@ -79,8 +79,13 @@ export function resolveExtends(
|
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Map a manifest cliTools entry (name -> version) to a BinRequirement.
|
|
84
|
+
*
|
|
85
|
+
* Exported so the TUI's CLI Tools screen maps a catalogue tool the same way the
|
|
86
|
+
* resolver does, rather than keeping a second copy of the mapping.
|
|
87
|
+
*/
|
|
88
|
+
export function cliToolToBin(name: string, version: string): BinRequirement {
|
|
84
89
|
const tool = cliTools.find((t) => t.name === name);
|
|
85
90
|
const pinned = version && version !== "latest" ? version : undefined;
|
|
86
91
|
if (tool) {
|