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.
- package/package.json +4 -4
- package/src/__tests__/marketplace-refresh.test.ts +113 -0
- package/src/__tests__/mcp-registry-install-screen.test.tsx +732 -0
- package/src/__tests__/mcp-registry-install.test.ts +551 -0
- package/src/__tests__/mcp-registry-screen-debounce.test.tsx +195 -0
- package/src/__tests__/mcp-registry-screen.test.ts +209 -0
- package/src/__tests__/spawn-bounded.test.ts +211 -0
- package/src/services/marketplace-catalog-git.ts +17 -26
- package/src/services/marketplace-refresh.ts +63 -43
- package/src/services/mcp-registry.ts +150 -0
- package/src/ui/keymap.ts +1 -0
- package/src/ui/renderers/mcpRenderers.tsx +6 -1
- package/src/ui/screens/McpRegistryScreen.tsx +146 -26
- package/src/utils/spawn-bounded.ts +202 -0
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
/**
|