magus-cli 7.11.6 → 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.11.
|
|
3
|
+
"version": "7.11.7",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
"typescript": "^5.6.3"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"magus-cli-darwin-arm64": "7.11.
|
|
69
|
-
"magus-cli-darwin-x64": "7.11.
|
|
70
|
-
"magus-cli-linux-x64": "7.11.
|
|
68
|
+
"magus-cli-darwin-arm64": "7.11.7",
|
|
69
|
+
"magus-cli-darwin-x64": "7.11.7",
|
|
70
|
+
"magus-cli-linux-x64": "7.11.7"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -344,3 +344,116 @@ describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-
|
|
|
344
344
|
expect(readPin(dir).pin).toBe("x@dirty");
|
|
345
345
|
});
|
|
346
346
|
});
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Point the clone's origin at a fake ssh that hangs. git pull -> git fetch ->
|
|
350
|
+
* ssh: the fake ssh is NOT runBounded's direct child, so killing `git pull`
|
|
351
|
+
* leaves it holding the inherited stdout/stderr open. It records its own pid and
|
|
352
|
+
* its grandparent's (the `git pull` runBounded spawned) so the case can check
|
|
353
|
+
* them and the cleanup can kill them by pid, never by pattern.
|
|
354
|
+
*/
|
|
355
|
+
function hangSshTransport(dir: string): { sshPid: string; pullPid: string } {
|
|
356
|
+
const sshPid = path.join(tmp, "fake-ssh.pid");
|
|
357
|
+
const pullPid = path.join(tmp, "git-pull.pid");
|
|
358
|
+
const fakeSsh = path.join(tmp, "fake-ssh.sh");
|
|
359
|
+
fs.writeFileSync(
|
|
360
|
+
fakeSsh,
|
|
361
|
+
[
|
|
362
|
+
"#!/bin/sh",
|
|
363
|
+
`echo $$ > '${sshPid}'`,
|
|
364
|
+
`ps -o ppid= -p $PPID | tr -d ' ' > '${pullPid}'`,
|
|
365
|
+
`ps -o command= -p "$(cat '${pullPid}')" > '${pullPid}.cmd'`,
|
|
366
|
+
"exec sleep 60",
|
|
367
|
+
"",
|
|
368
|
+
].join("\n"),
|
|
369
|
+
{ mode: 0o755 },
|
|
370
|
+
);
|
|
371
|
+
git(dir, "remote", "set-url", "origin", "ssh://example.invalid/repo.git");
|
|
372
|
+
git(dir, "config", "core.sshCommand", fakeSsh);
|
|
373
|
+
hungPidFiles = [sshPid, pullPid];
|
|
374
|
+
return { sshPid, pullPid };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function readPid(file: string): number {
|
|
378
|
+
return Number(fs.readFileSync(file, "utf8").trim());
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** True once `pid` no longer exists, polling briefly to let the runtime reap it. */
|
|
382
|
+
async function goneWithin(pid: number, ms: number): Promise<boolean> {
|
|
383
|
+
const until = Date.now() + ms;
|
|
384
|
+
for (;;) {
|
|
385
|
+
try {
|
|
386
|
+
process.kill(pid, 0);
|
|
387
|
+
} catch {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (Date.now() > until) return false;
|
|
391
|
+
await Bun.sleep(20);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// pid files written by hangSshTransport; killed by pid after each case.
|
|
396
|
+
let hungPidFiles: string[] = [];
|
|
397
|
+
|
|
398
|
+
afterEach(() => {
|
|
399
|
+
for (const file of hungPidFiles) {
|
|
400
|
+
if (!fs.existsSync(file)) continue;
|
|
401
|
+
const pid = readPid(file);
|
|
402
|
+
if (pid > 0) {
|
|
403
|
+
try {
|
|
404
|
+
process.kill(pid, "SIGKILL");
|
|
405
|
+
} catch {
|
|
406
|
+
// already gone
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
hungPidFiles = [];
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("fastForwardCloneIfPresent — a hung ssh transport", () => {
|
|
414
|
+
// REGRESSION: magus update stalls on the marketplaces step past the 30 s git timeout (ssh grandchild holds the pipes) — Fixed in /dev:fix session dev-fix-20261102-160435-22278 (MTL-365)
|
|
415
|
+
it("settles as 'failed' near the 30 s git timeout when ssh (a grandchild of git pull) holds the pipes", async () => {
|
|
416
|
+
const dir = installClone("magus");
|
|
417
|
+
hangSshTransport(dir);
|
|
418
|
+
|
|
419
|
+
const started = Date.now();
|
|
420
|
+
const status = await fastForwardCloneIfPresent("magus");
|
|
421
|
+
const elapsed = Date.now() - started;
|
|
422
|
+
|
|
423
|
+
expect(status).toBe("failed");
|
|
424
|
+
// 30 s timeout + grace. On the bug it settles only when ssh exits (~60 s).
|
|
425
|
+
expect(elapsed).toBeLessThan(40_000);
|
|
426
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
427
|
+
}, 45_000);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe("fastForwardCloneIfPresent — per-clone deadline", () => {
|
|
431
|
+
// REGRESSION: the marketplaces step ran 56 min — a per-clone deadline bounds it whatever hangs (MTL-365)
|
|
432
|
+
it("returns 'failed' at the deadline while ssh still hangs, clone intact", async () => {
|
|
433
|
+
const dir = installClone("magus");
|
|
434
|
+
hangSshTransport(dir);
|
|
435
|
+
|
|
436
|
+
const started = Date.now();
|
|
437
|
+
const status = await fastForwardCloneIfPresent("magus", 1_000);
|
|
438
|
+
const elapsed = Date.now() - started;
|
|
439
|
+
|
|
440
|
+
expect(status).toBe("failed");
|
|
441
|
+
// Deadline 1 s plus slack. Without the deadline it settles at the 30 s git timeout.
|
|
442
|
+
expect(elapsed).toBeLessThan(3_000);
|
|
443
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
444
|
+
}, 10_000);
|
|
445
|
+
|
|
446
|
+
it("kills the in-flight git pull when the deadline wins, rather than leaving it running", async () => {
|
|
447
|
+
const dir = installClone("magus");
|
|
448
|
+
const { pullPid } = hangSshTransport(dir);
|
|
449
|
+
|
|
450
|
+
const status = await fastForwardCloneIfPresent("magus", 1_000);
|
|
451
|
+
|
|
452
|
+
expect(status).toBe("failed");
|
|
453
|
+
// The fake ssh got far enough to record its grandparent, the git pull.
|
|
454
|
+
expect(fs.existsSync(pullPid)).toBe(true);
|
|
455
|
+
expect(fs.readFileSync(`${pullPid}.cmd`, "utf8")).toContain("pull");
|
|
456
|
+
// Without the abort, git pull lives on until its own 30 s timeout.
|
|
457
|
+
expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
|
|
458
|
+
}, 10_000);
|
|
459
|
+
});
|
|
@@ -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
|
/**
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
* A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
20
|
import fs from "node:fs";
|
|
22
21
|
import path from "node:path";
|
|
22
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
23
23
|
import {
|
|
24
24
|
getConfiguredMarketplaces,
|
|
25
25
|
getGlobalClaudeDir,
|
|
@@ -29,6 +29,9 @@ import {
|
|
|
29
29
|
/** Per-git-invocation timeout. A hung pull must never block launching Claude. */
|
|
30
30
|
const GIT_TIMEOUT_MS = 30_000;
|
|
31
31
|
|
|
32
|
+
/** Hard cap per clone, whatever hangs: `git status` then `git pull`, each GIT_TIMEOUT_MS, plus slack. */
|
|
33
|
+
const CLONE_DEADLINE_MS = 2 * GIT_TIMEOUT_MS + 5_000;
|
|
34
|
+
|
|
32
35
|
export interface RefreshResult {
|
|
33
36
|
/** Clones fast-forwarded to a newer commit (or already current). */
|
|
34
37
|
refreshed: string[];
|
|
@@ -42,46 +45,36 @@ function cloneDir(name: string): string {
|
|
|
42
45
|
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`, or as
|
|
50
|
+
* soon as `signal` aborts (an already-aborted signal runs nothing). Throws on
|
|
51
|
+
* anything but exit 0.
|
|
52
|
+
*/
|
|
53
|
+
async function runGit(
|
|
47
54
|
cwd: string,
|
|
48
55
|
args: string[],
|
|
49
56
|
timeoutMs: number,
|
|
57
|
+
signal?: AbortSignal,
|
|
50
58
|
): Promise<string> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
stdout += b.toString();
|
|
61
|
-
});
|
|
62
|
-
proc.stderr?.on("data", (b) => {
|
|
63
|
-
stderr += b.toString();
|
|
64
|
-
});
|
|
65
|
-
proc.on("error", reject);
|
|
66
|
-
proc.on("close", (code, signal) => {
|
|
67
|
-
if (code === 0) {
|
|
68
|
-
resolve(stdout);
|
|
69
|
-
} else {
|
|
70
|
-
reject(
|
|
71
|
-
new Error(
|
|
72
|
-
`git ${args.join(" ")} ${
|
|
73
|
-
signal ? `killed (${signal})` : `exited ${code}`
|
|
74
|
-
}: ${stderr.trim()}`,
|
|
75
|
-
),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
});
|
|
59
|
+
const r = await runBounded("git", args, { cwd, timeoutMs, signal });
|
|
60
|
+
if (r.spawnError) throw r.spawnError;
|
|
61
|
+
if (r.code === 0 && !r.aborted) return r.stdout;
|
|
62
|
+
const how = r.aborted
|
|
63
|
+
? "aborted"
|
|
64
|
+
: r.signal
|
|
65
|
+
? `killed (${r.signal})`
|
|
66
|
+
: `exited ${r.code}`;
|
|
67
|
+
throw new Error(`git ${args.join(" ")} ${how}: ${r.stderr.trim()}`);
|
|
80
68
|
}
|
|
81
69
|
|
|
82
70
|
/** True if the clone has uncommitted changes we must not clobber. */
|
|
83
|
-
async function isDirty(dir: string): Promise<boolean> {
|
|
84
|
-
const out = await runGit(
|
|
71
|
+
async function isDirty(dir: string, signal?: AbortSignal): Promise<boolean> {
|
|
72
|
+
const out = await runGit(
|
|
73
|
+
dir,
|
|
74
|
+
["status", "--porcelain"],
|
|
75
|
+
GIT_TIMEOUT_MS,
|
|
76
|
+
signal,
|
|
77
|
+
);
|
|
85
78
|
return out.trim().length > 0;
|
|
86
79
|
}
|
|
87
80
|
|
|
@@ -99,23 +92,50 @@ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
|
|
|
99
92
|
* deletes: the worst case is `failed` (stale clone left intact). Shared by the
|
|
100
93
|
* batch refresh below and by claude-cli's "plugin not found" recovery, so both
|
|
101
94
|
* paths get the same safe semantics instead of the old `marketplace update`.
|
|
95
|
+
*
|
|
96
|
+
* Bounded by `deadlineMs`: when it passes, the result is `failed` and the git
|
|
97
|
+
* command then in flight (`status` or `pull`) is SIGKILLed; no further git
|
|
98
|
+
* command starts. Only that direct child is killed: a `git fetch` or `ssh` it
|
|
99
|
+
* spawned may linger until it exits on its own, holding nothing we wait on.
|
|
100
|
+
* The clone directory is never deleted, but a pull killed while it updates the
|
|
101
|
+
* working tree can leave that update part-way and a `.git/index.lock` behind;
|
|
102
|
+
* later pulls then return `failed` until the lock is removed. The deadline sits
|
|
103
|
+
* well above a healthy pull, so that is the price of never hanging, not a
|
|
104
|
+
* normal outcome.
|
|
102
105
|
*/
|
|
103
106
|
export async function fastForwardCloneIfPresent(
|
|
104
107
|
name: string,
|
|
108
|
+
deadlineMs: number = CLONE_DEADLINE_MS,
|
|
105
109
|
): Promise<CloneRefresh> {
|
|
106
110
|
const dir = cloneDir(name);
|
|
107
111
|
// No clone on disk (claude never installed it) or not a git repo.
|
|
108
112
|
if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
|
|
113
|
+
const abort = new AbortController();
|
|
114
|
+
const work = (async (): Promise<CloneRefresh> => {
|
|
115
|
+
try {
|
|
116
|
+
// Never clobber a user's local edits to the clone.
|
|
117
|
+
if (await isDirty(dir, abort.signal)) return "skipped";
|
|
118
|
+
// --ff-only is the safety guarantee: it fetches and fast-forwards, and
|
|
119
|
+
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
120
|
+
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
121
|
+
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS, abort.signal);
|
|
122
|
+
return "refreshed";
|
|
123
|
+
} catch {
|
|
124
|
+
return "failed";
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
128
|
+
const deadline = new Promise<CloneRefresh>((resolve) => {
|
|
129
|
+
timer = setTimeout(() => {
|
|
130
|
+
// Kill the in-flight git child rather than leave it running.
|
|
131
|
+
abort.abort();
|
|
132
|
+
resolve("failed");
|
|
133
|
+
}, deadlineMs);
|
|
134
|
+
});
|
|
109
135
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
114
|
-
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
115
|
-
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
|
|
116
|
-
return "refreshed";
|
|
117
|
-
} catch {
|
|
118
|
-
return "failed";
|
|
136
|
+
return await Promise.race([work, deadline]);
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
119
139
|
}
|
|
120
140
|
}
|
|
121
141
|
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn-bounded.ts — run an argv (never a shell) with a hard deadline that
|
|
3
|
+
* actually holds.
|
|
4
|
+
*
|
|
5
|
+
* Guarantee: the returned promise settles, exactly once, no later than
|
|
6
|
+
* `timeoutMs + KILL_SETTLE_MS`, whatever the child or its descendants do — even
|
|
7
|
+
* if the child never delivers "exit" at all (stuck in uninterruptible I/O). It
|
|
8
|
+
* never rejects: a spawn failure (ENOENT etc.) comes back as `spawnError`.
|
|
9
|
+
*
|
|
10
|
+
* The gotcha this exists for: a timeout kill reaches only the DIRECT child,
|
|
11
|
+
* while "close" waits for EVERY holder of the stdout/stderr pipes. `git pull`
|
|
12
|
+
* runs `git fetch`, which runs `ssh`; kill `git pull` and ssh still holds the
|
|
13
|
+
* inherited pipes, so a promise that settles on "close" waits for ssh to give up
|
|
14
|
+
* on its own (122 s observed, against a 30 s timeout). So:
|
|
15
|
+
* - normal path: settle on "close", so all output is read;
|
|
16
|
+
* - after a normal "exit" with no "close": settle after a short grace, or at
|
|
17
|
+
* the deadline, whichever comes first — the deadline is never extended;
|
|
18
|
+
* - deadline with the child still running: SIGKILL it, settle on its "exit",
|
|
19
|
+
* and if that never comes, settle anyway KILL_SETTLE_MS later;
|
|
20
|
+
* - `signal` aborted: SIGKILL the child and settle at once; an already-aborted
|
|
21
|
+
* signal spawns nothing;
|
|
22
|
+
* - on settle: destroy the pipe ends, so an orphan's copies cannot pin the
|
|
23
|
+
* event loop.
|
|
24
|
+
*
|
|
25
|
+
* No `detached` / process-group kill: on POSIX that is setsid(), which drops the
|
|
26
|
+
* controlling terminal, and ssh then cannot reach /dev/tty for a key passphrase
|
|
27
|
+
* or a security-key PIN. The cost is that an orphaned descendant may outlive the
|
|
28
|
+
* call until it exits or hits SIGPIPE; it blocks nothing.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
type ChildProcess,
|
|
33
|
+
type StdioOptions,
|
|
34
|
+
spawn,
|
|
35
|
+
} from "node:child_process";
|
|
36
|
+
|
|
37
|
+
/** How long to wait for "close" after a normal "exit" before settling anyway. */
|
|
38
|
+
const CLOSE_GRACE_MS = 1_500;
|
|
39
|
+
|
|
40
|
+
/** How long after our SIGKILL to wait for "exit" before settling without it. */
|
|
41
|
+
const KILL_SETTLE_MS = 1_000;
|
|
42
|
+
|
|
43
|
+
export interface BoundedOptions {
|
|
44
|
+
cwd?: string;
|
|
45
|
+
env?: NodeJS.ProcessEnv;
|
|
46
|
+
/** Defaults to ["pipe", "pipe", "pipe"]. */
|
|
47
|
+
stdio?: StdioOptions;
|
|
48
|
+
timeoutMs: number;
|
|
49
|
+
/** Abort: SIGKILL the child and settle at once. Already aborted: spawn nothing. */
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BoundedResult {
|
|
54
|
+
code: number | null;
|
|
55
|
+
signal: NodeJS.Signals | null;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
/** True when our deadline fired while the child was still running, and we SIGKILLed it. */
|
|
59
|
+
timedOut: boolean;
|
|
60
|
+
/** True when `options.signal` aborted the call (the child, if any, was SIGKILLed). */
|
|
61
|
+
aborted: boolean;
|
|
62
|
+
/** Set when the process could not be spawned at all (ENOENT, EACCES, ...). */
|
|
63
|
+
spawnError?: Error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isRunning(child: ChildProcess): boolean {
|
|
67
|
+
return child.exitCode === null && child.signalCode === null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function runBounded(
|
|
71
|
+
cmd: string,
|
|
72
|
+
args: string[],
|
|
73
|
+
options: BoundedOptions,
|
|
74
|
+
): Promise<BoundedResult> {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const abortSignal = options.signal;
|
|
77
|
+
if (abortSignal?.aborted) {
|
|
78
|
+
resolve({
|
|
79
|
+
code: null,
|
|
80
|
+
signal: null,
|
|
81
|
+
stdout: "",
|
|
82
|
+
stderr: "",
|
|
83
|
+
timedOut: false,
|
|
84
|
+
aborted: true,
|
|
85
|
+
});
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let stdout = "";
|
|
90
|
+
let stderr = "";
|
|
91
|
+
let timedOut = false;
|
|
92
|
+
let aborted = false;
|
|
93
|
+
let settled = false;
|
|
94
|
+
/** Set by "exit"; the child is gone even if "close" never comes. */
|
|
95
|
+
let exited: { code: number | null; signal: NodeJS.Signals | null } | null =
|
|
96
|
+
null;
|
|
97
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
let killSettleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
|
|
100
|
+
let child: ChildProcess;
|
|
101
|
+
try {
|
|
102
|
+
child = spawn(cmd, args, {
|
|
103
|
+
cwd: options.cwd,
|
|
104
|
+
env: options.env,
|
|
105
|
+
stdio: options.stdio ?? ["pipe", "pipe", "pipe"],
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
resolve({
|
|
109
|
+
code: null,
|
|
110
|
+
signal: null,
|
|
111
|
+
stdout: "",
|
|
112
|
+
stderr: "",
|
|
113
|
+
timedOut: false,
|
|
114
|
+
aborted: false,
|
|
115
|
+
spawnError: err instanceof Error ? err : new Error(String(err)),
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const settle = (
|
|
121
|
+
code: number | null,
|
|
122
|
+
signal: NodeJS.Signals | null,
|
|
123
|
+
spawnError?: Error,
|
|
124
|
+
) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
settled = true;
|
|
127
|
+
clearTimeout(deadlineTimer);
|
|
128
|
+
clearTimeout(graceTimer);
|
|
129
|
+
clearTimeout(killSettleTimer);
|
|
130
|
+
abortSignal?.removeEventListener("abort", onAbort);
|
|
131
|
+
child.stdout?.destroy();
|
|
132
|
+
child.stderr?.destroy();
|
|
133
|
+
child.stdin?.destroy();
|
|
134
|
+
resolve({
|
|
135
|
+
code,
|
|
136
|
+
signal,
|
|
137
|
+
stdout,
|
|
138
|
+
stderr,
|
|
139
|
+
timedOut,
|
|
140
|
+
aborted,
|
|
141
|
+
spawnError,
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** SIGKILL the child, then settle on its "exit" — or without it, soon. */
|
|
146
|
+
const killAndBoundSettle = () => {
|
|
147
|
+
child.kill("SIGKILL");
|
|
148
|
+
killSettleTimer = setTimeout(
|
|
149
|
+
() => settle(null, "SIGKILL"),
|
|
150
|
+
KILL_SETTLE_MS,
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// An explicit timer, not spawn's `timeout` option, so we know the kill was
|
|
155
|
+
// ours. It stays armed until settle: it is also what caps the close grace.
|
|
156
|
+
// `settle` above reads it, but no child event or abort can fire before this
|
|
157
|
+
// line runs, so the reference is always initialised by then.
|
|
158
|
+
const deadlineTimer = setTimeout(() => {
|
|
159
|
+
if (exited) {
|
|
160
|
+
// Exited on its own, still waiting for "close": the grace ends here.
|
|
161
|
+
settle(exited.code, exited.signal);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!isRunning(child)) {
|
|
165
|
+
// Exited, but the "exit" event has not been delivered yet.
|
|
166
|
+
settle(child.exitCode, child.signalCode);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
timedOut = true;
|
|
170
|
+
killAndBoundSettle();
|
|
171
|
+
}, options.timeoutMs);
|
|
172
|
+
|
|
173
|
+
function onAbort() {
|
|
174
|
+
aborted = true;
|
|
175
|
+
if (exited) {
|
|
176
|
+
settle(exited.code, exited.signal);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (isRunning(child)) child.kill("SIGKILL");
|
|
180
|
+
settle(child.exitCode, child.signalCode ?? "SIGKILL");
|
|
181
|
+
}
|
|
182
|
+
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
183
|
+
|
|
184
|
+
child.stdout?.on("data", (b: Buffer | string) => {
|
|
185
|
+
stdout += b.toString();
|
|
186
|
+
});
|
|
187
|
+
child.stderr?.on("data", (b: Buffer | string) => {
|
|
188
|
+
stderr += b.toString();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
child.on("error", (err) => settle(null, null, err));
|
|
192
|
+
child.on("exit", (code, signal) => {
|
|
193
|
+
exited = { code, signal };
|
|
194
|
+
if (timedOut) {
|
|
195
|
+
settle(code, signal);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
graceTimer = setTimeout(() => settle(code, signal), CLOSE_GRACE_MS);
|
|
199
|
+
});
|
|
200
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
201
|
+
});
|
|
202
|
+
}
|