magus-cli 7.7.0 → 7.9.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.
Files changed (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -0,0 +1,631 @@
1
+ /**
2
+ * magus 7.8.0 QA spec, section D — deprecated plugins.
3
+ *
4
+ * Written blind: from the spec and the exported signatures of
5
+ * services/update-plan.ts, services/update-engine.ts,
6
+ * ui/renderers/pluginStatusText.ts and cli/update-view.ts. No function body
7
+ * was read.
8
+ *
9
+ * Coverage map (requirement → describe block):
10
+ * D1 unlisted by a READ marketplace → remove/deprecated → "D1"
11
+ * D2 remove scopes = installed scopes ([] if declared only) → "D2"
12
+ * D3 unread marketplace → unknown/marketplace-unread → "D3"
13
+ * D4 pluginNeedsWork is true for remove → "D4"
14
+ * D5 apply: uninstall each scope, drop from profile, verify → "D5"
15
+ * D6 Plugins row text → "D6"
16
+ * D7 CLI view Deprecated group → "D7"
17
+ */
18
+
19
+ import { afterEach, describe, expect, test } from "bun:test";
20
+ import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
21
+ import { planReport } from "../cli/update-view.js";
22
+ import type { PluginScope } from "../services/claude-cli.js";
23
+ import type { PluginInfo } from "../services/plugin-manager.js";
24
+ import {
25
+ type ApplyReporter,
26
+ type PluginApplyDeps,
27
+ type PluginRemoveDeps,
28
+ applyPlugins,
29
+ planWorkItems,
30
+ pluginName,
31
+ pluginNeedsWork,
32
+ } from "../services/update-engine.js";
33
+ import {
34
+ type PluginUpdateItem,
35
+ type UpdatePlan,
36
+ classifyPlugin,
37
+ indexCatalog,
38
+ planHasWork,
39
+ planIsBehind,
40
+ planPluginUpdates,
41
+ } from "../services/update-plan.js";
42
+ import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
43
+
44
+ const PROJECT = "/tmp/blind-780-project";
45
+ const SCOPES: PluginScope[] = ["user", "project", "local"];
46
+
47
+ afterEach(() => setColorEnabled(null));
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Builders
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
54
+ return {
55
+ name: over.id.split("@")[0] as string,
56
+ version: null,
57
+ description: "",
58
+ marketplace: over.id.split("@")[1] ?? "magus",
59
+ marketplaceDisplay: "",
60
+ ...over,
61
+ };
62
+ }
63
+
64
+ /** A plugin the marketplace publishes — which is what makes "magus" a READ marketplace. */
65
+ const LISTED = info({
66
+ id: "alive@magus",
67
+ version: "1.0.0",
68
+ projectScope: { installed: true, version: "1.0.0" },
69
+ });
70
+
71
+ /** Installed here, but its marketplace (which we read) does not list it. */
72
+ function orphan(id: string, scopes: Partial<Record<PluginScope, string>>) {
73
+ return info({
74
+ id,
75
+ version: scopes.project ?? scopes.user ?? scopes.local ?? null,
76
+ isOrphaned: true,
77
+ userScope: scopes.user
78
+ ? { installed: true, version: scopes.user }
79
+ : undefined,
80
+ projectScope: scopes.project
81
+ ? { installed: true, version: scopes.project }
82
+ : undefined,
83
+ localScope: scopes.local
84
+ ? { installed: true, version: scopes.local }
85
+ : undefined,
86
+ });
87
+ }
88
+
89
+ function itemFor(
90
+ items: PluginUpdateItem[],
91
+ pluginId: string,
92
+ ): PluginUpdateItem {
93
+ const found = items.find((i) => i.pluginId === pluginId);
94
+ if (!found) throw new Error(`${pluginId} not planned`);
95
+ return found;
96
+ }
97
+
98
+ function removeItem(over: Partial<PluginUpdateItem> = {}): PluginUpdateItem {
99
+ return {
100
+ pluginId: "old@magus",
101
+ pinned: "latest",
102
+ installed: "0.9.0",
103
+ loaded: "0.9.0",
104
+ available: null,
105
+ target: null,
106
+ action: "remove",
107
+ reason: "deprecated",
108
+ scopes: ["project"],
109
+ beforeVersions: { project: "0.9.0" },
110
+ ...over,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * A machine: which scopes each plugin is installed at, and which plugins the
116
+ * profile lists. The remove deps act on it; the other deps must not be called.
117
+ */
118
+ function machine(
119
+ installed: Record<string, PluginScope[]>,
120
+ profile: string[],
121
+ behaviour: {
122
+ uninstallThrows?: boolean;
123
+ uninstallIsNoop?: boolean;
124
+ dropReturns?: boolean;
125
+ } = {},
126
+ ) {
127
+ const state = {
128
+ installed: new Map(
129
+ Object.entries(installed).map(([k, v]) => [k, new Set(v)]),
130
+ ),
131
+ profile: new Set(profile),
132
+ uninstalls: [] as Array<{ id: string; scope: PluginScope }>,
133
+ drops: [] as Array<{ id: string; profile: string | null | undefined }>,
134
+ otherCalls: [] as string[],
135
+ };
136
+ const remove: PluginRemoveDeps = {
137
+ uninstall: async (id, scope) => {
138
+ state.uninstalls.push({ id, scope });
139
+ if (behaviour.uninstallThrows) throw new Error("uninstall exploded");
140
+ if (!behaviour.uninstallIsNoop) state.installed.get(id)?.delete(scope);
141
+ },
142
+ isInstalled: async (id, scope) =>
143
+ state.installed.get(id)?.has(scope) ?? false,
144
+ dropFromProfile: async (id, _project, profile) => {
145
+ state.drops.push({ id, profile });
146
+ if (behaviour.dropReturns === false) return false;
147
+ state.profile.delete(id);
148
+ return !state.profile.has(id);
149
+ },
150
+ };
151
+ const unexpected = (name: string) => async () => {
152
+ state.otherCalls.push(name);
153
+ throw new Error(`${name} must not be called for a remove`);
154
+ };
155
+ const deps: PluginApplyDeps = {
156
+ install: unexpected("install"),
157
+ update: unexpected("update"),
158
+ repair: unexpected("repair"),
159
+ readInstalled: async () => null,
160
+ saveInstalled: unexpected("saveInstalled"),
161
+ repairStaleLoad: unexpected("repairStaleLoad"),
162
+ remove,
163
+ };
164
+ return { deps, state };
165
+ }
166
+
167
+ function recordingReporter() {
168
+ const finished: Array<{ ok: boolean; name: string; detail: string }> = [];
169
+ const reporter: ApplyReporter = {
170
+ begin: () => {},
171
+ finish: (ok, name, detail) => finished.push({ ok, name, detail }),
172
+ detach: (work) => work(),
173
+ };
174
+ return { reporter, finished };
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // D1 / D2 / D3 — the planner
179
+ // ---------------------------------------------------------------------------
180
+
181
+ describe("D1 a plugin its READ marketplace does not list is deprecated", () => {
182
+ test("D1 installed at project scope, unlisted → remove / deprecated", () => {
183
+ const items = planPluginUpdates({}, [
184
+ LISTED,
185
+ orphan("old@magus", { project: "0.9.0" }),
186
+ ]);
187
+
188
+ const old = itemFor(items, "old@magus");
189
+ expect({ action: old.action, reason: old.reason }).toEqual({
190
+ action: "remove",
191
+ reason: "deprecated",
192
+ });
193
+ });
194
+
195
+ test("D1 declared by the profile only, installed nowhere, unlisted → remove / deprecated", () => {
196
+ const items = planPluginUpdates({ "gone@magus": "latest" }, [LISTED]);
197
+
198
+ const gone = itemFor(items, "gone@magus");
199
+ expect({ action: gone.action, reason: gone.reason }).toEqual({
200
+ action: "remove",
201
+ reason: "deprecated",
202
+ });
203
+ });
204
+
205
+ test("D1 declared by the profile under an exact pin and unlisted → still deprecated", () => {
206
+ const items = planPluginUpdates({ "gone@magus": "1.2.3" }, [LISTED]);
207
+
208
+ expect(itemFor(items, "gone@magus").reason).toBe("deprecated");
209
+ });
210
+
211
+ test("D1 declared AND installed, unlisted → remove / deprecated", () => {
212
+ const items = planPluginUpdates({ "old@magus": "latest" }, [
213
+ LISTED,
214
+ orphan("old@magus", { user: "0.9.0" }),
215
+ ]);
216
+
217
+ expect(itemFor(items, "old@magus").action).toBe("remove");
218
+ });
219
+
220
+ test("D1 the same through classifyPlugin over indexCatalog", () => {
221
+ const index = indexCatalog([
222
+ LISTED,
223
+ orphan("old@magus", { local: "0.9.0" }),
224
+ ]);
225
+
226
+ const old = classifyPlugin("old@magus", "latest", index);
227
+
228
+ expect({ action: old.action, reason: old.reason }).toEqual({
229
+ action: "remove",
230
+ reason: "deprecated",
231
+ });
232
+ });
233
+
234
+ test("D1 the listed plugin beside it is not deprecated", () => {
235
+ const items = planPluginUpdates({}, [
236
+ LISTED,
237
+ orphan("old@magus", { project: "0.9.0" }),
238
+ ]);
239
+
240
+ expect(itemFor(items, "alive@magus").action).not.toBe("remove");
241
+ });
242
+ });
243
+
244
+ describe("D2 scopes of a remove item", () => {
245
+ test("D2 one installed scope → exactly that scope", () => {
246
+ const items = planPluginUpdates({}, [
247
+ LISTED,
248
+ orphan("old@magus", { project: "0.9.0" }),
249
+ ]);
250
+
251
+ expect(itemFor(items, "old@magus").scopes).toEqual(["project"]);
252
+ });
253
+
254
+ test("D2 installed at user and local → both, and nothing else", () => {
255
+ const items = planPluginUpdates({}, [
256
+ LISTED,
257
+ orphan("old@magus", { user: "0.9.0", local: "0.8.0" }),
258
+ ]);
259
+
260
+ expect([...itemFor(items, "old@magus").scopes].sort()).toEqual([
261
+ "local",
262
+ "user",
263
+ ]);
264
+ });
265
+
266
+ test("D2 installed at all three scopes → all three", () => {
267
+ const items = planPluginUpdates({}, [
268
+ LISTED,
269
+ orphan("old@magus", { user: "0.9.0", project: "0.9.0", local: "0.9.0" }),
270
+ ]);
271
+
272
+ expect([...itemFor(items, "old@magus").scopes].sort()).toEqual([
273
+ "local",
274
+ "project",
275
+ "user",
276
+ ]);
277
+ });
278
+
279
+ test("D2 declared only → scopes is empty", () => {
280
+ const items = planPluginUpdates({ "gone@magus": "latest" }, [LISTED]);
281
+
282
+ expect(itemFor(items, "gone@magus").scopes).toEqual([]);
283
+ });
284
+ });
285
+
286
+ describe("D3 a plugin whose marketplace was NOT read", () => {
287
+ test("D3 installed, its marketplace contributes no listed id → unknown / marketplace-unread", () => {
288
+ const items = planPluginUpdates({}, [
289
+ LISTED,
290
+ orphan("thing@unread-market", { project: "1.0.0" }),
291
+ ]);
292
+
293
+ const thing = itemFor(items, "thing@unread-market");
294
+ expect({ action: thing.action, reason: thing.reason }).toEqual({
295
+ action: "unknown",
296
+ reason: "marketplace-unread",
297
+ });
298
+ });
299
+
300
+ test("D3 declared by the profile and installed, marketplace unread → unknown, not remove", () => {
301
+ const items = planPluginUpdates({ "thing@unread-market": "latest" }, [
302
+ LISTED,
303
+ orphan("thing@unread-market", { user: "1.0.0" }),
304
+ ]);
305
+
306
+ const thing = itemFor(items, "thing@unread-market");
307
+ expect({ action: thing.action, reason: thing.reason }).toEqual({
308
+ action: "unknown",
309
+ reason: "marketplace-unread",
310
+ });
311
+ });
312
+
313
+ test("D3 declared only, marketplace unread → never planned as a removal", () => {
314
+ const items = planPluginUpdates({ "thing@unread-market": "latest" }, [
315
+ LISTED,
316
+ ]);
317
+
318
+ const thing = itemFor(items, "thing@unread-market");
319
+ expect(thing.action).not.toBe("remove");
320
+ expect(thing.reason).not.toBe("deprecated");
321
+ });
322
+
323
+ test("D3 a marketplace whose only entries are orphans counts as unread", () => {
324
+ const items = planPluginUpdates({}, [
325
+ orphan("a@ghost-market", { project: "1.0.0" }),
326
+ orphan("b@ghost-market", { project: "1.0.0" }),
327
+ ]);
328
+
329
+ for (const id of ["a@ghost-market", "b@ghost-market"]) {
330
+ expect(itemFor(items, id).action).toBe("unknown");
331
+ }
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // D4 — pluginNeedsWork
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe("D4 pluginNeedsWork", () => {
340
+ test("D4 true for a remove item installed at a scope", () => {
341
+ expect(pluginNeedsWork(removeItem())).toBe(true);
342
+ });
343
+
344
+ test("D4 true for a remove item with no scopes (declared only)", () => {
345
+ expect(pluginNeedsWork(removeItem({ scopes: [], installed: null }))).toBe(
346
+ true,
347
+ );
348
+ });
349
+
350
+ test("D4 true for the remove items the planner produces", () => {
351
+ const items = planPluginUpdates({ "gone@magus": "latest" }, [
352
+ LISTED,
353
+ orphan("old@magus", { project: "0.9.0" }),
354
+ ]);
355
+
356
+ expect(pluginNeedsWork(itemFor(items, "old@magus"))).toBe(true);
357
+ expect(pluginNeedsWork(itemFor(items, "gone@magus"))).toBe(true);
358
+ });
359
+ });
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // D5 — applying a remove
363
+ // ---------------------------------------------------------------------------
364
+
365
+ describe("D5 applying a remove item", () => {
366
+ test("D5 uninstalls at every scope, drops it from the profile, counts it removed", async () => {
367
+ const m = machine({ "old@magus": ["user", "project"] }, ["old@magus"]);
368
+
369
+ const result = await applyPlugins(
370
+ [removeItem({ scopes: ["user", "project"] })],
371
+ PROJECT,
372
+ m.deps,
373
+ );
374
+
375
+ expect(m.state.uninstalls.map((u) => `${u.id}:${u.scope}`).sort()).toEqual([
376
+ "old@magus:project",
377
+ "old@magus:user",
378
+ ]);
379
+ expect(m.state.drops.map((d) => d.id)).toEqual(["old@magus"]);
380
+ expect(result.removed.map(pluginName)).toEqual(["old"]);
381
+ expect(result.failed).toEqual([]);
382
+ expect(m.state.otherCalls).toEqual([]);
383
+ });
384
+
385
+ test("D5 afterwards it is installed nowhere and the profile no longer lists it", async () => {
386
+ const m = machine({ "old@magus": ["local"] }, ["old@magus", "keep@magus"]);
387
+
388
+ await applyPlugins([removeItem({ scopes: ["local"] })], PROJECT, m.deps);
389
+
390
+ expect(
391
+ SCOPES.filter((s) => m.state.installed.get("old@magus")?.has(s)),
392
+ ).toEqual([]);
393
+ expect([...m.state.profile]).toEqual(["keep@magus"]);
394
+ });
395
+
396
+ test("D5 declared only (no scopes): no uninstall, profile edited, counted removed", async () => {
397
+ const m = machine({}, ["gone@magus"]);
398
+
399
+ const result = await applyPlugins(
400
+ [
401
+ removeItem({
402
+ pluginId: "gone@magus",
403
+ scopes: [],
404
+ installed: null,
405
+ loaded: null,
406
+ beforeVersions: {},
407
+ }),
408
+ ],
409
+ PROJECT,
410
+ m.deps,
411
+ );
412
+
413
+ expect(m.state.uninstalls).toEqual([]);
414
+ expect(m.state.drops.map((d) => d.id)).toEqual(["gone@magus"]);
415
+ expect(result.removed.map(pluginName)).toEqual(["gone"]);
416
+ expect(result.failed).toEqual([]);
417
+ });
418
+
419
+ test("D5 the uninstall throws → failed, not removed", async () => {
420
+ const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
421
+ uninstallThrows: true,
422
+ });
423
+
424
+ const result = await applyPlugins([removeItem()], PROJECT, m.deps);
425
+
426
+ expect(result.removed).toEqual([]);
427
+ expect(result.failed.map(pluginName)).toEqual(["old"]);
428
+ });
429
+
430
+ test("D5 the uninstall returns but it is still installed → failed, not removed", async () => {
431
+ const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
432
+ uninstallIsNoop: true,
433
+ });
434
+
435
+ const result = await applyPlugins([removeItem()], PROJECT, m.deps);
436
+
437
+ expect(result.removed).toEqual([]);
438
+ expect(result.failed.map(pluginName)).toEqual(["old"]);
439
+ });
440
+
441
+ test("D5 the profile still lists it afterwards → failed, not removed", async () => {
442
+ const m = machine({ "old@magus": ["project"] }, ["old@magus"], {
443
+ dropReturns: false,
444
+ });
445
+
446
+ const result = await applyPlugins([removeItem()], PROJECT, m.deps);
447
+
448
+ expect(result.removed).toEqual([]);
449
+ expect(result.failed.map(pluginName)).toEqual(["old"]);
450
+ });
451
+
452
+ test("D5 still installed at a scope the item did not name → failed (installed nowhere is the bar)", async () => {
453
+ const m = machine({ "old@magus": ["project", "user"] }, ["old@magus"]);
454
+
455
+ const result = await applyPlugins(
456
+ [removeItem({ scopes: ["project"] })],
457
+ PROJECT,
458
+ m.deps,
459
+ );
460
+
461
+ expect(result.removed).toEqual([]);
462
+ expect(result.failed.map(pluginName)).toEqual(["old"]);
463
+ });
464
+
465
+ test("D5 a remove item applied with no remove deps fails rather than claiming a removal", async () => {
466
+ const m = machine({ "old@magus": ["project"] }, ["old@magus"]);
467
+ const { remove: _ignored, ...withoutRemove } = m.deps;
468
+
469
+ const result = await applyPlugins([removeItem()], PROJECT, withoutRemove);
470
+
471
+ expect(result.removed).toEqual([]);
472
+ expect(result.failed.map(pluginName)).toEqual(["old"]);
473
+ });
474
+
475
+ test("D5 the reporter hears a failure for a failed removal and a success for a removal", async () => {
476
+ const m = machine({ "old@magus": ["project"], "dead@magus": ["user"] }, [
477
+ "old@magus",
478
+ "dead@magus",
479
+ ]);
480
+ const failing = machine({ "dead@magus": ["user"] }, ["dead@magus"], {
481
+ uninstallThrows: true,
482
+ });
483
+ const ok = recordingReporter();
484
+ const bad = recordingReporter();
485
+
486
+ await applyPlugins([removeItem()], PROJECT, m.deps, ok.reporter);
487
+ await applyPlugins(
488
+ [removeItem({ pluginId: "dead@magus", scopes: ["user"] })],
489
+ PROJECT,
490
+ failing.deps,
491
+ bad.reporter,
492
+ );
493
+
494
+ expect(ok.finished.map((f) => f.ok)).toEqual([true]);
495
+ expect(bad.finished.map((f) => f.ok)).toEqual([false]);
496
+ });
497
+ });
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // D6 — the Plugins row text
501
+ // ---------------------------------------------------------------------------
502
+
503
+ describe("D6 Plugins row text", () => {
504
+ test("D6 a remove item reads ' deprecated — press a to remove' in the danger tone", () => {
505
+ expect(pluginStatusText(removeItem())).toEqual({
506
+ suffix: " deprecated — press a to remove",
507
+ tone: "danger",
508
+ });
509
+ });
510
+
511
+ test("D6 the same for a declared-only remove item", () => {
512
+ expect(
513
+ pluginStatusText(removeItem({ scopes: [], installed: null })),
514
+ ).toEqual({ suffix: " deprecated — press a to remove", tone: "danger" });
515
+ });
516
+
517
+ test("D6 the planner's own remove item renders the same text", () => {
518
+ const items = planPluginUpdates({}, [
519
+ LISTED,
520
+ orphan("old@magus", { project: "0.9.0" }),
521
+ ]);
522
+
523
+ expect(pluginStatusText(itemFor(items, "old@magus"))).toEqual({
524
+ suffix: " deprecated — press a to remove",
525
+ tone: "danger",
526
+ });
527
+ });
528
+
529
+ test("D6 never contains 'marketplace', even when the item's note does", () => {
530
+ const text = pluginStatusText(
531
+ removeItem({ note: "no longer listed by its marketplace" }),
532
+ );
533
+
534
+ expect(text.suffix.toLowerCase()).not.toContain("marketplace");
535
+ });
536
+ });
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // D7 — the CLI view
540
+ // ---------------------------------------------------------------------------
541
+
542
+ function planWith(plugins: PluginUpdateItem[]): UpdatePlan {
543
+ return {
544
+ profileId: "blind",
545
+ plugins,
546
+ bins: [],
547
+ skills: [],
548
+ binSpecs: new Map(),
549
+ };
550
+ }
551
+
552
+ const CURRENT: PluginUpdateItem = {
553
+ pluginId: "alive@magus",
554
+ pinned: "latest",
555
+ installed: "1.0.0",
556
+ loaded: "1.0.0",
557
+ available: "1.0.0",
558
+ target: null,
559
+ action: "current",
560
+ reason: "up-to-date",
561
+ scopes: ["project"],
562
+ beforeVersions: { project: "1.0.0" },
563
+ };
564
+
565
+ describe("D7 CLI view", () => {
566
+ test("D7 remove items are listed under a 'Deprecated' group with a DEPRECATED chip", () => {
567
+ setColorEnabled(false);
568
+
569
+ const lines = planReport(planWith([CURRENT, removeItem()])).map((l) =>
570
+ stripAnsi(l),
571
+ );
572
+
573
+ const heading = lines.findIndex((l) => /^\s*Deprecated\b/.test(l));
574
+ const row = lines.findIndex((l) => l.includes("old@magus"));
575
+ expect(heading).toBeGreaterThanOrEqual(0);
576
+ expect(row).toBeGreaterThan(heading);
577
+ expect(lines[row]).toContain("DEPRECATED");
578
+ const nextHeading = lines.findIndex(
579
+ (l, i) => i > heading && /^\s{0,2}[A-Z][a-z]+\s+\d+\s*$/.test(l),
580
+ );
581
+ if (nextHeading !== -1) expect(row).toBeLessThan(nextHeading);
582
+ });
583
+
584
+ test("D7 the Deprecated group counts its items", () => {
585
+ setColorEnabled(false);
586
+
587
+ const lines = planReport(
588
+ planWith([
589
+ CURRENT,
590
+ removeItem(),
591
+ removeItem({ pluginId: "gone@magus", scopes: [], installed: null }),
592
+ ]),
593
+ ).map((l) => stripAnsi(l));
594
+
595
+ expect(lines.some((l) => /^\s*Deprecated\s+2\s*$/.test(l))).toBe(true);
596
+ expect(lines.filter((l) => l.includes("DEPRECATED"))).toHaveLength(2);
597
+ });
598
+
599
+ test("D7 the current plugin is not under the Deprecated group", () => {
600
+ setColorEnabled(false);
601
+
602
+ const lines = planReport(planWith([CURRENT, removeItem()])).map((l) =>
603
+ stripAnsi(l),
604
+ );
605
+
606
+ const aliveRow = lines.find((l) => l.includes("alive@magus")) ?? "";
607
+ expect(aliveRow).not.toContain("DEPRECATED");
608
+ });
609
+
610
+ test("D7 remove items count as items to apply", () => {
611
+ const plan = planWith([
612
+ CURRENT,
613
+ removeItem(),
614
+ removeItem({ pluginId: "gone@magus", scopes: [], installed: null }),
615
+ ]);
616
+
617
+ const work = planWorkItems(plan);
618
+
619
+ expect(work).toHaveLength(2);
620
+ expect(work.map(pluginName).sort()).toEqual(["gone", "old"]);
621
+ expect(planHasWork(plan)).toBe(true);
622
+ expect(planIsBehind(plan)).toBe(true);
623
+ });
624
+
625
+ test("D7 a plan of current plugins only has nothing to apply (control)", () => {
626
+ const plan = planWith([CURRENT]);
627
+
628
+ expect(planWorkItems(plan)).toEqual([]);
629
+ expect(planHasWork(plan)).toBe(false);
630
+ });
631
+ });