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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.11.6",
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.6",
69
- "magus-cli-darwin-x64": "7.11.6",
70
- "magus-cli-linux-x64": "7.11.6"
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 { refreshRegisteredMarketplaces, fastForwardCloneIfPresent } =
29
- await import("../services/marketplace-refresh.js");
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 (flags.dryRun || flags.check) {
374
+ if (readOnly) {
369
375
  say("read-only — selecting, not pulling");
370
- // Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
371
- // read and is the one diagnostic a read-only run must not lose.
372
- const report = await reportRegisteredMarketplaces();
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
- onStart: (names) => {
379
- // Only now is there a denominator; until `onStart` fires the step
380
- // shows a sweep rather than a meter that would have to guess one.
381
- steps.marketplaces.progress = { done: 0, total: names.length };
382
- say(
383
- names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`,
384
- );
385
- },
386
- onSettled: (name, status) => {
387
- settled++;
388
- if (steps.marketplaces.progress)
389
- steps.marketplaces.progress.done = settled;
390
- say(`${name} ${status}`);
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
- `${refresh.refreshed.length} refreshed`,
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)