magus-cli 7.5.2 → 7.5.4
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__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
3
3
|
import type {
|
|
4
|
+
InstalledPluginEntry,
|
|
4
5
|
InstalledPluginsRegistry,
|
|
5
6
|
PluginRelease,
|
|
6
7
|
} from "../types/index.js";
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
getLocalInstalledPluginVersions,
|
|
18
19
|
getProjectInstalledPluginVersions,
|
|
19
20
|
getSettingsPath,
|
|
21
|
+
ownRegistryEntry,
|
|
20
22
|
pickRegistryEntry,
|
|
21
23
|
readInstalledPluginsRegistry,
|
|
22
24
|
readSettings,
|
|
@@ -61,8 +63,25 @@ export {
|
|
|
61
63
|
let localMarketplacesPromise: Promise<Map<string, LocalMarketplace>> | null =
|
|
62
64
|
null;
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* A plugin's state at one scope. There are exactly two: installed, or not.
|
|
68
|
+
*
|
|
69
|
+
* `installed` means the settings file for that scope turns the plugin on,
|
|
70
|
+
* `enabledPlugins[id] === true` — `~/.claude/settings.json` for user,
|
|
71
|
+
* `<dir>/.claude/settings.json` for project, `<dir>/.claude/settings.local.json`
|
|
72
|
+
* for local. That is the fact Claude Code loads by. A registry row alone never
|
|
73
|
+
* makes it load a plugin: measured on 2.1.281, started in a subdirectory, Claude
|
|
74
|
+
* Code reads only that directory's `settings.json`. Read it through
|
|
75
|
+
* {@link isInstalledInScope}, never directly.
|
|
76
|
+
*
|
|
77
|
+
* `version` is the registry version that loads at this scope, and it can be
|
|
78
|
+
* inherited from another checkout of the same repository. It means something
|
|
79
|
+
* only where the plugin is installed — {@link versionInScope} hides it
|
|
80
|
+
* everywhere else. Installed with no version is the one broken sub-state,
|
|
81
|
+
* {@link scopeFilesMissing}.
|
|
82
|
+
*/
|
|
64
83
|
export interface ScopeStatus {
|
|
65
|
-
|
|
84
|
+
installed: boolean;
|
|
66
85
|
version?: string;
|
|
67
86
|
}
|
|
68
87
|
|
|
@@ -73,7 +92,14 @@ export interface PluginInfo {
|
|
|
73
92
|
description: string;
|
|
74
93
|
marketplace: string;
|
|
75
94
|
marketplaceDisplay: string;
|
|
76
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The highest version at a scope where the plugin is INSTALLED —
|
|
97
|
+
* {@link highestInstalledVersion}, the classifier's `installed`. Undefined
|
|
98
|
+
* when no installed scope has one: installed nowhere, or installed with its
|
|
99
|
+
* files missing. A row inherited from another checkout, at a scope whose
|
|
100
|
+
* settings do not turn the plugin on, is not an install Claude Code loads here
|
|
101
|
+
* and never supplies this.
|
|
102
|
+
*/
|
|
77
103
|
installedVersion?: string;
|
|
78
104
|
/**
|
|
79
105
|
* The version Claude Code's loader will ACTUALLY load in this project, or
|
|
@@ -172,91 +198,181 @@ export interface PluginInfo {
|
|
|
172
198
|
updateCheckFailure?: MarketplaceFetchFailure;
|
|
173
199
|
}
|
|
174
200
|
|
|
201
|
+
/** The three scopes a plugin carries — all the installed-state helpers read. */
|
|
202
|
+
type PluginScopes = Pick<
|
|
203
|
+
PluginInfo,
|
|
204
|
+
"userScope" | "projectScope" | "localScope"
|
|
205
|
+
>;
|
|
206
|
+
|
|
175
207
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
208
|
+
* THE test for "installed at this scope": the scope's settings file turns the
|
|
209
|
+
* plugin on. See {@link ScopeStatus}.
|
|
178
210
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* It previously rendered identically to a working install.
|
|
211
|
+
* Every consumer asks this and nothing else — the list row's squares and name
|
|
212
|
+
* colour, the detail pane's dots, the header count, the classifier, the scope
|
|
213
|
+
* keys, the mate slots — so no two of them can show a different state.
|
|
183
214
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
215
|
+
* A version is not part of the test. Installed with no version is still
|
|
216
|
+
* installed, in the broken sub-state {@link scopeFilesMissing} names.
|
|
217
|
+
*/
|
|
218
|
+
export function isInstalledInScope(scope: ScopeStatus | undefined): boolean {
|
|
219
|
+
return scope?.installed === true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Installed at user, project or local scope. */
|
|
223
|
+
export function isInstalledAnywhere(plugin: PluginScopes | undefined): boolean {
|
|
224
|
+
return (
|
|
225
|
+
isInstalledInScope(plugin?.userScope) ||
|
|
226
|
+
isInstalledInScope(plugin?.projectScope) ||
|
|
227
|
+
isInstalledInScope(plugin?.localScope)
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The version at this scope, or undefined where the plugin is not installed.
|
|
186
233
|
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
234
|
+
* A scope that is not installed can still carry a registry version, inherited
|
|
235
|
+
* from another checkout of the repository. Shown, it claims an install Claude
|
|
236
|
+
* Code does not load in this directory.
|
|
189
237
|
*/
|
|
190
|
-
export function
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
!!plugin.projectScope?.enabled ||
|
|
195
|
-
!!plugin.localScope?.enabled;
|
|
196
|
-
return enabledSomewhere && !plugin.installedVersion;
|
|
238
|
+
export function versionInScope(
|
|
239
|
+
scope: ScopeStatus | undefined,
|
|
240
|
+
): string | undefined {
|
|
241
|
+
return isInstalledInScope(scope) ? scope?.version : undefined;
|
|
197
242
|
}
|
|
198
243
|
|
|
199
244
|
/**
|
|
200
|
-
*
|
|
245
|
+
* Installed at this scope with no version: Claude Code wants to load the plugin
|
|
246
|
+
* and has nothing to load. The repair is `install`.
|
|
201
247
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
248
|
+
* "0.0.0" is a version — Anthropic's official plugins record exactly that — so
|
|
249
|
+
* it is not missing, which is why this tests presence, not `isKnownVersion`.
|
|
250
|
+
*/
|
|
251
|
+
export function scopeFilesMissing(scope: ScopeStatus | undefined): boolean {
|
|
252
|
+
return isInstalledInScope(scope) && !scope?.version;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The three scopes, in the one order every per-scope list uses: user, project, local. */
|
|
256
|
+
function scopeStatuses(
|
|
257
|
+
plugin: PluginScopes | undefined,
|
|
258
|
+
): Array<[PluginScope, ScopeStatus | undefined]> {
|
|
259
|
+
return [
|
|
260
|
+
["user", plugin?.userScope],
|
|
261
|
+
["project", plugin?.projectScope],
|
|
262
|
+
["local", plugin?.localScope],
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Scope → version, for the scopes where the plugin is installed and a version
|
|
268
|
+
* resolves.
|
|
208
269
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
270
|
+
* Only installed scopes. A scope that is not can still carry a registry version
|
|
271
|
+
* inherited from another checkout, and counting it planned an update — and
|
|
272
|
+
* printed a version — for a plugin Claude Code does not load here.
|
|
273
|
+
*/
|
|
274
|
+
export function installedScopeVersions(
|
|
275
|
+
plugin: PluginScopes | undefined,
|
|
276
|
+
): Array<[PluginScope, string]> {
|
|
277
|
+
const out: Array<[PluginScope, string]> = [];
|
|
278
|
+
for (const [scope, status] of scopeStatuses(plugin)) {
|
|
279
|
+
const version = versionInScope(status);
|
|
280
|
+
if (version) out.push([scope, version]);
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The scopes where the plugin is installed with its files missing. */
|
|
286
|
+
export function filesMissingScopes(
|
|
287
|
+
plugin: PluginScopes | undefined,
|
|
288
|
+
): PluginScope[] {
|
|
289
|
+
return scopeStatuses(plugin)
|
|
290
|
+
.filter(([, status]) => scopeFilesMissing(status))
|
|
291
|
+
.map(([scope]) => scope);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The highest version installed across scopes, or null.
|
|
212
296
|
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
297
|
+
* THE installed version: the classifier's `installed` and
|
|
298
|
+
* `PluginInfo.installedVersion` are both this, so the Plugins header, the row
|
|
299
|
+
* and the status line cannot name different versions for one plugin.
|
|
300
|
+
*
|
|
301
|
+
* `compareVersions` answers null for an unorderable pair, and null is not
|
|
302
|
+
* `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
|
|
303
|
+
* ranked. "Highest" is not meaningful across commit ids; picking one
|
|
304
|
+
* deterministically is, and every caller only needs *an* installed version.
|
|
215
305
|
*/
|
|
216
|
-
export function
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
306
|
+
export function highestInstalledVersion(
|
|
307
|
+
plugin: PluginScopes | undefined,
|
|
308
|
+
): string | null {
|
|
309
|
+
const versions = installedScopeVersions(plugin).map(([, v]) => v);
|
|
310
|
+
if (versions.length === 0) return null;
|
|
311
|
+
return versions.reduce((a, b) =>
|
|
312
|
+
(compareVersions(a, b) ?? -1) >= 0 ? a : b,
|
|
313
|
+
);
|
|
224
314
|
}
|
|
225
315
|
|
|
226
316
|
/**
|
|
227
|
-
*
|
|
317
|
+
* Files missing, at plugin level: installed somewhere, and no installed scope
|
|
318
|
+
* has a version — the classifier's own test for reason `files-missing`.
|
|
319
|
+
*
|
|
320
|
+
* Broken, not healthy: Claude Code will not load the plugin, and because the
|
|
321
|
+
* update check is `installedVersion && compare(...)`, it is also invisible to
|
|
322
|
+
* every update path.
|
|
228
323
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
324
|
+
* Read from the scopes, never from `installedVersion`: a version that field
|
|
325
|
+
* once took from a scope NOT installed here made the header say "● Installed"
|
|
326
|
+
* beside a status line saying files missing.
|
|
232
327
|
*
|
|
233
|
-
*
|
|
328
|
+
* `isOrphaned` is a different failure — no longer offered by any marketplace —
|
|
329
|
+
* and has its own rendering already.
|
|
330
|
+
*
|
|
331
|
+
* "0.0.0" means installed-with-unknown-version (Anthropic's official plugins
|
|
332
|
+
* record exactly that), so it must NOT count as missing.
|
|
333
|
+
*/
|
|
334
|
+
export function hasMissingFiles(plugin: PluginInfo): boolean {
|
|
335
|
+
if (plugin.isOrphaned) return false;
|
|
336
|
+
return (
|
|
337
|
+
isInstalledAnywhere(plugin) && highestInstalledVersion(plugin) === null
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* One scope's status, from the scope's `enabledPlugins` map and the registry
|
|
343
|
+
* versions resolved for it.
|
|
234
344
|
*
|
|
235
|
-
*
|
|
236
|
-
* and
|
|
237
|
-
*
|
|
238
|
-
*
|
|
345
|
+
* Exported and taken as arguments so all three scopes share one implementation
|
|
346
|
+
* and it is unit-tested. `installed` is the settings flag and nothing else. The
|
|
347
|
+
* version is carried whenever the registry resolves one; {@link versionInScope}
|
|
348
|
+
* is what keeps it off screen for a scope where the plugin is not installed.
|
|
349
|
+
* Neither fact present means no scope at all.
|
|
239
350
|
*/
|
|
240
351
|
export function buildScopeStatus(
|
|
241
352
|
enabledPlugins: Record<string, boolean>,
|
|
242
353
|
installedVersions: Record<string, string>,
|
|
243
354
|
pluginId: string,
|
|
244
355
|
): ScopeStatus | undefined {
|
|
245
|
-
const
|
|
356
|
+
const flag = enabledPlugins[pluginId];
|
|
246
357
|
const version = installedVersions[pluginId];
|
|
247
|
-
if (
|
|
248
|
-
return {
|
|
358
|
+
if (flag === undefined && version === undefined) return undefined;
|
|
359
|
+
return { installed: flag === true, version };
|
|
249
360
|
}
|
|
250
361
|
|
|
251
362
|
/**
|
|
252
363
|
* What a scope toggle (`u` / `p` / `l`, or the Enter scope picker) should do.
|
|
253
364
|
*
|
|
254
|
-
* `install`
|
|
255
|
-
* against Claude Code 2.1.223 in
|
|
256
|
-
* `.claude/settings.json` with no registry
|
|
257
|
-
* `claude plugin install dev@magus --scope project`
|
|
258
|
-
* wrote the registry entry. It does not report
|
|
259
|
-
* needs no uninstall-first dance (unlike content
|
|
365
|
+
* `install` for a scope that is not installed, and for one whose files are
|
|
366
|
+
* missing — there it is the repair. Measured against Claude Code 2.1.223 in
|
|
367
|
+
* exactly that state — `dev@magus` in `.claude/settings.json` with no registry
|
|
368
|
+
* entry for the project path — `claude plugin install dev@magus --scope project`
|
|
369
|
+
* installed it normally and wrote the registry entry. It does not report
|
|
370
|
+
* "already installed", so this needs no uninstall-first dance (unlike content
|
|
371
|
+
* drift, see repairPlugin).
|
|
372
|
+
*
|
|
373
|
+
* Files missing must never resolve to `uninstall`. The scope key runs
|
|
374
|
+
* `cliUninstallPlugin` with no confirmation, and that answer removed a plugin
|
|
375
|
+
* the row beside it was reporting as broken — the user asked for the repair.
|
|
260
376
|
*
|
|
261
377
|
* `behind` is the answer to "is this scope provably behind?", and it comes from
|
|
262
378
|
* the plugin classifier (`scopeIsBehind` in update-plan.ts), never from a
|
|
@@ -269,18 +385,7 @@ export function resolveScopeAction(
|
|
|
269
385
|
scope: ScopeStatus | undefined,
|
|
270
386
|
behind: boolean,
|
|
271
387
|
): "install" | "update" | "uninstall" {
|
|
272
|
-
|
|
273
|
-
// part of `isInstalledInScope`. The two ask different questions:
|
|
274
|
-
// "is it on disk" (installation) versus "what should this key do next".
|
|
275
|
-
//
|
|
276
|
-
// Installed-but-disabled must NOT resolve to uninstall. The scope key runs
|
|
277
|
-
// `cliUninstallPlugin` with no confirmation, so that combination would delete
|
|
278
|
-
// a plugin the user had merely switched off — with the row beside it showing
|
|
279
|
-
// no scope square, which reads as "not installed" and invites the press.
|
|
280
|
-
// `install` re-installs and re-enables, which is what the key meant before
|
|
281
|
-
// installation and enablement were separated, and is the only non-destructive
|
|
282
|
-
// answer here.
|
|
283
|
-
if (!isInstalledInScope(scope) || !scope?.enabled) return "install";
|
|
388
|
+
if (!isInstalledInScope(scope) || scopeFilesMissing(scope)) return "install";
|
|
284
389
|
return behind ? "update" : "uninstall";
|
|
285
390
|
}
|
|
286
391
|
|
|
@@ -288,6 +393,7 @@ export async function getAvailablePlugins(
|
|
|
288
393
|
projectPath?: string,
|
|
289
394
|
): Promise<PluginInfo[]> {
|
|
290
395
|
const configuredMarketplaces = await getConfiguredMarketplaces(projectPath);
|
|
396
|
+
// The project scope's two facts. They also seed the orphan list below.
|
|
291
397
|
const enabledPlugins = await getEnabledPlugins(projectPath);
|
|
292
398
|
const installedVersions = await getInstalledPluginVersions(projectPath);
|
|
293
399
|
|
|
@@ -303,12 +409,9 @@ export async function getAvailablePlugins(
|
|
|
303
409
|
const loadedVersionOf = (pluginId: string): string | null =>
|
|
304
410
|
loadedVersions[pluginId] ?? null;
|
|
305
411
|
|
|
306
|
-
//
|
|
412
|
+
// The other two scopes.
|
|
307
413
|
const userEnabledPlugins = await getGlobalEnabledPlugins();
|
|
308
414
|
const userInstalledVersions = await getGlobalInstalledPluginVersions();
|
|
309
|
-
const projectEnabledPlugins = await getEnabledPlugins(projectPath);
|
|
310
|
-
const projectInstalledVersions =
|
|
311
|
-
await getInstalledPluginVersions(projectPath);
|
|
312
415
|
const localEnabledPlugins = await getLocalEnabledPlugins(projectPath);
|
|
313
416
|
const localInstalledVersions =
|
|
314
417
|
await getLocalInstalledPluginVersions(projectPath);
|
|
@@ -316,21 +419,13 @@ export async function getAvailablePlugins(
|
|
|
316
419
|
const plugins: PluginInfo[] = [];
|
|
317
420
|
const seenPluginIds = new Set<string>();
|
|
318
421
|
|
|
319
|
-
|
|
320
|
-
// One implementation, shared with the global view and unit-tested. Both
|
|
321
|
-
// copies used to key scope existence off the settings flag, so an installed
|
|
322
|
-
// plugin with no settings key produced no scope at all.
|
|
323
|
-
const scopeStatusFor = (pluginId: string) => ({
|
|
422
|
+
const scopeStatusFor = (pluginId: string): PluginScopes => ({
|
|
324
423
|
userScope: buildScopeStatus(
|
|
325
424
|
userEnabledPlugins,
|
|
326
425
|
userInstalledVersions,
|
|
327
426
|
pluginId,
|
|
328
427
|
),
|
|
329
|
-
projectScope: buildScopeStatus(
|
|
330
|
-
projectEnabledPlugins,
|
|
331
|
-
projectInstalledVersions,
|
|
332
|
-
pluginId,
|
|
333
|
-
),
|
|
428
|
+
projectScope: buildScopeStatus(enabledPlugins, installedVersions, pluginId),
|
|
334
429
|
localScope: buildScopeStatus(
|
|
335
430
|
localEnabledPlugins,
|
|
336
431
|
localInstalledVersions,
|
|
@@ -338,6 +433,17 @@ export async function getAvailablePlugins(
|
|
|
338
433
|
),
|
|
339
434
|
});
|
|
340
435
|
|
|
436
|
+
// A version is an install only at a scope where Claude Code loads the plugin.
|
|
437
|
+
// The registry resolves a row here for plugins no settings file in this
|
|
438
|
+
// directory turns on — inherited from another checkout of the repository.
|
|
439
|
+
// This used to take the PROJECT lookup whenever the plugin was installed at
|
|
440
|
+
// any scope, so a plugin installed at user scope at 0.9.0 read "(v0.8.1
|
|
441
|
+
// installed)" off an inherited project row beside a status saying current at
|
|
442
|
+
// 0.9.0 — and with its user files missing, the header said "● Installed".
|
|
443
|
+
// The classifier's rule, from the same per-scope versions, is the only rule.
|
|
444
|
+
const installedVersionFor = (scopes: PluginScopes): string | undefined =>
|
|
445
|
+
highestInstalledVersion(scopes) ?? undefined;
|
|
446
|
+
|
|
341
447
|
// Get all marketplace names (configured + official/featured defaults)
|
|
342
448
|
// Always include official and featured marketplaces so users can browse them
|
|
343
449
|
const marketplaceNames = new Set<string>();
|
|
@@ -396,9 +502,8 @@ export async function getAvailablePlugins(
|
|
|
396
502
|
|
|
397
503
|
for (const plugin of marketplacePlugins) {
|
|
398
504
|
const pluginId = `${plugin.name}@${mpName}`;
|
|
399
|
-
const installedVersion = installedVersions[pluginId];
|
|
400
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
401
505
|
const scopeStatus = scopeStatusFor(pluginId);
|
|
506
|
+
const installedVersion = installedVersionFor(scopeStatus);
|
|
402
507
|
|
|
403
508
|
seenPluginIds.add(pluginId);
|
|
404
509
|
plugins.push({
|
|
@@ -408,7 +513,6 @@ export async function getAvailablePlugins(
|
|
|
408
513
|
description: plugin.description,
|
|
409
514
|
marketplace: mpName,
|
|
410
515
|
marketplaceDisplay: marketplace.displayName,
|
|
411
|
-
enabled: isEnabled,
|
|
412
516
|
installedVersion: installedVersion,
|
|
413
517
|
loadedVersion: loadedVersionOf(pluginId),
|
|
414
518
|
hasUpdate: isBehind(plugin.version, installedVersion) === true,
|
|
@@ -436,9 +540,8 @@ export async function getAvailablePlugins(
|
|
|
436
540
|
const pluginId = `${localPlugin.name}@${mpName}`;
|
|
437
541
|
if (seenPluginIds.has(pluginId)) continue;
|
|
438
542
|
|
|
439
|
-
const installedVersion = installedVersions[pluginId];
|
|
440
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
441
543
|
const scopeStatus = scopeStatusFor(pluginId);
|
|
544
|
+
const installedVersion = installedVersionFor(scopeStatus);
|
|
442
545
|
|
|
443
546
|
seenPluginIds.add(pluginId);
|
|
444
547
|
plugins.push({
|
|
@@ -448,7 +551,6 @@ export async function getAvailablePlugins(
|
|
|
448
551
|
description: localPlugin.description || "",
|
|
449
552
|
marketplace: mpName,
|
|
450
553
|
marketplaceDisplay: localMp.name || formatMarketplaceName(mpName),
|
|
451
|
-
enabled: isEnabled,
|
|
452
554
|
installedVersion: installedVersion,
|
|
453
555
|
loadedVersion: loadedVersionOf(pluginId),
|
|
454
556
|
hasUpdate: isBehind(localPlugin.version, installedVersion) === true,
|
|
@@ -466,7 +568,7 @@ export async function getAvailablePlugins(
|
|
|
466
568
|
}
|
|
467
569
|
}
|
|
468
570
|
|
|
469
|
-
// Add orphaned plugins (
|
|
571
|
+
// Add orphaned plugins (in this project's settings or registry, but not in any cache)
|
|
470
572
|
const allPluginIds = new Set([
|
|
471
573
|
...Object.keys(enabledPlugins),
|
|
472
574
|
...Object.keys(installedVersions),
|
|
@@ -479,9 +581,8 @@ export async function getAvailablePlugins(
|
|
|
479
581
|
if (!parsed) continue;
|
|
480
582
|
|
|
481
583
|
const { pluginName, marketplace: mpName } = parsed;
|
|
482
|
-
const installedVersion = installedVersions[pluginId];
|
|
483
|
-
const isEnabled = enabledPlugins[pluginId] === true;
|
|
484
584
|
const scopeStatus = scopeStatusFor(pluginId);
|
|
585
|
+
const installedVersion = installedVersionFor(scopeStatus);
|
|
485
586
|
|
|
486
587
|
// Try to get plugin info from local marketplace cache (fallback)
|
|
487
588
|
const localMp = localMarketplaces.get(mpName);
|
|
@@ -498,7 +599,6 @@ export async function getAvailablePlugins(
|
|
|
498
599
|
description,
|
|
499
600
|
marketplace: mpName,
|
|
500
601
|
marketplaceDisplay: localMp?.name || formatMarketplaceName(mpName),
|
|
501
|
-
enabled: isEnabled,
|
|
502
602
|
installedVersion: installedVersion,
|
|
503
603
|
loadedVersion: loadedVersionOf(pluginId),
|
|
504
604
|
hasUpdate,
|
|
@@ -507,7 +607,7 @@ export async function getAvailablePlugins(
|
|
|
507
607
|
});
|
|
508
608
|
}
|
|
509
609
|
|
|
510
|
-
await annotateContentDrift(plugins,
|
|
610
|
+
await annotateContentDrift(plugins, projectPath);
|
|
511
611
|
return plugins;
|
|
512
612
|
}
|
|
513
613
|
|
|
@@ -538,19 +638,58 @@ export function contentDriftCandidate(plugin: PluginInfo): boolean {
|
|
|
538
638
|
return compareVersions(plugin.version, plugin.installedVersion) === 0;
|
|
539
639
|
}
|
|
540
640
|
|
|
641
|
+
/**
|
|
642
|
+
* The registry row whose commit a drift check compares: the row behind an
|
|
643
|
+
* INSTALLED scope at `installedVersion`, picked the way that scope's version
|
|
644
|
+
* was resolved — so a worktree that inherits a version inherits its
|
|
645
|
+
* `gitCommitSha` with it, and drift detection does not switch off for exactly
|
|
646
|
+
* the plugins that just started reporting as installed.
|
|
647
|
+
*
|
|
648
|
+
* It used to be the project lookup for every plugin. For one installed only at
|
|
649
|
+
* user scope, beside a project row inherited from another checkout, that
|
|
650
|
+
* compared the inherited row's commit — another version's files — against the
|
|
651
|
+
* user install's version.
|
|
652
|
+
*
|
|
653
|
+
* Project, then local, then user: this directory's rows before the user row,
|
|
654
|
+
* the precedence the project lookup always had.
|
|
655
|
+
*
|
|
656
|
+
* Exported to be tested directly; `annotateContentDrift` needs the registry.
|
|
657
|
+
*/
|
|
658
|
+
export function contentDriftEntry(
|
|
659
|
+
plugin: PluginInfo,
|
|
660
|
+
entries: InstalledPluginEntry[] | undefined,
|
|
661
|
+
projectPath: string,
|
|
662
|
+
fallbackPaths: string[],
|
|
663
|
+
): InstalledPluginEntry | undefined {
|
|
664
|
+
const atShown = new Set(
|
|
665
|
+
installedScopeVersions(plugin)
|
|
666
|
+
.filter(([, version]) => version === plugin.installedVersion)
|
|
667
|
+
.map(([scope]) => scope),
|
|
668
|
+
);
|
|
669
|
+
for (const scope of ["project", "local", "user"] as const) {
|
|
670
|
+
if (!atShown.has(scope)) continue;
|
|
671
|
+
const entry =
|
|
672
|
+
scope === "user"
|
|
673
|
+
? pickRegistryEntry(entries, "user")
|
|
674
|
+
: pickRegistryEntry(entries, scope, projectPath, fallbackPaths);
|
|
675
|
+
if (entry) return entry;
|
|
676
|
+
}
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
|
|
541
680
|
/**
|
|
542
681
|
* Flag plugins whose files moved without their version moving.
|
|
543
682
|
*
|
|
544
683
|
* Runs after the plugin list is assembled so every code path that builds a
|
|
545
684
|
* PluginInfo gets the same treatment. Which plugins are worth asking about is
|
|
546
|
-
* {@link contentDriftCandidate}
|
|
685
|
+
* {@link contentDriftCandidate}, and which row to ask about is
|
|
686
|
+
* {@link contentDriftEntry}.
|
|
547
687
|
*
|
|
548
688
|
* Best-effort throughout: a plugin whose drift cannot be determined is left
|
|
549
689
|
* exactly as it is today.
|
|
550
690
|
*/
|
|
551
691
|
async function annotateContentDrift(
|
|
552
692
|
plugins: PluginInfo[],
|
|
553
|
-
scope: "user" | "project" | "local",
|
|
554
693
|
projectPath?: string,
|
|
555
694
|
): Promise<void> {
|
|
556
695
|
let registry: InstalledPluginsRegistry;
|
|
@@ -560,20 +699,16 @@ async function annotateContentDrift(
|
|
|
560
699
|
return;
|
|
561
700
|
}
|
|
562
701
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
// detection silently switches off for exactly the plugins that just started
|
|
566
|
-
// reporting as installed.
|
|
567
|
-
const target = scope === "user" ? undefined : (projectPath ?? process.cwd());
|
|
568
|
-
const fallbackPaths = target ? await inheritablePaths(target) : [];
|
|
702
|
+
const target = projectPath ?? process.cwd();
|
|
703
|
+
const fallbackPaths = await inheritablePaths(target);
|
|
569
704
|
|
|
570
705
|
await Promise.all(
|
|
571
706
|
plugins.map(async (plugin) => {
|
|
572
707
|
if (!contentDriftCandidate(plugin)) return;
|
|
573
708
|
|
|
574
|
-
const entry =
|
|
709
|
+
const entry = contentDriftEntry(
|
|
710
|
+
plugin,
|
|
575
711
|
registry.plugins[plugin.id],
|
|
576
|
-
scope,
|
|
577
712
|
target,
|
|
578
713
|
fallbackPaths,
|
|
579
714
|
);
|
|
@@ -762,6 +897,13 @@ export async function saveInstalledPluginVersion(
|
|
|
762
897
|
* version we got are therefore different questions, and only this one can answer
|
|
763
898
|
* the second. Callers must record what this returns, never what they intended:
|
|
764
899
|
* writing the intent fabricates state that the drift gate then trusts.
|
|
900
|
+
*
|
|
901
|
+
* Only THIS project's own row at `scope` ({@link ownRegistryEntry}) — no
|
|
902
|
+
* inherited row and no user-row fallback. A fallback answered for a row the
|
|
903
|
+
* command never wrote: the apply engine took it as "this project holds a row"
|
|
904
|
+
* and ran `update` where only `install` is safe, and a read-back after a
|
|
905
|
+
* project install that wrote nothing returned the user row's version, to be
|
|
906
|
+
* recorded as the project's.
|
|
765
907
|
*/
|
|
766
908
|
export async function readInstalledVersionForScope(
|
|
767
909
|
pluginId: string,
|
|
@@ -769,7 +911,7 @@ export async function readInstalledVersionForScope(
|
|
|
769
911
|
projectPath: string = process.cwd(),
|
|
770
912
|
): Promise<string | null> {
|
|
771
913
|
const registry = await readInstalledPluginsRegistry();
|
|
772
|
-
const entry =
|
|
914
|
+
const entry = ownRegistryEntry(
|
|
773
915
|
registry.plugins?.[pluginId],
|
|
774
916
|
scope,
|
|
775
917
|
projectPath,
|