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
|
@@ -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;
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
* working precisely when the HTTP catalog cannot.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import { spawn } from "node:child_process";
|
|
31
30
|
import fs from "node:fs";
|
|
32
31
|
import path from "node:path";
|
|
33
32
|
import { claudeConfigDirOrNull } from "../utils/config-dir.js";
|
|
33
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
34
34
|
import {
|
|
35
35
|
type MarketplacePlugin,
|
|
36
36
|
normalizeCatalogJson,
|
|
@@ -55,36 +55,27 @@ function cloneDir(name: string): string | null {
|
|
|
55
55
|
return path.join(configDir, "plugins", "marketplaces", name);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function git(
|
|
58
|
+
async function git(
|
|
59
59
|
cwd: string,
|
|
60
60
|
args: string[],
|
|
61
61
|
timeoutMs: number,
|
|
62
62
|
): Promise<{ ok: boolean; out: string }> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
SSH_ASKPASS: "echo",
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
let out = "";
|
|
80
|
-
child.stdout.on("data", (d: Buffer | string) => {
|
|
81
|
-
out += String(d);
|
|
82
|
-
});
|
|
83
|
-
child.on("error", () => resolve({ ok: false, out: "" }));
|
|
84
|
-
child.on("close", (code: number | null) =>
|
|
85
|
-
resolve({ ok: code === 0, out }),
|
|
86
|
-
);
|
|
63
|
+
const r = await runBounded("git", args, {
|
|
64
|
+
cwd,
|
|
65
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
66
|
+
timeoutMs,
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
// Never let git stop for credentials: this runs inside a TUI with no
|
|
70
|
+
// way to answer a prompt, and a private marketplace we cannot read
|
|
71
|
+
// should degrade to "unknown", not hang the screen forever.
|
|
72
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
73
|
+
GIT_ASKPASS: "echo",
|
|
74
|
+
SSH_ASKPASS: "echo",
|
|
75
|
+
},
|
|
87
76
|
});
|
|
77
|
+
if (r.spawnError) return { ok: false, out: "" };
|
|
78
|
+
return { ok: r.code === 0, out: r.stdout };
|
|
88
79
|
}
|
|
89
80
|
|
|
90
81
|
/**
|
|
@@ -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
|
/**
|
|
@@ -17,71 +17,114 @@
|
|
|
17
17
|
* A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
20
|
import fs from "node:fs";
|
|
22
21
|
import path from "node:path";
|
|
22
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
23
|
+
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
|
|
|
29
35
|
/** Per-git-invocation timeout. A hung pull must never block launching Claude. */
|
|
30
36
|
const GIT_TIMEOUT_MS = 30_000;
|
|
31
37
|
|
|
38
|
+
/** Hard cap per clone, whatever hangs: `git status` then `git pull`, each GIT_TIMEOUT_MS, plus slack. */
|
|
39
|
+
const CLONE_DEADLINE_MS = 2 * GIT_TIMEOUT_MS + 5_000;
|
|
40
|
+
|
|
32
41
|
export interface RefreshResult {
|
|
33
42
|
/** Clones fast-forwarded to a newer commit (or already current). */
|
|
34
43
|
refreshed: string[];
|
|
35
44
|
/** Pull errored (network, diverged, timeout) — clone left intact and stale. */
|
|
36
45
|
failed: string[];
|
|
37
|
-
/**
|
|
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
|
+
*/
|
|
38
60
|
skipped: string[];
|
|
39
61
|
}
|
|
40
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
|
+
|
|
41
94
|
function cloneDir(name: string): string {
|
|
42
95
|
return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
|
|
43
96
|
}
|
|
44
97
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`, or as
|
|
100
|
+
* soon as `signal` aborts (an already-aborted signal runs nothing). Throws on
|
|
101
|
+
* anything but exit 0.
|
|
102
|
+
*/
|
|
103
|
+
async function runGit(
|
|
47
104
|
cwd: string,
|
|
48
105
|
args: string[],
|
|
49
106
|
timeoutMs: number,
|
|
107
|
+
signal?: AbortSignal,
|
|
50
108
|
): Promise<string> {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
stdout += b.toString();
|
|
61
|
-
});
|
|
62
|
-
proc.stderr?.on("data", (b) => {
|
|
63
|
-
stderr += b.toString();
|
|
64
|
-
});
|
|
65
|
-
proc.on("error", reject);
|
|
66
|
-
proc.on("close", (code, signal) => {
|
|
67
|
-
if (code === 0) {
|
|
68
|
-
resolve(stdout);
|
|
69
|
-
} else {
|
|
70
|
-
reject(
|
|
71
|
-
new Error(
|
|
72
|
-
`git ${args.join(" ")} ${
|
|
73
|
-
signal ? `killed (${signal})` : `exited ${code}`
|
|
74
|
-
}: ${stderr.trim()}`,
|
|
75
|
-
),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
});
|
|
109
|
+
const r = await runBounded("git", args, { cwd, timeoutMs, signal });
|
|
110
|
+
if (r.spawnError) throw r.spawnError;
|
|
111
|
+
if (r.code === 0 && !r.aborted) return r.stdout;
|
|
112
|
+
const how = r.aborted
|
|
113
|
+
? "aborted"
|
|
114
|
+
: r.signal
|
|
115
|
+
? `killed (${r.signal})`
|
|
116
|
+
: `exited ${r.code}`;
|
|
117
|
+
throw new Error(`git ${args.join(" ")} ${how}: ${r.stderr.trim()}`);
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
/** True if the clone has uncommitted changes we must not clobber. */
|
|
83
|
-
async function isDirty(dir: string): Promise<boolean> {
|
|
84
|
-
const out = await runGit(
|
|
121
|
+
async function isDirty(dir: string, signal?: AbortSignal): Promise<boolean> {
|
|
122
|
+
const out = await runGit(
|
|
123
|
+
dir,
|
|
124
|
+
["status", "--porcelain"],
|
|
125
|
+
GIT_TIMEOUT_MS,
|
|
126
|
+
signal,
|
|
127
|
+
);
|
|
85
128
|
return out.trim().length > 0;
|
|
86
129
|
}
|
|
87
130
|
|
|
@@ -99,23 +142,50 @@ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
|
|
|
99
142
|
* deletes: the worst case is `failed` (stale clone left intact). Shared by the
|
|
100
143
|
* batch refresh below and by claude-cli's "plugin not found" recovery, so both
|
|
101
144
|
* paths get the same safe semantics instead of the old `marketplace update`.
|
|
145
|
+
*
|
|
146
|
+
* Bounded by `deadlineMs`: when it passes, the result is `failed` and the git
|
|
147
|
+
* command then in flight (`status` or `pull`) is SIGKILLed; no further git
|
|
148
|
+
* command starts. Only that direct child is killed: a `git fetch` or `ssh` it
|
|
149
|
+
* spawned may linger until it exits on its own, holding nothing we wait on.
|
|
150
|
+
* The clone directory is never deleted, but a pull killed while it updates the
|
|
151
|
+
* working tree can leave that update part-way and a `.git/index.lock` behind;
|
|
152
|
+
* later pulls then return `failed` until the lock is removed. The deadline sits
|
|
153
|
+
* well above a healthy pull, so that is the price of never hanging, not a
|
|
154
|
+
* normal outcome.
|
|
102
155
|
*/
|
|
103
156
|
export async function fastForwardCloneIfPresent(
|
|
104
157
|
name: string,
|
|
158
|
+
deadlineMs: number = CLONE_DEADLINE_MS,
|
|
105
159
|
): Promise<CloneRefresh> {
|
|
106
160
|
const dir = cloneDir(name);
|
|
107
161
|
// No clone on disk (claude never installed it) or not a git repo.
|
|
108
162
|
if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
|
|
163
|
+
const abort = new AbortController();
|
|
164
|
+
const work = (async (): Promise<CloneRefresh> => {
|
|
165
|
+
try {
|
|
166
|
+
// Never clobber a user's local edits to the clone.
|
|
167
|
+
if (await isDirty(dir, abort.signal)) return "skipped";
|
|
168
|
+
// --ff-only is the safety guarantee: it fetches and fast-forwards, and
|
|
169
|
+
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
170
|
+
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
171
|
+
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS, abort.signal);
|
|
172
|
+
return "refreshed";
|
|
173
|
+
} catch {
|
|
174
|
+
return "failed";
|
|
175
|
+
}
|
|
176
|
+
})();
|
|
177
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
178
|
+
const deadline = new Promise<CloneRefresh>((resolve) => {
|
|
179
|
+
timer = setTimeout(() => {
|
|
180
|
+
// Kill the in-flight git child rather than leave it running.
|
|
181
|
+
abort.abort();
|
|
182
|
+
resolve("failed");
|
|
183
|
+
}, deadlineMs);
|
|
184
|
+
});
|
|
109
185
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// aborts without mutating the tree if it can't. It never deletes the dir,
|
|
114
|
-
// so a failure degrades to "stale clone", never "deleted marketplace".
|
|
115
|
-
await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
|
|
116
|
-
return "refreshed";
|
|
117
|
-
} catch {
|
|
118
|
-
return "failed";
|
|
186
|
+
return await Promise.race([work, deadline]);
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
119
189
|
}
|
|
120
190
|
}
|
|
121
191
|
|
|
@@ -150,46 +220,62 @@ function tap(fn: (() => void) | undefined): void {
|
|
|
150
220
|
}
|
|
151
221
|
}
|
|
152
222
|
|
|
153
|
-
/**
|
|
154
|
-
* Non-destructively fast-forward the on-disk clone of every registered,
|
|
155
|
-
* auto-update-enabled, github-sourced marketplace.
|
|
156
|
-
*
|
|
157
|
-
* @param skip marketplaces already refreshed this run (e.g. re-registered ones),
|
|
158
|
-
* so they aren't pulled twice.
|
|
159
|
-
* @param progress optional taps for a live progress display.
|
|
160
|
-
* @returns which marketplaces were refreshed, which failed (clone left intact),
|
|
161
|
-
* and which were skipped. Failures and skips are collected rather than thrown —
|
|
162
|
-
* one unreachable marketplace must not block launching Claude. Pulls run
|
|
163
|
-
* concurrently, so a slow one doesn't serialize the rest onto the launch path.
|
|
164
|
-
*/
|
|
165
223
|
/**
|
|
166
224
|
* Select and report, writing nothing.
|
|
167
225
|
*
|
|
168
|
-
* Everything above the pull loop is a read — the marketplace registry
|
|
169
|
-
* entry's `autoUpdate` flag
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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`.
|
|
175
233
|
*/
|
|
176
234
|
export async function reportRegisteredMarketplaces(
|
|
177
235
|
skip: Iterable<string> = [],
|
|
236
|
+
options: RefreshOptions = {},
|
|
178
237
|
): Promise<RefreshResult> {
|
|
179
|
-
return
|
|
238
|
+
return selectAndRefresh(skip, {}, { ...options, reportOnly: true });
|
|
180
239
|
}
|
|
181
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
|
+
*/
|
|
182
257
|
export async function refreshRegisteredMarketplaces(
|
|
183
258
|
skip: Iterable<string> = [],
|
|
184
259
|
progress: RefreshProgress = {},
|
|
185
|
-
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 },
|
|
186
269
|
): Promise<RefreshResult> {
|
|
187
270
|
const refreshed: string[] = [];
|
|
188
271
|
const failed: string[] = [];
|
|
272
|
+
const fresh: string[] = [];
|
|
189
273
|
const skipped: string[] = [];
|
|
274
|
+
const now = options.now ?? Date.now();
|
|
190
275
|
|
|
191
276
|
const configured = await getConfiguredMarketplaces();
|
|
192
277
|
const skipSet = new Set(skip);
|
|
278
|
+
const needed = await neededMarketplaces(options.pluginIds ?? []);
|
|
193
279
|
|
|
194
280
|
// Select eligible marketplaces first so the pulls can run concurrently.
|
|
195
281
|
const eligible: string[] = [];
|
|
@@ -208,18 +294,25 @@ export async function refreshRegisteredMarketplaces(
|
|
|
208
294
|
skipped.push(name);
|
|
209
295
|
continue;
|
|
210
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
|
+
}
|
|
211
307
|
eligible.push(name);
|
|
212
308
|
}
|
|
213
309
|
|
|
214
310
|
// A read-only caller stops here, with the selection verdict intact and not
|
|
215
|
-
// one clone touched. `eligible` becomes `
|
|
216
|
-
// 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.
|
|
217
314
|
if (options.reportOnly) {
|
|
218
|
-
return {
|
|
219
|
-
refreshed: [],
|
|
220
|
-
failed: [],
|
|
221
|
-
skipped: [...skipped, ...eligible],
|
|
222
|
-
};
|
|
315
|
+
return { refreshed: [], failed: [], fresh, pending: eligible, skipped };
|
|
223
316
|
}
|
|
224
317
|
|
|
225
318
|
// Fast-forward every eligible clone in parallel so a slow one doesn't
|
|
@@ -241,5 +334,9 @@ export async function refreshRegisteredMarketplaces(
|
|
|
241
334
|
else skipped.push(name); // absent | skipped
|
|
242
335
|
});
|
|
243
336
|
|
|
244
|
-
|
|
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 };
|
|
245
342
|
}
|