claudeup 4.36.0 → 4.38.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.
Files changed (47) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  4. package/src/__tests__/catalog-notice.test.ts +155 -0
  5. package/src/__tests__/community-fetch.test.ts +545 -0
  6. package/src/__tests__/community-registry.test.ts +269 -0
  7. package/src/__tests__/community-staleness.test.ts +722 -0
  8. package/src/__tests__/github-budget.test.ts +200 -0
  9. package/src/__tests__/open-file.test.ts +59 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  11. package/src/__tests__/style-wrap.test.ts +220 -0
  12. package/src/__tests__/styles-manager.test.ts +1124 -0
  13. package/src/__tests__/styles-origins.test.ts +416 -0
  14. package/src/__tests__/styles-screen-state.test.ts +460 -0
  15. package/src/__tests__/styles-status-line.test.ts +72 -0
  16. package/src/__tests__/styles-sync.test.ts +452 -0
  17. package/src/__tests__/tabbar-layout.test.ts +62 -0
  18. package/src/__tests__/terminology-filler.test.ts +214 -0
  19. package/src/data/community-styles.ts +521 -0
  20. package/src/main.tsx +15 -0
  21. package/src/services/catalog-cache-store.ts +312 -0
  22. package/src/services/community-fetcher.ts +90 -0
  23. package/src/services/community-styles.ts +1194 -0
  24. package/src/services/github-budget.ts +274 -0
  25. package/src/services/marketplace-catalog-git.ts +170 -0
  26. package/src/services/marketplace-catalog.ts +95 -0
  27. package/src/services/marketplace-fetcher.ts +310 -87
  28. package/src/services/plugin-manager.ts +103 -92
  29. package/src/services/styles-manager.ts +1400 -0
  30. package/src/services/terminology-filler.ts +266 -0
  31. package/src/ui/App.tsx +15 -3
  32. package/src/ui/adapters/catalogNotice.ts +122 -0
  33. package/src/ui/adapters/stylesAdapter.ts +403 -0
  34. package/src/ui/components/TabBar.tsx +43 -9
  35. package/src/ui/components/layout/ScreenLayout.tsx +19 -2
  36. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  37. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  38. package/src/ui/registry.ts +6 -0
  39. package/src/ui/renderers/pluginRenderers.tsx +39 -1
  40. package/src/ui/renderers/styleRenderers.tsx +809 -0
  41. package/src/ui/screens/PluginsScreen.tsx +138 -29
  42. package/src/ui/screens/StylesScreen.tsx +1089 -0
  43. package/src/ui/screens/index.ts +1 -0
  44. package/src/ui/state/reducer.ts +124 -3
  45. package/src/ui/state/types.ts +76 -3
  46. package/src/utils/config-dir.ts +47 -0
  47. package/src/utils/open-file.ts +84 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.36.0",
3
+ "version": "4.38.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.36.0",
68
- "claudeup-darwin-x64": "4.36.0",
69
- "claudeup-linux-x64": "4.36.0"
67
+ "claudeup-darwin-arm64": "4.38.0",
68
+ "claudeup-darwin-x64": "4.38.0",
69
+ "claudeup-linux-x64": "4.38.0"
70
70
  }
71
71
  }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * verify-community-registry.ts — the gate between a proposed community style
4
+ * entry and a committed one.
5
+ *
6
+ * This is NOT a test. It hits the real network, it is run by a human who is
7
+ * curating the registry, and it must never run in CI: it would burn the shared
8
+ * 60/hr `api.github.com` budget on every push and then fail on GitHub's rate
9
+ * limit rather than on our bug, which is the worst possible signal.
10
+ *
11
+ * Two modes:
12
+ *
13
+ * --discover list the real `.md` blobs and their upstream
14
+ * --discover <repo> <dir> `name`, for every registry source or for one
15
+ * candidate repo. Prints a paste-ready registry
16
+ * block. NEVER hand-write a path from a README:
17
+ * a wrong path is a 404 the user sees, and the
18
+ * error text blames claudeup for it, correctly.
19
+ *
20
+ * --check re-fetch every committed entry and report 404s,
21
+ * size outliers and unparseable files. Run this
22
+ * before committing a registry change.
23
+ *
24
+ * Cost: `--discover` spends ONE api.github.com call per repo (a recursive git
25
+ * tree), then reads names over raw.githubusercontent.com, which costs nothing.
26
+ * `--check` spends zero API budget.
27
+ */
28
+
29
+ import { parseArgs } from "node:util";
30
+ import {
31
+ COMMUNITY_SOURCES,
32
+ COMMUNITY_STYLES,
33
+ type CommunityStyleSource,
34
+ coordinateProblem,
35
+ rawUrlFor,
36
+ } from "../src/data/community-styles.js";
37
+ import { githubFetcher } from "../src/services/community-fetcher.js";
38
+ import { MAX_STYLE_BYTES } from "../src/services/community-styles.js";
39
+ import { splitFrontmatter } from "../src/services/styles-manager.js";
40
+
41
+ interface DiscoveredFile {
42
+ path: string;
43
+ /** Upstream's own `name`, or the basename when the file declares none. */
44
+ displayName: string;
45
+ description: string;
46
+ bytes: number;
47
+ }
48
+
49
+ /** `attention-span/output-styles/zen-master.md` -> `zen-master`. */
50
+ function slugFromPath(filePath: string): string {
51
+ return (filePath.split("/").pop() ?? filePath).replace(/\.md$/, "");
52
+ }
53
+
54
+ async function discover(
55
+ repo: string,
56
+ dir: string,
57
+ ref: string,
58
+ ): Promise<DiscoveredFile[]> {
59
+ // ONE API call for the whole repo. Asking per-directory would cost one call
60
+ // per level and tell us less.
61
+ const treeUrl = `https://api.github.com/repos/${repo}/git/trees/${ref}?recursive=1`;
62
+ const tree = await githubFetcher(treeUrl, {
63
+ accept: "application/vnd.github+json",
64
+ });
65
+ if (tree.status !== 200) {
66
+ throw new Error(
67
+ `${repo}: HTTP ${tree.status} listing the git tree${
68
+ tree.status === 403 || tree.status === 429
69
+ ? " (rate limited — wait, or export GITHUB_TOKEN)"
70
+ : ""
71
+ }`,
72
+ );
73
+ }
74
+ const parsed = JSON.parse(tree.body) as {
75
+ truncated?: boolean;
76
+ tree?: Array<{ path?: string; type?: string }>;
77
+ };
78
+ if (parsed.truncated) {
79
+ console.warn(
80
+ ` ! ${repo}: git tree was truncated by GitHub — list is partial`,
81
+ );
82
+ }
83
+
84
+ const prefix = `${dir}/`;
85
+ const paths = (parsed.tree ?? [])
86
+ .filter(
87
+ (entry) =>
88
+ entry.type === "blob" &&
89
+ typeof entry.path === "string" &&
90
+ entry.path.startsWith(prefix) &&
91
+ entry.path.endsWith(".md") &&
92
+ // One directory deep only: the cache is flat and a nested style would
93
+ // need a slug that is not a plain basename.
94
+ !entry.path.slice(prefix.length).includes("/"),
95
+ )
96
+ .map((entry) => entry.path as string)
97
+ .sort();
98
+
99
+ const files: DiscoveredFile[] = [];
100
+ for (const filePath of paths) {
101
+ // raw.githubusercontent.com costs no API budget, so reading every file to
102
+ // get its real `name` is free. This is the only way to get a displayName
103
+ // that is upstream's own rather than one we invented from a filename.
104
+ const raw = await githubFetcher(
105
+ `https://raw.githubusercontent.com/${repo}/${ref}/${filePath}`,
106
+ );
107
+ if (raw.status !== 200) {
108
+ console.warn(` ! ${filePath}: HTTP ${raw.status}`);
109
+ continue;
110
+ }
111
+ const { frontmatter } = splitFrontmatter(raw.body);
112
+ files.push({
113
+ path: filePath,
114
+ displayName: frontmatter.name || slugFromPath(filePath),
115
+ description: frontmatter.description || "",
116
+ bytes: Buffer.byteLength(raw.body, "utf8"),
117
+ });
118
+ }
119
+ return files;
120
+ }
121
+
122
+ function printDiscovered(
123
+ sourceId: string,
124
+ repo: string,
125
+ files: DiscoveredFile[],
126
+ ): void {
127
+ console.log(`\n${repo} — ${files.length} style file(s)`);
128
+ console.log("─".repeat(72));
129
+ for (const file of files) {
130
+ const flag = file.bytes > MAX_STYLE_BYTES ? " ** OVER 64 KiB **" : "";
131
+ console.log(
132
+ ` {\n\t\tid: "${sourceId}--${slugFromPath(file.path)}",\n\t\tsourceId: "${sourceId}",\n\t\tpath: "${file.path}",\n\t\tdisplayName: ${JSON.stringify(file.displayName)},\n\t\tsummary: ${JSON.stringify(file.description.slice(0, 100))},\n\t},${flag}`,
133
+ );
134
+ }
135
+ }
136
+
137
+ async function runDiscover(repo?: string, dir?: string): Promise<number> {
138
+ const targets: Array<{ id: string; repo: string; dir: string; ref: string }> =
139
+ repo && dir
140
+ ? [{ id: repo.split("/")[1] ?? repo, repo, dir, ref: "HEAD" }]
141
+ : COMMUNITY_SOURCES.map((source: CommunityStyleSource) => ({
142
+ id: source.id,
143
+ repo: source.repo,
144
+ dir: source.dir,
145
+ ref: source.ref,
146
+ }));
147
+
148
+ let failures = 0;
149
+ for (const target of targets) {
150
+ try {
151
+ const files = await discover(target.repo, target.dir, target.ref);
152
+ printDiscovered(target.id, target.repo, files);
153
+ if (files.length === 0) {
154
+ // The rule that keeps `caveman` and `humanizer` out: zero style files
155
+ // means there is nothing to import, whatever the star count says.
156
+ console.log(
157
+ " (no style files — this repo must NOT get a registry entry)",
158
+ );
159
+ }
160
+ } catch (error) {
161
+ failures++;
162
+ console.error(
163
+ ` ! ${target.repo}: ${error instanceof Error ? error.message : String(error)}`,
164
+ );
165
+ }
166
+ }
167
+ return failures;
168
+ }
169
+
170
+ async function runCheck(): Promise<number> {
171
+ let problems = 0;
172
+ console.log(
173
+ `Checking ${COMMUNITY_STYLES.length} entries across ${COMMUNITY_SOURCES.length} sources — zero API budget.\n`,
174
+ );
175
+
176
+ for (const style of COMMUNITY_STYLES) {
177
+ const source = COMMUNITY_SOURCES.find((s) => s.id === style.sourceId);
178
+ if (!source) {
179
+ console.error(
180
+ `✗ ${style.id}: sourceId "${style.sourceId}" resolves to nothing`,
181
+ );
182
+ problems++;
183
+ continue;
184
+ }
185
+ const coordinate = coordinateProblem(style, source);
186
+ if (coordinate) {
187
+ console.error(`✗ ${style.id}: ${coordinate}`);
188
+ problems++;
189
+ continue;
190
+ }
191
+
192
+ const response = await githubFetcher(rawUrlFor(style, source));
193
+ if (response.status !== 200) {
194
+ console.error(
195
+ `✗ ${style.id}: HTTP ${response.status} — ${rawUrlFor(style, source)}`,
196
+ );
197
+ problems++;
198
+ continue;
199
+ }
200
+ const bytes = Buffer.byteLength(response.body, "utf8");
201
+ if (bytes > MAX_STYLE_BYTES) {
202
+ console.error(`✗ ${style.id}: ${bytes} bytes exceeds the 64 KiB cap`);
203
+ problems++;
204
+ continue;
205
+ }
206
+ const { frontmatter, body } = splitFrontmatter(response.body);
207
+ if (Object.keys(frontmatter).length === 0 || body.trim().length === 0) {
208
+ console.error(
209
+ `✗ ${style.id}: not a valid output style (no frontmatter or no body)`,
210
+ );
211
+ problems++;
212
+ continue;
213
+ }
214
+ // Advisory, not a failure: upstream is free to rename a style, and the
215
+ // coordinate id is deliberately independent of it. But a drifted
216
+ // displayName is what the user reads, so it is worth seeing.
217
+ const upstreamName = frontmatter.name;
218
+ const drift =
219
+ upstreamName && upstreamName !== style.displayName
220
+ ? ` (upstream name is now "${upstreamName}", registry says "${style.displayName}")`
221
+ : "";
222
+ console.log(
223
+ `✓ ${style.id.padEnd(38)} ${String(bytes).padStart(6)} B${drift}`,
224
+ );
225
+ }
226
+
227
+ console.log(
228
+ problems === 0
229
+ ? "\nRegistry is clean — safe to commit."
230
+ : `\n${problems} problem(s). Do NOT commit until they are fixed.`,
231
+ );
232
+ return problems;
233
+ }
234
+
235
+ async function main(): Promise<number> {
236
+ const { values, positionals } = parseArgs({
237
+ args: process.argv.slice(2),
238
+ options: {
239
+ discover: { type: "boolean", default: false },
240
+ check: { type: "boolean", default: false },
241
+ },
242
+ allowPositionals: true,
243
+ });
244
+
245
+ if (!values.discover && !values.check) {
246
+ console.error(
247
+ "usage: bun scripts/verify-community-registry.ts --discover [<repo> <dir>]\n" +
248
+ " bun scripts/verify-community-registry.ts --check",
249
+ );
250
+ return 1;
251
+ }
252
+
253
+ let problems = 0;
254
+ if (values.discover) {
255
+ problems += await runDiscover(positionals[0], positionals[1]);
256
+ }
257
+ if (values.check) {
258
+ problems += await runCheck();
259
+ }
260
+ return problems === 0 ? 0 : 1;
261
+ }
262
+
263
+ if (import.meta.main) {
264
+ main()
265
+ .then((code) => process.exit(code))
266
+ .catch((error) => {
267
+ console.error(
268
+ `error: ${error instanceof Error ? error.message : String(error)}`,
269
+ );
270
+ process.exit(1);
271
+ });
272
+ }
@@ -0,0 +1,271 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ } from "bun:test";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import {
12
+ CATALOG_TTL_MS,
13
+ clearCachedCatalogs,
14
+ clearStoredCooldown,
15
+ readCachedCatalog,
16
+ readStoredCooldowns,
17
+ resetCatalogCacheMemo,
18
+ writeCachedCatalog,
19
+ writeStoredCooldown,
20
+ } from "../services/catalog-cache-store";
21
+ import {
22
+ cooldownFor,
23
+ hydrateGitHubBudget,
24
+ recordRateLimit,
25
+ resetGitHubBudget,
26
+ } from "../services/github-budget";
27
+ import type { MarketplacePlugin } from "../services/marketplace-catalog";
28
+
29
+ /**
30
+ * These cover the cross-LAUNCH behaviour, which is the only kind that matters for
31
+ * a TUI. Measured before this store existed, two consecutive processes:
32
+ *
33
+ * [process-1] 6 HTTP requests in 13.32s
34
+ * [process-2] 6 HTTP requests in 13.01s
35
+ *
36
+ * Every cache was a module-level Map, so a relaunch re-fetched all six catalogs —
37
+ * and, worse, forgot that the previous launch had just been rate-limited and spent
38
+ * six more requests rediscovering it.
39
+ *
40
+ * A fresh module registry is the closest a unit test gets to a fresh process, so
41
+ * `resetGitHubBudget()` + `resetCatalogCacheMemo()` stand in for a relaunch: they
42
+ * drop all in-memory state while leaving the file on disk.
43
+ */
44
+
45
+ let configDir: string;
46
+ let prevConfigDir: string | undefined;
47
+
48
+ const NOW = 1_786_980_000_000;
49
+
50
+ function plugin(name: string, version: string): MarketplacePlugin {
51
+ return { name, version, description: "" };
52
+ }
53
+
54
+ beforeEach(() => {
55
+ configDir = fs.mkdtempSync(path.join(os.tmpdir(), "catalog-cache-"));
56
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
57
+ process.env.CLAUDE_CONFIG_DIR = configDir;
58
+ resetCatalogCacheMemo();
59
+ resetGitHubBudget();
60
+ });
61
+
62
+ afterEach(() => {
63
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
64
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
65
+ fs.rmSync(configDir, { recursive: true, force: true });
66
+ });
67
+
68
+ describe("catalog cache — survives a relaunch", () => {
69
+ it("serves a catalog written by a previous process", async () => {
70
+ await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
71
+
72
+ // Stand in for a relaunch: in-memory state gone, file intact.
73
+ resetCatalogCacheMemo();
74
+
75
+ const cached = await readCachedCatalog("magus");
76
+ expect(cached).not.toBeNull();
77
+ expect(cached?.plugins).toHaveLength(1);
78
+ expect(cached?.plugins[0]?.version).toBe("4.1.6");
79
+ expect(cached?.source).toBe("remote");
80
+ });
81
+
82
+ it("keeps the source, so a cached answer is still labelled honestly", async () => {
83
+ // A `remote-git` answer stays `remote-git` after a round trip through disk.
84
+ // Flattening it to "cached" would lose the authoritative/unverified
85
+ // distinction the whole fix rests on.
86
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote-git");
87
+ resetCatalogCacheMemo();
88
+ expect((await readCachedCatalog("magus"))?.source).toBe("remote-git");
89
+ });
90
+
91
+ it("expires once past the TTL", async () => {
92
+ await writeCachedCatalog(
93
+ "magus",
94
+ [plugin("terminal", "4.1.6")],
95
+ "remote",
96
+ NOW,
97
+ );
98
+ resetCatalogCacheMemo();
99
+
100
+ expect(await readCachedCatalog("magus", NOW + CATALOG_TTL_MS - 1000)).not.toBeNull();
101
+ expect(await readCachedCatalog("magus", NOW + CATALOG_TTL_MS + 1000)).toBeNull();
102
+ });
103
+
104
+ it("ignores an entry stamped in the future", async () => {
105
+ // A clock that jumped backwards must not pin a cache entry forever.
106
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote", NOW);
107
+ resetCatalogCacheMemo();
108
+ expect(await readCachedCatalog("magus", NOW - 60_000)).toBeNull();
109
+ });
110
+
111
+ it("never caches an empty catalog", async () => {
112
+ // An empty list is indistinguishable from an unclassified failure. Caching it
113
+ // would serve "this marketplace has no plugins" for an hour — the original bug
114
+ // with a longer memory.
115
+ await writeCachedCatalog("magus", [], "remote");
116
+ resetCatalogCacheMemo();
117
+ expect(await readCachedCatalog("magus")).toBeNull();
118
+ });
119
+
120
+ it("is dropped by an explicit refresh", async () => {
121
+ await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
122
+ await clearCachedCatalogs();
123
+ resetCatalogCacheMemo();
124
+ expect(await readCachedCatalog("magus")).toBeNull();
125
+ });
126
+
127
+ it("survives a corrupt cache file instead of throwing", async () => {
128
+ fs.writeFileSync(
129
+ path.join(configDir, "claudeup-catalog-cache.json"),
130
+ "{ not json",
131
+ );
132
+ resetCatalogCacheMemo();
133
+ expect(await readCachedCatalog("magus")).toBeNull();
134
+
135
+ // And it must recover: a bad file cannot poison later writes.
136
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
137
+ resetCatalogCacheMemo();
138
+ expect(await readCachedCatalog("magus")).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("rate-limit cooldown — survives a relaunch", () => {
143
+ /**
144
+ * Anchored to the real clock, not the fixed NOW the catalog tests use.
145
+ * `hydrateGitHubBudget()` takes no clock parameter — it filters expired entries
146
+ * against `Date.now()`, exactly as it does in production — so a cooldown stamped
147
+ * relative to a fixed past NOW would be discarded as expired before the
148
+ * assertion ever ran.
149
+ */
150
+ let base: number;
151
+ beforeEach(() => {
152
+ base = Date.now();
153
+ });
154
+
155
+ /** Let a write-behind cooldown reach disk. */
156
+ const flush = () => new Promise((r) => setTimeout(r, 80));
157
+
158
+ // Only this block triggers write-behind, so only this block pays for draining
159
+ // it. Held here rather than in the outer afterEach so the catalog tests — which
160
+ // await every write — are not each charged the delay too.
161
+ afterEach(flush);
162
+
163
+ it("is still in force in the next process", async () => {
164
+ // THE defect this store exists to fix. Without persistence, launch two fired
165
+ // six more doomed requests at a host it had already been refused by.
166
+ recordRateLimit("raw.githubusercontent.com", new Headers(), base);
167
+ await flush();
168
+
169
+ resetGitHubBudget();
170
+ resetCatalogCacheMemo();
171
+ expect(cooldownFor("raw.githubusercontent.com", base)).toBeNull(); // memory only
172
+
173
+ await hydrateGitHubBudget();
174
+ const restored = cooldownFor("raw.githubusercontent.com", base);
175
+ expect(restored).not.toBeNull();
176
+ expect(restored?.until).toBeGreaterThan(base);
177
+ });
178
+
179
+ it("keeps escalating the backoff across relaunches", async () => {
180
+ // Without the strike count surviving, every relaunch restarts the backoff at
181
+ // 30s — so a tool that is relaunched often never actually backs off, which is
182
+ // the behaviour that got the IP rate-limited in the first place.
183
+ //
184
+ // Written as one strike per launch, which is the real shape: a launch makes its
185
+ // requests, learns it is limited once, and exits.
186
+ const first = recordRateLimit("raw.githubusercontent.com", undefined, base);
187
+ expect(first.strikes).toBe(1);
188
+ await flush();
189
+
190
+ resetGitHubBudget();
191
+ resetCatalogCacheMemo();
192
+ await hydrateGitHubBudget();
193
+
194
+ const second = recordRateLimit("raw.githubusercontent.com", undefined, base);
195
+ expect(second.strikes).toBe(2);
196
+ expect(second.until).toBeGreaterThan(first.until);
197
+ await flush();
198
+
199
+ resetGitHubBudget();
200
+ resetCatalogCacheMemo();
201
+ await hydrateGitHubBudget();
202
+
203
+ const third = recordRateLimit("raw.githubusercontent.com", undefined, base);
204
+ expect(third.strikes).toBe(3);
205
+ expect(third.until).toBeGreaterThan(second.until);
206
+ });
207
+
208
+ it("preserves whether the timing was GitHub's or ours", async () => {
209
+ // `exact` decides between "resets in 4m" and "next attempt in 4m". Losing it
210
+ // across a relaunch would make the UI state a claim GitHub never made.
211
+ recordRateLimit(
212
+ "api.github.com",
213
+ new Headers({ "retry-after": "600" }),
214
+ base,
215
+ );
216
+ await flush();
217
+
218
+ resetGitHubBudget();
219
+ await hydrateGitHubBudget();
220
+ expect(cooldownFor("api.github.com", base)?.exact).toBe(true);
221
+ });
222
+
223
+ it("drops an expired cooldown rather than restoring it", async () => {
224
+ await writeStoredCooldown("raw.githubusercontent.com", {
225
+ until: NOW - 1000,
226
+ exact: false,
227
+ strikes: 4,
228
+ });
229
+ resetCatalogCacheMemo();
230
+
231
+ // Expired entries are not returned, so a stale strike count from yesterday
232
+ // cannot make today's first failure back off for ten minutes.
233
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
234
+ });
235
+
236
+ it("is cleared for a host that recovers", async () => {
237
+ await writeStoredCooldown("raw.githubusercontent.com", {
238
+ until: NOW + 60_000,
239
+ exact: false,
240
+ strikes: 1,
241
+ });
242
+ await clearStoredCooldown("raw.githubusercontent.com");
243
+ resetCatalogCacheMemo();
244
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
245
+ });
246
+
247
+ it("keeps catalogs when only the cooldown is cleared, and vice versa", async () => {
248
+ // They share one file; a write to either must not clobber the other.
249
+ await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
250
+ await writeStoredCooldown("raw.githubusercontent.com", {
251
+ until: NOW + 60_000,
252
+ exact: false,
253
+ strikes: 1,
254
+ });
255
+
256
+ await clearStoredCooldown("raw.githubusercontent.com");
257
+ resetCatalogCacheMemo();
258
+ expect(await readCachedCatalog("magus")).not.toBeNull();
259
+
260
+ await writeStoredCooldown("raw.githubusercontent.com", {
261
+ until: NOW + 60_000,
262
+ exact: false,
263
+ strikes: 1,
264
+ });
265
+ await clearCachedCatalogs();
266
+ resetCatalogCacheMemo();
267
+ expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([
268
+ "raw.githubusercontent.com",
269
+ ]);
270
+ });
271
+ });