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
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
|
+
});
|