magus-cli 7.5.3 → 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 (59) 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__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/mate-availability.test.ts +11 -13
  13. package/src/__tests__/plugin-contents.test.ts +0 -1
  14. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  15. package/src/__tests__/plugin-row-text.test.ts +18 -21
  16. package/src/__tests__/profile-command.test.ts +2 -2
  17. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  18. package/src/__tests__/profile-skills.test.ts +3 -3
  19. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  20. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  21. package/src/__tests__/profile-state.test.ts +27 -22
  22. package/src/__tests__/scope-action.test.ts +35 -38
  23. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  24. package/src/__tests__/select-restore.test.ts +209 -0
  25. package/src/__tests__/stale-load-repair.test.ts +1 -2
  26. package/src/__tests__/update-apply.test.ts +18 -2
  27. package/src/__tests__/update-null-readback.test.ts +300 -0
  28. package/src/__tests__/update-plan-parity.test.ts +249 -0
  29. package/src/__tests__/update-plan.test.ts +66 -67
  30. package/src/__tests__/version-unknown-state.test.ts +38 -39
  31. package/src/cli/bootstrap.ts +239 -66
  32. package/src/cli/doctor.ts +4 -3
  33. package/src/cli/install.ts +83 -27
  34. package/src/cli/prompt.ts +7 -3
  35. package/src/cli/router.ts +1 -0
  36. package/src/cli/select.ts +168 -0
  37. package/src/cli/update-view.ts +118 -6
  38. package/src/cli/update.ts +6 -4
  39. package/src/services/claude-settings.ts +28 -0
  40. package/src/services/manifest.ts +31 -0
  41. package/src/services/mate-availability.ts +23 -29
  42. package/src/services/plugin-manager.ts +247 -105
  43. package/src/services/profile-absorb.ts +10 -7
  44. package/src/services/profile-activate.ts +39 -18
  45. package/src/services/profile-startup.ts +160 -4
  46. package/src/services/profile-state.ts +7 -6
  47. package/src/services/tracked-live-files.ts +24 -3
  48. package/src/services/update-engine.ts +28 -10
  49. package/src/services/update-plan.ts +78 -94
  50. package/src/ui/App.tsx +7 -2
  51. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  52. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  53. package/src/ui/hooks/useProfileStartupModal.ts +87 -7
  54. package/src/ui/keymap.ts +1 -1
  55. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  56. package/src/ui/renderers/pluginRowText.ts +6 -2
  57. package/src/ui/renderers/pluginStatusText.ts +4 -3
  58. package/src/ui/screens/PluginsScreen.tsx +28 -41
  59. package/src/cli/tui-prompt.tsx +0 -143
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Spec 7.5.4 re-review — REQ-11: an update that records no version is "no change",
3
+ * never "moved".
4
+ *
5
+ * Written blind: from the spec and the exported signatures of `applyPlugins`,
6
+ * `PluginApplyDeps`, `ApplyReporter`, `PluginApplyResult` and `PluginUpdateItem` only.
7
+ *
8
+ * magus judges an update by READ-BACK: after the installer runs it reads the version
9
+ * recorded at each scope it touched, and only a changed version counts as moved. These
10
+ * tests drive `applyPlugins` with a deps double whose installer always "succeeds" and whose
11
+ * `readInstalled` answers per case, and assert on the result and on what the reporter was
12
+ * told.
13
+ *
14
+ * `applyPlugins` runs in a CHILD process that starts with HOME, CLAUDE_CONFIG_DIR and XDG_*
15
+ * pointing at temp dirs (Bun's `os.homedir()` ignores a HOME changed at runtime), colours
16
+ * off, and a PATH holding only `bun` and `git` — so anything it might reach outside the
17
+ * injected deps cannot touch the real `~/.claude` or a real `claude` CLI. The deps double
18
+ * and the reporter live in the child, record every call, and the child writes the result,
19
+ * the `finish` records and the call log as JSON. The optional deps `loadedVersion`,
20
+ * `registryRows` and `inThisRepository` are left unset, as the spec requires.
21
+ */
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import {
24
+ mkdir,
25
+ mkdtemp,
26
+ readFile,
27
+ realpath,
28
+ rm,
29
+ symlink,
30
+ writeFile,
31
+ } from "node:fs/promises";
32
+ import { tmpdir } from "node:os";
33
+ import { join } from "node:path";
34
+ import type { PluginApplyResult } from "../services/update-engine.js";
35
+ import type { PluginUpdateItem } from "../services/update-plan.js";
36
+
37
+ const SRC = join(import.meta.dir, "..");
38
+ const MODULES = {
39
+ ansi: join(SRC, "cli", "ansi.ts"),
40
+ engine: join(SRC, "services", "update-engine.ts"),
41
+ };
42
+
43
+ const CHILD_TIMEOUT_MS = 45_000;
44
+ const CHILD_TEST_TIMEOUT_MS = 60_000;
45
+
46
+ /** The one item every REQ-11 case applies, exactly as the spec states it. */
47
+ const ITEM: PluginUpdateItem = {
48
+ pluginId: "p@m",
49
+ pinned: "latest",
50
+ installed: "1.0.0",
51
+ loaded: null,
52
+ available: "2.0.0",
53
+ target: "2.0.0",
54
+ action: "update",
55
+ reason: "behind",
56
+ scopes: ["project"],
57
+ beforeVersions: { project: "1.0.0" },
58
+ };
59
+
60
+ /**
61
+ * What the double's `readInstalled` resolves, for every call:
62
+ * - `null`: always `null` — the installer exited 0 and recorded nothing (REQ-11a).
63
+ * - `moves`: `"1.0.0"` until `deps.update` or `deps.install` has been called, `"2.0.0"`
64
+ * after — a real move (REQ-11b).
65
+ * - `stays`: always `"1.0.0"` — the installer left the old version (REQ-11c).
66
+ */
67
+ type Readback = "null" | "moves" | "stays";
68
+
69
+ const APPLY_CHILD = `
70
+ import { readFileSync, writeFileSync } from "node:fs";
71
+
72
+ const [readback, itemPath, project, resultPath] = process.argv.slice(2);
73
+ const ansi = await import(${JSON.stringify(MODULES.ansi)});
74
+ const engine = await import(${JSON.stringify(MODULES.engine)});
75
+
76
+ if (typeof ansi.setColorEnabled === "function") ansi.setColorEnabled(false);
77
+ for (const stream of [process.stdin, process.stdout, process.stderr]) {
78
+ Object.defineProperty(stream, "isTTY", { value: false, configurable: true });
79
+ }
80
+
81
+ const item = JSON.parse(readFileSync(itemPath, "utf8"));
82
+ const calls = [];
83
+ const begins = [];
84
+ const finishes = [];
85
+ let installerRan = false;
86
+
87
+ const deps = {
88
+ install: async (pluginId, scope) => {
89
+ calls.push(["install", pluginId, scope]);
90
+ installerRan = true;
91
+ },
92
+ update: async (pluginId, scope) => {
93
+ calls.push(["update", pluginId, scope]);
94
+ installerRan = true;
95
+ },
96
+ repair: async (pluginId, scope, projectPath) => {
97
+ calls.push(["repair", pluginId, scope, projectPath]);
98
+ },
99
+ readInstalled: async (pluginId, scope, projectPath) => {
100
+ let version;
101
+ if (readback === "null") version = null;
102
+ else if (readback === "stays") version = "1.0.0";
103
+ else version = installerRan ? "2.0.0" : "1.0.0";
104
+ calls.push(["readInstalled", pluginId, scope, projectPath, version]);
105
+ return version;
106
+ },
107
+ saveInstalled: async (pluginId, version, scope, projectPath) => {
108
+ calls.push(["saveInstalled", pluginId, version, scope, projectPath]);
109
+ },
110
+ repairStaleLoad: async (pluginId, target, projectPath) => {
111
+ calls.push(["repairStaleLoad", pluginId, target, projectPath]);
112
+ return { pluginId, target, rewritten: 0, backup: null, loaded: target, ok: true };
113
+ },
114
+ };
115
+
116
+ const report = {
117
+ begin: (name) => {
118
+ begins.push(name);
119
+ },
120
+ finish: (ok, name, detail, ms) => {
121
+ finishes.push({ ok, name, detail, ms });
122
+ },
123
+ detach: async (work) => work(),
124
+ };
125
+
126
+ const out = { result: null, begins, finishes, calls, error: null };
127
+ try {
128
+ out.result = await engine.applyPlugins([item], project, deps, report);
129
+ } catch (e) {
130
+ out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
131
+ }
132
+ writeFileSync(resultPath, JSON.stringify(out));
133
+ process.exit(0);
134
+ `;
135
+
136
+ interface FinishRecord {
137
+ ok: boolean;
138
+ name: string;
139
+ detail: string;
140
+ ms: number;
141
+ }
142
+
143
+ interface ApplyRun {
144
+ result: PluginApplyResult | null;
145
+ begins: string[];
146
+ finishes: FinishRecord[];
147
+ calls: unknown[][];
148
+ error: string | null;
149
+ }
150
+
151
+ const scratch: string[] = [];
152
+ let helperDir = "";
153
+ let binDir = "";
154
+
155
+ async function tempRoot(prefix: string): Promise<string> {
156
+ const dir = await realpath(await mkdtemp(join(tmpdir(), prefix)));
157
+ scratch.push(dir);
158
+ return dir;
159
+ }
160
+
161
+ beforeAll(async () => {
162
+ helperDir = await tempRoot("unrb-helpers-");
163
+ binDir = join(helperDir, "bin");
164
+ await mkdir(binDir);
165
+ await symlink(process.execPath, join(binDir, "bun"));
166
+ const git = Bun.which("git");
167
+ if (git) await symlink(git, join(binDir, "git"));
168
+ await writeFile(join(helperDir, "apply-child.ts"), APPLY_CHILD);
169
+ });
170
+
171
+ afterAll(async () => {
172
+ for (const dir of scratch) await rm(dir, { recursive: true, force: true });
173
+ });
174
+
175
+ /**
176
+ * Apply ITEM in a child with the given read-back behaviour. `projectPath` is a fresh
177
+ * temp dir; HOME, CLAUDE_CONFIG_DIR (`<HOME>/.claude`) and XDG_* are temp dirs beside it.
178
+ */
179
+ async function runApply(readback: Readback): Promise<ApplyRun> {
180
+ const base = await tempRoot("unrb-");
181
+ const home = join(base, "home");
182
+ const xdg = join(base, "xdg");
183
+ await mkdir(join(home, ".claude"), { recursive: true });
184
+ for (const kind of ["config", "cache", "data", "state"]) {
185
+ await mkdir(join(xdg, kind), { recursive: true });
186
+ }
187
+ const project = join(base, "proj");
188
+ await mkdir(project, { recursive: true });
189
+ const itemPath = join(base, "item.json");
190
+ await writeFile(itemPath, JSON.stringify(ITEM));
191
+ const resultPath = join(base, `result-${readback}.json`);
192
+ const env = {
193
+ PATH: `${binDir}:/usr/bin:/bin`,
194
+ HOME: home,
195
+ CLAUDE_CONFIG_DIR: join(home, ".claude"),
196
+ XDG_CONFIG_HOME: join(xdg, "config"),
197
+ XDG_CACHE_HOME: join(xdg, "cache"),
198
+ XDG_DATA_HOME: join(xdg, "data"),
199
+ XDG_STATE_HOME: join(xdg, "state"),
200
+ TMPDIR: tmpdir(),
201
+ NO_COLOR: "1",
202
+ TERM: "dumb",
203
+ GIT_CONFIG_NOSYSTEM: "1",
204
+ GIT_CONFIG_GLOBAL: "/dev/null",
205
+ };
206
+ const proc = Bun.spawn({
207
+ cmd: [
208
+ process.execPath,
209
+ "--no-env-file",
210
+ join(helperDir, "apply-child.ts"),
211
+ readback,
212
+ itemPath,
213
+ project,
214
+ resultPath,
215
+ ],
216
+ cwd: project,
217
+ env,
218
+ stdin: "ignore",
219
+ stdout: "pipe",
220
+ stderr: "pipe",
221
+ });
222
+ const timer = setTimeout(() => proc.kill(), CHILD_TIMEOUT_MS);
223
+ const [stdout, stderr, code] = await Promise.all([
224
+ new Response(proc.stdout).text(),
225
+ new Response(proc.stderr).text(),
226
+ proc.exited,
227
+ ]);
228
+ clearTimeout(timer);
229
+ let raw: string;
230
+ try {
231
+ raw = await readFile(resultPath, "utf8");
232
+ } catch {
233
+ throw new Error(
234
+ `apply child wrote no result (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
235
+ );
236
+ }
237
+ return JSON.parse(raw) as ApplyRun;
238
+ }
239
+
240
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
241
+
242
+ /**
243
+ * Every `finish` detail reported for `pluginId`, colour codes stripped, joined by
244
+ * newlines. Fails the test when nothing was reported for it.
245
+ */
246
+ function finishDetail(run: ApplyRun, pluginId: string): string {
247
+ const mine = run.finishes.filter((f) => f.name.includes(pluginId));
248
+ expect(mine.length).toBeGreaterThan(0);
249
+ return mine.map((f) => f.detail.replace(ANSI, "")).join("\n");
250
+ }
251
+
252
+ describe("REQ-11 an update that records no version is no change, never moved", () => {
253
+ test(
254
+ "REQ-11a when read-back resolves null after the installer exits 0, the item is unchanged, not counted, and not reported as 2.0.0",
255
+ async () => {
256
+ const run = await runApply("null");
257
+
258
+ expect(run.error).toBeNull();
259
+ expect(run.result).not.toBeNull();
260
+ expect(run.result?.ok).toBe(0);
261
+ expect(run.result?.unchanged).toContain("p@m");
262
+ expect(run.result?.failed).toEqual([]);
263
+ const detail = finishDetail(run, "p@m");
264
+ expect(detail).toContain("no change");
265
+ expect(detail).not.toContain("2.0.0");
266
+ },
267
+ CHILD_TEST_TIMEOUT_MS,
268
+ );
269
+
270
+ test(
271
+ "REQ-11b negative control: when read-back moves from 1.0.0 to 2.0.0 after the installer, the item counts as changed and reports 2.0.0",
272
+ async () => {
273
+ const run = await runApply("moves");
274
+
275
+ expect(run.error).toBeNull();
276
+ expect(run.result).not.toBeNull();
277
+ expect(run.result?.ok).toBe(1);
278
+ expect(run.result?.unchanged).toEqual([]);
279
+ const detail = finishDetail(run, "p@m");
280
+ expect(detail).toContain("2.0.0");
281
+ },
282
+ CHILD_TEST_TIMEOUT_MS,
283
+ );
284
+
285
+ test(
286
+ "REQ-11c when the installer leaves 1.0.0 in place, the item is unchanged, not counted, and reported as no change at 1.0.0",
287
+ async () => {
288
+ const run = await runApply("stays");
289
+
290
+ expect(run.error).toBeNull();
291
+ expect(run.result).not.toBeNull();
292
+ expect(run.result?.ok).toBe(0);
293
+ expect(run.result?.unchanged).toContain("p@m");
294
+ const detail = finishDetail(run, "p@m");
295
+ expect(detail).toContain("no change");
296
+ expect(detail).toContain("1.0.0");
297
+ },
298
+ CHILD_TEST_TIMEOUT_MS,
299
+ );
300
+ });
@@ -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
+ });