magus-cli 7.10.4 → 7.11.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__/blind/plugin-deps/doctor.test.ts +2 -2
- package/src/__tests__/blind/plugin-deps/hook.test.ts +2 -2
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +1 -1
- package/src/__tests__/blind/plugin-deps/schema.test.ts +6 -6
- package/src/__tests__/blind/requires-min-version/TEST-PLAN.md +54 -0
- package/src/__tests__/blind/requires-min-version/manifests.test.ts +68 -0
- package/src/__tests__/blind/requires-min-version/parse.test.ts +266 -0
- package/src/__tests__/blind/requires-min-version/probe.test.ts +359 -0
- package/src/__tests__/blind/requires-min-version/render.test.ts +252 -0
- package/src/__tests__/blind/requires-min-version/support.ts +83 -0
- package/src/__tests__/blind/requires-min-version/surfaces.test.ts +231 -0
- package/src/__tests__/blind/review-followups/bun-install-path.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-permissions.test.ts +1 -1
- package/src/__tests__/blind/review-followups/ledger-pruning.test.ts +1 -1
- package/src/__tests__/blind/review-followups/path-advice.test.ts +1 -1
- package/src/__tests__/deps-check-hook.test.ts +9 -9
- package/src/__tests__/deps-plan.test.ts +1 -1
- package/src/__tests__/deps-report.test.ts +5 -5
- package/src/__tests__/doctor-json.test.ts +2 -2
- package/src/__tests__/marketplace-refresh.test.ts +1 -1
- package/src/__tests__/plugin-requires.test.ts +2 -2
- package/src/__tests__/profile-worktree-include.test.ts +212 -0
- package/src/__tests__/requires-probe.test.ts +2 -2
- package/src/cli/deps-view.ts +5 -3
- package/src/services/deps-plan.ts +38 -14
- package/src/services/deps-report.ts +24 -7
- package/src/services/doctor-bins.ts +40 -12
- package/src/services/profile-activate.ts +9 -0
- package/src/services/profile-live.ts +26 -0
- package/src/services/profile-skills.ts +14 -4
- package/src/services/profile-state.ts +18 -0
- package/src/services/resolver.ts +1 -0
- package/src/shared/plugin/hooks/deps-check.ts +7 -3
- package/src/shared/plugin/hooks/lib/requires-probe.ts +257 -33
- package/src/ui/adapters/cliToolsAdapter.ts +6 -1
- package/src/ui/renderers/cliToolRenderers.tsx +7 -1
- package/src/ui/screens/CliToolsScreen.tsx +8 -1
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Raw,
|
|
7
|
+
type World,
|
|
8
|
+
configureClaude,
|
|
9
|
+
linkExecutable,
|
|
10
|
+
makeHookPlugin,
|
|
11
|
+
makePlugin,
|
|
12
|
+
makeWorld,
|
|
13
|
+
removeWorld,
|
|
14
|
+
requirement,
|
|
15
|
+
requires,
|
|
16
|
+
runDoctor,
|
|
17
|
+
runHook,
|
|
18
|
+
sessionInput,
|
|
19
|
+
writeExecutable,
|
|
20
|
+
} from "./support";
|
|
21
|
+
|
|
22
|
+
// Fake bun: answers --version, does nothing for `add` (so an "upgrade" leaves
|
|
23
|
+
// the old binary in place). Never the real bun.
|
|
24
|
+
async function fakeBun(world: World): Promise<void> {
|
|
25
|
+
await writeExecutable(
|
|
26
|
+
join(world.bin, "bun"),
|
|
27
|
+
'if [ "$1" = "--version" ]; then echo 1.4.0; exit 0; fi\nexit 0',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function bunAndWidget(minVersion: string): Raw {
|
|
32
|
+
return requires([
|
|
33
|
+
requirement("bun", [{ tool: "bun", via: "bootstrap" }]),
|
|
34
|
+
requirement("widget", [{ package: "widget", via: "bun" }], { minVersion }),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function installPlugin(
|
|
39
|
+
world: World,
|
|
40
|
+
name: string,
|
|
41
|
+
value: Raw,
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
const root = await makePlugin(world, name, value);
|
|
44
|
+
await configureClaude(world, [{ key: `${name}@test`, root, scope: "user" }]);
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseReport<T>(result: {
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
exitCode: number | null;
|
|
52
|
+
}): T {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(result.stdout) as T;
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`doctor printed no JSON (exit ${result.exitCode}); stderr: ${result.stderr.slice(0, 400)}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface Dep {
|
|
63
|
+
name: string;
|
|
64
|
+
status: string;
|
|
65
|
+
found?: string;
|
|
66
|
+
minVersion?: string;
|
|
67
|
+
fix?: { text: string };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("magus doctor with an outdated dependency", () => {
|
|
71
|
+
// REQ-27: DEPS_REPORT_SCHEMA 2; status "outdated"; found/minVersion only then.
|
|
72
|
+
test("REQ-27 --json reports schema 2 and an outdated dep with found and floor", async () => {
|
|
73
|
+
const world = await makeWorld();
|
|
74
|
+
try {
|
|
75
|
+
await fakeBun(world);
|
|
76
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
77
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
78
|
+
const result = await runDoctor(world, ["--json"]);
|
|
79
|
+
expect(result.timedOut).toBe(false);
|
|
80
|
+
const report = parseReport<{ schema: number; deps: Dep[] }>(result);
|
|
81
|
+
expect(report.schema).toBe(2);
|
|
82
|
+
const widget = report.deps.find((d) => d.name === "widget");
|
|
83
|
+
expect(widget).toMatchObject({
|
|
84
|
+
found: "1.2.0",
|
|
85
|
+
minVersion: "2.0.0",
|
|
86
|
+
status: "outdated",
|
|
87
|
+
});
|
|
88
|
+
expect(widget?.fix?.text).toBe("bun add -g widget@latest");
|
|
89
|
+
const bun = report.deps.find((d) => d.name === "bun");
|
|
90
|
+
expect(bun?.status).toBe("present");
|
|
91
|
+
expect(bun).not.toHaveProperty("found");
|
|
92
|
+
expect(bun).not.toHaveProperty("minVersion");
|
|
93
|
+
} finally {
|
|
94
|
+
await removeWorld(world);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// REQ-28: "magus doctor text: Outdated rows are failures and count toward the exit code."
|
|
99
|
+
test("REQ-28 text doctor exits non-zero for an outdated dep, zero once satisfied", async () => {
|
|
100
|
+
const world = await makeWorld();
|
|
101
|
+
try {
|
|
102
|
+
await fakeBun(world);
|
|
103
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
104
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
105
|
+
const outdated = await runDoctor(world, []);
|
|
106
|
+
expect(outdated.timedOut).toBe(false);
|
|
107
|
+
expect(outdated.exitCode).not.toBe(0);
|
|
108
|
+
expect(outdated.stdout + outdated.stderr).toContain("1.2.0");
|
|
109
|
+
|
|
110
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 2.0.0");
|
|
111
|
+
const healthy = await runDoctor(world, []);
|
|
112
|
+
expect(healthy.exitCode).toBe(0);
|
|
113
|
+
} finally {
|
|
114
|
+
await removeWorld(world);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// REQ-29: after install, still outdated → failed; remedy names found, floor, onPath.
|
|
119
|
+
test("REQ-29 --fix that leaves the old binary first on PATH reports failed with a remedy", async () => {
|
|
120
|
+
const world = await makeWorld();
|
|
121
|
+
try {
|
|
122
|
+
await fakeBun(world);
|
|
123
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.2.0");
|
|
124
|
+
await installPlugin(world, "testdata", bunAndWidget("2.0.0"));
|
|
125
|
+
const result = await runDoctor(world, ["--fix", "--yes", "--json"]);
|
|
126
|
+
expect(result.timedOut).toBe(false);
|
|
127
|
+
expect(result.exitCode).not.toBe(0);
|
|
128
|
+
const report = parseReport<{
|
|
129
|
+
applied?: Array<{
|
|
130
|
+
name: string;
|
|
131
|
+
status: string;
|
|
132
|
+
remedy?: string;
|
|
133
|
+
argv?: string[];
|
|
134
|
+
}>;
|
|
135
|
+
}>(result);
|
|
136
|
+
expect(report.applied).toBeDefined();
|
|
137
|
+
const applied = (report.applied ?? []).find((a) => a.name === "widget");
|
|
138
|
+
expect(applied?.status).toBe("failed");
|
|
139
|
+
// The executor may resolve argv[0] to an absolute path ("exactly what
|
|
140
|
+
// ran"); the spec fixes the rendered step, not how bun is located.
|
|
141
|
+
expect(applied?.argv?.[0]?.split("/").pop()).toBe("bun");
|
|
142
|
+
expect(applied?.argv?.slice(1)).toEqual(["add", "-g", "widget@latest"]);
|
|
143
|
+
expect(applied?.remedy).toContain("1.2.0");
|
|
144
|
+
expect(applied?.remedy).toContain("2.0.0");
|
|
145
|
+
expect(applied?.remedy).toContain(join(world.bin, "widget"));
|
|
146
|
+
} finally {
|
|
147
|
+
await removeWorld(world);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("SessionStart hook with an outdated dependency", () => {
|
|
153
|
+
// REQ-30: "An outdated dep is claimed and reported once per session, like a missing one."
|
|
154
|
+
test("REQ-30 reports an outdated dep once per session", async () => {
|
|
155
|
+
const world = await makeWorld();
|
|
156
|
+
try {
|
|
157
|
+
const plugin = await makeHookPlugin(
|
|
158
|
+
world,
|
|
159
|
+
"outdated",
|
|
160
|
+
requires([
|
|
161
|
+
requirement("widget", [{ formula: "widget", via: "brew" }], {
|
|
162
|
+
minVersion: "2.0.0",
|
|
163
|
+
}),
|
|
164
|
+
]),
|
|
165
|
+
);
|
|
166
|
+
// The hook itself needs a bun to execute TypeScript, as in the schema-1 suite.
|
|
167
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
168
|
+
await writeExecutable(join(world.bin, "brew"), "exit 0");
|
|
169
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 1.5.0");
|
|
170
|
+
const input = sessionInput("outdated-session");
|
|
171
|
+
const first = await runHook(
|
|
172
|
+
world,
|
|
173
|
+
plugin,
|
|
174
|
+
"outdated",
|
|
175
|
+
[world.bin],
|
|
176
|
+
input,
|
|
177
|
+
);
|
|
178
|
+
expect(first.timedOut).toBe(false);
|
|
179
|
+
expect(first.exitCode).toBe(0);
|
|
180
|
+
const banner = JSON.parse(first.stdout) as { systemMessage: string };
|
|
181
|
+
expect(banner.systemMessage).toContain("magus-deps");
|
|
182
|
+
expect(banner.systemMessage).toContain(
|
|
183
|
+
"outdated widget 1.5.0 (needs 2.0.0)",
|
|
184
|
+
);
|
|
185
|
+
const second = await runHook(
|
|
186
|
+
world,
|
|
187
|
+
plugin,
|
|
188
|
+
"outdated",
|
|
189
|
+
[world.bin],
|
|
190
|
+
input,
|
|
191
|
+
);
|
|
192
|
+
expect(second.exitCode).toBe(0);
|
|
193
|
+
expect(second.stdout).toBe("");
|
|
194
|
+
expect(
|
|
195
|
+
await readdir(
|
|
196
|
+
join(world.tmp, "magus-deps-outdated-session", "claimed"),
|
|
197
|
+
),
|
|
198
|
+
).toHaveLength(1);
|
|
199
|
+
} finally {
|
|
200
|
+
await removeWorld(world);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("REQ-30 a satisfied floor keeps the hook silent", async () => {
|
|
205
|
+
const world = await makeWorld();
|
|
206
|
+
try {
|
|
207
|
+
const plugin = await makeHookPlugin(
|
|
208
|
+
world,
|
|
209
|
+
"fresh",
|
|
210
|
+
requires([
|
|
211
|
+
requirement("widget", [{ formula: "widget", via: "brew" }], {
|
|
212
|
+
minVersion: "2.0.0",
|
|
213
|
+
}),
|
|
214
|
+
]),
|
|
215
|
+
);
|
|
216
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
217
|
+
await writeExecutable(join(world.bin, "widget"), "echo widget 2.3.0");
|
|
218
|
+
const result = await runHook(
|
|
219
|
+
world,
|
|
220
|
+
plugin,
|
|
221
|
+
"fresh",
|
|
222
|
+
[world.bin],
|
|
223
|
+
sessionInput("fresh-session"),
|
|
224
|
+
);
|
|
225
|
+
expect(result.exitCode).toBe(0);
|
|
226
|
+
expect(result.stdout).toBe("");
|
|
227
|
+
} finally {
|
|
228
|
+
await removeWorld(world);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -18,7 +18,7 @@ test("a Bun under BUN_INSTALL is reported as installed but absent from Claude Co
|
|
|
18
18
|
try {
|
|
19
19
|
const plugin = await makeHookPlugin(world, "bun-install", {
|
|
20
20
|
bin: [],
|
|
21
|
-
schema:
|
|
21
|
+
schema: 2,
|
|
22
22
|
});
|
|
23
23
|
const shellPath = await makeShellPath(world);
|
|
24
24
|
const bunInstall = join(world.root, "bun-install");
|
|
@@ -43,7 +43,7 @@ test("the TypeScript hook and shell fallback create ledger directories private t
|
|
|
43
43
|
const world = await makeWorld();
|
|
44
44
|
try {
|
|
45
45
|
const plugin = await makeHookPlugin(world, "private-ledger", {
|
|
46
|
-
schema:
|
|
46
|
+
schema: 2,
|
|
47
47
|
bin: [],
|
|
48
48
|
});
|
|
49
49
|
const shellPath = await makeShellPath(world);
|
|
@@ -26,7 +26,7 @@ test("each stale ledger is pruned even when another stale ledger cannot be remov
|
|
|
26
26
|
let blocked = "";
|
|
27
27
|
try {
|
|
28
28
|
const plugin = await makeHookPlugin(world, "ledger-pruning", {
|
|
29
|
-
schema:
|
|
29
|
+
schema: 2,
|
|
30
30
|
bin: [],
|
|
31
31
|
});
|
|
32
32
|
const shellPath = await makeShellPath(world);
|
|
@@ -17,7 +17,7 @@ test("doctor does not advise adding a PATH line already present in the shell rc
|
|
|
17
17
|
const rcLine = 'export PATH="$HOME/.bun/bin:$PATH"';
|
|
18
18
|
await writeFile(join(world.home, ".zshrc"), `${rcLine}\n`);
|
|
19
19
|
const plugin = await makePlugin(world, "path-advice", {
|
|
20
|
-
schema:
|
|
20
|
+
schema: 2,
|
|
21
21
|
bin: [
|
|
22
22
|
{
|
|
23
23
|
name: "bun",
|
|
@@ -105,14 +105,14 @@ const ledger = (sessionId = "s-1") => join(tmp, `magus-deps-${sessionId}`);
|
|
|
105
105
|
describe("deps-check hook", () => {
|
|
106
106
|
test("healthy: prints nothing, and the ledger proves it ran", () => {
|
|
107
107
|
tool("jq");
|
|
108
|
-
const dir = plugin("dev", { schema:
|
|
108
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
109
109
|
const r = runHook("dev", dir);
|
|
110
110
|
expect(r).toEqual({ stdout: "", exitCode: 0 });
|
|
111
111
|
expect(readFileSync(join(ledger(), "seen", "dev"), "utf8")).toBe("ts");
|
|
112
112
|
});
|
|
113
113
|
|
|
114
114
|
test("missing: one systemMessage with the marker, the dep and its fix", () => {
|
|
115
|
-
const dir = plugin("dev", { schema:
|
|
115
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
116
116
|
const r = runHook("dev", dir);
|
|
117
117
|
expect(r.exitCode).toBe(0);
|
|
118
118
|
const out = JSON.parse(r.stdout) as {
|
|
@@ -134,7 +134,7 @@ describe("deps-check hook", () => {
|
|
|
134
134
|
|
|
135
135
|
test("magus on PATH: the fix names magus alone", () => {
|
|
136
136
|
tool("magus");
|
|
137
|
-
const dir = plugin("dev", { schema:
|
|
137
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
138
138
|
const out = JSON.parse(runHook("dev", dir).stdout) as {
|
|
139
139
|
systemMessage: string;
|
|
140
140
|
};
|
|
@@ -149,7 +149,7 @@ describe("deps-check hook", () => {
|
|
|
149
149
|
JSON.parse(
|
|
150
150
|
runHook(
|
|
151
151
|
"dev",
|
|
152
|
-
plugin("dev", { schema:
|
|
152
|
+
plugin("dev", { schema: 2, bin: [JQ] }),
|
|
153
153
|
session,
|
|
154
154
|
input,
|
|
155
155
|
).stdout,
|
|
@@ -174,10 +174,10 @@ describe("deps-check hook", () => {
|
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
test("one identical declaration in two plugins is reported once per session", () => {
|
|
177
|
-
const first = runHook("dev", plugin("dev", { schema:
|
|
177
|
+
const first = runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
|
|
178
178
|
const second = runHook(
|
|
179
179
|
"code-search",
|
|
180
|
-
plugin("code-search", { schema:
|
|
180
|
+
plugin("code-search", { schema: 2, bin: [JQ] }),
|
|
181
181
|
);
|
|
182
182
|
expect(first.stdout).toContain("magus-deps · dev: missing jq");
|
|
183
183
|
expect(second.stdout).toBe("");
|
|
@@ -188,14 +188,14 @@ describe("deps-check hook", () => {
|
|
|
188
188
|
});
|
|
189
189
|
|
|
190
190
|
test("no readable session id: no ledger, and the banner still prints", () => {
|
|
191
|
-
const dir = plugin("dev", { schema:
|
|
191
|
+
const dir = plugin("dev", { schema: 2, bin: [JQ] });
|
|
192
192
|
const r = runHook("dev", dir, null);
|
|
193
193
|
expect(r.stdout).toContain("magus-deps · dev: missing jq");
|
|
194
194
|
expect(existsSync(tmp) && readdirSync(tmp)).toEqual([]);
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
test("an invalid declaration is reported, not swallowed", () => {
|
|
198
|
-
const dir = plugin("dev", { schema:
|
|
198
|
+
const dir = plugin("dev", { schema: 2, bin: [{ name: "jq" }] });
|
|
199
199
|
const out = JSON.parse(runHook("dev", dir).stdout) as {
|
|
200
200
|
systemMessage: string;
|
|
201
201
|
};
|
|
@@ -213,7 +213,7 @@ describe("deps-check hook", () => {
|
|
|
213
213
|
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
|
214
214
|
require("node:fs").utimesSync(old, twoDaysAgo, twoDaysAgo);
|
|
215
215
|
tool("jq");
|
|
216
|
-
runHook("dev", plugin("dev", { schema:
|
|
216
|
+
runHook("dev", plugin("dev", { schema: 2, bin: [JQ] }));
|
|
217
217
|
expect(existsSync(old)).toBe(false);
|
|
218
218
|
expect(existsSync(fresh)).toBe(true);
|
|
219
219
|
});
|
|
@@ -66,7 +66,7 @@ const TMUX_MCP = {
|
|
|
66
66
|
],
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
const manifest = (bin: unknown[], schema =
|
|
69
|
+
const manifest = (bin: unknown[], schema = 2) => ({
|
|
70
70
|
requires: { schema, bin },
|
|
71
71
|
});
|
|
72
72
|
|
|
@@ -117,9 +117,9 @@ describe("collectDeps", () => {
|
|
|
117
117
|
const collected = await collectDeps(
|
|
118
118
|
root,
|
|
119
119
|
sources({
|
|
120
|
-
"new@x": manifest([BUN],
|
|
121
|
-
"old@x": manifest([BUN],
|
|
122
|
-
"bad@x": { requires: { schema:
|
|
120
|
+
"new@x": manifest([BUN], 3),
|
|
121
|
+
"old@x": manifest([BUN], 1),
|
|
122
|
+
"bad@x": { requires: { schema: 2, bin: [{ name: "x" }] } },
|
|
123
123
|
}),
|
|
124
124
|
);
|
|
125
125
|
expect(collected.problems.map((p) => [p.plugin, p.kind])).toEqual([
|
|
@@ -132,7 +132,7 @@ describe("collectDeps", () => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
describe("depsReportJson", () => {
|
|
135
|
-
test("names tmux-mcp missing with its release install, in the schema-
|
|
135
|
+
test("names tmux-mcp missing with its release install, in the schema-2 shape", async () => {
|
|
136
136
|
const state = await assessDeps(
|
|
137
137
|
root,
|
|
138
138
|
env,
|
|
@@ -36,7 +36,7 @@ beforeEach(() => {
|
|
|
36
36
|
JSON.stringify({
|
|
37
37
|
name: "fake",
|
|
38
38
|
requires: {
|
|
39
|
-
schema:
|
|
39
|
+
schema: 2,
|
|
40
40
|
bin: [
|
|
41
41
|
{
|
|
42
42
|
name: "faketool",
|
|
@@ -117,7 +117,7 @@ describe("magus doctor --json", () => {
|
|
|
117
117
|
const r = doctor("--json");
|
|
118
118
|
expect(r.code).toBe(1);
|
|
119
119
|
const doc = JSON.parse(r.out);
|
|
120
|
-
expect(doc.schema).toBe(
|
|
120
|
+
expect(doc.schema).toBe(2);
|
|
121
121
|
expect(doc.plugins).toEqual(["fake@magus"]);
|
|
122
122
|
expect(doc.deps).toEqual([
|
|
123
123
|
{
|
|
@@ -23,7 +23,7 @@ const BUN: Requirement = {
|
|
|
23
23
|
};
|
|
24
24
|
const declares = (bin: Requirement[]) => ({
|
|
25
25
|
name: "p",
|
|
26
|
-
requires: { schema:
|
|
26
|
+
requires: { schema: 2, bin },
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
let dir: string;
|
|
@@ -60,7 +60,7 @@ describe("readPluginRequires", () => {
|
|
|
60
60
|
test("an invalid declaration is reported, never dropped to empty", async () => {
|
|
61
61
|
await fs.outputJson(join(dir, ".claude-plugin", "plugin.json"), {
|
|
62
62
|
name: "p",
|
|
63
|
-
requires: { schema:
|
|
63
|
+
requires: { schema: 2, bin: [{ name: "x", via: "brew" }] },
|
|
64
64
|
});
|
|
65
65
|
const r = await readPluginRequires(dir);
|
|
66
66
|
expect(r?.ok).toBe(false);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code copies git-ignored files into a NEW worktree (`claude -w`,
|
|
3
|
+
* EnterWorktree, agent isolation) only when the launch root's
|
|
4
|
+
* `.worktreeinclude` lists them. magus git-ignores the set-up state it
|
|
5
|
+
* generates, so it must also list that state there — or every new worktree of
|
|
6
|
+
* a profiled repo starts without the project's plugins.
|
|
7
|
+
*
|
|
8
|
+
* Exercised against a real git repository, with HOME and git config pointed at
|
|
9
|
+
* a scratch folder, because "listed AND git-ignored" is a statement about git.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
13
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import fs from "fs-extra";
|
|
17
|
+
import { writeManifest } from "../services/manifest.js";
|
|
18
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
19
|
+
import { updateSkillIgnoreLines } from "../services/profile-skills.js";
|
|
20
|
+
import {
|
|
21
|
+
inspectProfileState,
|
|
22
|
+
prepareProfileState,
|
|
23
|
+
} from "../services/profile-state.js";
|
|
24
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
25
|
+
|
|
26
|
+
// A profile with no plugins: activation renders the project's files with no
|
|
27
|
+
// install, CLI call or network.
|
|
28
|
+
const MANIFEST: ProfileManifest = {
|
|
29
|
+
version: 2,
|
|
30
|
+
profiles: { team: { name: "Team", plugins: {} } },
|
|
31
|
+
default: "team",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The set-up state a new worktree needs to start with the project's plugins. */
|
|
35
|
+
const REQUIRED = [
|
|
36
|
+
".claude/settings.json",
|
|
37
|
+
".mcp.json",
|
|
38
|
+
".claude/models.json",
|
|
39
|
+
".claude/profiles/active.json",
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const GIT_CFG = [
|
|
43
|
+
"-c",
|
|
44
|
+
"user.name=test",
|
|
45
|
+
"-c",
|
|
46
|
+
"user.email=test@example.com",
|
|
47
|
+
"-c",
|
|
48
|
+
"commit.gpgsign=false",
|
|
49
|
+
"-c",
|
|
50
|
+
"init.defaultBranch=main",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const git = (cwd: string, ...args: string[]): string =>
|
|
54
|
+
execFileSync("git", [...GIT_CFG, ...args], {
|
|
55
|
+
cwd,
|
|
56
|
+
encoding: "utf8",
|
|
57
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
58
|
+
}).trim();
|
|
59
|
+
|
|
60
|
+
/** Whether git treats `rel` as ignored in `repo` (exit 0 = ignored). */
|
|
61
|
+
const ignoredByGit = (repo: string, rel: string): boolean =>
|
|
62
|
+
spawnSync("git", [...GIT_CFG, "check-ignore", "-q", "--", rel], {
|
|
63
|
+
cwd: repo,
|
|
64
|
+
}).status === 0;
|
|
65
|
+
|
|
66
|
+
const ENV_KEYS = [
|
|
67
|
+
"HOME",
|
|
68
|
+
"CLAUDE_CONFIG_DIR",
|
|
69
|
+
"XDG_CONFIG_HOME",
|
|
70
|
+
"XDG_CACHE_HOME",
|
|
71
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
72
|
+
"GIT_CEILING_DIRECTORIES",
|
|
73
|
+
"GIT_DIR",
|
|
74
|
+
"GIT_WORK_TREE",
|
|
75
|
+
"GIT_INDEX_FILE",
|
|
76
|
+
"GIT_COMMON_DIR",
|
|
77
|
+
"GIT_OBJECT_DIRECTORY",
|
|
78
|
+
"GIT_PREFIX",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
let savedEnv: Record<string, string | undefined> = {};
|
|
82
|
+
let base: string;
|
|
83
|
+
let project: string;
|
|
84
|
+
let log: ReturnType<typeof spyOn>;
|
|
85
|
+
let err: ReturnType<typeof spyOn>;
|
|
86
|
+
|
|
87
|
+
const includePath = () => path.join(project, ".worktreeinclude");
|
|
88
|
+
|
|
89
|
+
/** The non-blank, non-comment lines of `.worktreeinclude`. */
|
|
90
|
+
async function includeLines(): Promise<string[]> {
|
|
91
|
+
const text = await fs.readFile(includePath(), "utf8");
|
|
92
|
+
return text
|
|
93
|
+
.split("\n")
|
|
94
|
+
.map((l) => l.trim())
|
|
95
|
+
.filter((l) => l !== "" && !l.startsWith("#"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function activate(): Promise<void> {
|
|
99
|
+
await activateProfile(MANIFEST, "team", project, { onDisplace: () => {} });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(async () => {
|
|
103
|
+
savedEnv = {};
|
|
104
|
+
for (const key of ENV_KEYS) savedEnv[key] = process.env[key];
|
|
105
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
106
|
+
|
|
107
|
+
base = await fs.realpath(
|
|
108
|
+
await fs.mkdtemp(path.join(os.tmpdir(), "magus-wt-include-")),
|
|
109
|
+
);
|
|
110
|
+
const home = path.join(base, "home");
|
|
111
|
+
await fs.ensureDir(path.join(home, ".claude"));
|
|
112
|
+
process.env.HOME = home;
|
|
113
|
+
process.env.XDG_CONFIG_HOME = path.join(home, ".config");
|
|
114
|
+
process.env.XDG_CACHE_HOME = path.join(home, ".cache");
|
|
115
|
+
process.env.CLAUDE_CONFIG_DIR = path.join(home, ".claude");
|
|
116
|
+
process.env.GIT_CONFIG_NOSYSTEM = "1";
|
|
117
|
+
process.env.GIT_CEILING_DIRECTORIES = base;
|
|
118
|
+
|
|
119
|
+
project = path.join(base, "repo");
|
|
120
|
+
await fs.ensureDir(project);
|
|
121
|
+
git(project, "init", "-q");
|
|
122
|
+
await writeManifest(MANIFEST, project);
|
|
123
|
+
git(project, "add", "-A");
|
|
124
|
+
git(project, "commit", "-qm", "manifest");
|
|
125
|
+
|
|
126
|
+
log = spyOn(console, "log").mockImplementation(() => {});
|
|
127
|
+
err = spyOn(console, "error").mockImplementation(() => {});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
afterEach(async () => {
|
|
131
|
+
log.mockRestore();
|
|
132
|
+
err.mockRestore();
|
|
133
|
+
for (const key of ENV_KEYS) {
|
|
134
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
135
|
+
else process.env[key] = savedEnv[key];
|
|
136
|
+
}
|
|
137
|
+
await fs.remove(base);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("activation lists the set-up state in .worktreeinclude", () => {
|
|
141
|
+
test("lists every generated file and never the backups", async () => {
|
|
142
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
143
|
+
await activate();
|
|
144
|
+
|
|
145
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
146
|
+
const lines = await includeLines();
|
|
147
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
148
|
+
expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("every listed path is git-ignored, so Claude Code copies it", async () => {
|
|
152
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
153
|
+
await activate();
|
|
154
|
+
|
|
155
|
+
const lines = await includeLines();
|
|
156
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
157
|
+
const notIgnored = lines.filter((l) => !ignoredByGit(project, l));
|
|
158
|
+
expect(notIgnored).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a person's own .worktreeinclude lines survive activation untouched", async () => {
|
|
162
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
163
|
+
const own =
|
|
164
|
+
"# mine: local secrets for every worktree\n.env\nconfig/local.yaml\n";
|
|
165
|
+
await fs.writeFile(includePath(), own);
|
|
166
|
+
|
|
167
|
+
await activate();
|
|
168
|
+
|
|
169
|
+
const text = await fs.readFile(includePath(), "utf8");
|
|
170
|
+
expect(text.startsWith(own)).toBe(true);
|
|
171
|
+
const lines = await includeLines();
|
|
172
|
+
expect(lines).toContain(".env");
|
|
173
|
+
expect(lines).toContain("config/local.yaml");
|
|
174
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("an already set-up project gets the include on the next gate pass", () => {
|
|
179
|
+
test("inspectProfileState writes nothing; prepareProfileState adds the block", async () => {
|
|
180
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
181
|
+
await activate();
|
|
182
|
+
// A project set up by a release that never wrote the include.
|
|
183
|
+
await fs.remove(includePath());
|
|
184
|
+
expect(
|
|
185
|
+
await fs.pathExists(
|
|
186
|
+
path.join(project, ".claude", "profiles", "active.json"),
|
|
187
|
+
),
|
|
188
|
+
).toBe(true);
|
|
189
|
+
|
|
190
|
+
await inspectProfileState(project);
|
|
191
|
+
expect(await fs.pathExists(includePath())).toBe(false);
|
|
192
|
+
|
|
193
|
+
const prep = await prepareProfileState(project);
|
|
194
|
+
expect(prep.problems).toEqual([]);
|
|
195
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
196
|
+
const lines = await includeLines();
|
|
197
|
+
for (const entry of REQUIRED) expect(lines).toContain(entry);
|
|
198
|
+
expect(lines.some((l) => l.includes(".magus-backups"))).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("magus-installed skill folders follow the gitignore into the include", () => {
|
|
203
|
+
test("a recorded skill folder is listed, and unlisted when removed", async () => {
|
|
204
|
+
// REGRESSION: new git worktrees of a magus-profiled repo start without project plugins — Fixed in /dev:fix session dev-fix-20261015-002521-dc0f06b1
|
|
205
|
+
await updateSkillIgnoreLines(project, [], ["team-skill"]);
|
|
206
|
+
expect(await fs.pathExists(includePath())).toBe(true);
|
|
207
|
+
expect(await includeLines()).toContain(".claude/skills/team-skill/");
|
|
208
|
+
|
|
209
|
+
await updateSkillIgnoreLines(project, ["team-skill"], []);
|
|
210
|
+
expect(await includeLines()).not.toContain(".claude/skills/team-skill/");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -156,10 +156,10 @@ describe("parseRequires", () => {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("schema: newer asks for a newer magus-cli, older for a newer plugin", () => {
|
|
159
|
-
const newer = parseRequires({ requires: { schema:
|
|
159
|
+
const newer = parseRequires({ requires: { schema: 3, bin: [] } });
|
|
160
160
|
expect(newer.ok ? null : newer.kind).toBe("newer-schema");
|
|
161
161
|
expect(newer.ok ? "" : newer.errors[0]).toContain("update magus-cli");
|
|
162
|
-
const older = parseRequires({ requires: { schema:
|
|
162
|
+
const older = parseRequires({ requires: { schema: 1, bin: [] } });
|
|
163
163
|
expect(older.ok ? null : older.kind).toBe("older-schema");
|
|
164
164
|
expect(older.ok ? "" : older.errors[0]).toContain("update the plugin");
|
|
165
165
|
const none = parseRequires({ requires: { bin: [] } });
|