claudeup 4.36.0 → 4.37.0
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__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/services/catalog-cache-store.ts +218 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/utils/config-dir.ts +47 -0
|
@@ -1,13 +1,43 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll, beforeAll } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import {
|
|
3
|
-
resolveMarketplacePlugins,
|
|
4
6
|
clearMarketplaceCache,
|
|
7
|
+
fetchMarketplacePlugins,
|
|
8
|
+
getMarketplaceFetchFailures,
|
|
9
|
+
resolveMarketplacePlugins,
|
|
5
10
|
} from "../services/marketplace-fetcher";
|
|
11
|
+
import { resetGitHubBudget } from "../services/github-budget";
|
|
6
12
|
import type {
|
|
7
13
|
LocalMarketplace,
|
|
8
14
|
LocalMarketplacePlugin,
|
|
9
15
|
} from "../services/local-marketplace";
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Point the marketplace-clone lookup at an EMPTY temp directory.
|
|
19
|
+
*
|
|
20
|
+
* `resolveMarketplacePlugins` now consults a git remote ref before falling back
|
|
21
|
+
* to the checked-out clone. Without this, these tests found the operator's real
|
|
22
|
+
* `magus` clone, performed a live `git fetch`, and asserted against whatever
|
|
23
|
+
* happened to be published — 17 real plugins instead of the 2 in the fixture, and
|
|
24
|
+
* three seconds per test.
|
|
25
|
+
*/
|
|
26
|
+
let prevConfigDir: string | undefined;
|
|
27
|
+
let emptyConfigDir: string;
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
emptyConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "mp-fallback-"));
|
|
31
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
32
|
+
process.env.CLAUDE_CONFIG_DIR = emptyConfigDir;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
37
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
38
|
+
fs.rmSync(emptyConfigDir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
11
41
|
/**
|
|
12
42
|
* Regression coverage for the "deprecated everything" bug.
|
|
13
43
|
*
|
|
@@ -33,6 +63,10 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
33
63
|
// Session-level cache could otherwise carry over a previous test's
|
|
34
64
|
// successful fetch and short-circuit the fallback path.
|
|
35
65
|
clearMarketplaceCache();
|
|
66
|
+
// The per-host cooldown is deliberately NOT cleared by clearMarketplaceCache
|
|
67
|
+
// — it reflects GitHub's state, not ours — so a 429 in one test would
|
|
68
|
+
// otherwise gate every fetch in the next for the full backoff.
|
|
69
|
+
resetGitHubBudget();
|
|
36
70
|
});
|
|
37
71
|
|
|
38
72
|
it("falls back to the local cache when the remote fetch returns empty", async () => {
|
|
@@ -64,18 +98,45 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
64
98
|
local,
|
|
65
99
|
);
|
|
66
100
|
|
|
67
|
-
expect(resolved).toHaveLength(2);
|
|
68
|
-
expect(resolved.map((p) => p.name).sort()).toEqual([
|
|
101
|
+
expect(resolved.plugins).toHaveLength(2);
|
|
102
|
+
expect(resolved.plugins.map((p) => p.name).sort()).toEqual([
|
|
69
103
|
"code-analysis",
|
|
70
104
|
"dev",
|
|
71
105
|
]);
|
|
72
|
-
const codeAnalysis = resolved.find(
|
|
106
|
+
const codeAnalysis = resolved.plugins.find(
|
|
107
|
+
(p) => p.name === "code-analysis",
|
|
108
|
+
);
|
|
73
109
|
expect(codeAnalysis?.version).toBe("5.3.0");
|
|
74
110
|
expect(codeAnalysis?.description).toBe("Codebase investigation tools");
|
|
75
111
|
expect(codeAnalysis?.category).toBe("development");
|
|
76
112
|
expect(codeAnalysis?.author?.name).toBe("Jack Rudenko");
|
|
77
113
|
});
|
|
78
114
|
|
|
115
|
+
it("labels the fallback answer as local-clone, not remote", async () => {
|
|
116
|
+
// The regression this guards: the fallback plugins were returned in the
|
|
117
|
+
// same shape as a successful remote fetch, so the caller compared installed
|
|
118
|
+
// versions against a clone nothing refreshes and reported "up to date".
|
|
119
|
+
// The source must travel with the data.
|
|
120
|
+
const local = new Map<string, LocalMarketplace>([
|
|
121
|
+
[
|
|
122
|
+
"magus",
|
|
123
|
+
buildLocalMarketplace("magus", [
|
|
124
|
+
{ name: "terminal", version: "4.1.4", description: "" },
|
|
125
|
+
]),
|
|
126
|
+
],
|
|
127
|
+
]);
|
|
128
|
+
const resolved = await resolveMarketplacePlugins(
|
|
129
|
+
"magus",
|
|
130
|
+
"not a valid repo string",
|
|
131
|
+
local,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(resolved.source).toBe("local-clone");
|
|
135
|
+
expect(resolved.failure).toBeDefined();
|
|
136
|
+
expect(resolved.failure?.kind).toBe("invalid-repo");
|
|
137
|
+
expect(resolved.failure?.marketplace).toBe("magus");
|
|
138
|
+
});
|
|
139
|
+
|
|
79
140
|
it("returns empty when both remote and cache are empty", async () => {
|
|
80
141
|
const local = new Map<string, LocalMarketplace>();
|
|
81
142
|
const resolved = await resolveMarketplacePlugins(
|
|
@@ -83,7 +144,9 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
83
144
|
"not a valid repo string",
|
|
84
145
|
local,
|
|
85
146
|
);
|
|
86
|
-
expect(resolved).toEqual([]);
|
|
147
|
+
expect(resolved.plugins).toEqual([]);
|
|
148
|
+
expect(resolved.source).toBe("none");
|
|
149
|
+
expect(resolved.failure).toBeDefined();
|
|
87
150
|
});
|
|
88
151
|
|
|
89
152
|
it("returns empty when remote fails AND cache exists but has no plugins", async () => {
|
|
@@ -95,7 +158,8 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
95
158
|
"not a valid repo string",
|
|
96
159
|
local,
|
|
97
160
|
);
|
|
98
|
-
expect(resolved).toEqual([]);
|
|
161
|
+
expect(resolved.plugins).toEqual([]);
|
|
162
|
+
expect(resolved.source).toBe("none");
|
|
99
163
|
});
|
|
100
164
|
|
|
101
165
|
it("does not consult the cache when the remote returns plugins", async () => {
|
|
@@ -115,6 +179,134 @@ describe("resolveMarketplacePlugins — offline fallback", () => {
|
|
|
115
179
|
"not a valid repo string",
|
|
116
180
|
local,
|
|
117
181
|
);
|
|
118
|
-
expect(resolved).toEqual([]);
|
|
182
|
+
expect(resolved.plugins).toEqual([]);
|
|
183
|
+
expect(resolved.source).toBe("none");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The defect class these cover: a failed check that renders as a passed one.
|
|
189
|
+
*
|
|
190
|
+
* `fetchMarketplacePlugins` used to return `[]` for a rate limit, a timeout, a
|
|
191
|
+
* bad repo string and a genuinely empty marketplace alike. Every caller then had
|
|
192
|
+
* to guess, and all of them guessed "fine".
|
|
193
|
+
*/
|
|
194
|
+
describe("fetchMarketplacePlugins — failures are values", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
clearMarketplaceCache();
|
|
197
|
+
resetGitHubBudget();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("classifies a rate limit distinctly from a network problem", async () => {
|
|
201
|
+
const original = globalThis.fetch;
|
|
202
|
+
globalThis.fetch = (async () =>
|
|
203
|
+
new Response("rate limited", {
|
|
204
|
+
status: 429,
|
|
205
|
+
statusText: "Too Many Requests",
|
|
206
|
+
})) as typeof globalThis.fetch;
|
|
207
|
+
try {
|
|
208
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
209
|
+
expect(result.plugins).toEqual([]);
|
|
210
|
+
expect(result.failure?.kind).toBe("rate-limited");
|
|
211
|
+
expect(result.failure?.httpStatus).toBe(429);
|
|
212
|
+
expect(result.failure?.detail).toContain("rate limit");
|
|
213
|
+
} finally {
|
|
214
|
+
globalThis.fetch = original;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("records the failure so the UI can report it without re-fetching", async () => {
|
|
219
|
+
const original = globalThis.fetch;
|
|
220
|
+
let calls = 0;
|
|
221
|
+
globalThis.fetch = (async () => {
|
|
222
|
+
calls++;
|
|
223
|
+
return new Response("nope", { status: 500, statusText: "Server Error" });
|
|
224
|
+
}) as typeof globalThis.fetch;
|
|
225
|
+
try {
|
|
226
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
227
|
+
|
|
228
|
+
const failures = getMarketplaceFetchFailures();
|
|
229
|
+
expect(failures.map((f) => f.marketplace)).toContain("magus");
|
|
230
|
+
expect(failures.find((f) => f.marketplace === "magus")?.kind).toBe("http");
|
|
231
|
+
|
|
232
|
+
// Second call must be served from the negative cache. Re-attempting on
|
|
233
|
+
// every screen mount is what cost 30-45s of "Loading..." per tab switch.
|
|
234
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
235
|
+
expect(calls).toBe(1);
|
|
236
|
+
} finally {
|
|
237
|
+
globalThis.fetch = original;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reports no failures when the fetch succeeds", async () => {
|
|
242
|
+
const original = globalThis.fetch;
|
|
243
|
+
globalThis.fetch = (async () =>
|
|
244
|
+
Response.json({
|
|
245
|
+
metadata: { version: "9.0.3" },
|
|
246
|
+
plugins: [{ name: "terminal", version: "4.1.6" }],
|
|
247
|
+
})) as typeof globalThis.fetch;
|
|
248
|
+
try {
|
|
249
|
+
const result = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
250
|
+
expect(result.failure).toBeUndefined();
|
|
251
|
+
expect(result.plugins).toHaveLength(1);
|
|
252
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
253
|
+
} finally {
|
|
254
|
+
globalThis.fetch = original;
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not send a request while the rate-limit window is still open", async () => {
|
|
259
|
+
// Deliberate: an explicit refresh clears OUR caches but not GitHub's limit.
|
|
260
|
+
// Retrying inside a known window cannot succeed, and on some endpoints a
|
|
261
|
+
// rejected request extends the penalty — so the screen waits and counts down
|
|
262
|
+
// instead. `clearMarketplaceCache()` must not be a way around that.
|
|
263
|
+
const original = globalThis.fetch;
|
|
264
|
+
let calls = 0;
|
|
265
|
+
globalThis.fetch = (async () => {
|
|
266
|
+
calls++;
|
|
267
|
+
return new Response("nope", { status: 429 });
|
|
268
|
+
}) as typeof globalThis.fetch;
|
|
269
|
+
try {
|
|
270
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
271
|
+
expect(calls).toBe(1);
|
|
272
|
+
|
|
273
|
+
clearMarketplaceCache();
|
|
274
|
+
const second = await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
275
|
+
|
|
276
|
+
expect(calls).toBe(1); // no second request went out
|
|
277
|
+
expect(second.failure?.kind).toBe("rate-limited");
|
|
278
|
+
expect(second.failure?.retryAt).toBeGreaterThan(Date.now());
|
|
279
|
+
} finally {
|
|
280
|
+
globalThis.fetch = original;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("clears the recorded failure once the window passes and the fetch succeeds", async () => {
|
|
285
|
+
const original = globalThis.fetch;
|
|
286
|
+
globalThis.fetch = (async () =>
|
|
287
|
+
new Response("nope", { status: 429 })) as typeof globalThis.fetch;
|
|
288
|
+
try {
|
|
289
|
+
await fetchMarketplacePlugins("magus", "MadAppGang/magus");
|
|
290
|
+
expect(getMarketplaceFetchFailures()).toHaveLength(1);
|
|
291
|
+
|
|
292
|
+
// The cooldown expiring is what unblocks the retry — this stands in for the
|
|
293
|
+
// clock reaching `retryAt`, which is when the screen refetches by itself.
|
|
294
|
+
resetGitHubBudget();
|
|
295
|
+
clearMarketplaceCache();
|
|
296
|
+
globalThis.fetch = (async () =>
|
|
297
|
+
Response.json({
|
|
298
|
+
plugins: [{ name: "terminal", version: "4.2.0" }],
|
|
299
|
+
})) as typeof globalThis.fetch;
|
|
300
|
+
|
|
301
|
+
const recovered = await fetchMarketplacePlugins(
|
|
302
|
+
"magus",
|
|
303
|
+
"MadAppGang/magus",
|
|
304
|
+
);
|
|
305
|
+
expect(recovered.failure).toBeUndefined();
|
|
306
|
+
expect(recovered.plugins).toHaveLength(1);
|
|
307
|
+
expect(getMarketplaceFetchFailures()).toEqual([]);
|
|
308
|
+
} finally {
|
|
309
|
+
globalThis.fetch = original;
|
|
310
|
+
}
|
|
119
311
|
});
|
|
120
312
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog-cache-store.ts — the catalog cache and the rate-limit cooldown, on disk.
|
|
3
|
+
*
|
|
4
|
+
* Why on disk
|
|
5
|
+
* -----------
|
|
6
|
+
* Every cache in this area used to be a module-level `Map`, which for a TUI means
|
|
7
|
+
* "cleared on every launch". Measured 2026-08-17, two consecutive processes:
|
|
8
|
+
*
|
|
9
|
+
* [process-1] 6 HTTP requests in 13.32s
|
|
10
|
+
* [process-2] 6 HTTP requests in 13.01s
|
|
11
|
+
*
|
|
12
|
+
* So each launch re-fetched all six catalogs, and the second process had no idea
|
|
13
|
+
* the first had just been rate-limited — it spent six more requests rediscovering
|
|
14
|
+
* that. On some endpoints a rejected request extends the penalty, so an in-memory
|
|
15
|
+
* cooldown is not merely useless across launches, it is counterproductive.
|
|
16
|
+
*
|
|
17
|
+
* Persisting the cooldown is the more important half. A stale catalog costs the
|
|
18
|
+
* user a version number that is an hour old; re-firing six doomed requests on
|
|
19
|
+
* every launch costs them the ability to check at all.
|
|
20
|
+
*
|
|
21
|
+
* Trade-off, stated
|
|
22
|
+
* -----------------
|
|
23
|
+
* A cached catalog means a release published inside the TTL is not visible yet.
|
|
24
|
+
* That is why the TTL is short, why `r` forces a real refetch, and why the age is
|
|
25
|
+
* available for display. A cache the user cannot see through or override would be
|
|
26
|
+
* the same trap as a frozen clone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { claudeConfigDirOrNull } from "../utils/config-dir.js";
|
|
32
|
+
import { withFileLock } from "../utils/file-locking.js";
|
|
33
|
+
import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long a cached catalog is served before we ask again.
|
|
37
|
+
*
|
|
38
|
+
* Matches the hour that `update-cache.ts` already uses for its own check, so the
|
|
39
|
+
* tool has one refresh rhythm rather than two.
|
|
40
|
+
*/
|
|
41
|
+
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
interface StoredCatalog {
|
|
44
|
+
fetchedAt: number;
|
|
45
|
+
/** Which transport answered — carried so the UI can still say "unverified". */
|
|
46
|
+
source: string;
|
|
47
|
+
plugins: MarketplacePlugin[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StoredCooldown {
|
|
51
|
+
until: number;
|
|
52
|
+
exact: boolean;
|
|
53
|
+
strikes: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StoreShape {
|
|
57
|
+
version: 1;
|
|
58
|
+
catalogs: Record<string, StoredCatalog>;
|
|
59
|
+
cooldowns: Record<string, StoredCooldown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const EMPTY: StoreShape = { version: 1, catalogs: {}, cooldowns: {} };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
|
|
66
|
+
* content-drift.ts and marketplace-catalog-git.ts.
|
|
67
|
+
*
|
|
68
|
+
* Null when a test has not chosen a config dir, which makes this whole store inert
|
|
69
|
+
* rather than letting an unisolated test read the operator's live cooldown and
|
|
70
|
+
* decide its own outcome from it. See utils/config-dir.ts.
|
|
71
|
+
*/
|
|
72
|
+
function storePath(): string | null {
|
|
73
|
+
const configDir = claudeConfigDirOrNull();
|
|
74
|
+
if (!configDir) return null;
|
|
75
|
+
return path.join(configDir, "claudeup-catalog-cache.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read-through memo for this process. The disk file is the cross-launch cache;
|
|
80
|
+
* this just avoids re-reading and re-parsing it on every lookup within one run.
|
|
81
|
+
*/
|
|
82
|
+
let memo: { path: string; data: StoreShape } | null = null;
|
|
83
|
+
|
|
84
|
+
async function load(): Promise<StoreShape> {
|
|
85
|
+
const file = storePath();
|
|
86
|
+
if (!file) return { version: 1, catalogs: {}, cooldowns: {} };
|
|
87
|
+
if (memo?.path === file) return memo.data;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
90
|
+
// A cache from a future/unknown layout is discarded rather than migrated:
|
|
91
|
+
// it is regenerable by definition, so guessing at it buys nothing.
|
|
92
|
+
const data = parsed?.version === 1 ? parsed : { ...EMPTY };
|
|
93
|
+
data.catalogs ??= {};
|
|
94
|
+
data.cooldowns ??= {};
|
|
95
|
+
memo = { path: file, data };
|
|
96
|
+
return data;
|
|
97
|
+
} catch {
|
|
98
|
+
// Absent or corrupt. A cache must never be a failure mode — start empty.
|
|
99
|
+
const data: StoreShape = { ...EMPTY, catalogs: {}, cooldowns: {} };
|
|
100
|
+
memo = { path: file, data };
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Apply a mutation and persist it.
|
|
107
|
+
*
|
|
108
|
+
* Locked and re-read inside the lock, because two claudeup processes can be open
|
|
109
|
+
* at once and a blind read-modify-write would drop the other's cooldown — exactly
|
|
110
|
+
* the record we most need to keep.
|
|
111
|
+
*/
|
|
112
|
+
async function mutate(fn: (data: StoreShape) => void): Promise<void> {
|
|
113
|
+
const file = storePath();
|
|
114
|
+
if (!file) return;
|
|
115
|
+
try {
|
|
116
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
117
|
+
await withFileLock(file, async () => {
|
|
118
|
+
let onDisk: StoreShape;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
121
|
+
onDisk =
|
|
122
|
+
parsed?.version === 1
|
|
123
|
+
? { version: 1, catalogs: parsed.catalogs ?? {}, cooldowns: parsed.cooldowns ?? {} }
|
|
124
|
+
: { version: 1, catalogs: {}, cooldowns: {} };
|
|
125
|
+
} catch {
|
|
126
|
+
onDisk = { version: 1, catalogs: {}, cooldowns: {} };
|
|
127
|
+
}
|
|
128
|
+
fn(onDisk);
|
|
129
|
+
// Temp + rename, so a crash mid-write cannot leave a half-written file
|
|
130
|
+
// that the next launch has to treat as corrupt.
|
|
131
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
132
|
+
await fs.writeFile(tmp, JSON.stringify(onDisk), "utf-8");
|
|
133
|
+
await fs.rename(tmp, file);
|
|
134
|
+
memo = { path: file, data: onDisk };
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// Never let a cache write break the run. Worst case we re-fetch next time.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ─── Catalogs ────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
export interface CachedCatalog {
|
|
144
|
+
plugins: MarketplacePlugin[];
|
|
145
|
+
source: string;
|
|
146
|
+
/** Milliseconds since it was fetched — for display, and for the TTL test. */
|
|
147
|
+
ageMs: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A cached catalog for this marketplace, if one is present and still fresh. */
|
|
151
|
+
export async function readCachedCatalog(
|
|
152
|
+
marketplace: string,
|
|
153
|
+
now: number = Date.now(),
|
|
154
|
+
): Promise<CachedCatalog | null> {
|
|
155
|
+
const data = await load();
|
|
156
|
+
const entry = data.catalogs[marketplace];
|
|
157
|
+
if (!entry) return null;
|
|
158
|
+
const ageMs = now - entry.fetchedAt;
|
|
159
|
+
if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
|
|
160
|
+
return { plugins: entry.plugins, source: entry.source, ageMs };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function writeCachedCatalog(
|
|
164
|
+
marketplace: string,
|
|
165
|
+
plugins: MarketplacePlugin[],
|
|
166
|
+
source: string,
|
|
167
|
+
now: number = Date.now(),
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
// An empty catalog is never worth caching: it is indistinguishable from a
|
|
170
|
+
// failure we did not classify, and serving it would resurrect the original bug.
|
|
171
|
+
if (plugins.length === 0) return;
|
|
172
|
+
await mutate((data) => {
|
|
173
|
+
data.catalogs[marketplace] = { fetchedAt: now, source, plugins };
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Drop every cached catalog. Backs the explicit refresh (`r`). */
|
|
178
|
+
export async function clearCachedCatalogs(): Promise<void> {
|
|
179
|
+
await mutate((data) => {
|
|
180
|
+
data.catalogs = {};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Cooldowns ───────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export async function readStoredCooldowns(
|
|
187
|
+
now: number = Date.now(),
|
|
188
|
+
): Promise<Record<string, StoredCooldown>> {
|
|
189
|
+
const data = await load();
|
|
190
|
+
const live: Record<string, StoredCooldown> = {};
|
|
191
|
+
for (const [host, cooldown] of Object.entries(data.cooldowns)) {
|
|
192
|
+
// Expired entries are dropped on read rather than persisted forever. The
|
|
193
|
+
// strike count goes with them: a cooldown that lapsed a day ago should not
|
|
194
|
+
// make today's first failure back off for ten minutes.
|
|
195
|
+
if (cooldown.until > now) live[host] = cooldown;
|
|
196
|
+
}
|
|
197
|
+
return live;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function writeStoredCooldown(
|
|
201
|
+
host: string,
|
|
202
|
+
cooldown: StoredCooldown,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
await mutate((data) => {
|
|
205
|
+
data.cooldowns[host] = cooldown;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function clearStoredCooldown(host: string): Promise<void> {
|
|
210
|
+
await mutate((data) => {
|
|
211
|
+
delete data.cooldowns[host];
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Test seam: forget this process's memo so the next read hits disk. */
|
|
216
|
+
export function resetCatalogCacheMemo(): void {
|
|
217
|
+
memo = null;
|
|
218
|
+
}
|