magus-cli 7.11.5 → 7.11.7

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.
@@ -0,0 +1,195 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { act, useEffect } from "react";
7
+ import { KeyRouter } from "../ui/input/KeyRouter.js";
8
+ import McpRegistryScreen from "../ui/screens/McpRegistryScreen.js";
9
+ import { AppProvider, useNavigation } from "../ui/state/AppContext.js";
10
+ import { DimensionsProvider } from "../ui/state/DimensionsContext.js";
11
+
12
+ // REGRESSION: MTL-337 debounce window and cursor jump — round 2 of /dev:fix session dev-fix-20261101-153739-a08a5829
13
+ //
14
+ // The real McpRegistryScreen is mounted under the real providers, with a fake
15
+ // `fetch` for the registry endpoint and a throwaway project path. Keys go
16
+ // through the test renderer's mock keyboard; the outcome is read from what the
17
+ // screen wrote to .mcp.json.
18
+
19
+ // Real-looking hosts for the servers whose host decides "official"; every
20
+ // other name gets an example.org host.
21
+ const HOSTS: Record<string, string> = {
22
+ "com.notion/mcp": "https://mcp.notion.com/mcp",
23
+ "ai.smithery/smithery-notion": "https://server.smithery.ai/notion/mcp",
24
+ "com.linear/mcp": "https://mcp.linear.com/mcp",
25
+ };
26
+
27
+ const server = (name: string) => ({
28
+ name,
29
+ url: HOSTS[name] ?? `https://${name.replace(/[^a-z]/g, "")}.example.org/mcp`,
30
+ short_description: `${name} description`,
31
+ });
32
+
33
+ type Deferred = { query: string; resolve: (names: string[]) => void };
34
+
35
+ let cleanup: Array<() => Promise<void> | void> = [];
36
+ const realFetch = globalThis.fetch;
37
+
38
+ afterEach(async () => {
39
+ globalThis.fetch = realFetch;
40
+ for (const fn of cleanup.reverse()) await fn();
41
+ cleanup = [];
42
+ });
43
+
44
+ function GoToRegistry() {
45
+ const { navigateToScreen } = useNavigation();
46
+ // biome-ignore lint/correctness/useExhaustiveDependencies: navigate once on mount
47
+ useEffect(() => navigateToScreen("mcp-registry"), []);
48
+ return null;
49
+ }
50
+
51
+ async function mount(initialNames: string[]) {
52
+ const project = mkdtempSync(join(tmpdir(), "mcp-registry-screen-"));
53
+ cleanup.push(() => rmSync(project, { recursive: true, force: true }));
54
+
55
+ const pending: Deferred[] = [];
56
+ globalThis.fetch = (async (input: unknown) => {
57
+ const url = new URL(String(input));
58
+ const query = url.searchParams.get("q") ?? "";
59
+ const names = await new Promise<string[]>((resolve) => {
60
+ if (query === "") resolve(initialNames);
61
+ else pending.push({ query, resolve });
62
+ });
63
+ return new Response(JSON.stringify({ servers: names.map(server) }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json" },
66
+ });
67
+ }) as typeof fetch;
68
+
69
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
70
+ await testRender(
71
+ <AppProvider initialProjectPath={project}>
72
+ <DimensionsProvider>
73
+ <KeyRouter>
74
+ <GoToRegistry />
75
+ <McpRegistryScreen />
76
+ </KeyRouter>
77
+ </DimensionsProvider>
78
+ </AppProvider>,
79
+ { width: 110, height: 30 },
80
+ );
81
+ cleanup.push(async () => {
82
+ await act(async () => renderer.destroy());
83
+ });
84
+
85
+ const settle = async (ms = 30) => {
86
+ for (let i = 0; i < 3; i++) {
87
+ await new Promise((r) => setTimeout(r, ms));
88
+ await renderOnce();
89
+ }
90
+ };
91
+ const send = async (fn: () => void | Promise<void>, ms?: number) =>
92
+ act(async () => {
93
+ await fn();
94
+ await settle(ms);
95
+ });
96
+ await act(() => settle());
97
+
98
+ const mcpJson = () => {
99
+ const path = join(project, ".mcp.json");
100
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
101
+ };
102
+ return { mockInput, send, settle, pending, mcpJson, frame: captureCharFrame };
103
+ }
104
+
105
+ describe("registry screen: the list is not actionable while a query change is pending", () => {
106
+ test("typing a query then Enter, Enter inside the debounce installs nothing from the old list", async () => {
107
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
108
+
109
+ // "/" opens the search box, the text is typed, the first Enter leaves it,
110
+ // the second Enter installs the row under the cursor. All of it happens
111
+ // well inside the 300 ms debounce, before loadServers has started.
112
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
113
+ await m.send(() => m.mockInput.pressEnter(), 5);
114
+ await m.send(() => m.mockInput.pressEnter(), 5);
115
+
116
+ expect(m.pending).toHaveLength(0); // debounce has not fired yet
117
+ expect(m.mcpJson()).not.toContain("com.old/");
118
+ });
119
+
120
+ test("after the new query's response lands, Enter installs the new list's first row", async () => {
121
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
122
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
123
+ await m.send(() => m.mockInput.pressEnter(), 5);
124
+ await m.settle(400); // debounce fires, request is now pending
125
+ expect(m.pending).toHaveLength(1);
126
+ await m.send(() => m.pending[0]?.resolve(["com.notion/mcp"]));
127
+ await m.send(() => m.mockInput.pressEnter(), 100);
128
+
129
+ expect(m.mcpJson()).toContain("com.notion/mcp");
130
+ });
131
+ });
132
+
133
+ describe("registry screen: the cursor follows the name only when the query is unchanged", () => {
134
+ test("a new query puts the cursor on row 0 of the new results, even when the old row-0 server is in them", async () => {
135
+ const m = await mount(["com.old/alpha", "com.old/beta"]);
136
+ await m.send(() => m.mockInput.typeText("/x"), 5);
137
+ await m.send(() => m.mockInput.pressEnter(), 5);
138
+ await m.settle(400);
139
+ expect(m.pending).toHaveLength(1);
140
+ // The old row-0 server comes back, but second.
141
+ await m.send(() =>
142
+ m.pending[0]?.resolve(["com.new/first", "com.old/alpha"]),
143
+ );
144
+ await m.send(() => m.mockInput.pressEnter(), 100);
145
+
146
+ expect(m.mcpJson()).toContain("com.new/first");
147
+ expect(m.mcpJson()).not.toContain("com.old/alpha");
148
+ });
149
+ });
150
+
151
+ describe("registry screen: the official badge follows the search", () => {
152
+ const rowLines = (frame: string, needle: string) =>
153
+ frame.split("\n").filter((line) => line.includes(needle));
154
+
155
+ test("the unfiltered list shows no official badge on any row", async () => {
156
+ const m = await mount([
157
+ "com.linear/mcp",
158
+ "ai.smithery/smithery-notion",
159
+ "com.notion/mcp",
160
+ ]);
161
+ await m.send(() => m.settle(400)); // the initial load runs behind the debounce
162
+ expect(m.frame()).toContain("notion/mcp"); // the rows did render
163
+ expect(m.frame()).not.toContain("official");
164
+ });
165
+
166
+ test("after searching notion only the real notion server is badged, and it is first", async () => {
167
+ const m = await mount([
168
+ "com.linear/mcp",
169
+ "ai.smithery/smithery-notion",
170
+ "com.notion/mcp",
171
+ ]);
172
+ await m.send(() => m.mockInput.typeText("/notion"), 5);
173
+ await m.send(() => m.mockInput.pressEnter(), 5);
174
+ await m.settle(400);
175
+ expect(m.pending).toHaveLength(1);
176
+ // The endpoint answers with the third-party server before the real one.
177
+ await m.send(() =>
178
+ m.pending[0]?.resolve(["ai.smithery/smithery-notion", "com.notion/mcp"]),
179
+ );
180
+
181
+ await m.send(() => m.settle(100));
182
+ const frame = m.frame();
183
+ const lines = frame.split("\n");
184
+ const notionRow = lines.findIndex((l) => l.includes("com.notion/mcp"));
185
+ const smitheryRow = lines.findIndex((l) =>
186
+ l.includes("ai.smithery/smithery-notion"),
187
+ );
188
+ expect(notionRow).toBeGreaterThanOrEqual(0);
189
+ expect(notionRow).toBeLessThan(smitheryRow); // official first
190
+ expect(lines[notionRow]).toContain("official");
191
+ expect(rowLines(frame, "smithery-notion").join("\n")).not.toContain(
192
+ "official",
193
+ );
194
+ });
195
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseKeypress } from "@opentui/core";
3
+ import {
4
+ createLatestOnlyGuard,
5
+ isOfficialMcpServer,
6
+ officialFirst,
7
+ } from "../services/mcp-registry.js";
8
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
9
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
10
+
11
+ // REGRESSION: MTL-337 registry screen installs the wrong server / no remove / no official first — Fixed in /dev:fix session dev-fix-20261101-153739-a08a5829
12
+
13
+ const srv = (name: string, url: string) => ({
14
+ name,
15
+ url,
16
+ short_description: "",
17
+ });
18
+
19
+ const press = (raw: string): KeyPress => {
20
+ const k = parseKeypress(raw);
21
+ return {
22
+ name: k?.name ?? "",
23
+ sequence: k?.sequence ?? "",
24
+ ctrl: k?.ctrl === true,
25
+ meta: k?.meta === true,
26
+ shift: k?.shift === true,
27
+ };
28
+ };
29
+
30
+ describe("isOfficialMcpServer", () => {
31
+ const notion = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
32
+
33
+ test("an empty or whitespace query makes nothing official", () => {
34
+ expect(isOfficialMcpServer(notion, "")).toBe(false);
35
+ expect(isOfficialMcpServer(notion, " ")).toBe(false);
36
+ });
37
+
38
+ test("com.notion/mcp served from mcp.notion.com is official for the query notion", () => {
39
+ expect(isOfficialMcpServer(notion, "notion")).toBe(true);
40
+ });
41
+
42
+ test("a third-party host is not official for the brand it mentions", () => {
43
+ expect(
44
+ isOfficialMcpServer(
45
+ srv("ai.smithery/smithery-notion", "https://server.smithery.ai/notion"),
46
+ "notion",
47
+ ),
48
+ ).toBe(false);
49
+ expect(
50
+ isOfficialMcpServer(
51
+ srv("com.foreverworks/notion", "https://mcp.foreverworks.com/notion"),
52
+ "notion",
53
+ ),
54
+ ).toBe(false);
55
+ });
56
+
57
+ test("a server is official for its own brand, but not for another brand's query", () => {
58
+ const smithery = srv(
59
+ "ai.smithery/smithery-notion",
60
+ "https://server.smithery.ai/notion",
61
+ );
62
+ expect(isOfficialMcpServer(smithery, "smithery")).toBe(true);
63
+ expect(isOfficialMcpServer(notion, "smithery")).toBe(false);
64
+ });
65
+
66
+ test("the query is case-insensitive and split into words", () => {
67
+ expect(isOfficialMcpServer(notion, "Notion MCP")).toBe(true);
68
+ expect(isOfficialMcpServer(notion, "mcp-NOTION")).toBe(true);
69
+ });
70
+
71
+ test("a word must equal the brand, a substring does not count", () => {
72
+ expect(isOfficialMcpServer(notion, "notional")).toBe(false);
73
+ expect(isOfficialMcpServer(notion, "not")).toBe(false);
74
+ });
75
+
76
+ test("com.mcparmory/notion packaged on pypi is not official", () => {
77
+ expect(
78
+ isOfficialMcpServer(
79
+ srv("com.mcparmory/notion", "pypi:mcparmory-notion"),
80
+ "mcparmory notion",
81
+ ),
82
+ ).toBe(false);
83
+ });
84
+
85
+ test("an io.github.<owner> namespace is never official, even when the host matches", () => {
86
+ expect(
87
+ isOfficialMcpServer(
88
+ srv("io.github.someone/notion", "https://github.io/someone/notion"),
89
+ "github someone notion",
90
+ ),
91
+ ).toBe(false);
92
+ expect(
93
+ isOfficialMcpServer(
94
+ srv("io.github.notion/mcp", "https://mcp.github.io/mcp"),
95
+ "github notion",
96
+ ),
97
+ ).toBe(false);
98
+ });
99
+
100
+ test("npm: and pypi: sources are not official", () => {
101
+ expect(
102
+ isOfficialMcpServer(srv("com.notion/mcp", "npm:@notion/mcp"), "notion"),
103
+ ).toBe(false);
104
+ expect(
105
+ isOfficialMcpServer(srv("com.notion/mcp", "pypi:notion-mcp"), "notion"),
106
+ ).toBe(false);
107
+ });
108
+
109
+ test("an unparsable URL is not official", () => {
110
+ expect(
111
+ isOfficialMcpServer(srv("com.notion/mcp", "not a url"), "notion"),
112
+ ).toBe(false);
113
+ expect(isOfficialMcpServer(srv("com.notion/mcp", ""), "notion")).toBe(
114
+ false,
115
+ );
116
+ });
117
+
118
+ test("a namespace that is only a string prefix of the host does not match on a label boundary", () => {
119
+ expect(
120
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "not"),
121
+ ).toBe(false);
122
+ expect(
123
+ isOfficialMcpServer(srv("com.not/x", "https://notion.com/mcp"), "notion"),
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe("officialFirst", () => {
129
+ const a = srv("io.github.a/one", "https://a.example.org/mcp");
130
+ const o1 = srv("com.notion/mcp", "https://mcp.notion.com/mcp");
131
+ const b = srv("com.mcparmory/notion", "pypi:mcparmory-notion");
132
+ const o2 = srv("com.notion/second", "https://api.notion.com/second");
133
+ const other = srv("com.linear/mcp", "https://mcp.linear.com/mcp");
134
+
135
+ test("official servers move to the front, endpoint order kept inside each group", () => {
136
+ expect(officialFirst([a, o1, b, other, o2], "notion")).toEqual([
137
+ o1,
138
+ o2,
139
+ a,
140
+ b,
141
+ other,
142
+ ]);
143
+ });
144
+
145
+ test("with an empty query the order is unchanged", () => {
146
+ expect(officialFirst([a, o1, b, o2], "")).toEqual([a, o1, b, o2]);
147
+ });
148
+
149
+ test("does not mutate its input", () => {
150
+ const input = [a, o1, b];
151
+ const snapshot = [...input];
152
+ const out = officialFirst(input, "notion");
153
+ expect(input).toEqual(snapshot);
154
+ expect(out).not.toBe(input);
155
+ });
156
+
157
+ test("an empty list stays empty", () => {
158
+ expect(officialFirst([], "notion")).toEqual([]);
159
+ });
160
+ });
161
+
162
+ describe("MCP registry keymap", () => {
163
+ test("d is bound to a remove action on the mcp-registry screen", () => {
164
+ expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("d"))?.id).toBe(
165
+ "remove",
166
+ );
167
+ });
168
+ });
169
+
170
+ describe("stale registry responses", () => {
171
+ // The screen (McpRegistryScreen.tsx) is a React/OpenTUI component whose
172
+ // loadServers closes over component state; the repo has no harness that
173
+ // drives it with fake fetches. The guard is therefore tested as the small
174
+ // pure helper the screen will call: take a token before each request, apply
175
+ // the response only when isLatest(token) still holds.
176
+ test("only the newest request's response is applied when responses arrive out of order", async () => {
177
+ const guard = createLatestOnlyGuard();
178
+ const deferred = <T>() => {
179
+ let resolve!: (v: T) => void;
180
+ const promise = new Promise<T>((r) => {
181
+ resolve = r;
182
+ });
183
+ return { promise, resolve };
184
+ };
185
+ const first = deferred<string[]>();
186
+ const second = deferred<string[]>();
187
+ let applied: string[] | null = null;
188
+ const appliedFrom: string[] = [];
189
+
190
+ const load = async (label: string, p: Promise<string[]>) => {
191
+ const token = guard.next();
192
+ const result = await p;
193
+ if (guard.isLatest(token)) {
194
+ applied = result;
195
+ appliedFrom.push(label);
196
+ }
197
+ };
198
+
199
+ const a = load("old", first.promise);
200
+ const b = load("new", second.promise);
201
+ second.resolve(["new-server"]); // newest answers first
202
+ await b;
203
+ first.resolve(["old-server"]); // stale answer lands last
204
+ await a;
205
+
206
+ expect(applied).toEqual(["new-server"]);
207
+ expect(appliedFrom).toEqual(["new"]);
208
+ });
209
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * runBounded settles on its deadline even when a descendant of the child keeps
3
+ * the stdout/stderr pipes open — the shape of `git pull` -> `git fetch` -> `ssh`.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { runBounded } from "../utils/spawn-bounded.js";
11
+
12
+ let tmp: string;
13
+ let pidFile: string;
14
+
15
+ beforeEach(() => {
16
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "spawn-bounded-"));
17
+ pidFile = path.join(tmp, "grandchild.pid");
18
+ });
19
+
20
+ afterEach(() => {
21
+ // Kill the leftover grandchild by pid, never by pattern.
22
+ if (fs.existsSync(pidFile)) {
23
+ const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
24
+ if (pid > 0) {
25
+ try {
26
+ process.kill(pid, "SIGKILL");
27
+ } catch {
28
+ // already gone
29
+ }
30
+ }
31
+ }
32
+ fs.rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ function writeScript(name: string, body: string): string {
36
+ const file = path.join(tmp, name);
37
+ fs.writeFileSync(file, `#!/bin/sh\n${body}`, { mode: 0o755 });
38
+ return file;
39
+ }
40
+
41
+ /**
42
+ * `sh -c` argv: background a `sleep 30` that inherits stdout, then exit 0.
43
+ * Run through /bin/sh rather than a freshly written script: macOS's first exec
44
+ * of a new file can take ~300 ms, which would push the exit past a 200 ms
45
+ * deadline and turn a normal exit into a timeout.
46
+ */
47
+ function exitLeavingHolder(): string[] {
48
+ return ["-c", `sleep 30 & echo $! > '${pidFile}'; exit 0`];
49
+ }
50
+
51
+ describe("runBounded", () => {
52
+ it("settles at the deadline while a grandchild still holds the pipes", async () => {
53
+ const script = writeScript(
54
+ "holder.sh",
55
+ `sleep 30 &\necho $! > '${pidFile}'\nwait\n`,
56
+ );
57
+
58
+ const started = Date.now();
59
+ const r = await runBounded(script, [], { timeoutMs: 300 });
60
+ const elapsed = Date.now() - started;
61
+
62
+ expect(r.timedOut).toBe(true);
63
+ expect(r.signal).toBe("SIGKILL");
64
+ expect(r.code).not.toBe(0);
65
+ expect(elapsed).toBeLessThan(2_000);
66
+ }, 5_000);
67
+
68
+ it("settles at the deadline with stdout-only pipes, too", async () => {
69
+ const script = writeScript(
70
+ "holder.sh",
71
+ `sleep 30 &\necho $! > '${pidFile}'\nwait\n`,
72
+ );
73
+
74
+ const started = Date.now();
75
+ const r = await runBounded(script, [], {
76
+ timeoutMs: 300,
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ });
79
+
80
+ expect(r.timedOut).toBe(true);
81
+ expect(r.signal).toBe("SIGKILL");
82
+ expect(Date.now() - started).toBeLessThan(2_000);
83
+ }, 5_000);
84
+
85
+ it("returns the full stdout of a child that exits normally", async () => {
86
+ const lines = Array.from({ length: 2000 }, (_, i) => `line ${i}`);
87
+ const script = writeScript(
88
+ "talker.sh",
89
+ `i=0\nwhile [ $i -lt 2000 ]; do echo "line $i"; i=$((i+1)); done\necho oops >&2\n`,
90
+ );
91
+
92
+ const r = await runBounded(script, [], { timeoutMs: 5_000 });
93
+
94
+ expect(r.code).toBe(0);
95
+ expect(r.signal).toBeNull();
96
+ expect(r.timedOut).toBe(false);
97
+ expect(r.spawnError).toBeUndefined();
98
+ expect(r.stdout).toBe(`${lines.join("\n")}\n`);
99
+ expect(r.stderr.trim()).toBe("oops");
100
+ }, 5_000);
101
+
102
+ it("reports a non-zero exit code without timing out", async () => {
103
+ const script = writeScript("fail.sh", "exit 3\n");
104
+
105
+ const r = await runBounded(script, [], { timeoutMs: 5_000 });
106
+
107
+ expect(r.code).toBe(3);
108
+ expect(r.timedOut).toBe(false);
109
+ }, 5_000);
110
+
111
+ it("after a normal exit with a background descendant holding stdout, settles after the close grace", async () => {
112
+ const started = Date.now();
113
+ const r = await runBounded("/bin/sh", exitLeavingHolder(), {
114
+ timeoutMs: 20_000,
115
+ });
116
+ const elapsed = Date.now() - started;
117
+
118
+ expect(r.timedOut).toBe(false);
119
+ expect(r.code).toBe(0);
120
+ // CLOSE_GRACE_MS is 1_500: not before it, and not much after it.
121
+ expect(elapsed).toBeGreaterThanOrEqual(1_400);
122
+ expect(elapsed).toBeLessThan(2_500);
123
+ }, 5_000);
124
+
125
+ it("never extends a normal exit's close grace past the deadline", async () => {
126
+ const started = Date.now();
127
+ const r = await runBounded("/bin/sh", exitLeavingHolder(), {
128
+ timeoutMs: 200,
129
+ });
130
+ const elapsed = Date.now() - started;
131
+
132
+ // The child exited by itself, so this is not a timeout.
133
+ expect(r.timedOut).toBe(false);
134
+ expect(r.code).toBe(0);
135
+ // Deadline 200 ms; the 1_500 ms grace must not outlive it.
136
+ expect(elapsed).toBeLessThan(700);
137
+ }, 5_000);
138
+
139
+ it("an abort mid-run kills the child and settles at once", async () => {
140
+ const script = writeScript(
141
+ "sleeper.sh",
142
+ `echo $$ > '${pidFile}'\nexec sleep 30\n`,
143
+ );
144
+ const controller = new AbortController();
145
+
146
+ const started = Date.now();
147
+ const pending = runBounded(script, [], {
148
+ timeoutMs: 20_000,
149
+ signal: controller.signal,
150
+ });
151
+ // Abort once the child is really running (it has written its pid).
152
+ while (!fs.existsSync(pidFile) || !fs.readFileSync(pidFile, "utf8")) {
153
+ await Bun.sleep(10);
154
+ }
155
+ const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
156
+ const abortedAt = Date.now();
157
+ controller.abort();
158
+ const r = await pending;
159
+ const settledAfterAbort = Date.now() - abortedAt;
160
+
161
+ expect(r.aborted).toBe(true);
162
+ expect(r.code).not.toBe(0);
163
+ expect(settledAfterAbort).toBeLessThan(300);
164
+ expect(Date.now() - started).toBeLessThan(2_000);
165
+
166
+ // The child is dead. Poll briefly: a SIGKILLed child is a zombie until the
167
+ // runtime reaps it, and signal 0 still succeeds on a zombie.
168
+ const dead = async (): Promise<boolean> => {
169
+ for (let i = 0; i < 50; i++) {
170
+ try {
171
+ process.kill(pid, 0);
172
+ } catch {
173
+ return true;
174
+ }
175
+ await Bun.sleep(20);
176
+ }
177
+ return false;
178
+ };
179
+ expect(await dead()).toBe(true);
180
+ }, 5_000);
181
+
182
+ it("an already-aborted signal settles at once and spawns nothing", async () => {
183
+ const marker = path.join(tmp, "ran");
184
+ const script = writeScript("marker.sh", `touch '${marker}'\n`);
185
+ const controller = new AbortController();
186
+ controller.abort();
187
+
188
+ const started = Date.now();
189
+ const r = await runBounded(script, [], {
190
+ timeoutMs: 5_000,
191
+ signal: controller.signal,
192
+ });
193
+ const elapsed = Date.now() - started;
194
+
195
+ expect(r.aborted).toBe(true);
196
+ expect(r.code).toBeNull();
197
+ expect(elapsed).toBeLessThan(100);
198
+ // Give a spawned child every chance to run before checking it did not.
199
+ await Bun.sleep(300);
200
+ expect(fs.existsSync(marker)).toBe(false);
201
+ }, 5_000);
202
+
203
+ it("settles with spawnError, never throws, for a command that does not exist", async () => {
204
+ const r = await runBounded(path.join(tmp, "no-such-command"), [], {
205
+ timeoutMs: 5_000,
206
+ });
207
+
208
+ expect(r.spawnError).toBeInstanceOf(Error);
209
+ expect(r.timedOut).toBe(false);
210
+ }, 5_000);
211
+ });
@@ -27,10 +27,10 @@
27
27
  * working precisely when the HTTP catalog cannot.
28
28
  */
29
29
 
30
- import { spawn } from "node:child_process";
31
30
  import fs from "node:fs";
32
31
  import path from "node:path";
33
32
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
33
+ import { runBounded } from "../utils/spawn-bounded.js";
34
34
  import {
35
35
  type MarketplacePlugin,
36
36
  normalizeCatalogJson,
@@ -55,36 +55,27 @@ function cloneDir(name: string): string | null {
55
55
  return path.join(configDir, "plugins", "marketplaces", name);
56
56
  }
57
57
 
58
- function git(
58
+ async function git(
59
59
  cwd: string,
60
60
  args: string[],
61
61
  timeoutMs: number,
62
62
  ): Promise<{ ok: boolean; out: string }> {
63
- return new Promise((resolve) => {
64
- const child = spawn("git", args, {
65
- cwd,
66
- stdio: ["ignore", "pipe", "ignore"],
67
- timeout: timeoutMs,
68
- killSignal: "SIGKILL",
69
- env: {
70
- ...process.env,
71
- // Never let git stop for credentials: this runs inside a TUI with no
72
- // way to answer a prompt, and a private marketplace we cannot read
73
- // should degrade to "unknown", not hang the screen forever.
74
- GIT_TERMINAL_PROMPT: "0",
75
- GIT_ASKPASS: "echo",
76
- SSH_ASKPASS: "echo",
77
- },
78
- });
79
- let out = "";
80
- child.stdout.on("data", (d: Buffer | string) => {
81
- out += String(d);
82
- });
83
- child.on("error", () => resolve({ ok: false, out: "" }));
84
- child.on("close", (code: number | null) =>
85
- resolve({ ok: code === 0, out }),
86
- );
63
+ const r = await runBounded("git", args, {
64
+ cwd,
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ timeoutMs,
67
+ env: {
68
+ ...process.env,
69
+ // Never let git stop for credentials: this runs inside a TUI with no
70
+ // way to answer a prompt, and a private marketplace we cannot read
71
+ // should degrade to "unknown", not hang the screen forever.
72
+ GIT_TERMINAL_PROMPT: "0",
73
+ GIT_ASKPASS: "echo",
74
+ SSH_ASKPASS: "echo",
75
+ },
87
76
  });
77
+ if (r.spawnError) return { ok: false, out: "" };
78
+ return { ok: r.code === 0, out: r.stdout };
88
79
  }
89
80
 
90
81
  /**