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.
Files changed (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Spec 7.5.4 v3 — REQ-7: the CLI and the Plugins screen plan updates with ONE list.
3
+ * REQ-10: a plugin whose files are missing is repaired in the scope where they are
4
+ * missing, never at project scope.
5
+ *
6
+ * Written blind: from the spec and the exported signatures and interfaces only.
7
+ * `planPluginUpdates` is what the CLI plans with and `classifyPluginRows` is what the
8
+ * Plugins screen classifies its rows with; for the same pins and catalog they must say
9
+ * the same thing about every plugin.
10
+ *
11
+ * Both are pure, so everything here runs in-process on hand-built `PluginInfo`s.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import type { PluginInfo, ScopeStatus } from "../services/plugin-manager.js";
15
+ import {
16
+ type PluginUpdateItem,
17
+ classifyPluginRows,
18
+ planPluginUpdates,
19
+ } from "../services/update-plan.js";
20
+
21
+ interface Spec {
22
+ name: string;
23
+ /** What the marketplace offers. */
24
+ offered: string;
25
+ installedVersion?: string;
26
+ userScope?: ScopeStatus;
27
+ projectScope?: ScopeStatus;
28
+ localScope?: ScopeStatus;
29
+ }
30
+
31
+ function pluginInfo(spec: Spec): PluginInfo {
32
+ return {
33
+ id: `${spec.name}@magus`,
34
+ name: spec.name,
35
+ version: spec.offered,
36
+ description: "",
37
+ marketplace: "magus",
38
+ marketplaceDisplay: "magus",
39
+ installedVersion: spec.installedVersion,
40
+ userScope: spec.userScope,
41
+ projectScope: spec.projectScope,
42
+ localScope: spec.localScope,
43
+ };
44
+ }
45
+
46
+ const PINNED_INSTALLED = "alpha@magus";
47
+ const PINNED_NOWHERE = "bravo@magus";
48
+ const UNPINNED_USER_BEHIND = "charlie@magus";
49
+ const UNPINNED_NOWHERE = "delta@magus";
50
+
51
+ /**
52
+ * The REQ-7a mix, built fresh for every test so no call can see another's objects.
53
+ * Deliberately NOT in id order: the spec compares the plan against the classified rows
54
+ * sorted by `pluginId`, so an id-ordered catalog would hide the plan's own ordering.
55
+ */
56
+ function mixedCatalog(): PluginInfo[] {
57
+ return [
58
+ // An UNPINNED plugin installed nowhere.
59
+ pluginInfo({
60
+ name: "delta",
61
+ offered: "4.0.0",
62
+ userScope: { installed: false },
63
+ projectScope: { installed: false },
64
+ localScope: { installed: false },
65
+ }),
66
+ // An UNPINNED plugin installed at USER scope only, behind the offered version.
67
+ pluginInfo({
68
+ name: "charlie",
69
+ offered: "3.1.0",
70
+ installedVersion: "3.0.0",
71
+ userScope: { installed: true, version: "3.0.0" },
72
+ projectScope: { installed: false },
73
+ localScope: { installed: false },
74
+ }),
75
+ // A pinned plugin installed at project scope.
76
+ pluginInfo({
77
+ name: "alpha",
78
+ offered: "2.0.0",
79
+ installedVersion: "2.0.0",
80
+ projectScope: { installed: true, version: "2.0.0" },
81
+ }),
82
+ // A pinned plugin installed nowhere.
83
+ pluginInfo({ name: "bravo", offered: "1.5.0" }),
84
+ ];
85
+ }
86
+
87
+ function pins(): Record<string, string> {
88
+ return { [PINNED_INSTALLED]: "2.0.0", [PINNED_NOWHERE]: "latest" };
89
+ }
90
+
91
+ function byPluginId(a: PluginUpdateItem, b: PluginUpdateItem): number {
92
+ return a.pluginId < b.pluginId ? -1 : a.pluginId > b.pluginId ? 1 : 0;
93
+ }
94
+
95
+ function planItem(
96
+ plan: PluginUpdateItem[],
97
+ pluginId: string,
98
+ ): PluginUpdateItem | undefined {
99
+ return plan.find((item) => item.pluginId === pluginId);
100
+ }
101
+
102
+ describe("REQ-7 one update list for the CLI and the Plugins screen", () => {
103
+ test("REQ-7a the Plugins screen's classified rows, sorted by pluginId, deep-equal the CLI's plan", () => {
104
+ const plugins = mixedCatalog();
105
+
106
+ const plan = planPluginUpdates(pins(), plugins);
107
+ const rows = [...classifyPluginRows(mixedCatalog(), pins()).values()].sort(
108
+ byPluginId,
109
+ );
110
+
111
+ // Not vacuous: two of the four plugins are required in the plan (REQ-7b, REQ-7d).
112
+ expect(plan.length).toBeGreaterThanOrEqual(2);
113
+ expect(rows).toEqual(plan);
114
+ });
115
+
116
+ test("REQ-7b the unpinned USER-scope plugin that is behind appears, with action update", () => {
117
+ const plan = planPluginUpdates(pins(), mixedCatalog());
118
+
119
+ const item = planItem(plan, UNPINNED_USER_BEHIND);
120
+
121
+ expect(item).toBeDefined();
122
+ expect(item?.action).toBe("update");
123
+ });
124
+
125
+ test("REQ-7c the unpinned plugin installed nowhere does not appear in the plan", () => {
126
+ const plan = planPluginUpdates(pins(), mixedCatalog());
127
+
128
+ expect(plan.map((item) => item.pluginId)).not.toContain(UNPINNED_NOWHERE);
129
+ });
130
+
131
+ test("REQ-7d the pinned plugin installed nowhere appears with action install", () => {
132
+ const plan = planPluginUpdates(pins(), mixedCatalog());
133
+
134
+ const item = planItem(plan, PINNED_NOWHERE);
135
+
136
+ expect(item).toBeDefined();
137
+ expect(item?.action).toBe("install");
138
+ });
139
+
140
+ test("REQ-7e the catalog as a Map keyed by id gives the same plan as the array", () => {
141
+ const asArray = planPluginUpdates(pins(), mixedCatalog());
142
+ const asMap = planPluginUpdates(
143
+ pins(),
144
+ new Map(mixedCatalog().map((plugin) => [plugin.id, plugin])),
145
+ );
146
+
147
+ expect(asArray.length).toBeGreaterThanOrEqual(2);
148
+ expect(asMap).toEqual(asArray);
149
+ });
150
+ });
151
+
152
+ describe("REQ-10 missing files are repaired where they are missing, never at project scope", () => {
153
+ test("REQ-10a an unpinned plugin installed at USER scope with no version is reinstalled at user: install / files-missing / [user]", () => {
154
+ const catalog = [
155
+ pluginInfo({
156
+ name: "echo",
157
+ offered: "1.2.0",
158
+ userScope: { installed: true },
159
+ projectScope: { installed: false },
160
+ localScope: { installed: false },
161
+ }),
162
+ ];
163
+
164
+ const item = planItem(planPluginUpdates({}, catalog), "echo@magus");
165
+
166
+ expect(item).toBeDefined();
167
+ expect({
168
+ action: item?.action,
169
+ reason: item?.reason,
170
+ scopes: item?.scopes,
171
+ }).toEqual({
172
+ action: "install",
173
+ reason: "files-missing",
174
+ scopes: ["user"],
175
+ });
176
+ });
177
+
178
+ test("REQ-10b an unpinned plugin installed at LOCAL scope with no version is reinstalled at local: install / files-missing / [local]", () => {
179
+ const catalog = [
180
+ pluginInfo({
181
+ name: "foxtrot",
182
+ offered: "1.2.0",
183
+ userScope: { installed: false },
184
+ projectScope: { installed: false },
185
+ localScope: { installed: true },
186
+ }),
187
+ ];
188
+
189
+ const item = planItem(planPluginUpdates({}, catalog), "foxtrot@magus");
190
+
191
+ expect(item).toBeDefined();
192
+ expect({
193
+ action: item?.action,
194
+ reason: item?.reason,
195
+ scopes: item?.scopes,
196
+ }).toEqual({
197
+ action: "install",
198
+ reason: "files-missing",
199
+ scopes: ["local"],
200
+ });
201
+ });
202
+
203
+ test("REQ-10c a pinned plugin installed nowhere is installed: install / absent", () => {
204
+ const catalog = [pluginInfo({ name: "golf", offered: "2.0.0" })];
205
+
206
+ const item = planItem(
207
+ planPluginUpdates({ "golf@magus": "latest" }, catalog),
208
+ "golf@magus",
209
+ );
210
+
211
+ expect(item).toBeDefined();
212
+ expect({ action: item?.action, reason: item?.reason }).toEqual({
213
+ action: "install",
214
+ reason: "absent",
215
+ });
216
+ });
217
+
218
+ /** Installed at USER 0.9.0, catalog 0.9.0, plus an inherited PROJECT row that is not turned on. */
219
+ function currentWithInheritedRow(): PluginInfo[] {
220
+ return [
221
+ pluginInfo({
222
+ name: "hotel",
223
+ offered: "0.9.0",
224
+ installedVersion: "0.9.0",
225
+ userScope: { installed: true, version: "0.9.0" },
226
+ projectScope: { installed: false, version: "0.8.1" },
227
+ }),
228
+ ];
229
+ }
230
+
231
+ // The spec does not say whether this plugin is pinned, so both are checked: the
232
+ // not-installed scope's version must not change the answer either way.
233
+ const PIN_CASES: Array<[label: string, pins: Record<string, string>]> = [
234
+ ["unpinned", {}],
235
+ ["pinned to latest", { "hotel@magus": "latest" }],
236
+ ];
237
+
238
+ for (const [label, pinSet] of PIN_CASES) {
239
+ test(`REQ-10d (${label}) a not-installed PROJECT row at 0.8.1 does not make a current USER install look behind: current`, () => {
240
+ const item = planItem(
241
+ planPluginUpdates(pinSet, currentWithInheritedRow()),
242
+ "hotel@magus",
243
+ );
244
+
245
+ expect(item).toBeDefined();
246
+ expect(item?.action).toBe("current");
247
+ });
248
+ }
249
+ });