claudeup 6.5.0 → 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__/catalog-notice.test.ts +3 -3
- 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__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +1 -0
- 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/data/marketplaces.ts +13 -0
- 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 +58 -1
- 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
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { BinCheckResult } from "../services/doctor-bins.js";
|
|
3
2
|
import type { PluginInfo } from "../services/plugin-manager.js";
|
|
3
|
+
import { type ToolFacts, classifyTool } from "../services/tool-probe.js";
|
|
4
4
|
import {
|
|
5
|
+
applyBins,
|
|
6
|
+
binCommandFor,
|
|
7
|
+
planWorkItems,
|
|
8
|
+
} from "../services/update-engine.js";
|
|
9
|
+
import {
|
|
10
|
+
type BinUpdateAction,
|
|
11
|
+
type BinUpdateItem,
|
|
12
|
+
type PluginUpdateItem,
|
|
5
13
|
type UpdatePlan,
|
|
14
|
+
binNeedsWork,
|
|
15
|
+
classifyPlugin,
|
|
16
|
+
classifyPluginRows,
|
|
17
|
+
countPluginStates,
|
|
18
|
+
indexCatalog,
|
|
19
|
+
pinForRow,
|
|
6
20
|
planBinUpdates,
|
|
7
21
|
planHasWork,
|
|
8
22
|
planIsBehind,
|
|
9
23
|
planPluginUpdates,
|
|
10
24
|
planSkillUpdates,
|
|
25
|
+
scopeIsBehind,
|
|
11
26
|
scopesNeeding,
|
|
12
27
|
summarizePlan,
|
|
13
28
|
} from "../services/update-plan.js";
|
|
@@ -30,6 +45,21 @@ function catalog(...infos: PluginInfo[]): Map<string, PluginInfo> {
|
|
|
30
45
|
return new Map(infos.map((i) => [i.id, i]));
|
|
31
46
|
}
|
|
32
47
|
|
|
48
|
+
/** Probe facts for a present, managed tool; override what the case is about. */
|
|
49
|
+
function toolFacts(name: string, over: Partial<ToolFacts> = {}): ToolFacts {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
present: true,
|
|
53
|
+
resolvedPath: `/bin/${name}`,
|
|
54
|
+
installed: "1.0.0",
|
|
55
|
+
latest: null,
|
|
56
|
+
behind: null,
|
|
57
|
+
managed: true,
|
|
58
|
+
sources: [],
|
|
59
|
+
...over,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
function plan(overrides: Partial<UpdatePlan> = {}): UpdatePlan {
|
|
34
64
|
return {
|
|
35
65
|
profileId: "test",
|
|
@@ -53,12 +83,14 @@ describe("planPluginUpdates — the profile declares it, the machine lacks it",
|
|
|
53
83
|
target: "4.7.0",
|
|
54
84
|
scopes: [],
|
|
55
85
|
});
|
|
86
|
+
expect(item.reason).toBe("absent");
|
|
56
87
|
});
|
|
57
88
|
|
|
58
89
|
test("a plugin missing from the catalog entirely is still an install", () => {
|
|
59
90
|
// The marketplace may not be registered yet — install registers it.
|
|
60
91
|
const [item] = planPluginUpdates({ "new@magus": "latest" }, catalog());
|
|
61
92
|
expect(item).toMatchObject({ action: "install", available: null });
|
|
93
|
+
expect(item.reason).toBe("absent");
|
|
62
94
|
});
|
|
63
95
|
|
|
64
96
|
test("an exact pin that is not installed installs that exact version", () => {
|
|
@@ -67,6 +99,7 @@ describe("planPluginUpdates — the profile declares it, the machine lacks it",
|
|
|
67
99
|
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
68
100
|
);
|
|
69
101
|
expect(item.target).toBe("4.6.1");
|
|
102
|
+
expect(item.reason).toBe("absent");
|
|
70
103
|
});
|
|
71
104
|
});
|
|
72
105
|
|
|
@@ -88,6 +121,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
|
|
|
88
121
|
target: "4.7.0",
|
|
89
122
|
scopes: ["project"],
|
|
90
123
|
});
|
|
124
|
+
expect(item.reason).toBe("behind");
|
|
91
125
|
});
|
|
92
126
|
|
|
93
127
|
test("the same version is current, with no scopes to touch", () => {
|
|
@@ -102,6 +136,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
|
|
|
102
136
|
),
|
|
103
137
|
);
|
|
104
138
|
expect(item).toMatchObject({ action: "current", scopes: [] });
|
|
139
|
+
expect(item.reason).toBe("up-to-date");
|
|
105
140
|
});
|
|
106
141
|
|
|
107
142
|
test("an older catalog version does not downgrade a 'latest' pin", () => {
|
|
@@ -116,6 +151,7 @@ describe("planPluginUpdates — advancing a 'latest' pin", () => {
|
|
|
116
151
|
),
|
|
117
152
|
);
|
|
118
153
|
expect(item.action).toBe("current");
|
|
154
|
+
expect(item.reason).toBe("up-to-date");
|
|
119
155
|
});
|
|
120
156
|
});
|
|
121
157
|
|
|
@@ -142,9 +178,13 @@ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
|
|
|
142
178
|
expect(item.action).toBe("unknown");
|
|
143
179
|
expect(item.note).toBe("GitHub rate limit");
|
|
144
180
|
expect(item.target).toBeNull();
|
|
181
|
+
expect(item.reason).toBe("catalog-unverified");
|
|
145
182
|
});
|
|
146
183
|
|
|
147
|
-
test("an unknown plugin
|
|
184
|
+
test("an unknown plugin is still work to look at, but does not fail --check", () => {
|
|
185
|
+
// The two predicates split here on purpose: the run has something to
|
|
186
|
+
// report (planHasWork), yet nothing is KNOWN to be behind, so the CI gate
|
|
187
|
+
// passes. Nothing is applied for an unknown either way.
|
|
148
188
|
const p = plan({
|
|
149
189
|
plugins: planPluginUpdates(
|
|
150
190
|
{ "dev@magus": "latest" },
|
|
@@ -159,6 +199,7 @@ describe("planPluginUpdates — a failed check is never 'up to date'", () => {
|
|
|
159
199
|
),
|
|
160
200
|
});
|
|
161
201
|
expect(planHasWork(p)).toBe(true);
|
|
202
|
+
expect(planIsBehind(p)).toBe(false);
|
|
162
203
|
});
|
|
163
204
|
});
|
|
164
205
|
|
|
@@ -178,6 +219,7 @@ describe("planPluginUpdates — content drift", () => {
|
|
|
178
219
|
);
|
|
179
220
|
expect(item.action).toBe("repair");
|
|
180
221
|
expect(item.scopes).toEqual(["user", "project"]);
|
|
222
|
+
expect(item.reason).toBe("content-stale");
|
|
181
223
|
});
|
|
182
224
|
});
|
|
183
225
|
|
|
@@ -199,6 +241,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
|
|
|
199
241
|
),
|
|
200
242
|
);
|
|
201
243
|
expect(item).toMatchObject({ action: "update", target: "9.9.9" });
|
|
244
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
202
245
|
});
|
|
203
246
|
|
|
204
247
|
test("an unreachable pin is NOTED, never silently abandoned", () => {
|
|
@@ -215,6 +258,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
|
|
|
215
258
|
expect(item.note).toBe(
|
|
216
259
|
"manifest pins 4.6.1; the marketplace offers only 9.9.9",
|
|
217
260
|
);
|
|
261
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
218
262
|
});
|
|
219
263
|
|
|
220
264
|
test("already on the only installable version is CURRENT — no churn", () => {
|
|
@@ -233,6 +277,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
|
|
|
233
277
|
);
|
|
234
278
|
expect(item.action).toBe("current");
|
|
235
279
|
expect(item.scopes).toEqual([]);
|
|
280
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
236
281
|
});
|
|
237
282
|
|
|
238
283
|
test("an unsatisfiable pin does not fail --check forever", () => {
|
|
@@ -264,6 +309,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
|
|
|
264
309
|
);
|
|
265
310
|
expect(item).toMatchObject({ action: "update", target: "4.6.1" });
|
|
266
311
|
expect(item.note).toBeUndefined();
|
|
312
|
+
expect(item.reason).toBe("behind");
|
|
267
313
|
});
|
|
268
314
|
|
|
269
315
|
test("falls back to the pin when the catalog offers nothing", () => {
|
|
@@ -278,6 +324,7 @@ describe("planPluginUpdates — an exact pin plans against what is deliverable",
|
|
|
278
324
|
),
|
|
279
325
|
);
|
|
280
326
|
expect(item.target).toBe("4.6.1");
|
|
327
|
+
expect(item.reason).toBe("behind");
|
|
281
328
|
});
|
|
282
329
|
});
|
|
283
330
|
|
|
@@ -299,6 +346,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
299
346
|
);
|
|
300
347
|
expect(item.action).toBe("update");
|
|
301
348
|
expect(item.scopes).toEqual(["project"]);
|
|
349
|
+
expect(item.reason).toBe("behind");
|
|
302
350
|
});
|
|
303
351
|
|
|
304
352
|
test("the same masking under an EXACT pin is also caught", () => {
|
|
@@ -319,6 +367,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
319
367
|
);
|
|
320
368
|
expect(item.action).toBe("update");
|
|
321
369
|
expect(item.scopes).toEqual(["project"]);
|
|
370
|
+
expect(item.reason).toBe("behind");
|
|
322
371
|
});
|
|
323
372
|
|
|
324
373
|
test("an unreachable pin moves EVERY scope to the installable version", () => {
|
|
@@ -335,6 +384,7 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
335
384
|
);
|
|
336
385
|
expect(item.target).toBe("9.9.9");
|
|
337
386
|
expect(item.scopes).toEqual(["user", "project"]);
|
|
387
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
338
388
|
});
|
|
339
389
|
|
|
340
390
|
test("all scopes at the target is genuinely current", () => {
|
|
@@ -350,9 +400,14 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
350
400
|
),
|
|
351
401
|
);
|
|
352
402
|
expect(item.action).toBe("current");
|
|
403
|
+
expect(item.reason).toBe("up-to-date");
|
|
353
404
|
});
|
|
354
405
|
|
|
355
|
-
test("an installed plugin no
|
|
406
|
+
test("an installed plugin whose catalog declares no version is 'unknown'", () => {
|
|
407
|
+
// This fixture puts dev@magus IN the catalog with a null version, so it is
|
|
408
|
+
// the declares-no-version case, not the withdrawn one — the assertion used
|
|
409
|
+
// to say "not offered", which is what conflated them. 278 of the 292
|
|
410
|
+
// plugins in claude-plugins-official are in exactly this state.
|
|
356
411
|
const [item] = planPluginUpdates(
|
|
357
412
|
{ "dev@magus": "latest" },
|
|
358
413
|
catalog(
|
|
@@ -364,7 +419,20 @@ describe("per-scope decisions — a stale scope is never masked", () => {
|
|
|
364
419
|
),
|
|
365
420
|
);
|
|
366
421
|
expect(item.action).toBe("unknown");
|
|
367
|
-
expect(item.note).toContain("
|
|
422
|
+
expect(item.note).toContain("publishes no version");
|
|
423
|
+
expect(item.reason).toBe("no-published-version");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("an installed plugin ABSENT from every catalog is 'not offered'", () => {
|
|
427
|
+
// The genuinely withdrawn case: no catalog entry at all, the installed
|
|
428
|
+
// version known only from the registry.
|
|
429
|
+
const [item] = planPluginUpdates(
|
|
430
|
+
{ "withdrawn@magus": "latest" },
|
|
431
|
+
catalog(info({ id: "dev@magus", version: "4.6.1" })),
|
|
432
|
+
);
|
|
433
|
+
expect(item.action).toBe("unknown");
|
|
434
|
+
expect(item.note).toContain("not offered by any configured marketplace");
|
|
435
|
+
expect(item.reason).toBe("not-offered");
|
|
368
436
|
});
|
|
369
437
|
});
|
|
370
438
|
|
|
@@ -391,16 +459,40 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
391
459
|
expect(planIsBehind(p)).toBe(true);
|
|
392
460
|
});
|
|
393
461
|
|
|
394
|
-
test("a present
|
|
395
|
-
// There is no version probe for a binary, only present/absent. "Upgrade"
|
|
396
|
-
// is best-effort advancement, not evidence anything is behind.
|
|
462
|
+
test("a present tool its installer calls current does NOT fail the gate, and is no work", () => {
|
|
397
463
|
const p = plan({
|
|
398
464
|
bins: planBinUpdates(
|
|
399
|
-
[{ name: "tmux
|
|
400
|
-
[
|
|
465
|
+
[{ name: "tmux", via: "brew", sources: [] }],
|
|
466
|
+
[toolFacts("tmux", { behind: false })],
|
|
401
467
|
),
|
|
402
468
|
});
|
|
403
|
-
expect(planHasWork(p)).toBe(
|
|
469
|
+
expect(planHasWork(p)).toBe(false);
|
|
470
|
+
expect(planIsBehind(p)).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("a tool its installer says is BEHIND does fail the gate", () => {
|
|
474
|
+
// Behaviour change, deliberate: "upgrade" used to mean "re-run the
|
|
475
|
+
// installer on a guess" and was excluded; it now means proven behind.
|
|
476
|
+
const p = plan({
|
|
477
|
+
bins: planBinUpdates(
|
|
478
|
+
[{ name: "claude-swap", via: "pip", sources: [] }],
|
|
479
|
+
[toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
|
|
480
|
+
),
|
|
481
|
+
});
|
|
482
|
+
expect(planIsBehind(p)).toBe(true);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("an UNKNOWN tool does not fail the gate, and is no work", () => {
|
|
486
|
+
// Tool unknowns are often structural and permanent (another npm owns the
|
|
487
|
+
// file, go is absent) — counting them makes a gate that never passes.
|
|
488
|
+
const p = plan({
|
|
489
|
+
bins: planBinUpdates(
|
|
490
|
+
[{ name: "crush", via: "npm", sources: [] }],
|
|
491
|
+
[toolFacts("crush", { managed: false })],
|
|
492
|
+
),
|
|
493
|
+
});
|
|
494
|
+
expect(p.bins[0]!.action).toBe("unknown");
|
|
495
|
+
expect(planHasWork(p)).toBe(false);
|
|
404
496
|
expect(planIsBehind(p)).toBe(false);
|
|
405
497
|
});
|
|
406
498
|
|
|
@@ -408,13 +500,17 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
408
500
|
const p = plan({
|
|
409
501
|
bins: planBinUpdates(
|
|
410
502
|
[{ name: "tmux-mcp", via: "bun", sources: [] }],
|
|
411
|
-
[
|
|
503
|
+
[toolFacts("tmux-mcp", { present: false, installed: null })],
|
|
412
504
|
),
|
|
413
505
|
});
|
|
414
506
|
expect(planIsBehind(p)).toBe(true);
|
|
415
507
|
});
|
|
416
508
|
|
|
417
|
-
test("an unverifiable plugin
|
|
509
|
+
test("an unverifiable plugin does NOT fail the gate, and is not applied", () => {
|
|
510
|
+
// Same rule as an unknown tool. The user's own profile declares 5 plugins
|
|
511
|
+
// whose marketplace publishes no version, so the old behaviour exited 1 on
|
|
512
|
+
// every run for ever — a gate that can never pass protects nothing. The
|
|
513
|
+
// count of unchecked items is printed beside the verdict instead.
|
|
418
514
|
const p = plan({
|
|
419
515
|
plugins: planPluginUpdates(
|
|
420
516
|
{ "dev@magus": "latest" },
|
|
@@ -428,7 +524,69 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
428
524
|
),
|
|
429
525
|
),
|
|
430
526
|
});
|
|
431
|
-
expect(
|
|
527
|
+
expect(p.plugins[0]?.action).toBe("unknown");
|
|
528
|
+
expect(planIsBehind(p)).toBe(false);
|
|
529
|
+
expect(planWorkItems(p)).toEqual([]);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("a plugin whose marketplace publishes no version does NOT fail the gate", () => {
|
|
533
|
+
// The real shape behind dry-run-1: listed, installed, no catalog version.
|
|
534
|
+
const p = plan({
|
|
535
|
+
plugins: planPluginUpdates(
|
|
536
|
+
{ "code-review@claude-plugins-official": "latest" },
|
|
537
|
+
catalog(
|
|
538
|
+
info({
|
|
539
|
+
id: "code-review@claude-plugins-official",
|
|
540
|
+
version: null,
|
|
541
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
542
|
+
}),
|
|
543
|
+
),
|
|
544
|
+
),
|
|
545
|
+
});
|
|
546
|
+
expect(p.plugins[0]?.reason).toBe("no-published-version");
|
|
547
|
+
expect(planIsBehind(p)).toBe(false);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("a plugin that is behind, absent or content-stale still fails the gate", () => {
|
|
551
|
+
const behind = plan({
|
|
552
|
+
plugins: planPluginUpdates(
|
|
553
|
+
{ "dev@magus": "latest" },
|
|
554
|
+
catalog(
|
|
555
|
+
info({
|
|
556
|
+
id: "dev@magus",
|
|
557
|
+
version: "4.7.0",
|
|
558
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
559
|
+
}),
|
|
560
|
+
),
|
|
561
|
+
),
|
|
562
|
+
});
|
|
563
|
+
expect(behind.plugins[0]?.action).toBe("update");
|
|
564
|
+
expect(planIsBehind(behind)).toBe(true);
|
|
565
|
+
|
|
566
|
+
const absent = plan({
|
|
567
|
+
plugins: planPluginUpdates(
|
|
568
|
+
{ "dev@magus": "latest" },
|
|
569
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
570
|
+
),
|
|
571
|
+
});
|
|
572
|
+
expect(absent.plugins[0]?.action).toBe("install");
|
|
573
|
+
expect(planIsBehind(absent)).toBe(true);
|
|
574
|
+
|
|
575
|
+
const stale = plan({
|
|
576
|
+
plugins: planPluginUpdates(
|
|
577
|
+
{ "dev@magus": "latest" },
|
|
578
|
+
catalog(
|
|
579
|
+
info({
|
|
580
|
+
id: "dev@magus",
|
|
581
|
+
version: "4.6.1",
|
|
582
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
583
|
+
contentStale: true,
|
|
584
|
+
}),
|
|
585
|
+
),
|
|
586
|
+
),
|
|
587
|
+
});
|
|
588
|
+
expect(stale.plugins[0]?.action).toBe("repair");
|
|
589
|
+
expect(planIsBehind(stale)).toBe(true);
|
|
432
590
|
});
|
|
433
591
|
|
|
434
592
|
test("a fully current profile with skills and bins passes the gate", () => {
|
|
@@ -445,7 +603,7 @@ describe("planIsBehind — the --check gate", () => {
|
|
|
445
603
|
),
|
|
446
604
|
bins: planBinUpdates(
|
|
447
605
|
[{ name: "tmux-mcp", via: "bun", version: "1.0.0", sources: [] }],
|
|
448
|
-
[
|
|
606
|
+
[toolFacts("tmux-mcp", { installed: "1.0.0" })],
|
|
449
607
|
),
|
|
450
608
|
skills: planSkillUpdates([skillRef], new Set(["release"])),
|
|
451
609
|
});
|
|
@@ -472,6 +630,7 @@ describe("nothing ever plans a downgrade", () => {
|
|
|
472
630
|
);
|
|
473
631
|
expect(item.action).toBe("current");
|
|
474
632
|
expect(item.scopes).toEqual([]);
|
|
633
|
+
expect(item.reason).toBe("pin-undeliverable");
|
|
475
634
|
});
|
|
476
635
|
|
|
477
636
|
test("an older pin with no catalog entry does not drag an install backwards", () => {
|
|
@@ -489,6 +648,7 @@ describe("nothing ever plans a downgrade", () => {
|
|
|
489
648
|
);
|
|
490
649
|
expect(item.action).toBe("current");
|
|
491
650
|
expect(item.scopes).toEqual([]);
|
|
651
|
+
expect(item.reason).toBe("up-to-date");
|
|
492
652
|
});
|
|
493
653
|
|
|
494
654
|
test("only the behind scope moves when one is ahead and one behind", () => {
|
|
@@ -504,6 +664,7 @@ describe("nothing ever plans a downgrade", () => {
|
|
|
504
664
|
),
|
|
505
665
|
);
|
|
506
666
|
expect(item.scopes).toEqual(["project"]);
|
|
667
|
+
expect(item.reason).toBe("behind");
|
|
507
668
|
});
|
|
508
669
|
|
|
509
670
|
test("scopesNeeding never returns a scope ahead of the target", () => {
|
|
@@ -519,8 +680,8 @@ describe("nothing ever plans a downgrade", () => {
|
|
|
519
680
|
|
|
520
681
|
describe("scopesNeeding", () => {
|
|
521
682
|
test("returns every outdated scope, not just user", () => {
|
|
522
|
-
//
|
|
523
|
-
//
|
|
683
|
+
// Fixing only 'user' leaves the project row behind, so the same drift
|
|
684
|
+
// reappears on every run.
|
|
524
685
|
const i = info({
|
|
525
686
|
id: "dev@magus",
|
|
526
687
|
userScope: { enabled: true, version: "4.6.1" },
|
|
@@ -546,29 +707,155 @@ describe("planBinUpdates", () => {
|
|
|
546
707
|
sources: ["profile:test.cliTools"],
|
|
547
708
|
...over,
|
|
548
709
|
});
|
|
549
|
-
const check = (name: string, present: boolean): BinCheckResult => ({
|
|
550
|
-
name,
|
|
551
|
-
present,
|
|
552
|
-
installCommand: "",
|
|
553
|
-
sources: [],
|
|
554
|
-
});
|
|
555
710
|
|
|
556
711
|
test("a missing binary is an install", () => {
|
|
557
|
-
const [item] = planBinUpdates(
|
|
558
|
-
|
|
712
|
+
const [item] = planBinUpdates(
|
|
713
|
+
[bin({})],
|
|
714
|
+
[toolFacts("tmux-mcp", { present: false, installed: null })],
|
|
715
|
+
);
|
|
716
|
+
expect(item).toMatchObject({ action: "install", reason: "absent" });
|
|
559
717
|
});
|
|
560
718
|
|
|
561
|
-
test("present and
|
|
562
|
-
|
|
563
|
-
|
|
719
|
+
test("present, unpinned and CURRENT is current — the upgrade loop is gone", () => {
|
|
720
|
+
// This test used to read "present and unpinned is an upgrade — that is
|
|
721
|
+
// what 'update' means", and that was the defect: tmux, browser-use and
|
|
722
|
+
// mcp were current on the reporter's machine (brew / uv said so) and were
|
|
723
|
+
// "upgraded" on every run, forever.
|
|
724
|
+
const [item] = planBinUpdates(
|
|
725
|
+
[bin({ name: "tmux", via: "brew" })],
|
|
726
|
+
[toolFacts("tmux", { installed: "3.7c", behind: false })],
|
|
727
|
+
);
|
|
728
|
+
expect(item).toMatchObject({
|
|
729
|
+
action: "current",
|
|
730
|
+
reason: "up-to-date",
|
|
731
|
+
installed: "3.7c",
|
|
732
|
+
target: null,
|
|
733
|
+
});
|
|
734
|
+
expect(item.version).toBeUndefined();
|
|
564
735
|
});
|
|
565
736
|
|
|
566
|
-
test("present and
|
|
737
|
+
test("present and unpinned is an upgrade ONLY when its installer says behind", () => {
|
|
738
|
+
const [item] = planBinUpdates(
|
|
739
|
+
[bin({ name: "claude-swap", via: "pip" })],
|
|
740
|
+
[toolFacts("claude-swap", { behind: true, latest: "0.26.0" })],
|
|
741
|
+
);
|
|
742
|
+
expect(item).toMatchObject({
|
|
743
|
+
action: "upgrade",
|
|
744
|
+
reason: "behind",
|
|
745
|
+
target: "0.26.0",
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("present and at its pin is current, keeping the pin", () => {
|
|
567
750
|
const [item] = planBinUpdates(
|
|
568
751
|
[bin({ version: "1.2.3" })],
|
|
569
|
-
[
|
|
752
|
+
[toolFacts("tmux-mcp", { installed: "1.2.3" })],
|
|
753
|
+
);
|
|
754
|
+
expect(item).toMatchObject({
|
|
755
|
+
action: "current",
|
|
756
|
+
reason: "at-pin",
|
|
757
|
+
version: "1.2.3",
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("a bin with no facts was never probed: unknown, with a note", () => {
|
|
762
|
+
const [item] = planBinUpdates([bin({})], []);
|
|
763
|
+
expect(item).toMatchObject({
|
|
764
|
+
action: "unknown",
|
|
765
|
+
reason: "not-probed",
|
|
766
|
+
note: "not probed",
|
|
767
|
+
});
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("the note is classifyTool's note, verbatim (the TUI shows the same string)", () => {
|
|
771
|
+
const f = toolFacts("crush", {
|
|
772
|
+
managed: false,
|
|
773
|
+
resolvedPath: "/n/crush.js",
|
|
774
|
+
owner: "npm (root /l)",
|
|
775
|
+
});
|
|
776
|
+
const [item] = planBinUpdates([bin({ name: "crush", via: "npm" })], [f]);
|
|
777
|
+
expect(item.note).toBe(
|
|
778
|
+
classifyTool(bin({ name: "crush", via: "npm" }), f).note,
|
|
570
779
|
);
|
|
571
|
-
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
describe("binNeedsWork — one predicate for apply, the meter and 'has work'", () => {
|
|
784
|
+
const actions: BinUpdateAction[] = [
|
|
785
|
+
"install",
|
|
786
|
+
"upgrade",
|
|
787
|
+
"current",
|
|
788
|
+
"unknown",
|
|
789
|
+
];
|
|
790
|
+
const itemFor = (action: BinUpdateAction): BinUpdateItem => ({
|
|
791
|
+
name: `t-${action}`,
|
|
792
|
+
action,
|
|
793
|
+
reason: "not-probed",
|
|
794
|
+
installed: null,
|
|
795
|
+
latest: null,
|
|
796
|
+
target: null,
|
|
797
|
+
sources: [],
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("install and upgrade are work; current and unknown are not", () => {
|
|
801
|
+
expect(actions.map((a) => binNeedsWork(itemFor(a)))).toEqual([
|
|
802
|
+
true,
|
|
803
|
+
true,
|
|
804
|
+
false,
|
|
805
|
+
false,
|
|
806
|
+
]);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("applyBins, planWorkItems and planHasWork agree for every action", async () => {
|
|
810
|
+
for (const action of actions) {
|
|
811
|
+
const item = itemFor(action);
|
|
812
|
+
const p = plan({
|
|
813
|
+
bins: [item],
|
|
814
|
+
binSpecs: new Map([
|
|
815
|
+
[item.name, { name: item.name, via: "bun", sources: [] }],
|
|
816
|
+
]),
|
|
817
|
+
});
|
|
818
|
+
const ran: string[] = [];
|
|
819
|
+
await applyBins(
|
|
820
|
+
p,
|
|
821
|
+
undefined,
|
|
822
|
+
async (c) => {
|
|
823
|
+
ran.push(c.cmd);
|
|
824
|
+
return true;
|
|
825
|
+
},
|
|
826
|
+
// Read-back seam: never the real PATH in a unit test.
|
|
827
|
+
async (s) => toolFacts(s.name, { installed: "2.0.0" }),
|
|
828
|
+
);
|
|
829
|
+
const expected = binNeedsWork(item);
|
|
830
|
+
expect(ran.length > 0).toBe(expected);
|
|
831
|
+
expect(planWorkItems(p).includes(item.name)).toBe(expected);
|
|
832
|
+
expect(planHasWork(p)).toBe(expected);
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("an upgrade TO the pin runs the pin command, not the upgrade command", () => {
|
|
837
|
+
const spec: ResolvedBin = {
|
|
838
|
+
name: "mnemex",
|
|
839
|
+
via: "bun",
|
|
840
|
+
version: "1.0.0",
|
|
841
|
+
sources: [],
|
|
842
|
+
};
|
|
843
|
+
expect(
|
|
844
|
+
binCommandFor(
|
|
845
|
+
{ ...itemFor("upgrade"), name: "mnemex", target: "1.0.0" },
|
|
846
|
+
spec,
|
|
847
|
+
),
|
|
848
|
+
).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@1.0.0"] });
|
|
849
|
+
expect(
|
|
850
|
+
binCommandFor(
|
|
851
|
+
{ ...itemFor("upgrade"), target: "2.0.0" },
|
|
852
|
+
{
|
|
853
|
+
...spec,
|
|
854
|
+
version: undefined,
|
|
855
|
+
},
|
|
856
|
+
),
|
|
857
|
+
).toEqual({ cmd: "bun", args: ["install", "-g", "mnemex@latest"] });
|
|
858
|
+
expect(binCommandFor(itemFor("unknown"), spec)).toBeNull();
|
|
572
859
|
});
|
|
573
860
|
});
|
|
574
861
|
|
|
@@ -623,9 +910,464 @@ describe("planHasWork / summarizePlan", () => {
|
|
|
623
910
|
),
|
|
624
911
|
bins: planBinUpdates(
|
|
625
912
|
[{ name: "tmux-mcp", via: "bun", sources: [] }],
|
|
626
|
-
[
|
|
913
|
+
[toolFacts("tmux-mcp", { installed: "1.0.0", latest: "1.1.0" })],
|
|
627
914
|
),
|
|
628
915
|
});
|
|
629
916
|
expect(summarizePlan(p)).toMatchObject({ installed: 1, updated: 2 });
|
|
630
917
|
});
|
|
918
|
+
|
|
919
|
+
test("unknown splits into plugins and tools, and the total is their sum", () => {
|
|
920
|
+
const p = plan({
|
|
921
|
+
plugins: planPluginUpdates(
|
|
922
|
+
{ "a@magus": "latest" },
|
|
923
|
+
catalog(
|
|
924
|
+
info({
|
|
925
|
+
id: "a@magus",
|
|
926
|
+
version: null,
|
|
927
|
+
projectScope: { enabled: true, version: "abc123" },
|
|
928
|
+
}),
|
|
929
|
+
),
|
|
930
|
+
),
|
|
931
|
+
bins: planBinUpdates(
|
|
932
|
+
[
|
|
933
|
+
{ name: "crush", via: "npm", sources: [] },
|
|
934
|
+
{ name: "claude", via: "npm", sources: [] },
|
|
935
|
+
{ name: "tmux", via: "brew", sources: [] },
|
|
936
|
+
],
|
|
937
|
+
[
|
|
938
|
+
toolFacts("crush", { managed: false }),
|
|
939
|
+
toolFacts("claude", { managed: false }),
|
|
940
|
+
toolFacts("tmux", { behind: false }),
|
|
941
|
+
],
|
|
942
|
+
),
|
|
943
|
+
});
|
|
944
|
+
expect(summarizePlan(p)).toMatchObject({
|
|
945
|
+
unknownPlugins: 1,
|
|
946
|
+
unknownTools: 2,
|
|
947
|
+
unknown: 3,
|
|
948
|
+
current: 1,
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
// ── The classifier: rules the TUI and CLI now share ─────────────────────────
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* An orphan as `getAvailablePlugins` builds one: installed, published by no
|
|
957
|
+
* marketplace we read, and — the trap — `version` filled in from its own
|
|
958
|
+
* installed version, so a naive compare finds them equal.
|
|
959
|
+
*/
|
|
960
|
+
function orphan(
|
|
961
|
+
id: string,
|
|
962
|
+
installedVersion: string,
|
|
963
|
+
over: Partial<PluginInfo> = {},
|
|
964
|
+
): PluginInfo {
|
|
965
|
+
return info({
|
|
966
|
+
id,
|
|
967
|
+
version: installedVersion,
|
|
968
|
+
installedVersion,
|
|
969
|
+
isOrphaned: true,
|
|
970
|
+
projectScope: { enabled: true, version: installedVersion },
|
|
971
|
+
...over,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
describe("classifyPlugin — an orphan is never 'current'", () => {
|
|
976
|
+
test("REGRESSION: an orphan used to plan current against its own version", () => {
|
|
977
|
+
// Before: `catalog.has(id)` was true for an orphan, its `version` equalled
|
|
978
|
+
// its installed version, and the plan said `current` while the TUI drew
|
|
979
|
+
// the same row red as "deprecated".
|
|
980
|
+
const [item] = planPluginUpdates(
|
|
981
|
+
{ "seo@magus": "latest" },
|
|
982
|
+
catalog(
|
|
983
|
+
orphan("seo@magus", "1.8.0"),
|
|
984
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
985
|
+
),
|
|
986
|
+
);
|
|
987
|
+
expect(item.action).toBe("unknown");
|
|
988
|
+
expect(item.action).not.toBe("current");
|
|
989
|
+
expect(item.available).toBeNull();
|
|
990
|
+
expect(item.target).toBeNull();
|
|
991
|
+
expect(item.scopes).toEqual([]);
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test("an orphan another marketplace publishes is 'moved', naming it", () => {
|
|
995
|
+
const [item] = planPluginUpdates(
|
|
996
|
+
{ "seo@magus": "latest" },
|
|
997
|
+
catalog(
|
|
998
|
+
orphan("seo@magus", "1.8.0", { movedTo: "magus-marketing" }),
|
|
999
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1000
|
+
),
|
|
1001
|
+
);
|
|
1002
|
+
expect(item).toMatchObject({
|
|
1003
|
+
action: "unknown",
|
|
1004
|
+
reason: "moved",
|
|
1005
|
+
note: "moved to magus-marketing",
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
test("an orphan whose marketplace we DID read is 'not offered'", () => {
|
|
1010
|
+
const [item] = planPluginUpdates(
|
|
1011
|
+
{ "seo@magus": "latest" },
|
|
1012
|
+
catalog(
|
|
1013
|
+
orphan("seo@magus", "1.8.0"),
|
|
1014
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1015
|
+
),
|
|
1016
|
+
);
|
|
1017
|
+
expect(item).toMatchObject({
|
|
1018
|
+
action: "unknown",
|
|
1019
|
+
reason: "not-offered",
|
|
1020
|
+
note: "not offered by any configured marketplace",
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
test("an orphan whose marketplace contributed nothing is 'marketplace-unread'", () => {
|
|
1025
|
+
// No listed id from @gone — we never read that catalog, so its absence is
|
|
1026
|
+
// not evidence it was dropped. Claiming "not offered" would be a guess.
|
|
1027
|
+
const [item] = planPluginUpdates(
|
|
1028
|
+
{ "thing@gone": "latest" },
|
|
1029
|
+
catalog(
|
|
1030
|
+
orphan("thing@gone", "2.0.0"),
|
|
1031
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1032
|
+
),
|
|
1033
|
+
);
|
|
1034
|
+
expect(item).toMatchObject({
|
|
1035
|
+
action: "unknown",
|
|
1036
|
+
reason: "marketplace-unread",
|
|
1037
|
+
note: "its marketplace gone was not read, so whether it still offers this plugin is unknown",
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("another ORPHAN from the same marketplace is not proof we read it", () => {
|
|
1042
|
+
// Two orphans under @gone used to make `marketplaceIsKnown` true for each
|
|
1043
|
+
// other. Neither is listed, so neither proves anything.
|
|
1044
|
+
const [item] = planPluginUpdates(
|
|
1045
|
+
{ "thing@gone": "latest" },
|
|
1046
|
+
catalog(orphan("thing@gone", "2.0.0"), orphan("other@gone", "1.0.0")),
|
|
1047
|
+
);
|
|
1048
|
+
expect(item.reason).toBe("marketplace-unread");
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
test("an orphan is unknown under an exact pin too", () => {
|
|
1052
|
+
const [item] = planPluginUpdates(
|
|
1053
|
+
{ "seo@magus": "1.8.0" },
|
|
1054
|
+
catalog(
|
|
1055
|
+
orphan("seo@magus", "1.8.0"),
|
|
1056
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1057
|
+
),
|
|
1058
|
+
);
|
|
1059
|
+
expect(item.action).toBe("unknown");
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
describe("classifyPlugin — an exact pin that cannot be ordered is unknown (F3)", () => {
|
|
1064
|
+
test("REGRESSION: a pinned plugin at an unorderable version used to be current", () => {
|
|
1065
|
+
// The "latest" path already refused this; the exact-pin path did not.
|
|
1066
|
+
const [item] = planPluginUpdates(
|
|
1067
|
+
{ "playground@official": "7.3.0" },
|
|
1068
|
+
catalog(
|
|
1069
|
+
info({
|
|
1070
|
+
id: "playground@official",
|
|
1071
|
+
version: "7.3.0",
|
|
1072
|
+
projectScope: { enabled: true, version: "unknown" },
|
|
1073
|
+
}),
|
|
1074
|
+
),
|
|
1075
|
+
);
|
|
1076
|
+
expect(item).toMatchObject({
|
|
1077
|
+
action: "unknown",
|
|
1078
|
+
reason: "unorderable",
|
|
1079
|
+
target: null,
|
|
1080
|
+
scopes: [],
|
|
1081
|
+
note: "installed version cannot be compared with the published 7.3.0",
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("with no published version the note names the pin instead", () => {
|
|
1086
|
+
const [item] = planPluginUpdates(
|
|
1087
|
+
{ "playground@official": "7.3.0" },
|
|
1088
|
+
catalog(
|
|
1089
|
+
info({
|
|
1090
|
+
id: "playground@official",
|
|
1091
|
+
version: null,
|
|
1092
|
+
projectScope: { enabled: true, version: "3ea32df27be7" },
|
|
1093
|
+
}),
|
|
1094
|
+
),
|
|
1095
|
+
);
|
|
1096
|
+
expect(item).toMatchObject({
|
|
1097
|
+
action: "unknown",
|
|
1098
|
+
reason: "unorderable",
|
|
1099
|
+
note: "installed version cannot be compared with the pin 7.3.0",
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("a scope that IS behind still wins: it is planned, not hidden", () => {
|
|
1104
|
+
const [item] = planPluginUpdates(
|
|
1105
|
+
{ "dev@magus": "4.7.0" },
|
|
1106
|
+
catalog(
|
|
1107
|
+
info({
|
|
1108
|
+
id: "dev@magus",
|
|
1109
|
+
version: "4.7.0",
|
|
1110
|
+
userScope: { enabled: true, version: "unknown" },
|
|
1111
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1112
|
+
}),
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
expect(item).toMatchObject({ action: "update", scopes: ["project"] });
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
describe("classifyPlugin — install reasons", () => {
|
|
1120
|
+
test("enabled in settings with no files anywhere says so", () => {
|
|
1121
|
+
const [item] = planPluginUpdates(
|
|
1122
|
+
{ "dev@magus": "latest" },
|
|
1123
|
+
catalog(
|
|
1124
|
+
info({
|
|
1125
|
+
id: "dev@magus",
|
|
1126
|
+
version: "4.7.0",
|
|
1127
|
+
projectScope: { enabled: true },
|
|
1128
|
+
}),
|
|
1129
|
+
),
|
|
1130
|
+
);
|
|
1131
|
+
expect(item).toMatchObject({
|
|
1132
|
+
action: "install",
|
|
1133
|
+
reason: "enabled-not-installed",
|
|
1134
|
+
note: "enabled in settings, but no files on disk",
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test("a plain absence carries no note", () => {
|
|
1139
|
+
const [item] = planPluginUpdates(
|
|
1140
|
+
{ "dev@magus": "latest" },
|
|
1141
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
1142
|
+
);
|
|
1143
|
+
expect(item.reason).toBe("absent");
|
|
1144
|
+
expect(item.note).toBeUndefined();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe("classifyPlugin — beforeVersions records every installed scope", () => {
|
|
1149
|
+
test("each installed scope's version, and nothing for an absent one", () => {
|
|
1150
|
+
const [item] = planPluginUpdates(
|
|
1151
|
+
{ "dev@magus": "latest" },
|
|
1152
|
+
catalog(
|
|
1153
|
+
info({
|
|
1154
|
+
id: "dev@magus",
|
|
1155
|
+
version: "4.7.0",
|
|
1156
|
+
userScope: { enabled: true, version: "4.6.0" },
|
|
1157
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1158
|
+
localScope: { enabled: true },
|
|
1159
|
+
}),
|
|
1160
|
+
),
|
|
1161
|
+
);
|
|
1162
|
+
expect(item.beforeVersions).toEqual({ user: "4.6.0", project: "4.6.1" });
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test("an uninstalled plugin has none", () => {
|
|
1166
|
+
const [item] = planPluginUpdates(
|
|
1167
|
+
{ "dev@magus": "latest" },
|
|
1168
|
+
catalog(info({ id: "dev@magus", version: "4.7.0" })),
|
|
1169
|
+
);
|
|
1170
|
+
expect(item.beforeVersions).toEqual({});
|
|
1171
|
+
});
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
describe("indexCatalog", () => {
|
|
1175
|
+
const idx = indexCatalog([
|
|
1176
|
+
info({ id: "dev@magus", version: "4.7.0" }),
|
|
1177
|
+
orphan("seo@magus", "1.8.0"),
|
|
1178
|
+
orphan("thing@gone", "2.0.0"),
|
|
1179
|
+
]);
|
|
1180
|
+
|
|
1181
|
+
test("isListed excludes orphans", () => {
|
|
1182
|
+
expect(idx.isListed("dev@magus")).toBe(true);
|
|
1183
|
+
expect(idx.isListed("seo@magus")).toBe(false);
|
|
1184
|
+
expect(idx.isListed("absent@magus")).toBe(false);
|
|
1185
|
+
// Still reachable by id — the classifier needs its scopes.
|
|
1186
|
+
expect(idx.get("seo@magus")?.isOrphaned).toBe(true);
|
|
1187
|
+
});
|
|
1188
|
+
|
|
1189
|
+
test("marketplaceIsKnown counts OTHER listed ids only", () => {
|
|
1190
|
+
expect(idx.marketplaceIsKnown("seo@magus")).toBe(true); // dev@magus is listed
|
|
1191
|
+
expect(idx.marketplaceIsKnown("absent@magus")).toBe(true);
|
|
1192
|
+
// dev@magus is the only listed @magus id — itself does not count.
|
|
1193
|
+
expect(idx.marketplaceIsKnown("dev@magus")).toBe(false);
|
|
1194
|
+
// Only an orphan there.
|
|
1195
|
+
expect(idx.marketplaceIsKnown("other@gone")).toBe(false);
|
|
1196
|
+
expect(idx.marketplaceIsKnown("no-marketplace")).toBe(false);
|
|
1197
|
+
expect(idx.marketplaceIsKnown("trailing@")).toBe(false);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
test("a Map and an array index identically", () => {
|
|
1201
|
+
const list = [
|
|
1202
|
+
info({ id: "a@m", version: "1.0.0" }),
|
|
1203
|
+
info({ id: "b@m", version: "1.0.0" }),
|
|
1204
|
+
];
|
|
1205
|
+
const fromMap = indexCatalog(catalog(...list));
|
|
1206
|
+
const fromArray = indexCatalog(list);
|
|
1207
|
+
for (const id of ["a@m", "b@m", "c@m"]) {
|
|
1208
|
+
expect(fromMap.isListed(id)).toBe(fromArray.isListed(id));
|
|
1209
|
+
expect(fromMap.marketplaceIsKnown(id)).toBe(
|
|
1210
|
+
fromArray.marketplaceIsKnown(id),
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
describe("classifyPlugin and planPluginUpdates are the same function", () => {
|
|
1217
|
+
test("planPluginUpdates is classifyPlugin over each pin, sorted", () => {
|
|
1218
|
+
const cat = catalog(
|
|
1219
|
+
info({
|
|
1220
|
+
id: "b@magus",
|
|
1221
|
+
version: "2.0.0",
|
|
1222
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1223
|
+
}),
|
|
1224
|
+
info({ id: "a@magus", version: "1.0.0" }),
|
|
1225
|
+
);
|
|
1226
|
+
const pins = { "b@magus": "latest", "a@magus": "1.0.0" };
|
|
1227
|
+
const idx = indexCatalog(cat);
|
|
1228
|
+
expect(planPluginUpdates(pins, cat)).toEqual([
|
|
1229
|
+
classifyPlugin("a@magus", "1.0.0", idx),
|
|
1230
|
+
classifyPlugin("b@magus", "latest", idx),
|
|
1231
|
+
]);
|
|
1232
|
+
});
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
describe("pinForRow — which pin a TUI row is judged against", () => {
|
|
1236
|
+
test("the profile's pin wins, exact or latest", () => {
|
|
1237
|
+
const i = info({
|
|
1238
|
+
id: "dev@magus",
|
|
1239
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1240
|
+
});
|
|
1241
|
+
expect(pinForRow(i, { "dev@magus": "4.6.1" })).toBe("4.6.1");
|
|
1242
|
+
expect(pinForRow(i, { "dev@magus": "latest" })).toBe("latest");
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
test("a declared plugin gets its pin even when it is installed nowhere", () => {
|
|
1246
|
+
expect(
|
|
1247
|
+
pinForRow(info({ id: "dev@magus" }), { "dev@magus": "latest" }),
|
|
1248
|
+
).toBe("latest");
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test("undeclared but installed in any scope is judged against latest", () => {
|
|
1252
|
+
for (const scope of ["userScope", "projectScope", "localScope"] as const) {
|
|
1253
|
+
const i = info({
|
|
1254
|
+
id: "dev@magus",
|
|
1255
|
+
[scope]: { enabled: false, version: "1.0.0" },
|
|
1256
|
+
});
|
|
1257
|
+
expect(pinForRow(i, {})).toBe("latest");
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
test("undeclared, enabled but not installed is judged against latest", () => {
|
|
1262
|
+
expect(
|
|
1263
|
+
pinForRow(info({ id: "dev@magus", localScope: { enabled: true } }), {}),
|
|
1264
|
+
).toBe("latest");
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("undeclared and neither installed nor enabled gets NO status", () => {
|
|
1268
|
+
// A catalog entry the user is only browsing. `install` would claim the
|
|
1269
|
+
// profile wants it, which nothing says.
|
|
1270
|
+
expect(
|
|
1271
|
+
pinForRow(info({ id: "dev@magus", version: "1.0.0" }), {}),
|
|
1272
|
+
).toBeNull();
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
test("an inherited key from Object.prototype is not a pin", () => {
|
|
1276
|
+
expect(
|
|
1277
|
+
pinForRow(info({ id: "toString", version: "1.0.0" }), {}),
|
|
1278
|
+
).toBeNull();
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
describe("classifyPluginRows", () => {
|
|
1283
|
+
test("one status per row with a pin; browse-only rows are absent", () => {
|
|
1284
|
+
const rows = [
|
|
1285
|
+
info({ id: "declared@magus", version: "1.0.0" }),
|
|
1286
|
+
info({
|
|
1287
|
+
id: "installed@magus",
|
|
1288
|
+
version: "2.0.0",
|
|
1289
|
+
projectScope: { enabled: true, version: "1.0.0" },
|
|
1290
|
+
}),
|
|
1291
|
+
info({ id: "browse@magus", version: "3.0.0" }),
|
|
1292
|
+
];
|
|
1293
|
+
const statuses = classifyPluginRows(rows, { "declared@magus": "latest" });
|
|
1294
|
+
expect([...statuses.keys()].sort()).toEqual([
|
|
1295
|
+
"declared@magus",
|
|
1296
|
+
"installed@magus",
|
|
1297
|
+
]);
|
|
1298
|
+
expect(statuses.get("declared@magus")).toMatchObject({
|
|
1299
|
+
action: "install",
|
|
1300
|
+
reason: "absent",
|
|
1301
|
+
});
|
|
1302
|
+
expect(statuses.get("installed@magus")).toMatchObject({
|
|
1303
|
+
action: "update",
|
|
1304
|
+
reason: "behind",
|
|
1305
|
+
});
|
|
1306
|
+
expect(statuses.has("browse@magus")).toBe(false);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
test("a declared row gets exactly what the CLI plans for it", () => {
|
|
1310
|
+
const rows = [
|
|
1311
|
+
info({
|
|
1312
|
+
id: "dev@magus",
|
|
1313
|
+
version: "4.7.0",
|
|
1314
|
+
projectScope: { enabled: true, version: "4.6.1" },
|
|
1315
|
+
}),
|
|
1316
|
+
info({ id: "terminal@magus", version: "5.0.1" }),
|
|
1317
|
+
];
|
|
1318
|
+
const pins = { "dev@magus": "4.6.1", "terminal@magus": "latest" };
|
|
1319
|
+
const cli = planPluginUpdates(pins, catalog(...rows));
|
|
1320
|
+
const tui = classifyPluginRows(rows, pins);
|
|
1321
|
+
for (const item of cli) expect(tui.get(item.pluginId)).toEqual(item);
|
|
1322
|
+
});
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
describe("countPluginStates", () => {
|
|
1326
|
+
test("counts every action, including zeros", () => {
|
|
1327
|
+
const items = [
|
|
1328
|
+
{ action: "install" },
|
|
1329
|
+
{ action: "update" },
|
|
1330
|
+
{ action: "update" },
|
|
1331
|
+
{ action: "repair" },
|
|
1332
|
+
{ action: "unknown" },
|
|
1333
|
+
] as PluginUpdateItem[];
|
|
1334
|
+
expect(countPluginStates(items)).toEqual({
|
|
1335
|
+
install: 1,
|
|
1336
|
+
update: 2,
|
|
1337
|
+
repair: 1,
|
|
1338
|
+
current: 0,
|
|
1339
|
+
unknown: 1,
|
|
1340
|
+
});
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
test("accepts a Map's values directly", () => {
|
|
1344
|
+
const m = new Map([["a", { action: "current" } as PluginUpdateItem]]);
|
|
1345
|
+
expect(countPluginStates(m.values()).current).toBe(1);
|
|
1346
|
+
});
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
describe("scopeIsBehind — the scope picker's one question", () => {
|
|
1350
|
+
const status = (over: Partial<PluginUpdateItem>) =>
|
|
1351
|
+
({ action: "update", scopes: ["project"], ...over }) as PluginUpdateItem;
|
|
1352
|
+
|
|
1353
|
+
test("an update touching the scope says yes", () => {
|
|
1354
|
+
expect(scopeIsBehind(status({}), "project")).toBe(true);
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
test("an update touching OTHER scopes says no for this one", () => {
|
|
1358
|
+
expect(scopeIsBehind(status({}), "user")).toBe(false);
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
test("repair, unknown, current and install all say no", () => {
|
|
1362
|
+
// `claude plugin install` on the same version is a no-op, so a repair
|
|
1363
|
+
// scope must not be offered "update".
|
|
1364
|
+
for (const action of ["repair", "unknown", "current", "install"] as const) {
|
|
1365
|
+
expect(scopeIsBehind(status({ action }), "project")).toBe(false);
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("no status says no", () => {
|
|
1370
|
+
expect(scopeIsBehind(null, "project")).toBe(false);
|
|
1371
|
+
expect(scopeIsBehind(undefined, "project")).toBe(false);
|
|
1372
|
+
});
|
|
631
1373
|
});
|