magus-cli 7.11.7 → 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.
|
|
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(() => {
|
|
@@ -457,3 +476,193 @@ describe("fastForwardCloneIfPresent — per-clone deadline", () => {
|
|
|
457
476
|
expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
|
|
458
477
|
}, 10_000);
|
|
459
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
|
+
});
|
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)
|
|
@@ -37,10 +37,11 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
|
37
37
|
*
|
|
38
38
|
* This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
|
|
39
39
|
* `magus update` and the TUI read a catalog written here within the hour
|
|
40
|
-
* before they go to the network. `update` pulls the marketplace clones
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
40
|
+
* before they go to the network. `update` pulls the marketplace clones (each
|
|
41
|
+
* at most once per TTL, machine-wide — see `cloneRefreshes`) and drops its
|
|
42
|
+
* in-process memo before planning, but it does NOT clear this file, so a
|
|
43
|
+
* release published inside the TTL is not seen by `update` either. Only an
|
|
44
|
+
* explicit refresh (`r` in the TUI) clears it, clone refresh times included. Anything that reports "no
|
|
44
45
|
* update" — or decides a plugin is no longer published — off a cached catalog
|
|
45
46
|
* is working from an answer up to an hour old, by design.
|
|
46
47
|
*/
|
|
@@ -93,14 +94,24 @@ interface StoreShape {
|
|
|
93
94
|
catalogs: Record<string, StoredCatalog>;
|
|
94
95
|
cooldowns: Record<string, StoredCooldown>;
|
|
95
96
|
communityStyles: Record<string, StoredCommunityCheck>;
|
|
97
|
+
/**
|
|
98
|
+
* Per marketplace: wall-clock ms of the last SUCCESSFUL fast-forward of its
|
|
99
|
+
* on-disk clone. Machine-wide, so one project's `magus update` spares every
|
|
100
|
+
* other project's the same pull within CATALOG_TTL_MS.
|
|
101
|
+
*/
|
|
102
|
+
cloneRefreshes: Record<string, number>;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
105
|
+
/** A fresh empty store. A function, so no caller can share and mutate one object. */
|
|
106
|
+
function emptyStore(): StoreShape {
|
|
107
|
+
return {
|
|
108
|
+
version: 1,
|
|
109
|
+
catalogs: {},
|
|
110
|
+
cooldowns: {},
|
|
111
|
+
communityStyles: {},
|
|
112
|
+
cloneRefreshes: {},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
104
115
|
|
|
105
116
|
/**
|
|
106
117
|
* Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
|
|
@@ -124,29 +135,24 @@ let memo: { path: string; data: StoreShape } | null = null;
|
|
|
124
135
|
|
|
125
136
|
async function load(): Promise<StoreShape> {
|
|
126
137
|
const file = storePath();
|
|
127
|
-
if (!file)
|
|
128
|
-
return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
138
|
+
if (!file) return emptyStore();
|
|
129
139
|
if (memo?.path === file) return memo.data;
|
|
130
140
|
try {
|
|
131
141
|
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
132
142
|
// A cache from a future/unknown layout is discarded rather than migrated:
|
|
133
143
|
// it is regenerable by definition, so guessing at it buys nothing.
|
|
134
|
-
const data = parsed?.version === 1 ? parsed :
|
|
144
|
+
const data = parsed?.version === 1 ? parsed : emptyStore();
|
|
135
145
|
data.catalogs ??= {};
|
|
136
146
|
data.cooldowns ??= {};
|
|
137
|
-
// Absent in every file written before
|
|
147
|
+
// Absent in every file written before these sections existed, so the
|
|
138
148
|
// default is the migration — an upgrade must not read as a corrupt cache.
|
|
139
149
|
data.communityStyles ??= {};
|
|
150
|
+
data.cloneRefreshes ??= {};
|
|
140
151
|
memo = { path: file, data };
|
|
141
152
|
return data;
|
|
142
153
|
} catch {
|
|
143
154
|
// Absent or corrupt. A cache must never be a failure mode — start empty.
|
|
144
|
-
const data
|
|
145
|
-
...EMPTY,
|
|
146
|
-
catalogs: {},
|
|
147
|
-
cooldowns: {},
|
|
148
|
-
communityStyles: {},
|
|
149
|
-
};
|
|
155
|
+
const data = emptyStore();
|
|
150
156
|
memo = { path: file, data };
|
|
151
157
|
return data;
|
|
152
158
|
}
|
|
@@ -179,15 +185,11 @@ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
|
|
|
179
185
|
catalogs: parsed.catalogs ?? {},
|
|
180
186
|
cooldowns: parsed.cooldowns ?? {},
|
|
181
187
|
communityStyles: parsed.communityStyles ?? {},
|
|
188
|
+
cloneRefreshes: parsed.cloneRefreshes ?? {},
|
|
182
189
|
}
|
|
183
|
-
:
|
|
190
|
+
: emptyStore();
|
|
184
191
|
} catch {
|
|
185
|
-
onDisk =
|
|
186
|
-
version: 1,
|
|
187
|
-
catalogs: {},
|
|
188
|
-
cooldowns: {},
|
|
189
|
-
communityStyles: {},
|
|
190
|
-
};
|
|
192
|
+
onDisk = emptyStore();
|
|
191
193
|
}
|
|
192
194
|
fn(onDisk);
|
|
193
195
|
// Temp + rename, so a crash mid-write cannot leave a half-written file
|
|
@@ -312,6 +314,44 @@ export async function clearCommunityChecks(): Promise<void> {
|
|
|
312
314
|
});
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
// ─── Marketplace clone refreshes ─────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Milliseconds since this marketplace's clone was last fast-forwarded
|
|
321
|
+
* successfully, or null when that was never, longer ago than CATALOG_TTL_MS, or
|
|
322
|
+
* in the future (a clock that moved back must not freeze the clone).
|
|
323
|
+
*
|
|
324
|
+
* Same TTL as the catalogs, on purpose: the clone and the catalog answer the
|
|
325
|
+
* same question, so they go stale on one rhythm and `r` clears both.
|
|
326
|
+
*/
|
|
327
|
+
export async function readCloneRefreshAge(
|
|
328
|
+
marketplace: string,
|
|
329
|
+
now: number = Date.now(),
|
|
330
|
+
): Promise<number | null> {
|
|
331
|
+
const at = (await load()).cloneRefreshes[marketplace];
|
|
332
|
+
if (typeof at !== "number") return null;
|
|
333
|
+
const ageMs = now - at;
|
|
334
|
+
if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
|
|
335
|
+
return ageMs;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Record a successful fast-forward. Never called for a failed or skipped pull. */
|
|
339
|
+
export async function writeCloneRefreshed(
|
|
340
|
+
marketplace: string,
|
|
341
|
+
now: number = Date.now(),
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
await mutate((data) => {
|
|
344
|
+
data.cloneRefreshes[marketplace] = now;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Drop every clone refresh time. Backs the explicit refresh (`r`). */
|
|
349
|
+
export async function clearCloneRefreshes(): Promise<void> {
|
|
350
|
+
await mutate((data) => {
|
|
351
|
+
data.cloneRefreshes = {};
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
315
355
|
/** Test seam: forget this process's memo so the next read hits disk. */
|
|
316
356
|
export function resetCatalogCacheMemo(): void {
|
|
317
357
|
memo = null;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { isValidGitHubRepo } from "../utils/string-utils.js";
|
|
11
11
|
import {
|
|
12
12
|
clearCachedCatalogs,
|
|
13
|
+
clearCloneRefreshes,
|
|
13
14
|
readCachedCatalog,
|
|
14
15
|
writeCachedCatalog,
|
|
15
16
|
} from "./catalog-cache-store.js";
|
|
@@ -145,6 +146,9 @@ export function clearMarketplaceCache(): void {
|
|
|
145
146
|
*/
|
|
146
147
|
export async function clearPersistedCatalogs(): Promise<void> {
|
|
147
148
|
await clearCachedCatalogs();
|
|
149
|
+
// The clone refresh times too: an explicit refresh means "ask again now", so
|
|
150
|
+
// the next `magus update` pulls every needed clone instead of trusting the hour.
|
|
151
|
+
await clearCloneRefreshes();
|
|
148
152
|
}
|
|
149
153
|
|
|
150
154
|
/**
|
|
@@ -20,9 +20,15 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { runBounded } from "../utils/spawn-bounded.js";
|
|
23
|
+
import { parsePluginId } from "../utils/string-utils.js";
|
|
24
|
+
import {
|
|
25
|
+
readCloneRefreshAge,
|
|
26
|
+
writeCloneRefreshed,
|
|
27
|
+
} from "./catalog-cache-store.js";
|
|
23
28
|
import {
|
|
24
29
|
getConfiguredMarketplaces,
|
|
25
30
|
getGlobalClaudeDir,
|
|
31
|
+
getGlobalEnabledPlugins,
|
|
26
32
|
getMarketplaceAutoUpdate,
|
|
27
33
|
} from "./claude-settings.js";
|
|
28
34
|
|
|
@@ -37,10 +43,54 @@ export interface RefreshResult {
|
|
|
37
43
|
refreshed: string[];
|
|
38
44
|
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
39
45
|
failed: string[];
|
|
40
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Not pulled because this machine already refreshed the clone successfully
|
|
48
|
+
* within CATALOG_TTL_MS — by this project's run or any other.
|
|
49
|
+
*/
|
|
50
|
+
fresh: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Report-only: selected, and a writing run would pull it. Always empty when
|
|
53
|
+
* the refresh actually ran.
|
|
54
|
+
*/
|
|
55
|
+
pending: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Not pulled, and not a failure: no installed plugin comes from it, not a
|
|
58
|
+
* github source, autoUpdate off, no clone on disk, or dirty tree.
|
|
59
|
+
*/
|
|
41
60
|
skipped: string[];
|
|
42
61
|
}
|
|
43
62
|
|
|
63
|
+
export interface RefreshOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Plugin ids (`name@marketplace`) this run installs — the profile's
|
|
66
|
+
* PROJECT/LOCAL set. Unioned with every plugin installed at USER scope;
|
|
67
|
+
* a marketplace none of them comes from is not pulled.
|
|
68
|
+
*/
|
|
69
|
+
pluginIds?: Iterable<string>;
|
|
70
|
+
/** Clock seam for the once-an-hour rule. */
|
|
71
|
+
now?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The marketplaces some installed plugin comes from: the ones passed in
|
|
76
|
+
* (PROJECT/LOCAL, from the profile) plus every plugin turned on at USER scope.
|
|
77
|
+
* A USER entry set to `false` is not installed and does not count.
|
|
78
|
+
*/
|
|
79
|
+
async function neededMarketplaces(
|
|
80
|
+
pluginIds: Iterable<string>,
|
|
81
|
+
): Promise<Set<string>> {
|
|
82
|
+
const ids = [...pluginIds];
|
|
83
|
+
for (const [id, on] of Object.entries(await getGlobalEnabledPlugins())) {
|
|
84
|
+
if (on === true) ids.push(id);
|
|
85
|
+
}
|
|
86
|
+
const needed = new Set<string>();
|
|
87
|
+
for (const id of ids) {
|
|
88
|
+
const parsed = parsePluginId(id);
|
|
89
|
+
if (parsed) needed.add(parsed.marketplace);
|
|
90
|
+
}
|
|
91
|
+
return needed;
|
|
92
|
+
}
|
|
93
|
+
|
|
44
94
|
function cloneDir(name: string): string {
|
|
45
95
|
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
46
96
|
}
|
|
@@ -170,46 +220,62 @@ function tap(fn: (() => void) | undefined): void {
|
|
|
170
220
|
}
|
|
171
221
|
}
|
|
172
222
|
|
|
173
|
-
/**
|
|
174
|
-
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
175
|
-
* auto-update-enabled, github-sourced marketplace.
|
|
176
|
-
*
|
|
177
|
-
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
178
|
-
* so they aren't pulled twice.
|
|
179
|
-
* @param progress optional taps for a live progress display.
|
|
180
|
-
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
181
|
-
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
182
|
-
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
183
|
-
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
184
|
-
*/
|
|
185
223
|
/**
|
|
186
224
|
* Select and report, writing nothing.
|
|
187
225
|
*
|
|
188
|
-
* Everything above the pull loop is a read — the marketplace registry
|
|
189
|
-
* entry's `autoUpdate` flag
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
226
|
+
* Everything above the pull loop is a read — the marketplace registry, each
|
|
227
|
+
* entry's `autoUpdate` flag, which marketplaces installed plugins come from,
|
|
228
|
+
* and when each clone was last refreshed — so a read-only caller can have the
|
|
229
|
+
* full selection verdict for free. `--dry-run` and `--check` promise to write
|
|
230
|
+
* nothing and therefore cannot pull (nor record a refresh time), but their
|
|
231
|
+
* report must name the same set a writing run would pull: that set is
|
|
232
|
+
* `pending`.
|
|
195
233
|
*/
|
|
196
234
|
export async function reportRegisteredMarketplaces(
|
|
197
235
|
skip: Iterable<string> = [],
|
|
236
|
+
options: RefreshOptions = {},
|
|
198
237
|
): Promise<RefreshResult> {
|
|
199
|
-
return
|
|
238
|
+
return selectAndRefresh(skip, {}, { ...options, reportOnly: true });
|
|
200
239
|
}
|
|
201
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
243
|
+
* auto-update-enabled, github-sourced marketplace that some installed plugin
|
|
244
|
+
* comes from (USER scope, or `options.pluginIds`), unless this machine already
|
|
245
|
+
* refreshed it within CATALOG_TTL_MS. A successful pull records its time; a
|
|
246
|
+
* failed one does not, so the next run retries.
|
|
247
|
+
*
|
|
248
|
+
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
249
|
+
* so they aren't pulled twice.
|
|
250
|
+
* @param progress optional taps for a live progress display.
|
|
251
|
+
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
252
|
+
* which were fresh, and which were skipped. Failures and skips are collected
|
|
253
|
+
* rather than thrown — one unreachable marketplace must not block launching
|
|
254
|
+
* Claude. Pulls run concurrently, so a slow one doesn't serialize the rest
|
|
255
|
+
* onto the launch path.
|
|
256
|
+
*/
|
|
202
257
|
export async function refreshRegisteredMarketplaces(
|
|
203
258
|
skip: Iterable<string> = [],
|
|
204
259
|
progress: RefreshProgress = {},
|
|
205
|
-
options:
|
|
260
|
+
options: RefreshOptions = {},
|
|
261
|
+
): Promise<RefreshResult> {
|
|
262
|
+
return selectAndRefresh(skip, progress, { ...options, reportOnly: false });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function selectAndRefresh(
|
|
266
|
+
skip: Iterable<string>,
|
|
267
|
+
progress: RefreshProgress,
|
|
268
|
+
options: RefreshOptions & { reportOnly: boolean },
|
|
206
269
|
): Promise<RefreshResult> {
|
|
207
270
|
const refreshed: string[] = [];
|
|
208
271
|
const failed: string[] = [];
|
|
272
|
+
const fresh: string[] = [];
|
|
209
273
|
const skipped: string[] = [];
|
|
274
|
+
const now = options.now ?? Date.now();
|
|
210
275
|
|
|
211
276
|
const configured = await getConfiguredMarketplaces();
|
|
212
277
|
const skipSet = new Set(skip);
|
|
278
|
+
const needed = await neededMarketplaces(options.pluginIds ?? []);
|
|
213
279
|
|
|
214
280
|
// Select eligible marketplaces first so the pulls can run concurrently.
|
|
215
281
|
const eligible: string[] = [];
|
|
@@ -228,18 +294,25 @@ export async function refreshRegisteredMarketplaces(
|
|
|
228
294
|
skipped.push(name);
|
|
229
295
|
continue;
|
|
230
296
|
}
|
|
297
|
+
// Nothing installed at any scope comes from it, so a pull buys nothing.
|
|
298
|
+
if (!needed.has(name)) {
|
|
299
|
+
skipped.push(name);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Refreshed on this machine within the hour, by any project's run.
|
|
303
|
+
if ((await readCloneRefreshAge(name, now)) !== null) {
|
|
304
|
+
fresh.push(name);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
231
307
|
eligible.push(name);
|
|
232
308
|
}
|
|
233
309
|
|
|
234
310
|
// A read-only caller stops here, with the selection verdict intact and not
|
|
235
|
-
// one clone touched. `eligible` becomes `
|
|
236
|
-
// claiming otherwise would be the false "all current" this
|
|
311
|
+
// one clone touched. `eligible` becomes `pending`, never `refreshed`: nothing
|
|
312
|
+
// was pulled, so claiming otherwise would be the false "all current" this
|
|
313
|
+
// module guards.
|
|
237
314
|
if (options.reportOnly) {
|
|
238
|
-
return {
|
|
239
|
-
refreshed: [],
|
|
240
|
-
failed: [],
|
|
241
|
-
skipped: [...skipped, ...eligible],
|
|
242
|
-
};
|
|
315
|
+
return { refreshed: [], failed: [], fresh, pending: eligible, skipped };
|
|
243
316
|
}
|
|
244
317
|
|
|
245
318
|
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
@@ -261,5 +334,9 @@ export async function refreshRegisteredMarketplaces(
|
|
|
261
334
|
else skipped.push(name); // absent | skipped
|
|
262
335
|
});
|
|
263
336
|
|
|
264
|
-
|
|
337
|
+
// Only a successful pull earns the hour. A failed, absent or dirty clone
|
|
338
|
+
// records nothing, so the next run — from any project — tries again.
|
|
339
|
+
for (const name of refreshed) await writeCloneRefreshed(name, now);
|
|
340
|
+
|
|
341
|
+
return { refreshed, failed, fresh, pending: [], skipped };
|
|
265
342
|
}
|