magus-cli 7.11.6 → 7.11.8
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 +324 -2
- package/src/__tests__/spawn-bounded.test.ts +211 -0
- package/src/cli/update.ts +39 -22
- package/src/services/catalog-cache-store.ts +67 -27
- package/src/services/marketplace-catalog-git.ts +17 -26
- package/src/services/marketplace-fetcher.ts +4 -0
- package/src/services/marketplace-refresh.ts +170 -73
- 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.8",
|
|
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.8",
|
|
69
|
+
"magus-cli-darwin-x64": "7.11.8",
|
|
70
|
+
"magus-cli-linux-x64": "7.11.8"
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -15,18 +15,36 @@ import path from "node:path";
|
|
|
15
15
|
// --- Mock the marketplace registry (the only magus dep of the refresher) ---
|
|
16
16
|
let configured: Record<string, { source: { source: string; repo?: string } }>;
|
|
17
17
|
let autoUpdate: Record<string, boolean | undefined>;
|
|
18
|
+
/**
|
|
19
|
+
* USER-scope `enabledPlugins`. `null` means "one USER plugin from every
|
|
20
|
+
* registered marketplace", so the cases written before the needed-only rule
|
|
21
|
+
* keep exercising the pull itself; the needed-only cases set it explicitly.
|
|
22
|
+
*/
|
|
23
|
+
let userEnabled: Record<string, boolean> | null;
|
|
18
24
|
|
|
19
25
|
mock.module("../services/claude-settings.js", () => ({
|
|
20
26
|
getConfiguredMarketplaces: async () => configured,
|
|
21
27
|
getMarketplaceAutoUpdate: async (name: string) => autoUpdate[name],
|
|
28
|
+
getGlobalEnabledPlugins: async () =>
|
|
29
|
+
userEnabled ??
|
|
30
|
+
Object.fromEntries(Object.keys(configured).map((n) => [`p@${n}`, true])),
|
|
22
31
|
// The refresher resolves clone paths via this helper; point it at our temp
|
|
23
32
|
// home so no real ~/.claude marketplace is ever touched. (Bun's os.homedir()
|
|
24
33
|
// ignores runtime $HOME changes, so mocking the helper is the reliable seam.)
|
|
25
34
|
getGlobalClaudeDir: () => path.join(tmp, ".claude"),
|
|
26
35
|
}));
|
|
27
36
|
|
|
28
|
-
const {
|
|
29
|
-
|
|
37
|
+
const {
|
|
38
|
+
refreshRegisteredMarketplaces,
|
|
39
|
+
reportRegisteredMarketplaces,
|
|
40
|
+
fastForwardCloneIfPresent,
|
|
41
|
+
} = await import("../services/marketplace-refresh.js");
|
|
42
|
+
const {
|
|
43
|
+
CATALOG_TTL_MS,
|
|
44
|
+
clearCloneRefreshes,
|
|
45
|
+
readCloneRefreshAge,
|
|
46
|
+
resetCatalogCacheMemo,
|
|
47
|
+
} = await import("../services/catalog-cache-store.js");
|
|
30
48
|
|
|
31
49
|
const gh = (repo = "MadAppGang/magus") => ({
|
|
32
50
|
source: { source: "github", repo },
|
|
@@ -110,6 +128,7 @@ beforeEach(() => {
|
|
|
110
128
|
|
|
111
129
|
configured = {};
|
|
112
130
|
autoUpdate = {};
|
|
131
|
+
userEnabled = null;
|
|
113
132
|
});
|
|
114
133
|
|
|
115
134
|
afterEach(() => {
|
|
@@ -344,3 +363,306 @@ describe("fastForwardCloneIfPresent — single-clone helper (shared with claude-
|
|
|
344
363
|
expect(readPin(dir).pin).toBe("x@dirty");
|
|
345
364
|
});
|
|
346
365
|
});
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Point the clone's origin at a fake ssh that hangs. git pull -> git fetch ->
|
|
369
|
+
* ssh: the fake ssh is NOT runBounded's direct child, so killing `git pull`
|
|
370
|
+
* leaves it holding the inherited stdout/stderr open. It records its own pid and
|
|
371
|
+
* its grandparent's (the `git pull` runBounded spawned) so the case can check
|
|
372
|
+
* them and the cleanup can kill them by pid, never by pattern.
|
|
373
|
+
*/
|
|
374
|
+
function hangSshTransport(dir: string): { sshPid: string; pullPid: string } {
|
|
375
|
+
const sshPid = path.join(tmp, "fake-ssh.pid");
|
|
376
|
+
const pullPid = path.join(tmp, "git-pull.pid");
|
|
377
|
+
const fakeSsh = path.join(tmp, "fake-ssh.sh");
|
|
378
|
+
fs.writeFileSync(
|
|
379
|
+
fakeSsh,
|
|
380
|
+
[
|
|
381
|
+
"#!/bin/sh",
|
|
382
|
+
`echo $$ > '${sshPid}'`,
|
|
383
|
+
`ps -o ppid= -p $PPID | tr -d ' ' > '${pullPid}'`,
|
|
384
|
+
`ps -o command= -p "$(cat '${pullPid}')" > '${pullPid}.cmd'`,
|
|
385
|
+
"exec sleep 60",
|
|
386
|
+
"",
|
|
387
|
+
].join("\n"),
|
|
388
|
+
{ mode: 0o755 },
|
|
389
|
+
);
|
|
390
|
+
git(dir, "remote", "set-url", "origin", "ssh://example.invalid/repo.git");
|
|
391
|
+
git(dir, "config", "core.sshCommand", fakeSsh);
|
|
392
|
+
hungPidFiles = [sshPid, pullPid];
|
|
393
|
+
return { sshPid, pullPid };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function readPid(file: string): number {
|
|
397
|
+
return Number(fs.readFileSync(file, "utf8").trim());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** True once `pid` no longer exists, polling briefly to let the runtime reap it. */
|
|
401
|
+
async function goneWithin(pid: number, ms: number): Promise<boolean> {
|
|
402
|
+
const until = Date.now() + ms;
|
|
403
|
+
for (;;) {
|
|
404
|
+
try {
|
|
405
|
+
process.kill(pid, 0);
|
|
406
|
+
} catch {
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
if (Date.now() > until) return false;
|
|
410
|
+
await Bun.sleep(20);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// pid files written by hangSshTransport; killed by pid after each case.
|
|
415
|
+
let hungPidFiles: string[] = [];
|
|
416
|
+
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
for (const file of hungPidFiles) {
|
|
419
|
+
if (!fs.existsSync(file)) continue;
|
|
420
|
+
const pid = readPid(file);
|
|
421
|
+
if (pid > 0) {
|
|
422
|
+
try {
|
|
423
|
+
process.kill(pid, "SIGKILL");
|
|
424
|
+
} catch {
|
|
425
|
+
// already gone
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
hungPidFiles = [];
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe("fastForwardCloneIfPresent — a hung ssh transport", () => {
|
|
433
|
+
// 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)
|
|
434
|
+
it("settles as 'failed' near the 30 s git timeout when ssh (a grandchild of git pull) holds the pipes", async () => {
|
|
435
|
+
const dir = installClone("magus");
|
|
436
|
+
hangSshTransport(dir);
|
|
437
|
+
|
|
438
|
+
const started = Date.now();
|
|
439
|
+
const status = await fastForwardCloneIfPresent("magus");
|
|
440
|
+
const elapsed = Date.now() - started;
|
|
441
|
+
|
|
442
|
+
expect(status).toBe("failed");
|
|
443
|
+
// 30 s timeout + grace. On the bug it settles only when ssh exits (~60 s).
|
|
444
|
+
expect(elapsed).toBeLessThan(40_000);
|
|
445
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
446
|
+
}, 45_000);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("fastForwardCloneIfPresent — per-clone deadline", () => {
|
|
450
|
+
// REGRESSION: the marketplaces step ran 56 min — a per-clone deadline bounds it whatever hangs (MTL-365)
|
|
451
|
+
it("returns 'failed' at the deadline while ssh still hangs, clone intact", async () => {
|
|
452
|
+
const dir = installClone("magus");
|
|
453
|
+
hangSshTransport(dir);
|
|
454
|
+
|
|
455
|
+
const started = Date.now();
|
|
456
|
+
const status = await fastForwardCloneIfPresent("magus", 1_000);
|
|
457
|
+
const elapsed = Date.now() - started;
|
|
458
|
+
|
|
459
|
+
expect(status).toBe("failed");
|
|
460
|
+
// Deadline 1 s plus slack. Without the deadline it settles at the 30 s git timeout.
|
|
461
|
+
expect(elapsed).toBeLessThan(3_000);
|
|
462
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
463
|
+
}, 10_000);
|
|
464
|
+
|
|
465
|
+
it("kills the in-flight git pull when the deadline wins, rather than leaving it running", async () => {
|
|
466
|
+
const dir = installClone("magus");
|
|
467
|
+
const { pullPid } = hangSshTransport(dir);
|
|
468
|
+
|
|
469
|
+
const status = await fastForwardCloneIfPresent("magus", 1_000);
|
|
470
|
+
|
|
471
|
+
expect(status).toBe("failed");
|
|
472
|
+
// The fake ssh got far enough to record its grandparent, the git pull.
|
|
473
|
+
expect(fs.existsSync(pullPid)).toBe(true);
|
|
474
|
+
expect(fs.readFileSync(`${pullPid}.cmd`, "utf8")).toContain("pull");
|
|
475
|
+
// Without the abort, git pull lives on until its own 30 s timeout.
|
|
476
|
+
expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
|
|
477
|
+
}, 10_000);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/** The clone's HEAD and origin's main, to tell "pulled" from "left behind". */
|
|
481
|
+
function headOf(dir: string): string {
|
|
482
|
+
return git(dir, "rev-parse", "HEAD").trim();
|
|
483
|
+
}
|
|
484
|
+
function originHead(): string {
|
|
485
|
+
return git(origin, "rev-parse", "main").trim();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
describe("refreshRegisteredMarketplaces — needed only (MTL-367)", () => {
|
|
489
|
+
it("does not pull a registered marketplace no installed plugin comes from, and reports it skipped", async () => {
|
|
490
|
+
const used = installClone("magus");
|
|
491
|
+
const unused = installClone("unused");
|
|
492
|
+
publishV2();
|
|
493
|
+
configured = { magus: gh(), unused: gh("x/unused") };
|
|
494
|
+
userEnabled = {};
|
|
495
|
+
|
|
496
|
+
const r = await refreshRegisteredMarketplaces(
|
|
497
|
+
[],
|
|
498
|
+
{},
|
|
499
|
+
{ pluginIds: ["dev@magus"] },
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
503
|
+
expect(r.skipped).toEqual(["unused"]);
|
|
504
|
+
expect(headOf(used)).toBe(originHead());
|
|
505
|
+
expect(headOf(unused)).not.toBe(originHead()); // left behind
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("pulls a marketplace used only by a USER-scope plugin", async () => {
|
|
509
|
+
const dir = installClone("userland");
|
|
510
|
+
installClone("unused");
|
|
511
|
+
publishV2();
|
|
512
|
+
configured = { userland: gh("x/userland"), unused: gh("x/unused") };
|
|
513
|
+
// `false` is not installed: only `true` counts.
|
|
514
|
+
userEnabled = { "tool@userland": true, "off@unused": false };
|
|
515
|
+
|
|
516
|
+
const r = await refreshRegisteredMarketplaces([], {}, { pluginIds: [] });
|
|
517
|
+
|
|
518
|
+
expect(r.refreshed).toEqual(["userland"]);
|
|
519
|
+
expect(r.skipped).toEqual(["unused"]);
|
|
520
|
+
expect(headOf(dir)).toBe(originHead());
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("report-only applies the same selection: an unused marketplace is skipped, a used one pending", async () => {
|
|
524
|
+
installClone("magus");
|
|
525
|
+
installClone("unused");
|
|
526
|
+
configured = { magus: gh(), unused: gh("x/unused") };
|
|
527
|
+
userEnabled = {};
|
|
528
|
+
|
|
529
|
+
const r = await reportRegisteredMarketplaces([], {
|
|
530
|
+
pluginIds: ["dev@magus"],
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(r.refreshed).toEqual([]);
|
|
534
|
+
expect(r.pending).toEqual(["magus"]);
|
|
535
|
+
expect(r.skipped).toEqual(["unused"]);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
describe("refreshRegisteredMarketplaces — at most once an hour, machine-wide (MTL-367)", () => {
|
|
540
|
+
let prevConfigDir: string | undefined;
|
|
541
|
+
const T0 = 1_790_000_000_000;
|
|
542
|
+
|
|
543
|
+
beforeEach(() => {
|
|
544
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
545
|
+
// The store lives under the temp home, never the operator's.
|
|
546
|
+
process.env.CLAUDE_CONFIG_DIR = path.join(tmp, ".claude");
|
|
547
|
+
resetCatalogCacheMemo();
|
|
548
|
+
});
|
|
549
|
+
afterEach(() => {
|
|
550
|
+
// biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined"
|
|
551
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
552
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
553
|
+
resetCatalogCacheMemo();
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("does not pull again within the hour, and reports it fresh", async () => {
|
|
557
|
+
const dir = installClone("magus");
|
|
558
|
+
configured = { magus: gh() };
|
|
559
|
+
|
|
560
|
+
const first = await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
561
|
+
expect(first.refreshed).toEqual(["magus"]);
|
|
562
|
+
|
|
563
|
+
publishV2(); // origin advances after the first pull
|
|
564
|
+
resetCatalogCacheMemo(); // a second process, same machine
|
|
565
|
+
const before = headOf(dir);
|
|
566
|
+
const second = await refreshRegisteredMarketplaces(
|
|
567
|
+
[],
|
|
568
|
+
{},
|
|
569
|
+
{ now: T0 + 10 * 60 * 1000 },
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
expect(second.refreshed).toEqual([]);
|
|
573
|
+
expect(second.fresh).toEqual(["magus"]);
|
|
574
|
+
expect(second.skipped).toEqual([]);
|
|
575
|
+
expect(headOf(dir)).toBe(before);
|
|
576
|
+
expect(headOf(dir)).not.toBe(originHead());
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("pulls again once the TTL has passed", async () => {
|
|
580
|
+
const dir = installClone("magus");
|
|
581
|
+
configured = { magus: gh() };
|
|
582
|
+
await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
583
|
+
|
|
584
|
+
publishV2();
|
|
585
|
+
const r = await refreshRegisteredMarketplaces(
|
|
586
|
+
[],
|
|
587
|
+
{},
|
|
588
|
+
{ now: T0 + CATALOG_TTL_MS + 1 },
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
592
|
+
expect(r.fresh).toEqual([]);
|
|
593
|
+
expect(headOf(dir)).toBe(originHead());
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("a failed pull records no time, so the next call pulls again", async () => {
|
|
597
|
+
const dir = installClone("magus");
|
|
598
|
+
git(dir, "remote", "set-url", "origin", path.join(tmp, "gone.git"));
|
|
599
|
+
configured = { magus: gh() };
|
|
600
|
+
|
|
601
|
+
const failed = await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
602
|
+
expect(failed.failed).toEqual(["magus"]);
|
|
603
|
+
expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
|
|
604
|
+
|
|
605
|
+
git(dir, "remote", "set-url", "origin", origin);
|
|
606
|
+
publishV2();
|
|
607
|
+
const retry = await refreshRegisteredMarketplaces(
|
|
608
|
+
[],
|
|
609
|
+
{},
|
|
610
|
+
{ now: T0 + 60_000 },
|
|
611
|
+
);
|
|
612
|
+
expect(retry.refreshed).toEqual(["magus"]);
|
|
613
|
+
expect(headOf(dir)).toBe(originHead());
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it("an explicit refresh (the TUI's `r`) clears the record, so the next call pulls", async () => {
|
|
617
|
+
const dir = installClone("magus");
|
|
618
|
+
configured = { magus: gh() };
|
|
619
|
+
await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
620
|
+
|
|
621
|
+
// `r` -> refreshAllMarketplaces -> clearPersistedCatalogs.
|
|
622
|
+
const { clearPersistedCatalogs } = await import(
|
|
623
|
+
"../services/marketplace-fetcher.js"
|
|
624
|
+
);
|
|
625
|
+
await clearPersistedCatalogs();
|
|
626
|
+
|
|
627
|
+
publishV2();
|
|
628
|
+
resetCatalogCacheMemo();
|
|
629
|
+
const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 60_000 });
|
|
630
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
631
|
+
expect(headOf(dir)).toBe(originHead());
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("clearCloneRefreshes drops the record", async () => {
|
|
635
|
+
installClone("magus");
|
|
636
|
+
configured = { magus: gh() };
|
|
637
|
+
await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
638
|
+
expect(await readCloneRefreshAge("magus", T0 + 1)).toBe(1);
|
|
639
|
+
|
|
640
|
+
await clearCloneRefreshes();
|
|
641
|
+
expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("report-only writes no time, and the next writing run pulls", async () => {
|
|
645
|
+
const dir = installClone("magus");
|
|
646
|
+
configured = { magus: gh() };
|
|
647
|
+
publishV2();
|
|
648
|
+
|
|
649
|
+
const report = await reportRegisteredMarketplaces([], { now: T0 });
|
|
650
|
+
expect(report.pending).toEqual(["magus"]);
|
|
651
|
+
resetCatalogCacheMemo();
|
|
652
|
+
expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
|
|
653
|
+
expect(headOf(dir)).not.toBe(originHead());
|
|
654
|
+
|
|
655
|
+
const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 1 });
|
|
656
|
+
expect(r.refreshed).toEqual(["magus"]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("report-only reports a clone refreshed within the hour as fresh", async () => {
|
|
660
|
+
installClone("magus");
|
|
661
|
+
configured = { magus: gh() };
|
|
662
|
+
await refreshRegisteredMarketplaces([], {}, { now: T0 });
|
|
663
|
+
|
|
664
|
+
const report = await reportRegisteredMarketplaces([], { now: T0 + 1000 });
|
|
665
|
+
expect(report.fresh).toEqual(["magus"]);
|
|
666
|
+
expect(report.pending).toEqual([]);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
@@ -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
|
+
});
|
package/src/cli/update.ts
CHANGED
|
@@ -364,36 +364,53 @@ async function updateProfile(
|
|
|
364
364
|
// own output, which is where this was caught. A read-only run plans against
|
|
365
365
|
// the clone as it stands; the notice below says so, so an unrefreshed
|
|
366
366
|
// catalog is never mistaken for a current one.
|
|
367
|
+
//
|
|
368
|
+
// Only marketplaces some installed plugin comes from are pulled: this
|
|
369
|
+
// profile's plugins, plus whatever is installed at USER scope (the refresher
|
|
370
|
+
// reads that itself). And each clone at most once an hour, machine-wide.
|
|
371
|
+
const readOnly = flags.dryRun || flags.check;
|
|
372
|
+
const pluginIds = Object.keys(closure.plugins);
|
|
367
373
|
const refresh = await runStep(steps.marketplaces, async (say) => {
|
|
368
|
-
if (
|
|
374
|
+
if (readOnly) {
|
|
369
375
|
say("read-only — selecting, not pulling");
|
|
370
|
-
// Report-only, NOT a skip: the
|
|
371
|
-
//
|
|
372
|
-
|
|
373
|
-
steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
|
|
374
|
-
return report;
|
|
376
|
+
// Report-only, NOT a skip: the selection is a pure read, so a read-only
|
|
377
|
+
// run can still say exactly what a real run would pull.
|
|
378
|
+
return reportRegisteredMarketplaces([], { pluginIds });
|
|
375
379
|
}
|
|
376
380
|
let settled = 0;
|
|
377
|
-
return refreshRegisteredMarketplaces(
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
381
|
+
return refreshRegisteredMarketplaces(
|
|
382
|
+
[],
|
|
383
|
+
{
|
|
384
|
+
onStart: (names) => {
|
|
385
|
+
// Only now is there a denominator; until `onStart` fires the step
|
|
386
|
+
// shows a sweep rather than a meter that would have to guess one.
|
|
387
|
+
steps.marketplaces.progress = { done: 0, total: names.length };
|
|
388
|
+
say(
|
|
389
|
+
names.length === 0
|
|
390
|
+
? "none eligible"
|
|
391
|
+
: `pulling ${names.join(", ")}`,
|
|
392
|
+
);
|
|
393
|
+
},
|
|
394
|
+
onSettled: (name, status) => {
|
|
395
|
+
settled++;
|
|
396
|
+
if (steps.marketplaces.progress)
|
|
397
|
+
steps.marketplaces.progress.done = settled;
|
|
398
|
+
say(`${name} ${status}`);
|
|
399
|
+
},
|
|
391
400
|
},
|
|
392
|
-
|
|
401
|
+
{ pluginIds },
|
|
402
|
+
);
|
|
393
403
|
});
|
|
394
404
|
steps.marketplaces.detail = [
|
|
395
|
-
|
|
405
|
+
readOnly
|
|
406
|
+
? `not refreshed (read-only) · ${refresh.pending.length} would refresh`
|
|
407
|
+
: `${refresh.refreshed.length} refreshed`,
|
|
396
408
|
refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
|
|
409
|
+
// Not "up to date": the clone was pulled within the hour, and upstream
|
|
410
|
+
// may have moved since. Say what is known.
|
|
411
|
+
refresh.fresh.length > 0
|
|
412
|
+
? `${refresh.fresh.length} refreshed in the last hour`
|
|
413
|
+
: "",
|
|
397
414
|
refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
|
|
398
415
|
]
|
|
399
416
|
.filter(Boolean)
|