magus-cli 7.8.0 → 7.9.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/README.md +1 -1
- package/package.json +4 -4
- package/src/__tests__/blackbox-parity.test.ts +16 -11
- package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
- package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
- package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
- package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
- package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
- package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
- package/src/__tests__/blind-780-cli-tools.test.ts +36 -48
- package/src/__tests__/blind-780-review2.test.ts +0 -25
- package/src/__tests__/cli-apply-seams.test.ts +2 -67
- package/src/__tests__/cli-tool-commands.test.ts +131 -6
- package/src/__tests__/cli-tools-adapter.test.ts +52 -2
- package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
- package/src/__tests__/deps-adapter.test.ts +191 -0
- package/src/__tests__/deps-check-hook.test.ts +220 -0
- package/src/__tests__/deps-plan.test.ts +896 -0
- package/src/__tests__/deps-report.test.ts +197 -0
- package/src/__tests__/doctor-bins.test.ts +134 -36
- package/src/__tests__/doctor-json.test.ts +261 -0
- package/src/__tests__/failure-popup-review2.test.tsx +1 -25
- package/src/__tests__/final-fixes-780.test.tsx +25 -9
- package/src/__tests__/fixtures/bins.ts +55 -0
- package/src/__tests__/install-plan.test.ts +15 -1
- package/src/__tests__/marketplace-refresh.test.ts +9 -3
- package/src/__tests__/no-shell-spawn.test.ts +77 -0
- package/src/__tests__/npm-package-name.test.ts +6 -9
- package/src/__tests__/plugin-requires.test.ts +138 -153
- package/src/__tests__/requires-probe.test.ts +858 -0
- package/src/__tests__/resolver.test.ts +129 -52
- package/src/__tests__/run.test.ts +108 -0
- package/src/__tests__/tool-probe.test.ts +1 -1
- package/src/__tests__/toolchain.test.ts +123 -52
- package/src/__tests__/update-plan.test.ts +1 -1
- package/src/cli/ansi.ts +2 -7
- package/src/cli/deps-view.ts +213 -0
- package/src/cli/doctor.ts +169 -24
- package/src/cli/install.ts +51 -66
- package/src/cli/profile.ts +20 -2
- package/src/cli/router.ts +6 -2
- package/src/cli/update.ts +28 -7
- package/src/data/cli-tools.ts +24 -9
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +4 -348
- package/src/services/cli-tool-commands.ts +104 -3
- package/src/services/deps-plan.ts +962 -0
- package/src/services/deps-report.ts +265 -0
- package/src/services/doctor-bins.ts +115 -43
- package/src/services/install-plan.ts +11 -1
- package/src/services/local-marketplace.ts +12 -206
- package/src/services/marketplace-refresh.ts +4 -4
- package/src/services/plugin-manager.ts +4 -18
- package/src/services/plugin-requires.ts +93 -162
- package/src/services/resolver.ts +168 -58
- package/src/services/tool-probe.ts +18 -25
- package/src/services/toolchain.ts +117 -111
- package/src/services/update-engine.ts +19 -14
- package/src/services/update-plan.ts +3 -3
- package/src/shared/plugin/hooks/bun-missing.sh +39 -0
- package/src/shared/plugin/hooks/deps-check.ts +224 -0
- package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
- package/src/types/index.ts +53 -16
- package/src/ui/App.tsx +6 -40
- package/src/ui/adapters/cliToolsAdapter.ts +75 -37
- package/src/ui/adapters/depsAdapter.ts +129 -0
- package/src/ui/adapters/startupReports.ts +0 -22
- package/src/ui/renderers/cliToolRenderers.tsx +135 -2
- package/src/ui/screens/CliToolsScreen.tsx +141 -51
- package/src/ui/screens/PluginsScreen.tsx +12 -48
- package/src/utils/command-utils.ts +4 -5
- package/src/utils/run.ts +123 -52
- package/src/utils/string-utils.ts +9 -0
- package/src/__tests__/go-module.test.ts +0 -43
- package/src/__tests__/plugin-setup.test.ts +0 -1016
- package/src/__tests__/shell-script-callers.test.ts +0 -92
- package/src/services/plugin-setup.ts +0 -965
- package/src/utils/go-module.ts +0 -36
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
chmod,
|
|
6
|
+
cp,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
realpath,
|
|
11
|
+
rm,
|
|
12
|
+
symlink,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
export const TOOLS_ROOT = join(import.meta.dir, "..", "..", "..", "..");
|
|
19
|
+
export const REPO_ROOT = join(TOOLS_ROOT, "..", "..");
|
|
20
|
+
export const MAGUS_MAIN = join(TOOLS_ROOT, "src", "main.tsx");
|
|
21
|
+
export const HOOK_REFERENCE = join(TOOLS_ROOT, "src", "shared", "plugin");
|
|
22
|
+
|
|
23
|
+
export interface World {
|
|
24
|
+
root: string;
|
|
25
|
+
home: string;
|
|
26
|
+
project: string;
|
|
27
|
+
bin: string;
|
|
28
|
+
tmp: string;
|
|
29
|
+
cache: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChildResult {
|
|
33
|
+
exitCode: number | null;
|
|
34
|
+
stdout: string;
|
|
35
|
+
stderr: string;
|
|
36
|
+
timedOut: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PluginEntry {
|
|
40
|
+
key: string;
|
|
41
|
+
root: string;
|
|
42
|
+
scope?: "user" | "project" | "local";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function makeWorld(): Promise<World> {
|
|
46
|
+
const root = await mkdtemp(join(tmpdir(), "magus-blind-"));
|
|
47
|
+
const world = {
|
|
48
|
+
root,
|
|
49
|
+
home: join(root, "home"),
|
|
50
|
+
project: join(root, "project"),
|
|
51
|
+
bin: join(root, "bin"),
|
|
52
|
+
tmp: join(root, "tmp"),
|
|
53
|
+
cache: join(root, "home", "cache"),
|
|
54
|
+
};
|
|
55
|
+
await Promise.all([
|
|
56
|
+
mkdir(world.home, { recursive: true }),
|
|
57
|
+
mkdir(world.project, { recursive: true }),
|
|
58
|
+
mkdir(world.bin, { recursive: true }),
|
|
59
|
+
mkdir(world.tmp, { recursive: true }),
|
|
60
|
+
mkdir(world.cache, { recursive: true }),
|
|
61
|
+
]);
|
|
62
|
+
return world;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function removeWorld(world: World): Promise<void> {
|
|
66
|
+
await rm(world.root, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function writeJson(path: string, value: unknown): Promise<void> {
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function writeExecutable(
|
|
75
|
+
path: string,
|
|
76
|
+
body: string,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
await mkdir(dirname(path), { recursive: true });
|
|
79
|
+
await writeFile(path, `#!/bin/sh\n${body}\n`);
|
|
80
|
+
await chmod(path, 0o755);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function linkExecutable(
|
|
84
|
+
dir: string,
|
|
85
|
+
name: string,
|
|
86
|
+
target: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
await mkdir(dir, { recursive: true });
|
|
89
|
+
await symlink(target, join(dir, name));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pinnedEnv(
|
|
93
|
+
world: World,
|
|
94
|
+
pathDirs: string[],
|
|
95
|
+
extra: Record<string, string> = {},
|
|
96
|
+
): NodeJS.ProcessEnv {
|
|
97
|
+
return {
|
|
98
|
+
HOME: world.home,
|
|
99
|
+
LANG: "C",
|
|
100
|
+
LC_ALL: "C",
|
|
101
|
+
MAGUS_NO_BINARY: "1",
|
|
102
|
+
NO_COLOR: "1",
|
|
103
|
+
PATH: pathDirs.join(":"),
|
|
104
|
+
SHELL: "/bin/zsh",
|
|
105
|
+
TMPDIR: world.tmp,
|
|
106
|
+
XDG_CACHE_HOME: world.cache,
|
|
107
|
+
...extra,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function runChild(
|
|
112
|
+
command: string,
|
|
113
|
+
args: string[],
|
|
114
|
+
options: {
|
|
115
|
+
cwd: string;
|
|
116
|
+
env: NodeJS.ProcessEnv;
|
|
117
|
+
input?: string;
|
|
118
|
+
timeoutMs?: number;
|
|
119
|
+
},
|
|
120
|
+
): Promise<ChildResult> {
|
|
121
|
+
const child = spawn(command, args, {
|
|
122
|
+
cwd: options.cwd,
|
|
123
|
+
env: options.env,
|
|
124
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
125
|
+
});
|
|
126
|
+
const stdout: Buffer[] = [];
|
|
127
|
+
const stderr: Buffer[] = [];
|
|
128
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
129
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
130
|
+
child.stdin.end(options.input);
|
|
131
|
+
let timedOut = false;
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
timedOut = true;
|
|
134
|
+
child.kill("SIGKILL");
|
|
135
|
+
}, options.timeoutMs ?? 15_000);
|
|
136
|
+
const exitCode = await new Promise<number | null>((resolve, reject) => {
|
|
137
|
+
child.on("error", reject);
|
|
138
|
+
child.on("close", resolve);
|
|
139
|
+
});
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
return {
|
|
142
|
+
exitCode,
|
|
143
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
144
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
145
|
+
timedOut,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function runDoctor(
|
|
150
|
+
world: World,
|
|
151
|
+
args: string[],
|
|
152
|
+
options: {
|
|
153
|
+
pathDirs?: string[];
|
|
154
|
+
input?: string;
|
|
155
|
+
extraEnv?: Record<string, string>;
|
|
156
|
+
timeoutMs?: number;
|
|
157
|
+
} = {},
|
|
158
|
+
): Promise<ChildResult> {
|
|
159
|
+
return runChild(
|
|
160
|
+
process.execPath,
|
|
161
|
+
["--no-env-file", MAGUS_MAIN, "doctor", ...args],
|
|
162
|
+
{
|
|
163
|
+
cwd: world.project,
|
|
164
|
+
env: pinnedEnv(world, options.pathDirs ?? [world.bin], options.extraEnv),
|
|
165
|
+
input: options.input,
|
|
166
|
+
timeoutMs: options.timeoutMs ?? 20_000,
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function makePlugin(
|
|
172
|
+
world: World,
|
|
173
|
+
name: string,
|
|
174
|
+
requires: unknown,
|
|
175
|
+
parent = join(world.root, "plugins"),
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
const root = join(parent, name);
|
|
178
|
+
await writeJson(join(root, ".claude-plugin", "plugin.json"), {
|
|
179
|
+
name,
|
|
180
|
+
version: "1.0.0",
|
|
181
|
+
description: `${name} black-box testdata`,
|
|
182
|
+
requires,
|
|
183
|
+
});
|
|
184
|
+
return root;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function configureClaude(
|
|
188
|
+
world: World,
|
|
189
|
+
entries: PluginEntry[],
|
|
190
|
+
): Promise<void> {
|
|
191
|
+
const claude = join(world.home, ".claude");
|
|
192
|
+
const clone = join(claude, "plugins", "marketplaces", "test");
|
|
193
|
+
const enabled = (scope: "user" | "project" | "local") =>
|
|
194
|
+
Object.fromEntries(
|
|
195
|
+
entries
|
|
196
|
+
.filter((entry) => (entry.scope ?? "user") === scope)
|
|
197
|
+
.map((entry) => [entry.key, true]),
|
|
198
|
+
);
|
|
199
|
+
await writeJson(join(claude, "settings.json"), {
|
|
200
|
+
enabledPlugins: enabled("user"),
|
|
201
|
+
});
|
|
202
|
+
await writeJson(join(world.project, ".claude", "settings.json"), {
|
|
203
|
+
enabledPlugins: enabled("project"),
|
|
204
|
+
});
|
|
205
|
+
await writeJson(join(world.project, ".claude", "settings.local.json"), {
|
|
206
|
+
enabledPlugins: enabled("local"),
|
|
207
|
+
});
|
|
208
|
+
await writeJson(join(claude, "plugins", "installed_plugins.json"), {
|
|
209
|
+
version: 2,
|
|
210
|
+
plugins: Object.fromEntries(
|
|
211
|
+
entries.map((entry) => [
|
|
212
|
+
entry.key,
|
|
213
|
+
[
|
|
214
|
+
{
|
|
215
|
+
installPath: entry.root,
|
|
216
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
217
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
218
|
+
projectPath: entry.scope === "project" ? world.project : undefined,
|
|
219
|
+
scope: entry.scope === "project" ? "project" : "user",
|
|
220
|
+
version: "1.0.0",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
]),
|
|
224
|
+
),
|
|
225
|
+
});
|
|
226
|
+
await writeJson(join(claude, "plugins", "known_marketplaces.json"), {
|
|
227
|
+
test: {
|
|
228
|
+
installLocation: clone,
|
|
229
|
+
lastUpdated: "2026-01-01T00:00:00.000Z",
|
|
230
|
+
source: { repo: "Example/test", source: "github" },
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
await writeJson(join(clone, ".claude-plugin", "marketplace.json"), {
|
|
234
|
+
name: "test",
|
|
235
|
+
plugins: entries.map((entry) => ({
|
|
236
|
+
name: entry.key.split("@")[0],
|
|
237
|
+
source: `./plugins/${entry.key.split("@")[0]}`,
|
|
238
|
+
version: "1.0.0",
|
|
239
|
+
})),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function makeHookPlugin(
|
|
244
|
+
world: World,
|
|
245
|
+
name: string,
|
|
246
|
+
requires: unknown,
|
|
247
|
+
): Promise<string> {
|
|
248
|
+
const root = await makePlugin(world, name, requires);
|
|
249
|
+
await mkdir(join(root, "hooks", "lib"), { recursive: true });
|
|
250
|
+
await Promise.all([
|
|
251
|
+
cp(
|
|
252
|
+
join(HOOK_REFERENCE, "hooks", "deps-check.ts"),
|
|
253
|
+
join(root, "hooks", "deps-check.ts"),
|
|
254
|
+
),
|
|
255
|
+
cp(
|
|
256
|
+
join(HOOK_REFERENCE, "hooks", "lib", "requires-probe.ts"),
|
|
257
|
+
join(root, "hooks", "lib", "requires-probe.ts"),
|
|
258
|
+
),
|
|
259
|
+
cp(
|
|
260
|
+
join(HOOK_REFERENCE, "hooks", "bun-missing.sh"),
|
|
261
|
+
join(root, "hooks", "bun-missing.sh"),
|
|
262
|
+
),
|
|
263
|
+
]);
|
|
264
|
+
return root;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function hookCommand(label: string): string {
|
|
268
|
+
return [
|
|
269
|
+
"command -v bun >/dev/null 2>&1 && exec bun",
|
|
270
|
+
"--env-file=/dev/null --config=/dev/null",
|
|
271
|
+
`"\${CLAUDE_PLUGIN_ROOT}/hooks/deps-check.ts" ${label} || exec sh`,
|
|
272
|
+
`"\${CLAUDE_PLUGIN_ROOT}/hooks/bun-missing.sh" ${label}`,
|
|
273
|
+
].join(" ");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function runHook(
|
|
277
|
+
world: World,
|
|
278
|
+
pluginRoot: string,
|
|
279
|
+
label: string,
|
|
280
|
+
pathDirs: string[],
|
|
281
|
+
input: string,
|
|
282
|
+
shell = "/bin/zsh",
|
|
283
|
+
): Promise<ChildResult> {
|
|
284
|
+
return runChild("/bin/sh", ["-c", hookCommand(label)], {
|
|
285
|
+
cwd: world.project,
|
|
286
|
+
env: pinnedEnv(world, pathDirs, {
|
|
287
|
+
CLAUDE_PLUGIN_ROOT: pluginRoot,
|
|
288
|
+
SHELL: shell,
|
|
289
|
+
}),
|
|
290
|
+
input,
|
|
291
|
+
timeoutMs: 9_500,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const SYSTEM_CANDIDATES: Record<string, string[]> = {
|
|
296
|
+
cat: ["/bin/cat", "/usr/bin/cat"],
|
|
297
|
+
find: ["/usr/bin/find", "/bin/find"],
|
|
298
|
+
mkdir: ["/bin/mkdir", "/usr/bin/mkdir"],
|
|
299
|
+
printf: ["/usr/bin/printf", "/bin/printf"],
|
|
300
|
+
rm: ["/bin/rm", "/usr/bin/rm"],
|
|
301
|
+
sed: ["/usr/bin/sed", "/bin/sed"],
|
|
302
|
+
sh: ["/bin/sh", "/usr/bin/sh"],
|
|
303
|
+
tar: ["/usr/bin/tar", "/bin/tar"],
|
|
304
|
+
tr: ["/usr/bin/tr", "/bin/tr"],
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
async function systemTool(name: string): Promise<string> {
|
|
308
|
+
for (const candidate of SYSTEM_CANDIDATES[name] ?? []) {
|
|
309
|
+
try {
|
|
310
|
+
await access(candidate, constants.X_OK);
|
|
311
|
+
return await realpath(candidate);
|
|
312
|
+
} catch {
|
|
313
|
+
// Try the next explicit system location.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`No system ${name} executable at a pinned location`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function makeShellPath(world: World): Promise<string> {
|
|
320
|
+
const dir = join(world.root, "shell-bin");
|
|
321
|
+
await mkdir(dir, { recursive: true });
|
|
322
|
+
for (const name of Object.keys(SYSTEM_CANDIDATES)) {
|
|
323
|
+
await linkExecutable(dir, name, await systemTool(name));
|
|
324
|
+
}
|
|
325
|
+
return dir;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function readUtf8(path: string): Promise<string> {
|
|
329
|
+
return readFile(path, "utf8");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function sessionInput(sessionId = "session-1"): string {
|
|
333
|
+
return JSON.stringify({
|
|
334
|
+
cwd: "/testdata",
|
|
335
|
+
hook_event_name: "SessionStart",
|
|
336
|
+
session_id: sessionId,
|
|
337
|
+
source: "startup",
|
|
338
|
+
transcript_path: "/testdata/transcript.jsonl",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type Requirement,
|
|
7
|
+
dedupeKey,
|
|
8
|
+
} from "../../../shared/plugin/hooks/lib/requires-probe.js";
|
|
9
|
+
import {
|
|
10
|
+
linkExecutable,
|
|
11
|
+
makeHookPlugin,
|
|
12
|
+
makeShellPath,
|
|
13
|
+
makeWorld,
|
|
14
|
+
readUtf8,
|
|
15
|
+
removeWorld,
|
|
16
|
+
runHook,
|
|
17
|
+
sessionInput,
|
|
18
|
+
writeExecutable,
|
|
19
|
+
} from "./helpers";
|
|
20
|
+
|
|
21
|
+
function requirement(
|
|
22
|
+
name: string,
|
|
23
|
+
install: Record<string, unknown>[],
|
|
24
|
+
check = [name, "--version"],
|
|
25
|
+
): Record<string, unknown> {
|
|
26
|
+
return { check, install, name };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requires(bin: Record<string, unknown>[]): Record<string, unknown> {
|
|
30
|
+
return { bin, schema: 1 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("registered SessionStart dependency hook", () => {
|
|
34
|
+
// Spec: AS-BUILT.md §3, the TypeScript hook writes seen/<label>=ts first,
|
|
35
|
+
// prints nothing when healthy, exits 0, and runs within the 10 s timeout.
|
|
36
|
+
test("is silent when healthy but records TypeScript branch observation", async () => {
|
|
37
|
+
const world = await makeWorld();
|
|
38
|
+
try {
|
|
39
|
+
const plugin = await makeHookPlugin(
|
|
40
|
+
world,
|
|
41
|
+
"healthy",
|
|
42
|
+
requires([
|
|
43
|
+
requirement("healthy-tool", [
|
|
44
|
+
{ formula: "healthy-tool", via: "brew" },
|
|
45
|
+
]),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
49
|
+
await writeExecutable(join(world.bin, "healthy-tool"), "exit 0");
|
|
50
|
+
const result = await runHook(
|
|
51
|
+
world,
|
|
52
|
+
plugin,
|
|
53
|
+
"healthy",
|
|
54
|
+
[world.bin],
|
|
55
|
+
sessionInput("healthy-session"),
|
|
56
|
+
);
|
|
57
|
+
expect(result.timedOut).toBe(false);
|
|
58
|
+
expect(result.exitCode).toBe(0);
|
|
59
|
+
expect(result.stdout).toBe("");
|
|
60
|
+
expect(
|
|
61
|
+
await readUtf8(
|
|
62
|
+
join(world.tmp, "magus-deps-healthy-session", "seen", "healthy"),
|
|
63
|
+
),
|
|
64
|
+
).toBe("ts");
|
|
65
|
+
} finally {
|
|
66
|
+
await removeWorld(world);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Spec: AS-BUILT.md §3, a missing dependency produces one JSON line with
|
|
71
|
+
// systemMessage and SessionStart additionalContext naming sorted findings.
|
|
72
|
+
test("emits the documented magus-deps JSON banner for a missing tool", async () => {
|
|
73
|
+
const world = await makeWorld();
|
|
74
|
+
try {
|
|
75
|
+
const plugin = await makeHookPlugin(
|
|
76
|
+
world,
|
|
77
|
+
"missing",
|
|
78
|
+
requires([
|
|
79
|
+
requirement("widget", [{ packages: ["widget"], via: "apt" }]),
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
83
|
+
await writeExecutable(join(world.bin, "apt-get"), "exit 0");
|
|
84
|
+
const result = await runHook(
|
|
85
|
+
world,
|
|
86
|
+
plugin,
|
|
87
|
+
"missing",
|
|
88
|
+
[world.bin],
|
|
89
|
+
sessionInput("missing-session"),
|
|
90
|
+
);
|
|
91
|
+
expect(result.timedOut).toBe(false);
|
|
92
|
+
expect(result.exitCode).toBe(0);
|
|
93
|
+
expect(result.stdout.trim().split("\n")).toHaveLength(1);
|
|
94
|
+
const banner = JSON.parse(result.stdout);
|
|
95
|
+
expect(banner.systemMessage).toContain(
|
|
96
|
+
"magus-deps · missing: missing widget.",
|
|
97
|
+
);
|
|
98
|
+
expect(banner.hookSpecificOutput).toEqual({
|
|
99
|
+
additionalContext: [
|
|
100
|
+
"missing's MCP servers and hooks will fail until these are ",
|
|
101
|
+
"installed: widget",
|
|
102
|
+
].join(""),
|
|
103
|
+
hookEventName: "SessionStart",
|
|
104
|
+
});
|
|
105
|
+
} finally {
|
|
106
|
+
await removeWorld(world);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Spec: AS-BUILT.md §3, per-session mkdir claims dedupe identical
|
|
111
|
+
// declarations across plugin copies so only the first reports.
|
|
112
|
+
test("deduplicates an identical declaration across two plugin copies", async () => {
|
|
113
|
+
const world = await makeWorld();
|
|
114
|
+
try {
|
|
115
|
+
const declaration = requires([
|
|
116
|
+
requirement("shared", [{ packages: ["shared"], via: "apt" }]),
|
|
117
|
+
]);
|
|
118
|
+
const first = await makeHookPlugin(world, "first", declaration);
|
|
119
|
+
const second = await makeHookPlugin(world, "second", declaration);
|
|
120
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
121
|
+
await writeExecutable(join(world.bin, "apt-get"), "exit 0");
|
|
122
|
+
const input = sessionInput("dedupe-session");
|
|
123
|
+
const one = await runHook(world, first, "first", [world.bin], input);
|
|
124
|
+
const two = await runHook(world, second, "second", [world.bin], input);
|
|
125
|
+
expect(one.stdout).toContain("magus-deps");
|
|
126
|
+
expect(two.stdout).toBe("");
|
|
127
|
+
const ledger = join(world.tmp, "magus-deps-dedupe-session");
|
|
128
|
+
expect(await readUtf8(join(ledger, "seen", "first"))).toBe("ts");
|
|
129
|
+
expect(await readUtf8(join(ledger, "seen", "second"))).toBe("ts");
|
|
130
|
+
expect(await readdir(join(ledger, "claimed"))).toHaveLength(1);
|
|
131
|
+
} finally {
|
|
132
|
+
await removeWorld(world);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Spec: AS-BUILT.md §3 and D11, different declarations with the same
|
|
137
|
+
// name get different keys and therefore are both reported.
|
|
138
|
+
test("reports both non-identical declarations of one dependency name", async () => {
|
|
139
|
+
const world = await makeWorld();
|
|
140
|
+
try {
|
|
141
|
+
const first = await makeHookPlugin(
|
|
142
|
+
world,
|
|
143
|
+
"first",
|
|
144
|
+
requires([
|
|
145
|
+
requirement("shared", [{ packages: ["shared-one"], via: "apt" }]),
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
const second = await makeHookPlugin(
|
|
149
|
+
world,
|
|
150
|
+
"second",
|
|
151
|
+
requires([
|
|
152
|
+
requirement("shared", [{ packages: ["shared-two"], via: "apt" }]),
|
|
153
|
+
]),
|
|
154
|
+
);
|
|
155
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
156
|
+
await writeExecutable(join(world.bin, "apt-get"), "exit 0");
|
|
157
|
+
const input = sessionInput("distinct-session");
|
|
158
|
+
const one = await runHook(world, first, "first", [world.bin], input);
|
|
159
|
+
const two = await runHook(world, second, "second", [world.bin], input);
|
|
160
|
+
expect(one.stdout).toContain("magus-deps");
|
|
161
|
+
expect(two.stdout).toContain("magus-deps");
|
|
162
|
+
expect(
|
|
163
|
+
await readdir(
|
|
164
|
+
join(world.tmp, "magus-deps-distinct-session", "claimed"),
|
|
165
|
+
),
|
|
166
|
+
).toHaveLength(2);
|
|
167
|
+
} finally {
|
|
168
|
+
await removeWorld(world);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Spec: AS-BUILT.md §3, an invalid/unreadable session_id creates no
|
|
173
|
+
// ledger or claims, so every copy reports rather than suppressing findings.
|
|
174
|
+
test("stays loud across copies when the session id is unusable", async () => {
|
|
175
|
+
const world = await makeWorld();
|
|
176
|
+
try {
|
|
177
|
+
const declaration = requires([
|
|
178
|
+
requirement("shared", [{ packages: ["shared"], via: "apt" }]),
|
|
179
|
+
]);
|
|
180
|
+
const first = await makeHookPlugin(world, "first", declaration);
|
|
181
|
+
const second = await makeHookPlugin(world, "second", declaration);
|
|
182
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
183
|
+
await writeExecutable(join(world.bin, "apt-get"), "exit 0");
|
|
184
|
+
const input = sessionInput("bad session id");
|
|
185
|
+
const one = await runHook(world, first, "first", [world.bin], input);
|
|
186
|
+
const two = await runHook(world, second, "second", [world.bin], input);
|
|
187
|
+
expect(one.stdout).toContain("magus-deps");
|
|
188
|
+
expect(two.stdout).toContain("magus-deps");
|
|
189
|
+
await expect(
|
|
190
|
+
access(join(world.tmp, "magus-deps-bad session id")),
|
|
191
|
+
).rejects.toThrow();
|
|
192
|
+
} finally {
|
|
193
|
+
await removeWorld(world);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Spec: AS-BUILT.md §3 sh branch gives the exact missing-Bun message,
|
|
198
|
+
// writes seen=sh, and claims bun-3182ab42b0f1 once across copies.
|
|
199
|
+
test("falls back without Bun and claims the canonical Bun key once", async () => {
|
|
200
|
+
const world = await makeWorld();
|
|
201
|
+
try {
|
|
202
|
+
const first = await makeHookPlugin(world, "first", requires([]));
|
|
203
|
+
const second = await makeHookPlugin(world, "second", requires([]));
|
|
204
|
+
const shellPath = await makeShellPath(world);
|
|
205
|
+
const input = sessionInput("no-bun-session");
|
|
206
|
+
const one = await runHook(world, first, "first", [shellPath], input);
|
|
207
|
+
const two = await runHook(world, second, "second", [shellPath], input);
|
|
208
|
+
expect(one.exitCode).toBe(0);
|
|
209
|
+
expect(one.timedOut).toBe(false);
|
|
210
|
+
expect(two.exitCode).toBe(0);
|
|
211
|
+
expect(two.stdout).toBe("");
|
|
212
|
+
const message = JSON.parse(one.stdout).systemMessage;
|
|
213
|
+
expect(message).toBe(
|
|
214
|
+
[
|
|
215
|
+
"magus-deps: Bun is missing — magus hooks and MCP servers need it ",
|
|
216
|
+
"(noticed by first).\n Install: curl -fsSL https://bun.sh/install ",
|
|
217
|
+
"| bash, then restart Claude Code from a new terminal.",
|
|
218
|
+
].join(""),
|
|
219
|
+
);
|
|
220
|
+
const bunRequirement: Requirement = {
|
|
221
|
+
check: ["bun", "--version"],
|
|
222
|
+
install: [{ tool: "bun", via: "bootstrap" }],
|
|
223
|
+
name: "bun",
|
|
224
|
+
};
|
|
225
|
+
const key = dedupeKey(bunRequirement);
|
|
226
|
+
expect(key).toBe("bun-3182ab42b0f1");
|
|
227
|
+
const ledger = join(world.tmp, "magus-deps-no-bun-session");
|
|
228
|
+
expect(await readUtf8(join(ledger, "seen", "first"))).toBe("sh");
|
|
229
|
+
expect(await readUtf8(join(ledger, "seen", "second"))).toBe("sh");
|
|
230
|
+
expect(await readdir(join(ledger, "claimed"))).toEqual([key]);
|
|
231
|
+
} finally {
|
|
232
|
+
await removeWorld(world);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Spec: AS-BUILT.md §3 sh branch reports off-PATH ~/.bun/bin/bun with
|
|
237
|
+
// zsh/bash/fish-specific rc line and file.
|
|
238
|
+
test("gives exact shell-specific advice for Bun installed off PATH", async () => {
|
|
239
|
+
const world = await makeWorld();
|
|
240
|
+
try {
|
|
241
|
+
const plugin = await makeHookPlugin(world, "offpath", requires([]));
|
|
242
|
+
const shellPath = await makeShellPath(world);
|
|
243
|
+
await writeExecutable(join(world.home, ".bun", "bin", "bun"), "exit 0");
|
|
244
|
+
const cases = [
|
|
245
|
+
{
|
|
246
|
+
file: "~/.zshrc",
|
|
247
|
+
line: 'export PATH="$HOME/.bun/bin:$PATH"',
|
|
248
|
+
shell: "/bin/zsh",
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
file: "~/.bashrc",
|
|
252
|
+
line: 'export PATH="$HOME/.bun/bin:$PATH"',
|
|
253
|
+
shell: "/bin/bash",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
file: "~/.config/fish/config.fish",
|
|
257
|
+
line: "fish_add_path $HOME/.bun/bin",
|
|
258
|
+
shell: "/usr/bin/fish",
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
for (const [index, item] of cases.entries()) {
|
|
262
|
+
const result = await runHook(
|
|
263
|
+
world,
|
|
264
|
+
plugin,
|
|
265
|
+
"offpath",
|
|
266
|
+
[shellPath],
|
|
267
|
+
sessionInput(`offpath-${index}`),
|
|
268
|
+
item.shell,
|
|
269
|
+
);
|
|
270
|
+
expect(result.exitCode).toBe(0);
|
|
271
|
+
const message = JSON.parse(result.stdout).systemMessage;
|
|
272
|
+
expect(message).toBe(
|
|
273
|
+
[
|
|
274
|
+
"magus-deps: Bun is installed at ~/.bun/bin but is not on the ",
|
|
275
|
+
"PATH Claude Code started with, so magus hooks and MCP servers ",
|
|
276
|
+
"cannot run (noticed by offpath).\n Add ",
|
|
277
|
+
`${item.line} to ${item.file}, then restart Claude Code from a `,
|
|
278
|
+
"new terminal.",
|
|
279
|
+
].join(""),
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
} finally {
|
|
283
|
+
await removeWorld(world);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Spec: AS-BUILT.md §3, invalid requires says “please report” under an
|
|
288
|
+
// invalid-label claim; a bad label is silent; both branches always exit 0.
|
|
289
|
+
test("reports invalid requires but ignores an invalid label", async () => {
|
|
290
|
+
const world = await makeWorld();
|
|
291
|
+
try {
|
|
292
|
+
const plugin = await makeHookPlugin(world, "invalid", {
|
|
293
|
+
bin: [],
|
|
294
|
+
mystery: true,
|
|
295
|
+
schema: 1,
|
|
296
|
+
});
|
|
297
|
+
await linkExecutable(world.bin, "bun", process.execPath);
|
|
298
|
+
const input = sessionInput("invalid-session");
|
|
299
|
+
const invalid = await runHook(
|
|
300
|
+
world,
|
|
301
|
+
plugin,
|
|
302
|
+
"invalid",
|
|
303
|
+
[world.bin],
|
|
304
|
+
input,
|
|
305
|
+
);
|
|
306
|
+
expect(invalid.exitCode).toBe(0);
|
|
307
|
+
const message = JSON.parse(invalid.stdout).systemMessage;
|
|
308
|
+
expect(message).toContain("magus-deps · invalid: requires is invalid (");
|
|
309
|
+
expect(message).toContain("mystery");
|
|
310
|
+
expect(message).toEndWith("— please report");
|
|
311
|
+
expect(
|
|
312
|
+
await readdir(join(world.tmp, "magus-deps-invalid-session", "claimed")),
|
|
313
|
+
).toEqual(["invalid-invalid"]);
|
|
314
|
+
const badLabel = await runHook(
|
|
315
|
+
world,
|
|
316
|
+
plugin,
|
|
317
|
+
"Bad!",
|
|
318
|
+
[world.bin],
|
|
319
|
+
sessionInput("bad-label-session"),
|
|
320
|
+
);
|
|
321
|
+
expect(badLabel.exitCode).toBe(0);
|
|
322
|
+
expect(badLabel.stdout).toBe("");
|
|
323
|
+
expect(badLabel.timedOut).toBe(false);
|
|
324
|
+
} finally {
|
|
325
|
+
await removeWorld(world);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|