claudeup 4.35.1 → 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/__tests__/scope-squares.test.tsx +165 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- 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/App.tsx +19 -12
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/pluginsAdapter.ts +174 -168
- package/src/ui/adapters/settingsAdapter.ts +119 -116
- package/src/ui/adapters/skillsAdapter.ts +203 -196
- package/src/ui/components/CategoryHeader.tsx +9 -8
- package/src/ui/components/EmptyFilterState.tsx +10 -5
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +10 -6
- package/src/ui/components/ScrollableList.tsx +3 -2
- package/src/ui/components/SearchInput.tsx +2 -1
- package/src/ui/components/StyledText.tsx +5 -4
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/components/layout/FooterHints.tsx +37 -30
- package/src/ui/components/layout/Panel.tsx +6 -5
- package/src/ui/components/layout/ProgressBar.tsx +7 -6
- package/src/ui/components/layout/ScopeTabs.tsx +6 -5
- package/src/ui/components/layout/ScreenLayout.tsx +46 -26
- package/src/ui/components/layout/index.ts +3 -3
- package/src/ui/components/modals/ConfirmModal.tsx +12 -11
- package/src/ui/components/modals/InputModal.tsx +14 -6
- package/src/ui/components/modals/LoadingModal.tsx +6 -5
- package/src/ui/components/modals/MessageModal.tsx +9 -8
- package/src/ui/components/modals/SelectModal.tsx +11 -7
- package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
- package/src/ui/components/primitives/ActionHints.tsx +26 -26
- package/src/ui/components/primitives/DetailSection.tsx +13 -12
- package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
- package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
- package/src/ui/components/primitives/MetaText.tsx +3 -3
- package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
- package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
- package/src/ui/components/primitives/SelectableRow.tsx +22 -16
- package/src/ui/hooks/useGitignoreModal.ts +78 -74
- package/src/ui/registry.ts +11 -11
- package/src/ui/renderers/cliToolRenderers.tsx +260 -203
- package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
- package/src/ui/renderers/mcpRenderers.tsx +121 -117
- package/src/ui/renderers/pluginRenderers.tsx +566 -471
- package/src/ui/renderers/profileRenderers.tsx +346 -300
- package/src/ui/renderers/settingsRenderers.tsx +183 -176
- package/src/ui/renderers/skillRenderers.tsx +410 -326
- package/src/ui/screens/AliasScreen.tsx +1336 -1309
- package/src/ui/screens/CliToolsScreen.tsx +92 -40
- package/src/ui/screens/EnvVarsScreen.tsx +19 -13
- package/src/ui/screens/GitignoreScreen.tsx +510 -493
- package/src/ui/screens/McpRegistryScreen.tsx +28 -21
- package/src/ui/screens/McpScreen.tsx +12 -3
- package/src/ui/screens/PluginsScreen.tsx +152 -33
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/ui/theme-mode.ts +73 -0
- package/src/ui/theme.ts +147 -53
- package/src/utils/config-dir.ts +47 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudeup",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.37.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.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
67
|
+
"claudeup-darwin-arm64": "4.37.0",
|
|
68
|
+
"claudeup-darwin-x64": "4.37.0",
|
|
69
|
+
"claudeup-linux-x64": "4.37.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { buildCatalogNoticeText } from "../ui/adapters/catalogNotice";
|
|
3
|
+
import type { MarketplaceFetchFailure } from "../services/marketplace-fetcher";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The banner is the only thing standing between the user and a screen that
|
|
7
|
+
* silently claims every plugin is up to date. So it has three obligations:
|
|
8
|
+
* appear whenever a check did not actually happen, stay silent when it did or
|
|
9
|
+
* when the state is intended, and stay legible either way.
|
|
10
|
+
*
|
|
11
|
+
* "Stay silent when intended" is not politeness. `autoUpdate: false` and a clone
|
|
12
|
+
* behind its remote are deliberate policy here — plugins are not auto-updated.
|
|
13
|
+
* An earlier version warned about both and suggested the command that would undo
|
|
14
|
+
* the policy, which trains the user to ignore the line.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const NOW = 1_786_980_000_000;
|
|
18
|
+
|
|
19
|
+
function failure(
|
|
20
|
+
marketplace: string,
|
|
21
|
+
kind: MarketplaceFetchFailure["kind"] = "rate-limited",
|
|
22
|
+
retryAt?: number,
|
|
23
|
+
): MarketplaceFetchFailure {
|
|
24
|
+
return { marketplace, kind, detail: `${kind} detail`, retryAt };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("buildCatalogNoticeText", () => {
|
|
28
|
+
it("says nothing when every catalog was actually checked", () => {
|
|
29
|
+
// The negative control. A banner that is always on carries no information.
|
|
30
|
+
expect(
|
|
31
|
+
buildCatalogNoticeText({
|
|
32
|
+
failures: [],
|
|
33
|
+
unverifiedPlugins: 0,
|
|
34
|
+
width: 120,
|
|
35
|
+
now: NOW,
|
|
36
|
+
}),
|
|
37
|
+
).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("names the reason, because it determines what the user should do", () => {
|
|
41
|
+
const rate = buildCatalogNoticeText({
|
|
42
|
+
failures: [failure("magus")],
|
|
43
|
+
unverifiedPlugins: 3,
|
|
44
|
+
width: 120,
|
|
45
|
+
now: NOW,
|
|
46
|
+
});
|
|
47
|
+
expect(rate).toContain("GitHub rate limit");
|
|
48
|
+
|
|
49
|
+
const slow = buildCatalogNoticeText({
|
|
50
|
+
failures: [failure("magus", "timeout")],
|
|
51
|
+
unverifiedPlugins: 3,
|
|
52
|
+
width: 120,
|
|
53
|
+
now: NOW,
|
|
54
|
+
});
|
|
55
|
+
expect(slow).toContain("GitHub timed out");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("names one or two failing marketplaces but only counts many", () => {
|
|
59
|
+
const few = buildCatalogNoticeText({
|
|
60
|
+
failures: [failure("magus"), failure("magus-alpha")],
|
|
61
|
+
unverifiedPlugins: 0,
|
|
62
|
+
width: 160,
|
|
63
|
+
now: NOW,
|
|
64
|
+
});
|
|
65
|
+
expect(few).toContain("magus-alpha");
|
|
66
|
+
|
|
67
|
+
const many = buildCatalogNoticeText({
|
|
68
|
+
failures: ["a", "b", "c", "d", "e", "f"].map((n) => failure(n)),
|
|
69
|
+
unverifiedPlugins: 0,
|
|
70
|
+
width: 160,
|
|
71
|
+
now: NOW,
|
|
72
|
+
});
|
|
73
|
+
expect(many).toContain("6 catalogs unchecked");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("counts down to the retry instead of saying 'later'", () => {
|
|
77
|
+
// Answering "when can I try again" is the point. Guessing costs another
|
|
78
|
+
// request against the very limit the user is waiting out.
|
|
79
|
+
const line = buildCatalogNoticeText({
|
|
80
|
+
failures: [failure("magus", "rate-limited", NOW + 240_000)],
|
|
81
|
+
unverifiedPlugins: 17,
|
|
82
|
+
width: 160,
|
|
83
|
+
now: NOW,
|
|
84
|
+
}) as string;
|
|
85
|
+
expect(line).toContain("retrying in 4m");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("uses the soonest retry when several catalogs are waiting", () => {
|
|
89
|
+
const line = buildCatalogNoticeText({
|
|
90
|
+
failures: [
|
|
91
|
+
failure("magus", "rate-limited", NOW + 600_000),
|
|
92
|
+
failure("magus-alpha", "rate-limited", NOW + 45_000),
|
|
93
|
+
],
|
|
94
|
+
unverifiedPlugins: 2,
|
|
95
|
+
width: 160,
|
|
96
|
+
now: NOW,
|
|
97
|
+
}) as string;
|
|
98
|
+
expect(line).toContain("retrying in 45s");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("falls back to the manual hint when no retry time is known", () => {
|
|
102
|
+
// raw.githubusercontent.com sends no retry-after, so for some failures there
|
|
103
|
+
// is genuinely no time to show. Inventing one would be worse than omitting it.
|
|
104
|
+
const line = buildCatalogNoticeText({
|
|
105
|
+
failures: [failure("magus", "network")],
|
|
106
|
+
unverifiedPlugins: 1,
|
|
107
|
+
width: 160,
|
|
108
|
+
now: NOW,
|
|
109
|
+
}) as string;
|
|
110
|
+
expect(line).toContain("r to retry");
|
|
111
|
+
expect(line).not.toContain("retrying in");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("ignores a retry time that has already passed", () => {
|
|
115
|
+
const line = buildCatalogNoticeText({
|
|
116
|
+
failures: [failure("magus", "rate-limited", NOW - 5_000)],
|
|
117
|
+
unverifiedPlugins: 1,
|
|
118
|
+
width: 160,
|
|
119
|
+
now: NOW,
|
|
120
|
+
}) as string;
|
|
121
|
+
expect(line).toContain("r to retry");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("keeps a retry affordance at every width, however narrow", () => {
|
|
125
|
+
const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
|
|
126
|
+
failure(n, "rate-limited", NOW + 90_000),
|
|
127
|
+
);
|
|
128
|
+
for (const width of [40, 60, 80, 100, 120, 200]) {
|
|
129
|
+
const line = buildCatalogNoticeText({
|
|
130
|
+
failures,
|
|
131
|
+
unverifiedPlugins: 331,
|
|
132
|
+
width,
|
|
133
|
+
now: NOW,
|
|
134
|
+
}) as string;
|
|
135
|
+
expect(line).not.toBeNull();
|
|
136
|
+
expect(line).toContain("retrying in");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("never renders wider than the pane", () => {
|
|
141
|
+
const failures = ["a", "b", "c", "d", "e", "f"].map((n) =>
|
|
142
|
+
failure(n, "rate-limited", NOW + 90_000),
|
|
143
|
+
);
|
|
144
|
+
for (const width of [40, 60, 80, 100, 120, 200]) {
|
|
145
|
+
const line = buildCatalogNoticeText({
|
|
146
|
+
failures,
|
|
147
|
+
unverifiedPlugins: 331,
|
|
148
|
+
width,
|
|
149
|
+
now: NOW,
|
|
150
|
+
}) as string;
|
|
151
|
+
// +1 for `⚠` rendering double-width while String.length counts it once.
|
|
152
|
+
expect(line.length + 1).toBeLessThanOrEqual(width);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
activeCooldowns,
|
|
4
|
+
cooldownFor,
|
|
5
|
+
describeCooldown,
|
|
6
|
+
formatWait,
|
|
7
|
+
hostOf,
|
|
8
|
+
recordRateLimit,
|
|
9
|
+
recordSuccess,
|
|
10
|
+
remainingFromHeaders,
|
|
11
|
+
resetGitHubBudget,
|
|
12
|
+
waitMs,
|
|
13
|
+
} from "../services/github-budget";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The design hinges on one measured asymmetry (2026-08-17):
|
|
17
|
+
*
|
|
18
|
+
* api.github.com 429/403 carries x-ratelimit-reset and retry-after
|
|
19
|
+
* raw.githubusercontent.com 429 carries NEITHER — only a request id and a date
|
|
20
|
+
*
|
|
21
|
+
* So the module must be able to say "resets in 4m" when GitHub said so, and must
|
|
22
|
+
* NOT say it otherwise. Fabricating a reset time would be the same sin as the
|
|
23
|
+
* original bug: reporting a guess as a fact.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const NOW = 1_786_980_000_000;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
resetGitHubBudget();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("hostOf", () => {
|
|
33
|
+
it("recognises the two budgeted hosts and nothing else", () => {
|
|
34
|
+
expect(hostOf("https://api.github.com/repos/x/y")).toBe("api.github.com");
|
|
35
|
+
expect(hostOf("https://raw.githubusercontent.com/x/y/main/f.json")).toBe(
|
|
36
|
+
"raw.githubusercontent.com",
|
|
37
|
+
);
|
|
38
|
+
expect(hostOf("https://github.com/x/y")).toBeNull();
|
|
39
|
+
expect(hostOf("not a url")).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("recordRateLimit — exact timing when GitHub provides it", () => {
|
|
44
|
+
it("honours retry-after in seconds", () => {
|
|
45
|
+
const cooldown = recordRateLimit(
|
|
46
|
+
"api.github.com",
|
|
47
|
+
new Headers({ "retry-after": "120" }),
|
|
48
|
+
NOW,
|
|
49
|
+
);
|
|
50
|
+
expect(cooldown.exact).toBe(true);
|
|
51
|
+
expect(cooldown.until).toBe(NOW + 120_000);
|
|
52
|
+
expect(describeCooldown(cooldown, NOW)).toBe(
|
|
53
|
+
"GitHub rate limit resets in 2m",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("honours retry-after as an HTTP date", () => {
|
|
58
|
+
const when = new Date(NOW + 300_000).toUTCString();
|
|
59
|
+
const cooldown = recordRateLimit(
|
|
60
|
+
"api.github.com",
|
|
61
|
+
new Headers({ "retry-after": when }),
|
|
62
|
+
NOW,
|
|
63
|
+
);
|
|
64
|
+
expect(cooldown.exact).toBe(true);
|
|
65
|
+
// UTCString drops sub-second precision, so allow a second of slack.
|
|
66
|
+
expect(Math.abs(cooldown.until - (NOW + 300_000))).toBeLessThanOrEqual(1000);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads x-ratelimit-reset as epoch SECONDS, not milliseconds", () => {
|
|
70
|
+
// Treating it as ms lands in 1970, so the cooldown expires instantly and the
|
|
71
|
+
// UI reports "not rate limited" while GitHub is still refusing every call.
|
|
72
|
+
const resetEpochSeconds = Math.floor(NOW / 1000) + 600;
|
|
73
|
+
const cooldown = recordRateLimit(
|
|
74
|
+
"api.github.com",
|
|
75
|
+
new Headers({ "x-ratelimit-reset": String(resetEpochSeconds) }),
|
|
76
|
+
NOW,
|
|
77
|
+
);
|
|
78
|
+
expect(cooldown.exact).toBe(true);
|
|
79
|
+
expect(cooldown.until).toBe(resetEpochSeconds * 1000);
|
|
80
|
+
expect(waitMs("api.github.com", NOW)).toBeGreaterThan(0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("caps an implausible server window so a bad header cannot wedge the UI", () => {
|
|
84
|
+
const cooldown = recordRateLimit(
|
|
85
|
+
"api.github.com",
|
|
86
|
+
new Headers({ "retry-after": String(60 * 60 * 24 * 30) }),
|
|
87
|
+
NOW,
|
|
88
|
+
);
|
|
89
|
+
expect(cooldown.until - NOW).toBeLessThanOrEqual(60 * 60_000);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("recordRateLimit — backoff when GitHub provides nothing", () => {
|
|
94
|
+
it("does not claim a reset time it was never told", () => {
|
|
95
|
+
// This is the raw.githubusercontent.com case, measured: a bare 429.
|
|
96
|
+
const cooldown = recordRateLimit(
|
|
97
|
+
"raw.githubusercontent.com",
|
|
98
|
+
new Headers(),
|
|
99
|
+
NOW,
|
|
100
|
+
);
|
|
101
|
+
expect(cooldown.exact).toBe(false);
|
|
102
|
+
expect(describeCooldown(cooldown, NOW)).toContain("next attempt in");
|
|
103
|
+
expect(describeCooldown(cooldown, NOW)).not.toContain("resets in");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("backs off further on each consecutive failure", () => {
|
|
107
|
+
const first = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
108
|
+
const second = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
109
|
+
const third = recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
110
|
+
expect(second.until).toBeGreaterThan(first.until);
|
|
111
|
+
expect(third.until).toBeGreaterThan(second.until);
|
|
112
|
+
expect(third.strikes).toBe(3);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("ignores a retry-after that is garbage or already past", () => {
|
|
116
|
+
const garbage = recordRateLimit(
|
|
117
|
+
"raw.githubusercontent.com",
|
|
118
|
+
new Headers({ "retry-after": "soon-ish" }),
|
|
119
|
+
NOW,
|
|
120
|
+
);
|
|
121
|
+
expect(garbage.exact).toBe(false);
|
|
122
|
+
expect(garbage.until).toBeGreaterThan(NOW);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("the gate", () => {
|
|
127
|
+
it("blocks while cooling and clears when the window passes", () => {
|
|
128
|
+
const cooldown = recordRateLimit(
|
|
129
|
+
"raw.githubusercontent.com",
|
|
130
|
+
undefined,
|
|
131
|
+
NOW,
|
|
132
|
+
);
|
|
133
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).not.toBeNull();
|
|
134
|
+
expect(
|
|
135
|
+
cooldownFor("raw.githubusercontent.com", cooldown.until + 1),
|
|
136
|
+
).toBeNull();
|
|
137
|
+
expect(waitMs("raw.githubusercontent.com", cooldown.until + 1)).toBe(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the two hosts on separate budgets", () => {
|
|
141
|
+
// They are separate buckets upstream. Blocking raw because the API is
|
|
142
|
+
// exhausted would stop work that would have succeeded.
|
|
143
|
+
recordRateLimit("api.github.com", new Headers({ "retry-after": "60" }), NOW);
|
|
144
|
+
expect(cooldownFor("api.github.com", NOW)).not.toBeNull();
|
|
145
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("clears the cooldown and the strike count on success", () => {
|
|
149
|
+
// Without resetting strikes, one bad afternoon pins the backoff at ten
|
|
150
|
+
// minutes for the rest of the session even though calls are succeeding.
|
|
151
|
+
recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
152
|
+
recordRateLimit("raw.githubusercontent.com", undefined, NOW);
|
|
153
|
+
recordSuccess("raw.githubusercontent.com");
|
|
154
|
+
expect(cooldownFor("raw.githubusercontent.com", NOW)).toBeNull();
|
|
155
|
+
|
|
156
|
+
const afterRecovery = recordRateLimit(
|
|
157
|
+
"raw.githubusercontent.com",
|
|
158
|
+
undefined,
|
|
159
|
+
NOW,
|
|
160
|
+
);
|
|
161
|
+
expect(afterRecovery.strikes).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("lists active cooldowns soonest first", () => {
|
|
165
|
+
recordRateLimit(
|
|
166
|
+
"api.github.com",
|
|
167
|
+
new Headers({ "retry-after": "600" }),
|
|
168
|
+
NOW,
|
|
169
|
+
);
|
|
170
|
+
recordRateLimit(
|
|
171
|
+
"raw.githubusercontent.com",
|
|
172
|
+
new Headers({ "retry-after": "30" }),
|
|
173
|
+
NOW,
|
|
174
|
+
);
|
|
175
|
+
const active = activeCooldowns(NOW);
|
|
176
|
+
expect(active.map((c) => c.host)).toEqual([
|
|
177
|
+
"raw.githubusercontent.com",
|
|
178
|
+
"api.github.com",
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("remainingFromHeaders", () => {
|
|
184
|
+
it("reads the budget so a caller can stop before tripping the limit", () => {
|
|
185
|
+
expect(
|
|
186
|
+
remainingFromHeaders(new Headers({ "x-ratelimit-remaining": "51" })),
|
|
187
|
+
).toBe(51);
|
|
188
|
+
expect(remainingFromHeaders(new Headers())).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("formatWait", () => {
|
|
193
|
+
it("reads naturally at every scale", () => {
|
|
194
|
+
expect(formatWait(0)).toBe("0s");
|
|
195
|
+
expect(formatWait(45_000)).toBe("45s");
|
|
196
|
+
expect(formatWait(90_000)).toBe("2m");
|
|
197
|
+
expect(formatWait(3_600_000)).toBe("1h 0m");
|
|
198
|
+
expect(formatWait(4_320_000)).toBe("1h 12m");
|
|
199
|
+
});
|
|
200
|
+
});
|