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
@@ -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,545 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import {
7
+ COMMUNITY_SOURCES,
8
+ type CommunityStyle,
9
+ type CommunityStyleSource,
10
+ } from "../data/community-styles.js";
11
+ import type {
12
+ StyleFetchResponse,
13
+ StyleFetcher,
14
+ } from "../services/community-fetcher.js";
15
+ import {
16
+ MAX_STYLE_BYTES,
17
+ fetchCommunityStyle,
18
+ fetchSource,
19
+ resetCommunityApiHeadroom,
20
+ resolveCommitPin,
21
+ } from "../services/community-styles.js";
22
+ import { resetGitHubBudget } from "../services/github-budget.js";
23
+ import { splitFrontmatter } from "../services/styles-manager.js";
24
+
25
+ // NOTHING here touches the network. `StyleFetcher` is a REQUIRED argument on
26
+ // every exported function in the service, so a test cannot reach GitHub by
27
+ // forgetting to stub — omitting the fetcher is a type error, not a silent live
28
+ // call. That is the entire reason the port exists.
29
+
30
+ let dir: string;
31
+
32
+ beforeEach(async () => {
33
+ dir = await mkdtemp(join(tmpdir(), "claudeup-community-"));
34
+ resetGitHubBudget();
35
+ resetCommunityApiHeadroom();
36
+ });
37
+ afterEach(async () => {
38
+ await rm(dir, { recursive: true, force: true });
39
+ resetGitHubBudget();
40
+ resetCommunityApiHeadroom();
41
+ });
42
+
43
+ const source: CommunityStyleSource = COMMUNITY_SOURCES[0];
44
+
45
+ const style: CommunityStyle = {
46
+ id: "attention-span--spartan",
47
+ sourceId: "attention-span",
48
+ path: "output-styles/spartan.md",
49
+ displayName: "Spartan",
50
+ summary: "Blunt and answer-first.",
51
+ };
52
+
53
+ /** The exact shape research.md captured from the real repo. */
54
+ const UPSTREAM = [
55
+ "---",
56
+ "name: Spartan",
57
+ "description: Blunt Spartan mode for ADHD. Answer-first, arrow points, zero warmth or filler.",
58
+ "keep-coding-instructions: true",
59
+ "---",
60
+ "",
61
+ "# Spartan",
62
+ "",
63
+ "- Answer first.",
64
+ "",
65
+ ].join("\n");
66
+
67
+ interface FakeCall {
68
+ url: string;
69
+ etag?: string | null;
70
+ }
71
+
72
+ /** A fetcher that records what it was asked and replies from a script. */
73
+ function fake(replies: Array<Partial<StyleFetchResponse> | (() => never)>): {
74
+ fetcher: StyleFetcher;
75
+ calls: FakeCall[];
76
+ } {
77
+ const calls: FakeCall[] = [];
78
+ let index = 0;
79
+ const fetcher: StyleFetcher = async (url, opts) => {
80
+ calls.push({ url, etag: opts?.etag });
81
+ const reply = replies[Math.min(index++, replies.length - 1)];
82
+ if (typeof reply === "function") reply();
83
+ return {
84
+ status: 200,
85
+ body: UPSTREAM,
86
+ etag: null,
87
+ contentType: "text/plain; charset=utf-8",
88
+ headers: new Headers(),
89
+ ...(reply as Partial<StyleFetchResponse>),
90
+ };
91
+ };
92
+ return { fetcher, calls };
93
+ }
94
+
95
+ async function fetchOne(
96
+ replies: Array<Partial<StyleFetchResponse> | (() => never)>,
97
+ over: Partial<Parameters<typeof fetchCommunityStyle>[0]> = {},
98
+ ) {
99
+ const { fetcher, calls } = fake(replies);
100
+ const result = await fetchCommunityStyle({
101
+ style,
102
+ source,
103
+ fetcher,
104
+ cacheDir: dir,
105
+ commit: "b860c9f8f3c7",
106
+ now: Date.parse("2026-08-18T12:00:00"),
107
+ ...over,
108
+ });
109
+ return { result, calls };
110
+ }
111
+
112
+ describe("a successful fetch", () => {
113
+ test("writes the file and stamps the full provenance set", async () => {
114
+ const { result } = await fetchOne([{}]);
115
+ expect(result.outcome).toBe("written");
116
+ if (result.outcome !== "written") return;
117
+
118
+ const { frontmatter } = splitFrontmatter(
119
+ await fs.readFile(result.file.path, "utf8"),
120
+ );
121
+ expect(frontmatter.name).toBe("attention-span--spartan");
122
+ expect(frontmatter["community-source"]).toBe("alexgreensh/attention-span");
123
+ expect(frontmatter["community-path"]).toBe("output-styles/spartan.md");
124
+ expect(frontmatter["community-ref"]).toBe("HEAD");
125
+ expect(frontmatter["community-commit"]).toBe("b860c9f8f3c7");
126
+ expect(frontmatter["community-sha256"]).toMatch(/^sha256:[0-9a-f]{64}$/);
127
+ expect(frontmatter["community-fetched"]).toBe("2026-08-18");
128
+ expect(frontmatter["community-licence"]).toBe("AGPL-3.0");
129
+ expect(frontmatter["community-author"]).toBe("alexgreensh");
130
+ });
131
+
132
+ test("names the file after the coordinate, flat, in the cache directory", async () => {
133
+ const { result } = await fetchOne([{}]);
134
+ if (result.outcome !== "written") throw new Error("expected a write");
135
+ expect(result.file.path).toBe(join(dir, "attention-span--spartan.md"));
136
+ });
137
+
138
+ test("forces keep-coding-instructions even when upstream turned it off", async () => {
139
+ // A style about how to COMMUNICATE has no business switching off how to
140
+ // write code, and a third-party file is exactly where that would arrive.
141
+ const hostile = UPSTREAM.replace(
142
+ "keep-coding-instructions: true",
143
+ "keep-coding-instructions: false",
144
+ );
145
+ const { result } = await fetchOne([{ body: hostile }]);
146
+ if (result.outcome !== "written") throw new Error("expected a write");
147
+ const { frontmatter } = splitFrontmatter(
148
+ await fs.readFile(result.file.path, "utf8"),
149
+ );
150
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
151
+ });
152
+
153
+ test("drops every upstream key we did not ask for", async () => {
154
+ // Frontmatter is rewritten, not passed through. This also blocks any
155
+ // future harness-recognised key we have not heard of yet.
156
+ const extra = UPSTREAM.replace(
157
+ "keep-coding-instructions: true",
158
+ "keep-coding-instructions: true\nsome-future-harness-key: dangerous\nmodel: opus",
159
+ );
160
+ const { result } = await fetchOne([{ body: extra }]);
161
+ if (result.outcome !== "written") throw new Error("expected a write");
162
+ const written = await fs.readFile(result.file.path, "utf8");
163
+ expect(written).not.toContain("some-future-harness-key");
164
+ expect(splitFrontmatter(written).frontmatter.model).toBeUndefined();
165
+ });
166
+
167
+ test("keeps upstream's description, quoted so its colon cannot break the YAML", async () => {
168
+ const colon = UPSTREAM.replace(
169
+ "description: Blunt Spartan mode for ADHD. Answer-first, arrow points, zero warmth or filler.",
170
+ "description: Spartan: blunt, answer-first",
171
+ );
172
+ const { result } = await fetchOne([{ body: colon }]);
173
+ if (result.outcome !== "written") throw new Error("expected a write");
174
+ const written = await fs.readFile(result.file.path, "utf8");
175
+ expect(written).toContain('description: "Spartan: blunt, answer-first"');
176
+ expect(splitFrontmatter(written).frontmatter.description).toBe(
177
+ "Spartan: blunt, answer-first",
178
+ );
179
+ });
180
+
181
+ test("carries the body through verbatim", async () => {
182
+ const { result } = await fetchOne([{}]);
183
+ if (result.outcome !== "written") throw new Error("expected a write");
184
+ const { body } = splitFrontmatter(
185
+ await fs.readFile(result.file.path, "utf8"),
186
+ );
187
+ expect(body).toContain("- Answer first.");
188
+ });
189
+
190
+ test("hashes the RAW UPSTREAM bytes, not our rewritten copy", async () => {
191
+ // The staleness check compares this against a fresh raw fetch. Hashing what
192
+ // we wrote would mean it never matched and every style read as changed
193
+ // forever.
194
+ const { createHash } = await import("node:crypto");
195
+ const expected = `sha256:${createHash("sha256").update(UPSTREAM).digest("hex")}`;
196
+ const { result } = await fetchOne([{}]);
197
+ if (result.outcome !== "written") throw new Error("expected a write");
198
+ expect(result.file.sha256).toBe(expected);
199
+ });
200
+
201
+ test("stamps unknown rather than lying when no pin was resolved", async () => {
202
+ const { result } = await fetchOne([{}], { commit: null });
203
+ if (result.outcome !== "written") throw new Error("expected a write");
204
+ const { frontmatter } = splitFrontmatter(
205
+ await fs.readFile(result.file.path, "utf8"),
206
+ );
207
+ expect(frontmatter["community-commit"]).toBe("unknown");
208
+ });
209
+
210
+ test("leaves no temp file behind", async () => {
211
+ await fetchOne([{}]);
212
+ const entries = await fs.readdir(dir);
213
+ expect(entries).toEqual(["attention-span--spartan.md"]);
214
+ });
215
+ });
216
+
217
+ describe("failures write nothing", () => {
218
+ const noFile = async () => {
219
+ expect(await fs.pathExists(join(dir, "attention-span--spartan.md"))).toBe(
220
+ false,
221
+ );
222
+ };
223
+
224
+ test("404 blames claudeup's registry, not the user", async () => {
225
+ const { result } = await fetchOne([
226
+ { status: 404, body: "404: Not Found" },
227
+ ]);
228
+ expect(result.outcome).toBe("failed");
229
+ if (result.outcome !== "failed") return;
230
+ expect(result.failure.kind).toBe("not-found");
231
+ expect(result.failure.detail).toContain("claudeup's registry entry");
232
+ await noFile();
233
+ });
234
+
235
+ test("429 records a cooldown and writes nothing", async () => {
236
+ const { result } = await fetchOne([
237
+ {
238
+ status: 429,
239
+ body: "",
240
+ headers: new Headers({ "retry-after": "120" }),
241
+ },
242
+ ]);
243
+ expect(result.outcome).toBe("failed");
244
+ if (result.outcome !== "failed") return;
245
+ expect(result.failure.kind).toBe("rate-limited");
246
+ // Against the INJECTED clock, not Date.now(). The service computes
247
+ // retryAt from the `now` the harness passes in; comparing that to the
248
+ // real clock made this a time bomb that went red once wall-clock time
249
+ // passed the fixture's noon-plus-120s. retry-after: 120 → exactly +120s.
250
+ expect(result.failure.retryAt).toBe(
251
+ Date.parse("2026-08-18T12:00:00") + 120_000,
252
+ );
253
+ await noFile();
254
+ });
255
+
256
+ test("a recorded cooldown stops the NEXT attempt before it leaves the process", async () => {
257
+ await fetchOne([
258
+ { status: 429, body: "", headers: new Headers({ "retry-after": "120" }) },
259
+ ]);
260
+ const { result, calls } = await fetchOne([{}]);
261
+ expect(result.outcome).toBe("failed");
262
+ // The point of sharing github-budget rather than forking it: a known limit
263
+ // is not rediscovered by spending another request against it.
264
+ expect(calls).toHaveLength(0);
265
+ });
266
+
267
+ test("a body over 64 KiB is rejected, with the real size in the message", async () => {
268
+ const huge = `${UPSTREAM}\n${"x".repeat(MAX_STYLE_BYTES)}`;
269
+ const { result } = await fetchOne([{ body: huge }]);
270
+ expect(result.outcome).toBe("failed");
271
+ if (result.outcome !== "failed") return;
272
+ expect(result.failure.kind).toBe("too-large");
273
+ expect(result.failure.detail).toMatch(/KiB/);
274
+ await noFile();
275
+ });
276
+
277
+ test("a file with no frontmatter is not an output style", async () => {
278
+ const { result } = await fetchOne([
279
+ { body: "# Just a heading\n\nWords.\n" },
280
+ ]);
281
+ expect(result.outcome).toBe("failed");
282
+ if (result.outcome !== "failed") return;
283
+ expect(result.failure.kind).toBe("invalid");
284
+ await noFile();
285
+ });
286
+
287
+ test("a file with frontmatter but no body is not an output style", async () => {
288
+ const { result } = await fetchOne([{ body: "---\nname: x\n---\n\n" }]);
289
+ expect(result.outcome).toBe("failed");
290
+ if (result.outcome !== "failed") return;
291
+ expect(result.failure.kind).toBe("invalid");
292
+ await noFile();
293
+ });
294
+
295
+ test("an HTML response is rejected on content-type", async () => {
296
+ // The shape a captive portal or a redirect to a login page produces.
297
+ const { result } = await fetchOne([
298
+ { body: "<html>sign in</html>", contentType: "text/html" },
299
+ ]);
300
+ expect(result.outcome).toBe("failed");
301
+ if (result.outcome !== "failed") return;
302
+ expect(result.failure.kind).toBe("invalid");
303
+ await noFile();
304
+ });
305
+
306
+ test("any other non-2xx reports the status", async () => {
307
+ const { result } = await fetchOne([{ status: 500, body: "" }]);
308
+ expect(result.outcome).toBe("failed");
309
+ if (result.outcome !== "failed") return;
310
+ expect(result.failure.kind).toBe("http");
311
+ expect(result.failure.httpStatus).toBe(500);
312
+ });
313
+
314
+ test("304 leaves the existing file untouched", async () => {
315
+ await fetchOne([{}]);
316
+ const before = await fs.readFile(
317
+ join(dir, "attention-span--spartan.md"),
318
+ "utf8",
319
+ );
320
+ const { result } = await fetchOne([{ status: 304, body: "" }], {
321
+ etag: 'W/"abc"',
322
+ });
323
+ expect(result.outcome).toBe("unchanged");
324
+ expect(
325
+ await fs.readFile(join(dir, "attention-span--spartan.md"), "utf8"),
326
+ ).toBe(before);
327
+ });
328
+ });
329
+
330
+ describe("thrown errors are classified on the code, never on the message", () => {
331
+ const thrower = (error: unknown) => () => {
332
+ throw error;
333
+ };
334
+
335
+ test("ENOTFOUND names MagicDNS, because that is what it usually is here", async () => {
336
+ // A generic "network error" sends the user to GitHub's status page. A
337
+ // split-DNS resolver hijacking raw.githubusercontent.com looks identical
338
+ // and has cost real time on this machine before.
339
+ const error = new TypeError("fetch failed");
340
+ (error as { cause?: unknown }).cause = { code: "ENOTFOUND" };
341
+ const { result } = await fetchOne([thrower(error)]);
342
+ expect(result.outcome).toBe("failed");
343
+ if (result.outcome !== "failed") return;
344
+ expect(result.failure.kind).toBe("dns");
345
+ expect(result.failure.detail).toContain("MagicDNS");
346
+ expect(result.failure.detail).toContain("dig raw.githubusercontent.com");
347
+ });
348
+
349
+ test("EAI_AGAIN is the same hazard", async () => {
350
+ const error = new TypeError("fetch failed");
351
+ (error as { cause?: unknown }).cause = { code: "EAI_AGAIN" };
352
+ const { result } = await fetchOne([thrower(error)]);
353
+ if (result.outcome !== "failed") throw new Error("expected a failure");
354
+ expect(result.failure.kind).toBe("dns");
355
+ });
356
+
357
+ test("no route reads as offline, and says the cache still works", async () => {
358
+ const error = new TypeError("fetch failed");
359
+ (error as { cause?: unknown }).cause = { code: "ENETUNREACH" };
360
+ const { result } = await fetchOne([thrower(error)]);
361
+ if (result.outcome !== "failed") throw new Error("expected a failure");
362
+ expect(result.failure.kind).toBe("offline");
363
+ expect(result.failure.detail).toContain("already fetched still work");
364
+ });
365
+
366
+ test("a timeout names the host rather than blaming the network", async () => {
367
+ const error = new Error("The operation timed out.");
368
+ error.name = "TimeoutError";
369
+ const { result } = await fetchOne([thrower(error)]);
370
+ if (result.outcome !== "failed") throw new Error("expected a failure");
371
+ expect(result.failure.kind).toBe("timeout");
372
+ expect(result.failure.detail).toContain("raw.githubusercontent.com");
373
+ });
374
+
375
+ test("an unclassified throw is reported as such, not guessed at", async () => {
376
+ const { result } = await fetchOne([thrower(new Error("something odd"))]);
377
+ if (result.outcome !== "failed") throw new Error("expected a failure");
378
+ expect(result.failure.kind).toBe("network");
379
+ expect(result.failure.detail).toContain("something odd");
380
+ });
381
+ });
382
+
383
+ describe("coordinate validation happens before any I/O", () => {
384
+ test("a traversing path throws WITHOUT calling the fetcher", async () => {
385
+ const { fetcher, calls } = fake([{}]);
386
+ await expect(
387
+ fetchCommunityStyle({
388
+ style: { ...style, path: "output-styles/../../../etc/passwd.md" },
389
+ source,
390
+ fetcher,
391
+ cacheDir: dir,
392
+ }),
393
+ ).rejects.toThrow(/invalid/);
394
+ // The assertion that matters: a bad coordinate never becomes a URL.
395
+ expect(calls).toHaveLength(0);
396
+ });
397
+
398
+ test("a malformed repo throws WITHOUT calling the fetcher", async () => {
399
+ const { fetcher, calls } = fake([{}]);
400
+ await expect(
401
+ fetchCommunityStyle({
402
+ style,
403
+ source: { ...source, repo: "not a repo" },
404
+ fetcher,
405
+ cacheDir: dir,
406
+ }),
407
+ ).rejects.toThrow(/invalid/);
408
+ expect(calls).toHaveLength(0);
409
+ });
410
+
411
+ test("the thrown message names the entry, so the bug is findable", async () => {
412
+ const { fetcher } = fake([{}]);
413
+ await expect(
414
+ fetchCommunityStyle({
415
+ style: { ...style, path: "README.md" },
416
+ source,
417
+ fetcher,
418
+ cacheDir: dir,
419
+ }),
420
+ ).rejects.toThrow(/attention-span--spartan/);
421
+ });
422
+ });
423
+
424
+ describe("the commit pin", () => {
425
+ const commitsBody = JSON.stringify([
426
+ { sha: "b860c9f8f3c7a1b2c3d4e5f60718293a4b5c6d7e" },
427
+ ]);
428
+
429
+ test("resolves a short sha from one API call", async () => {
430
+ const { fetcher, calls } = fake([
431
+ { body: commitsBody, contentType: "application/json" },
432
+ ]);
433
+ const pin = await resolveCommitPin({ source, fetcher });
434
+ expect(pin.commit).toBe("b860c9f8f3c7");
435
+ expect(calls).toHaveLength(1);
436
+ expect(calls[0].url).toContain(
437
+ "api.github.com/repos/alexgreensh/attention-span/commits",
438
+ );
439
+ expect(calls[0].url).toContain("per_page=1");
440
+ });
441
+
442
+ test("a failed pin never blocks the fetch — the raw content is free", async () => {
443
+ const { fetcher } = fake([{ status: 500, body: "" }]);
444
+ const pin = await resolveCommitPin({ source, fetcher });
445
+ expect(pin.commit).toBeNull();
446
+ expect(pin.reason).toBeTruthy();
447
+ });
448
+
449
+ test("stops spending API budget once the machine's reserve is reached", async () => {
450
+ // claudeup must not be the tool that exhausts a machine-wide 60/hr limit
451
+ // and breaks `gh` for the next hour to fill in one display field.
452
+ const { fetcher, calls } = fake([
453
+ {
454
+ body: commitsBody,
455
+ headers: new Headers({ "x-ratelimit-remaining": "3" }),
456
+ },
457
+ ]);
458
+ expect((await resolveCommitPin({ source, fetcher })).commit).toBe(
459
+ "b860c9f8f3c7",
460
+ );
461
+ const second = await resolveCommitPin({ source, fetcher });
462
+ expect(second.commit).toBeNull();
463
+ expect(second.reason).toMatch(/budget/);
464
+ // One call made, the second refused before it left the process.
465
+ expect(calls).toHaveLength(1);
466
+ });
467
+
468
+ test("a rate-limited pin is reported as unknown, never as resolved", async () => {
469
+ const { fetcher } = fake([
470
+ {
471
+ status: 403,
472
+ body: "",
473
+ headers: new Headers({ "x-ratelimit-remaining": "0" }),
474
+ },
475
+ ]);
476
+ const pin = await resolveCommitPin({ source, fetcher });
477
+ expect(pin.commit).toBeNull();
478
+ expect(pin.reason).toMatch(/rate limit/i);
479
+ });
480
+
481
+ test("a response with no commits yields no pin rather than a bogus one", async () => {
482
+ const { fetcher } = fake([{ body: "[]" }]);
483
+ expect((await resolveCommitPin({ source, fetcher })).commit).toBeNull();
484
+ });
485
+ });
486
+
487
+ describe("fetchSource", () => {
488
+ test("spends ONE API call for the whole batch, whatever its size", async () => {
489
+ // The budget design in one assertion: 22 styles across 5 repos costs 5 API
490
+ // calls, not 22. Per-style pinning would exhaust the hourly limit on first
491
+ // use.
492
+ const commitsBody = JSON.stringify([{ sha: "0123456789abcdef" }]);
493
+ const { fetcher, calls } = fake([
494
+ { body: commitsBody },
495
+ { body: UPSTREAM },
496
+ { body: UPSTREAM },
497
+ { body: UPSTREAM },
498
+ ]);
499
+ const styles = ["spartan", "rundown", "attention-kind"].map((slug) => ({
500
+ ...style,
501
+ id: `attention-span--${slug}`,
502
+ path: `output-styles/${slug}.md`,
503
+ }));
504
+
505
+ const result = await fetchSource({
506
+ source,
507
+ styles,
508
+ fetcher,
509
+ cacheDir: dir,
510
+ });
511
+
512
+ expect(result.pin.commit).toBe("0123456789ab");
513
+ expect(
514
+ calls.filter((call) => call.url.includes("api.github.com")),
515
+ ).toHaveLength(1);
516
+ expect(result.results.every((r) => r.result.outcome === "written")).toBe(
517
+ true,
518
+ );
519
+ expect((await fs.readdir(dir)).sort()).toEqual([
520
+ "attention-span--attention-kind.md",
521
+ "attention-span--rundown.md",
522
+ "attention-span--spartan.md",
523
+ ]);
524
+ });
525
+
526
+ test("every style in the batch carries the batch's pin", async () => {
527
+ const { fetcher } = fake([
528
+ { body: JSON.stringify([{ sha: "0123456789abcdef" }]) },
529
+ { body: UPSTREAM },
530
+ { body: UPSTREAM },
531
+ ]);
532
+ const styles = ["spartan", "rundown"].map((slug) => ({
533
+ ...style,
534
+ id: `attention-span--${slug}`,
535
+ path: `output-styles/${slug}.md`,
536
+ }));
537
+ await fetchSource({ source, styles, fetcher, cacheDir: dir });
538
+ for (const slug of ["spartan", "rundown"]) {
539
+ const { frontmatter } = splitFrontmatter(
540
+ await fs.readFile(join(dir, `attention-span--${slug}.md`), "utf8"),
541
+ );
542
+ expect(frontmatter["community-commit"]).toBe("0123456789ab");
543
+ }
544
+ });
545
+ });