magus-cli 7.5.3 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -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: {
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
+
});
|