claudeup 6.2.1 → 6.3.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/README.md +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +18 -4
- package/src/__tests__/plugin-setup.test.ts +12 -12
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +38 -13
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +183 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +1 -2
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +13 -3
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +9 -6
- package/src/services/plugin-setup.ts +7 -9
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -1,28 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
describe,
|
|
3
|
-
it,
|
|
4
|
-
expect,
|
|
5
|
-
beforeEach,
|
|
6
|
-
afterEach,
|
|
7
|
-
} from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
8
2
|
import fs from "node:fs";
|
|
9
3
|
import os from "node:os";
|
|
10
4
|
import path from "node:path";
|
|
11
5
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
CATALOG_TTL_MS,
|
|
7
|
+
clearCachedCatalogs,
|
|
8
|
+
clearStoredCooldown,
|
|
9
|
+
readCachedCatalog,
|
|
10
|
+
readStoredCooldowns,
|
|
11
|
+
resetCatalogCacheMemo,
|
|
12
|
+
writeCachedCatalog,
|
|
13
|
+
writeStoredCooldown,
|
|
20
14
|
} from "../services/catalog-cache-store";
|
|
21
15
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
cooldownFor,
|
|
17
|
+
hydrateGitHubBudget,
|
|
18
|
+
recordRateLimit,
|
|
19
|
+
resetGitHubBudget,
|
|
26
20
|
} from "../services/github-budget";
|
|
27
21
|
import type { MarketplacePlugin } from "../services/marketplace-catalog";
|
|
28
22
|
|
|
@@ -48,262 +42,268 @@ let prevConfigDir: string | undefined;
|
|
|
48
42
|
const NOW = 1_786_980_000_000;
|
|
49
43
|
|
|
50
44
|
function plugin(name: string, version: string): MarketplacePlugin {
|
|
51
|
-
|
|
45
|
+
return { name, version, description: "" };
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
beforeEach(() => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "catalog-cache-"));
|
|
50
|
+
prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
51
|
+
process.env.CLAUDE_CONFIG_DIR = configDir;
|
|
52
|
+
resetCatalogCacheMemo();
|
|
53
|
+
resetGitHubBudget();
|
|
60
54
|
});
|
|
61
55
|
|
|
62
56
|
afterEach(() => {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
// biome-ignore lint/performance/noDelete: assigning undefined to process.env stores the string "undefined"; only delete unsets the variable
|
|
58
|
+
if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
59
|
+
else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
|
|
60
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
66
61
|
});
|
|
67
62
|
|
|
68
63
|
describe("catalog cache — survives a relaunch", () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
64
|
+
it("serves a catalog written by a previous process", async () => {
|
|
65
|
+
await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
|
|
66
|
+
|
|
67
|
+
// Stand in for a relaunch: in-memory state gone, file intact.
|
|
68
|
+
resetCatalogCacheMemo();
|
|
69
|
+
|
|
70
|
+
const cached = await readCachedCatalog("magus");
|
|
71
|
+
expect(cached).not.toBeNull();
|
|
72
|
+
expect(cached?.plugins).toHaveLength(1);
|
|
73
|
+
expect(cached?.plugins[0]?.version).toBe("4.1.6");
|
|
74
|
+
expect(cached?.source).toBe("remote");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("keeps the source, so a cached answer is still labelled honestly", async () => {
|
|
78
|
+
// A `remote-git` answer stays `remote-git` after a round trip through disk.
|
|
79
|
+
// Flattening it to "cached" would lose the authoritative/unverified
|
|
80
|
+
// distinction the whole fix rests on.
|
|
81
|
+
await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote-git");
|
|
82
|
+
resetCatalogCacheMemo();
|
|
83
|
+
expect((await readCachedCatalog("magus"))?.source).toBe("remote-git");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("expires once past the TTL", async () => {
|
|
87
|
+
await writeCachedCatalog(
|
|
88
|
+
"magus",
|
|
89
|
+
[plugin("terminal", "4.1.6")],
|
|
90
|
+
"remote",
|
|
91
|
+
NOW,
|
|
92
|
+
);
|
|
93
|
+
resetCatalogCacheMemo();
|
|
94
|
+
|
|
95
|
+
expect(
|
|
96
|
+
await readCachedCatalog("magus", NOW + CATALOG_TTL_MS - 1000),
|
|
97
|
+
).not.toBeNull();
|
|
98
|
+
expect(
|
|
99
|
+
await readCachedCatalog("magus", NOW + CATALOG_TTL_MS + 1000),
|
|
100
|
+
).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("ignores an entry stamped in the future", async () => {
|
|
104
|
+
// A clock that jumped backwards must not pin a cache entry forever.
|
|
105
|
+
await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote", NOW);
|
|
106
|
+
resetCatalogCacheMemo();
|
|
107
|
+
expect(await readCachedCatalog("magus", NOW - 60_000)).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("never caches an empty catalog", async () => {
|
|
111
|
+
// An empty list is indistinguishable from an unclassified failure. Caching it
|
|
112
|
+
// would serve "this marketplace has no plugins" for an hour — the original bug
|
|
113
|
+
// with a longer memory.
|
|
114
|
+
await writeCachedCatalog("magus", [], "remote");
|
|
115
|
+
resetCatalogCacheMemo();
|
|
116
|
+
expect(await readCachedCatalog("magus")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("is dropped by an explicit refresh", async () => {
|
|
120
|
+
await writeCachedCatalog("magus", [plugin("terminal", "4.1.6")], "remote");
|
|
121
|
+
await clearCachedCatalogs();
|
|
122
|
+
resetCatalogCacheMemo();
|
|
123
|
+
expect(await readCachedCatalog("magus")).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("survives a corrupt cache file instead of throwing", async () => {
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
path.join(configDir, "claudeup-catalog-cache.json"),
|
|
129
|
+
"{ not json",
|
|
130
|
+
);
|
|
131
|
+
resetCatalogCacheMemo();
|
|
132
|
+
expect(await readCachedCatalog("magus")).toBeNull();
|
|
133
|
+
|
|
134
|
+
// And it must recover: a bad file cannot poison later writes.
|
|
135
|
+
await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
|
|
136
|
+
resetCatalogCacheMemo();
|
|
137
|
+
expect(await readCachedCatalog("magus")).not.toBeNull();
|
|
138
|
+
});
|
|
140
139
|
});
|
|
141
140
|
|
|
142
141
|
describe("rate-limit cooldown — survives a relaunch", () => {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Anchored to the real clock, not the fixed NOW the catalog tests use.
|
|
144
|
+
* `hydrateGitHubBudget()` takes no clock parameter — it filters expired entries
|
|
145
|
+
* against `Date.now()`, exactly as it does in production — so a cooldown stamped
|
|
146
|
+
* relative to a fixed past NOW would be discarded as expired before the
|
|
147
|
+
* assertion ever ran.
|
|
148
|
+
*/
|
|
149
|
+
let base: number;
|
|
150
|
+
beforeEach(() => {
|
|
151
|
+
base = Date.now();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Wait until a write-behind cooldown has actually reached disk.
|
|
156
|
+
*
|
|
157
|
+
* This used to be `setTimeout(80)` — a guess at how long an unawaited write
|
|
158
|
+
* takes, which is not a synchronisation point. `recordRateLimit` persists
|
|
159
|
+
* fire-and-forget by design (`github-budget.ts` explains why), so on a loaded
|
|
160
|
+
* runner the write had not landed when the next `hydrateGitHubBudget()` read,
|
|
161
|
+
* and the restored strike count came back low. That failed CI as
|
|
162
|
+
* `Expected: 3, Received: 2` on Linux while passing 15/15 on a quiet macOS
|
|
163
|
+
* laptop — the signature of a timing guess, not of a real defect.
|
|
164
|
+
*
|
|
165
|
+
* Polling the store itself removes the guess: it returns as soon as the value
|
|
166
|
+
* is observable, and only gives up after a deadline no healthy machine
|
|
167
|
+
* reaches. `expected` is the strike count the caller just recorded; passing
|
|
168
|
+
* nothing waits for any persisted entry at all.
|
|
169
|
+
*/
|
|
170
|
+
const flush = async (host: string, expected?: number): Promise<void> => {
|
|
171
|
+
const deadline = Date.now() + 5000;
|
|
172
|
+
for (;;) {
|
|
173
|
+
// `base` keeps the read on the same clock the writes used: cooldowns are
|
|
174
|
+
// dropped on read once expired, so reading with a later `now` could hide
|
|
175
|
+
// an entry that is genuinely there.
|
|
176
|
+
const stored = (await readStoredCooldowns(base))[host];
|
|
177
|
+
if (stored && (expected === undefined || stored.strikes >= expected)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (Date.now() > deadline) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`cooldown for ${host} never reached disk (wanted strikes >= ${expected ?? "any"}, got ${stored?.strikes ?? "nothing"})`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Teardown hygiene, NOT a synchronisation point — which is why it is a plain
|
|
190
|
+
// sleep and `flush` is not. Its only job is to let an in-flight write land
|
|
191
|
+
// before the temp config dir is removed; a write that arrives afterwards
|
|
192
|
+
// recreates a file in a deleted directory and harms nothing. Polling for a
|
|
193
|
+
// specific host here would be wrong: several tests in this block end with
|
|
194
|
+
// nothing on disk on purpose.
|
|
195
|
+
afterEach(() => new Promise((r) => setTimeout(r, 50)));
|
|
196
|
+
|
|
197
|
+
it("is still in force in the next process", async () => {
|
|
198
|
+
// THE defect this store exists to fix. Without persistence, launch two fired
|
|
199
|
+
// six more doomed requests at a host it had already been refused by.
|
|
200
|
+
recordRateLimit("raw.githubusercontent.com", new Headers(), base);
|
|
201
|
+
await flush("raw.githubusercontent.com");
|
|
202
|
+
|
|
203
|
+
resetGitHubBudget();
|
|
204
|
+
resetCatalogCacheMemo();
|
|
205
|
+
expect(cooldownFor("raw.githubusercontent.com", base)).toBeNull(); // memory only
|
|
206
|
+
|
|
207
|
+
await hydrateGitHubBudget();
|
|
208
|
+
const restored = cooldownFor("raw.githubusercontent.com", base);
|
|
209
|
+
expect(restored).not.toBeNull();
|
|
210
|
+
expect(restored?.until).toBeGreaterThan(base);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps escalating the backoff across relaunches", async () => {
|
|
214
|
+
// Without the strike count surviving, every relaunch restarts the backoff at
|
|
215
|
+
// 30s — so a tool that is relaunched often never actually backs off, which is
|
|
216
|
+
// the behaviour that got the IP rate-limited in the first place.
|
|
217
|
+
//
|
|
218
|
+
// Written as one strike per launch, which is the real shape: a launch makes its
|
|
219
|
+
// requests, learns it is limited once, and exits.
|
|
220
|
+
const first = recordRateLimit("raw.githubusercontent.com", undefined, base);
|
|
221
|
+
expect(first.strikes).toBe(1);
|
|
222
|
+
await flush("raw.githubusercontent.com", 1);
|
|
223
|
+
|
|
224
|
+
resetGitHubBudget();
|
|
225
|
+
resetCatalogCacheMemo();
|
|
226
|
+
await hydrateGitHubBudget();
|
|
227
|
+
|
|
228
|
+
const second = recordRateLimit(
|
|
229
|
+
"raw.githubusercontent.com",
|
|
230
|
+
undefined,
|
|
231
|
+
base,
|
|
232
|
+
);
|
|
233
|
+
expect(second.strikes).toBe(2);
|
|
234
|
+
expect(second.until).toBeGreaterThan(first.until);
|
|
235
|
+
await flush("raw.githubusercontent.com", 2);
|
|
236
|
+
|
|
237
|
+
resetGitHubBudget();
|
|
238
|
+
resetCatalogCacheMemo();
|
|
239
|
+
await hydrateGitHubBudget();
|
|
240
|
+
|
|
241
|
+
const third = recordRateLimit("raw.githubusercontent.com", undefined, base);
|
|
242
|
+
expect(third.strikes).toBe(3);
|
|
243
|
+
expect(third.until).toBeGreaterThan(second.until);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("preserves whether the timing was GitHub's or ours", async () => {
|
|
247
|
+
// `exact` decides between "resets in 4m" and "next attempt in 4m". Losing it
|
|
248
|
+
// across a relaunch would make the UI state a claim GitHub never made.
|
|
249
|
+
recordRateLimit(
|
|
250
|
+
"api.github.com",
|
|
251
|
+
new Headers({ "retry-after": "600" }),
|
|
252
|
+
base,
|
|
253
|
+
);
|
|
254
|
+
await flush("api.github.com");
|
|
255
|
+
|
|
256
|
+
resetGitHubBudget();
|
|
257
|
+
await hydrateGitHubBudget();
|
|
258
|
+
expect(cooldownFor("api.github.com", base)?.exact).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("drops an expired cooldown rather than restoring it", async () => {
|
|
262
|
+
await writeStoredCooldown("raw.githubusercontent.com", {
|
|
263
|
+
until: NOW - 1000,
|
|
264
|
+
exact: false,
|
|
265
|
+
strikes: 4,
|
|
266
|
+
});
|
|
267
|
+
resetCatalogCacheMemo();
|
|
268
|
+
|
|
269
|
+
// Expired entries are not returned, so a stale strike count from yesterday
|
|
270
|
+
// cannot make today's first failure back off for ten minutes.
|
|
271
|
+
expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("is cleared for a host that recovers", async () => {
|
|
275
|
+
await writeStoredCooldown("raw.githubusercontent.com", {
|
|
276
|
+
until: NOW + 60_000,
|
|
277
|
+
exact: false,
|
|
278
|
+
strikes: 1,
|
|
279
|
+
});
|
|
280
|
+
await clearStoredCooldown("raw.githubusercontent.com");
|
|
281
|
+
resetCatalogCacheMemo();
|
|
282
|
+
expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("keeps catalogs when only the cooldown is cleared, and vice versa", async () => {
|
|
286
|
+
// They share one file; a write to either must not clobber the other.
|
|
287
|
+
await writeCachedCatalog("magus", [plugin("dev", "4.0.1")], "remote");
|
|
288
|
+
await writeStoredCooldown("raw.githubusercontent.com", {
|
|
289
|
+
until: NOW + 60_000,
|
|
290
|
+
exact: false,
|
|
291
|
+
strikes: 1,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await clearStoredCooldown("raw.githubusercontent.com");
|
|
295
|
+
resetCatalogCacheMemo();
|
|
296
|
+
expect(await readCachedCatalog("magus")).not.toBeNull();
|
|
297
|
+
|
|
298
|
+
await writeStoredCooldown("raw.githubusercontent.com", {
|
|
299
|
+
until: NOW + 60_000,
|
|
300
|
+
exact: false,
|
|
301
|
+
strikes: 1,
|
|
302
|
+
});
|
|
303
|
+
await clearCachedCatalogs();
|
|
304
|
+
resetCatalogCacheMemo();
|
|
305
|
+
expect(Object.keys(await readStoredCooldowns(NOW))).toEqual([
|
|
306
|
+
"raw.githubusercontent.com",
|
|
307
|
+
]);
|
|
308
|
+
});
|
|
309
309
|
});
|