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,126 @@
1
+ /**
2
+ * Which registry row a question reads — the pure halves of two fixes whose
3
+ * end-to-end cases are in installed-version-scope.test.tsx.
4
+ *
5
+ * - `ownRegistryEntry`: "does THIS project hold a row at this scope?" — the
6
+ * apply engine's guard before `update`. It used to be answered by the lookup
7
+ * that falls back to inherited rows and then the user row.
8
+ * - `contentDriftEntry`: the row whose commit a content-drift check compares.
9
+ * It used to be the project lookup for every plugin, installed there or not.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { ownRegistryEntry } from "../services/claude-settings.js";
14
+ import {
15
+ type PluginInfo,
16
+ contentDriftEntry,
17
+ } from "../services/plugin-manager.js";
18
+ import type { InstalledPluginEntry } from "../types/index.js";
19
+
20
+ const REPO = "/work/repo";
21
+ const HERE = "/work/repo/sub";
22
+
23
+ const row = (over: Partial<InstalledPluginEntry>): InstalledPluginEntry => ({
24
+ scope: "user",
25
+ installPath: "/cache",
26
+ version: "1.0.0",
27
+ installedAt: "",
28
+ lastUpdated: "",
29
+ ...over,
30
+ });
31
+
32
+ describe("ownRegistryEntry — this project's own row, exactly", () => {
33
+ test("never falls back to the user row for project or local scope", () => {
34
+ const rows = [row({ scope: "user" })];
35
+ expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
36
+ expect(ownRegistryEntry(rows, "local", HERE)).toBeUndefined();
37
+ expect(ownRegistryEntry(rows, "user", HERE)?.scope).toBe("user");
38
+ });
39
+
40
+ test("never takes a row inherited from the repository root", () => {
41
+ const rows = [row({ scope: "project", projectPath: REPO })];
42
+ expect(ownRegistryEntry(rows, "project", HERE)).toBeUndefined();
43
+ });
44
+
45
+ test("matches scope and path exactly — a local row is not a project row", () => {
46
+ const local = row({ scope: "local", projectPath: HERE, version: "3.0.0" });
47
+ const project = row({
48
+ scope: "project",
49
+ projectPath: `${HERE}/`,
50
+ version: "2.0.0",
51
+ });
52
+ expect(ownRegistryEntry([local], "project", HERE)).toBeUndefined();
53
+ expect(ownRegistryEntry([local, project], "project", HERE)?.version).toBe(
54
+ "2.0.0",
55
+ );
56
+ expect(ownRegistryEntry([local, project], "local", HERE)?.version).toBe(
57
+ "3.0.0",
58
+ );
59
+ });
60
+ });
61
+
62
+ describe("contentDriftEntry — the row behind an installed scope", () => {
63
+ const USER = row({ scope: "user", version: "0.9.0", gitCommitSha: "user" });
64
+ const INHERITED = row({
65
+ scope: "project",
66
+ projectPath: REPO,
67
+ version: "0.8.1",
68
+ gitCommitSha: "inherited",
69
+ });
70
+ const OWN = row({
71
+ scope: "project",
72
+ projectPath: HERE,
73
+ version: "0.9.0",
74
+ gitCommitSha: "own",
75
+ });
76
+ const plugin = (over: Partial<PluginInfo>): PluginInfo => ({
77
+ id: "probe@magus",
78
+ name: "probe",
79
+ version: "0.9.0",
80
+ description: "",
81
+ marketplace: "magus",
82
+ marketplaceDisplay: "Magus",
83
+ installedVersion: "0.9.0",
84
+ ...over,
85
+ });
86
+
87
+ test("installed at user, beside an inherited project row of another version: the user row", () => {
88
+ const entry = contentDriftEntry(
89
+ plugin({
90
+ userScope: { installed: true, version: "0.9.0" },
91
+ projectScope: { installed: false, version: "0.8.1" },
92
+ }),
93
+ [USER, INHERITED],
94
+ HERE,
95
+ [REPO],
96
+ );
97
+ expect(entry?.gitCommitSha).toBe("user");
98
+ });
99
+
100
+ test("installed at both at the same version: this directory's row first", () => {
101
+ const entry = contentDriftEntry(
102
+ plugin({
103
+ userScope: { installed: true, version: "0.9.0" },
104
+ projectScope: { installed: true, version: "0.9.0" },
105
+ }),
106
+ [USER, OWN],
107
+ HERE,
108
+ [REPO],
109
+ );
110
+ expect(entry?.gitCommitSha).toBe("own");
111
+ });
112
+
113
+ test("no installed scope with a version: no row", () => {
114
+ const entry = contentDriftEntry(
115
+ plugin({
116
+ installedVersion: undefined,
117
+ userScope: { installed: true },
118
+ projectScope: { installed: false, version: "0.8.1" },
119
+ }),
120
+ [INHERITED],
121
+ HERE,
122
+ [REPO],
123
+ );
124
+ expect(entry).toBeUndefined();
125
+ });
126
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `selectOption` always puts the terminal back.
3
+ *
4
+ * It sets raw mode, attaches a keypress listener and hides the cursor, and only
5
+ * Enter, Esc and Ctrl+C undid them. A throw while drawing, a SIGTERM or a SIGHUP
6
+ * left the shell in raw mode with no cursor — typing blind until `reset`.
7
+ *
8
+ * `process.stdin` and `process.stdout` are swapped for fakes for the length of
9
+ * each case, so no real terminal is touched; `process.exit` is stubbed wherever
10
+ * a signal handler would call it.
11
+ */
12
+
13
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
14
+ import { EventEmitter } from "node:events";
15
+ import { selectOption } from "../cli/select.js";
16
+
17
+ const SHOW_CURSOR = "\x1b[?25h";
18
+ const HIDE_CURSOR = "\x1b[?25l";
19
+ /** Every frame row is written with this prefix; control sequences are not. */
20
+ const FRAME_ROW = "\x1b[2K";
21
+
22
+ class FakeStdin extends EventEmitter {
23
+ isTTY = true;
24
+ isRaw = false;
25
+ setRawMode(raw: boolean): this {
26
+ this.isRaw = raw;
27
+ return this;
28
+ }
29
+ resume(): this {
30
+ return this;
31
+ }
32
+ pause(): this {
33
+ return this;
34
+ }
35
+ key(name: string, extra: Record<string, unknown> = {}): void {
36
+ this.emit("keypress", "", { name, ...extra });
37
+ }
38
+ }
39
+
40
+ class FakeStdout {
41
+ isTTY = true;
42
+ writes: string[] = [];
43
+ frames = 0;
44
+ /** Throw on this frame (1-based): the first draw, or a redraw. */
45
+ constructor(private readonly throwOnFrame = 0) {}
46
+ write(chunk: string): boolean {
47
+ if (chunk.includes(FRAME_ROW) && ++this.frames === this.throwOnFrame) {
48
+ throw new Error("the terminal went away");
49
+ }
50
+ this.writes.push(chunk);
51
+ return true;
52
+ }
53
+ }
54
+
55
+ const WATCHED = ["exit", "SIGTERM", "SIGHUP"] as const;
56
+ const listenerCounts = () =>
57
+ Object.fromEntries(WATCHED.map((e) => [e, process.listenerCount(e)]));
58
+
59
+ const originals = {
60
+ stdin: Object.getOwnPropertyDescriptor(process, "stdin"),
61
+ stdout: Object.getOwnPropertyDescriptor(process, "stdout"),
62
+ };
63
+
64
+ function useFakes(stdout = new FakeStdout()) {
65
+ const stdin = new FakeStdin();
66
+ Object.defineProperty(process, "stdin", {
67
+ value: stdin,
68
+ configurable: true,
69
+ writable: true,
70
+ });
71
+ Object.defineProperty(process, "stdout", {
72
+ value: stdout,
73
+ configurable: true,
74
+ writable: true,
75
+ });
76
+ return { stdin, stdout };
77
+ }
78
+
79
+ let exitSpy: ReturnType<typeof spyOn> | null = null;
80
+
81
+ afterEach(() => {
82
+ if (originals.stdin) Object.defineProperty(process, "stdin", originals.stdin);
83
+ if (originals.stdout)
84
+ Object.defineProperty(process, "stdout", originals.stdout);
85
+ exitSpy?.mockRestore();
86
+ exitSpy = null;
87
+ });
88
+
89
+ const ITEMS = [{ label: "One" }, { label: "Two" }, { label: "Three" }];
90
+
91
+ /** Raw mode off, no keypress listener, the cursor shown after it was hidden. */
92
+ function expectRestored(stdin: FakeStdin, stdout: FakeStdout): void {
93
+ expect(stdin.isRaw).toBe(false);
94
+ expect(stdin.listenerCount("keypress")).toBe(0);
95
+ const hidden = stdout.writes.lastIndexOf(HIDE_CURSOR);
96
+ expect(hidden).toBeGreaterThanOrEqual(0);
97
+ expect(stdout.writes.lastIndexOf(SHOW_CURSOR)).toBeGreaterThan(hidden);
98
+ }
99
+
100
+ describe("an answer still behaves as it did", () => {
101
+ test("Enter picks the current row, and the terminal is back", async () => {
102
+ const { stdin, stdout } = useFakes();
103
+ const baseline = listenerCounts();
104
+ const answer = selectOption("Pick", ITEMS);
105
+ stdin.key("down");
106
+ stdin.key("return");
107
+
108
+ expect(await answer).toBe(1);
109
+ expectRestored(stdin, stdout);
110
+ // The cursor is shown last, after the one-line answer.
111
+ expect(stdout.writes.at(-1)).toBe(SHOW_CURSOR);
112
+ expect(stdout.writes.at(-2)).toContain("Two");
113
+ expect(listenerCounts()).toEqual(baseline);
114
+ });
115
+
116
+ test("Esc and Ctrl+C cancel with null, and the terminal is back", async () => {
117
+ for (const key of [{ name: "escape" }, { name: "c", ctrl: true }]) {
118
+ const { stdin, stdout } = useFakes();
119
+ const answer = selectOption("Pick", ITEMS);
120
+ stdin.key(key.name, key);
121
+ expect(await answer).toBeNull();
122
+ expectRestored(stdin, stdout);
123
+ }
124
+ });
125
+
126
+ test("keys after the answer in the same read are not handled", async () => {
127
+ const { stdin, stdout } = useFakes();
128
+ const answer = selectOption("Pick", ITEMS);
129
+ stdin.key("return");
130
+ stdin.key("down");
131
+ stdin.key("return");
132
+ expect(await answer).toBe(0);
133
+ expect(stdout.writes.filter((w) => w.includes("One")).length).toBe(2);
134
+ });
135
+ });
136
+
137
+ describe("anything else that ends it puts the terminal back too", () => {
138
+ test("a throw on the first draw rejects, and the terminal is back", async () => {
139
+ const { stdin, stdout } = useFakes(new FakeStdout(1));
140
+ const baseline = listenerCounts();
141
+
142
+ await expect(selectOption("Pick", ITEMS)).rejects.toThrow(
143
+ "the terminal went away",
144
+ );
145
+ expectRestored(stdin, stdout);
146
+ expect(listenerCounts()).toEqual(baseline);
147
+ });
148
+
149
+ test("a throw redrawing after a key rejects instead of escaping the listener", async () => {
150
+ const { stdin, stdout } = useFakes(new FakeStdout(2));
151
+ const answer = selectOption("Pick", ITEMS);
152
+
153
+ expect(() => stdin.key("down")).not.toThrow();
154
+ await expect(answer).rejects.toThrow("the terminal went away");
155
+ expectRestored(stdin, stdout);
156
+ });
157
+
158
+ for (const [signal, code] of [
159
+ ["SIGTERM", 143],
160
+ ["SIGHUP", 129],
161
+ ] as const) {
162
+ test(`${signal} puts the terminal back, then exits ${code}`, () => {
163
+ const { stdin, stdout } = useFakes();
164
+ const baseline = listenerCounts();
165
+ exitSpy = spyOn(process, "exit").mockImplementation(
166
+ (() => undefined) as never,
167
+ );
168
+ void selectOption("Pick", ITEMS);
169
+
170
+ process.emit(signal);
171
+
172
+ expectRestored(stdin, stdout);
173
+ expect(exitSpy).toHaveBeenCalledWith(code);
174
+ // One-shot, and the others went with it.
175
+ expect(listenerCounts()).toEqual(baseline);
176
+ });
177
+ }
178
+
179
+ test("the process exiting while it is open puts the terminal back", () => {
180
+ const { stdin, stdout } = useFakes();
181
+ const before = process.listeners("exit");
182
+ void selectOption("Pick", ITEMS);
183
+ const added = process.listeners("exit").filter((l) => !before.includes(l));
184
+
185
+ // Its own listener only: emitting "exit" would run everyone's.
186
+ expect(added).toHaveLength(1);
187
+ (added[0] as (code: number) => void)(0);
188
+
189
+ expectRestored(stdin, stdout);
190
+ expect(process.listeners("exit")).toEqual(before);
191
+ });
192
+ });
193
+
194
+ describe("the handlers live exactly as long as the list", () => {
195
+ test("one each while it is open, none after it answers", async () => {
196
+ const { stdin } = useFakes();
197
+ const baseline = listenerCounts();
198
+ const answer = selectOption("Pick", ITEMS);
199
+
200
+ expect(listenerCounts()).toEqual({
201
+ exit: baseline.exit + 1,
202
+ SIGTERM: baseline.SIGTERM + 1,
203
+ SIGHUP: baseline.SIGHUP + 1,
204
+ });
205
+ stdin.key("return");
206
+ await answer;
207
+ expect(listenerCounts()).toEqual(baseline);
208
+ });
209
+ });
@@ -874,10 +874,9 @@ describe("the popup count equals the Plugins header count", () => {
874
874
  description: "",
875
875
  marketplace: id.split("@")[1] ?? "magus",
876
876
  marketplaceDisplay: "",
877
- enabled: true,
878
877
  installedVersion: mine,
879
878
  loadedVersion: loaded[id] ?? null,
880
- projectScope: { enabled: true, version: mine },
879
+ projectScope: { installed: true, version: mine },
881
880
  } as PluginInfo;
882
881
  },
883
882
  );
@@ -177,7 +177,14 @@ describe("scope handling", () => {
177
177
  test("a fresh install goes to project scope only", async () => {
178
178
  const { deps, installed } = stubDeps("2.0.0");
179
179
  await applyPlugins(
180
- [item({ action: "install", installed: null, scopes: [] })],
180
+ [
181
+ item({
182
+ action: "install",
183
+ reason: "absent",
184
+ installed: null,
185
+ scopes: [],
186
+ }),
187
+ ],
181
188
  PROJECT,
182
189
  deps,
183
190
  );
@@ -228,7 +235,14 @@ describe("scope handling", () => {
228
235
  test("a fresh install runs `install`, never `update`", async () => {
229
236
  const { deps, installed, updated } = stubDeps("2.0.0");
230
237
  await applyPlugins(
231
- [item({ action: "install", installed: null, scopes: [] })],
238
+ [
239
+ item({
240
+ action: "install",
241
+ reason: "absent",
242
+ installed: null,
243
+ scopes: [],
244
+ }),
245
+ ],
232
246
  PROJECT,
233
247
  deps,
234
248
  );
@@ -715,6 +729,7 @@ describe("applyPlugins — read-back against beforeVersions", () => {
715
729
  [
716
730
  item({
717
731
  action: "install",
732
+ reason: "absent",
718
733
  installed: null,
719
734
  scopes: [],
720
735
  beforeVersions: {},
@@ -733,6 +748,7 @@ describe("applyPlugins — read-back against beforeVersions", () => {
733
748
  [
734
749
  item({
735
750
  action: "install",
751
+ reason: "absent",
736
752
  installed: null,
737
753
  scopes: [],
738
754
  beforeVersions: {},
@@ -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
+ });