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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +312 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -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/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +124 -3
- package/src/ui/state/types.ts +76 -3
- package/src/utils/config-dir.ts +47 -0
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog-cache-store.ts — the catalog cache and the rate-limit cooldown, on disk.
|
|
3
|
+
*
|
|
4
|
+
* Why on disk
|
|
5
|
+
* -----------
|
|
6
|
+
* Every cache in this area used to be a module-level `Map`, which for a TUI means
|
|
7
|
+
* "cleared on every launch". Measured 2026-08-17, two consecutive processes:
|
|
8
|
+
*
|
|
9
|
+
* [process-1] 6 HTTP requests in 13.32s
|
|
10
|
+
* [process-2] 6 HTTP requests in 13.01s
|
|
11
|
+
*
|
|
12
|
+
* So each launch re-fetched all six catalogs, and the second process had no idea
|
|
13
|
+
* the first had just been rate-limited — it spent six more requests rediscovering
|
|
14
|
+
* that. On some endpoints a rejected request extends the penalty, so an in-memory
|
|
15
|
+
* cooldown is not merely useless across launches, it is counterproductive.
|
|
16
|
+
*
|
|
17
|
+
* Persisting the cooldown is the more important half. A stale catalog costs the
|
|
18
|
+
* user a version number that is an hour old; re-firing six doomed requests on
|
|
19
|
+
* every launch costs them the ability to check at all.
|
|
20
|
+
*
|
|
21
|
+
* Trade-off, stated
|
|
22
|
+
* -----------------
|
|
23
|
+
* A cached catalog means a release published inside the TTL is not visible yet.
|
|
24
|
+
* That is why the TTL is short, why `r` forces a real refetch, and why the age is
|
|
25
|
+
* available for display. A cache the user cannot see through or override would be
|
|
26
|
+
* the same trap as a frozen clone.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
import { claudeConfigDirOrNull } from "../utils/config-dir.js";
|
|
32
|
+
import { withFileLock } from "../utils/file-locking.js";
|
|
33
|
+
import type { MarketplacePlugin } from "./marketplace-catalog.js";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How long a cached catalog is served before we ask again.
|
|
37
|
+
*
|
|
38
|
+
* Matches the hour that `update-cache.ts` already uses for its own check, so the
|
|
39
|
+
* tool has one refresh rhythm rather than two.
|
|
40
|
+
*/
|
|
41
|
+
export const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
interface StoredCatalog {
|
|
44
|
+
fetchedAt: number;
|
|
45
|
+
/** Which transport answered — carried so the UI can still say "unverified". */
|
|
46
|
+
source: string;
|
|
47
|
+
plugins: MarketplacePlugin[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StoredCooldown {
|
|
51
|
+
until: number;
|
|
52
|
+
exact: boolean;
|
|
53
|
+
strikes: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the last upstream check learned about one community-styles repository.
|
|
58
|
+
*
|
|
59
|
+
* Lives here rather than in a fourth cache file so it inherits this store's file
|
|
60
|
+
* lock, atomic write and CLAUDE_CONFIG_DIR test isolation — and, more to the
|
|
61
|
+
* point, so it sits beside the rate-limit cooldowns it is budgeted against.
|
|
62
|
+
*
|
|
63
|
+
* `dirHeadSha` is the pivot of the two-phase check: one API call per REPO
|
|
64
|
+
* answers "did anything in its styles directory move", and only a repo that
|
|
65
|
+
* moved is drilled into with free raw fetches. Per-style API calls would spend
|
|
66
|
+
* the machine's whole hourly budget on first use.
|
|
67
|
+
*/
|
|
68
|
+
export interface StoredCommunityCheck {
|
|
69
|
+
/** Wall-clock ms of the last successful phase A. Drives the 24h TTL. */
|
|
70
|
+
checkedAt: number;
|
|
71
|
+
/** Latest commit touching the repo's styles directory, as of that check. */
|
|
72
|
+
dirHeadSha: string;
|
|
73
|
+
/** Per coordinate id: the bytes we hold, and any update sitting in .pending/. */
|
|
74
|
+
styles: Record<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
sha256: string;
|
|
78
|
+
pendingSha256?: string;
|
|
79
|
+
/** Changed-line count of that pending update, so it survives a restart. */
|
|
80
|
+
pendingLines?: number;
|
|
81
|
+
}
|
|
82
|
+
>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StoreShape {
|
|
86
|
+
version: 1;
|
|
87
|
+
catalogs: Record<string, StoredCatalog>;
|
|
88
|
+
cooldowns: Record<string, StoredCooldown>;
|
|
89
|
+
communityStyles: Record<string, StoredCommunityCheck>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const EMPTY: StoreShape = {
|
|
93
|
+
version: 1,
|
|
94
|
+
catalogs: {},
|
|
95
|
+
cooldowns: {},
|
|
96
|
+
communityStyles: {},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
|
|
101
|
+
* content-drift.ts and marketplace-catalog-git.ts.
|
|
102
|
+
*
|
|
103
|
+
* Null when a test has not chosen a config dir, which makes this whole store inert
|
|
104
|
+
* rather than letting an unisolated test read the operator's live cooldown and
|
|
105
|
+
* decide its own outcome from it. See utils/config-dir.ts.
|
|
106
|
+
*/
|
|
107
|
+
function storePath(): string | null {
|
|
108
|
+
const configDir = claudeConfigDirOrNull();
|
|
109
|
+
if (!configDir) return null;
|
|
110
|
+
return path.join(configDir, "claudeup-catalog-cache.json");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read-through memo for this process. The disk file is the cross-launch cache;
|
|
115
|
+
* this just avoids re-reading and re-parsing it on every lookup within one run.
|
|
116
|
+
*/
|
|
117
|
+
let memo: { path: string; data: StoreShape } | null = null;
|
|
118
|
+
|
|
119
|
+
async function load(): Promise<StoreShape> {
|
|
120
|
+
const file = storePath();
|
|
121
|
+
if (!file)
|
|
122
|
+
return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
123
|
+
if (memo?.path === file) return memo.data;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
126
|
+
// A cache from a future/unknown layout is discarded rather than migrated:
|
|
127
|
+
// it is regenerable by definition, so guessing at it buys nothing.
|
|
128
|
+
const data = parsed?.version === 1 ? parsed : { ...EMPTY };
|
|
129
|
+
data.catalogs ??= {};
|
|
130
|
+
data.cooldowns ??= {};
|
|
131
|
+
// Absent in every file written before community styles existed, so this
|
|
132
|
+
// default is the migration — an upgrade must not read as a corrupt cache.
|
|
133
|
+
data.communityStyles ??= {};
|
|
134
|
+
memo = { path: file, data };
|
|
135
|
+
return data;
|
|
136
|
+
} catch {
|
|
137
|
+
// Absent or corrupt. A cache must never be a failure mode — start empty.
|
|
138
|
+
const data: StoreShape = {
|
|
139
|
+
...EMPTY,
|
|
140
|
+
catalogs: {},
|
|
141
|
+
cooldowns: {},
|
|
142
|
+
communityStyles: {},
|
|
143
|
+
};
|
|
144
|
+
memo = { path: file, data };
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Apply a mutation and persist it.
|
|
151
|
+
*
|
|
152
|
+
* Locked and re-read inside the lock, because two claudeup processes can be open
|
|
153
|
+
* at once and a blind read-modify-write would drop the other's cooldown — exactly
|
|
154
|
+
* the record we most need to keep.
|
|
155
|
+
*/
|
|
156
|
+
async function mutate(fn: (data: StoreShape) => void): Promise<void> {
|
|
157
|
+
const file = storePath();
|
|
158
|
+
if (!file) return;
|
|
159
|
+
try {
|
|
160
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
161
|
+
await withFileLock(file, async () => {
|
|
162
|
+
let onDisk: StoreShape;
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(
|
|
165
|
+
await fs.readFile(file, "utf-8"),
|
|
166
|
+
) as StoreShape;
|
|
167
|
+
// Every section is named explicitly: a key omitted here is silently
|
|
168
|
+
// dropped on the next write of any OTHER section.
|
|
169
|
+
onDisk =
|
|
170
|
+
parsed?.version === 1
|
|
171
|
+
? {
|
|
172
|
+
version: 1,
|
|
173
|
+
catalogs: parsed.catalogs ?? {},
|
|
174
|
+
cooldowns: parsed.cooldowns ?? {},
|
|
175
|
+
communityStyles: parsed.communityStyles ?? {},
|
|
176
|
+
}
|
|
177
|
+
: { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
178
|
+
} catch {
|
|
179
|
+
onDisk = {
|
|
180
|
+
version: 1,
|
|
181
|
+
catalogs: {},
|
|
182
|
+
cooldowns: {},
|
|
183
|
+
communityStyles: {},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
fn(onDisk);
|
|
187
|
+
// Temp + rename, so a crash mid-write cannot leave a half-written file
|
|
188
|
+
// that the next launch has to treat as corrupt.
|
|
189
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
190
|
+
await fs.writeFile(tmp, JSON.stringify(onDisk), "utf-8");
|
|
191
|
+
await fs.rename(tmp, file);
|
|
192
|
+
memo = { path: file, data: onDisk };
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
// Never let a cache write break the run. Worst case we re-fetch next time.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Catalogs ────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
export interface CachedCatalog {
|
|
202
|
+
plugins: MarketplacePlugin[];
|
|
203
|
+
source: string;
|
|
204
|
+
/** Milliseconds since it was fetched — for display, and for the TTL test. */
|
|
205
|
+
ageMs: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A cached catalog for this marketplace, if one is present and still fresh. */
|
|
209
|
+
export async function readCachedCatalog(
|
|
210
|
+
marketplace: string,
|
|
211
|
+
now: number = Date.now(),
|
|
212
|
+
): Promise<CachedCatalog | null> {
|
|
213
|
+
const data = await load();
|
|
214
|
+
const entry = data.catalogs[marketplace];
|
|
215
|
+
if (!entry) return null;
|
|
216
|
+
const ageMs = now - entry.fetchedAt;
|
|
217
|
+
if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
|
|
218
|
+
return { plugins: entry.plugins, source: entry.source, ageMs };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function writeCachedCatalog(
|
|
222
|
+
marketplace: string,
|
|
223
|
+
plugins: MarketplacePlugin[],
|
|
224
|
+
source: string,
|
|
225
|
+
now: number = Date.now(),
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
// An empty catalog is never worth caching: it is indistinguishable from a
|
|
228
|
+
// failure we did not classify, and serving it would resurrect the original bug.
|
|
229
|
+
if (plugins.length === 0) return;
|
|
230
|
+
await mutate((data) => {
|
|
231
|
+
data.catalogs[marketplace] = { fetchedAt: now, source, plugins };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop every cached catalog. Backs the explicit refresh (`r`). */
|
|
236
|
+
export async function clearCachedCatalogs(): Promise<void> {
|
|
237
|
+
await mutate((data) => {
|
|
238
|
+
data.catalogs = {};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Cooldowns ───────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
export async function readStoredCooldowns(
|
|
245
|
+
now: number = Date.now(),
|
|
246
|
+
): Promise<Record<string, StoredCooldown>> {
|
|
247
|
+
const data = await load();
|
|
248
|
+
const live: Record<string, StoredCooldown> = {};
|
|
249
|
+
for (const [host, cooldown] of Object.entries(data.cooldowns)) {
|
|
250
|
+
// Expired entries are dropped on read rather than persisted forever. The
|
|
251
|
+
// strike count goes with them: a cooldown that lapsed a day ago should not
|
|
252
|
+
// make today's first failure back off for ten minutes.
|
|
253
|
+
if (cooldown.until > now) live[host] = cooldown;
|
|
254
|
+
}
|
|
255
|
+
return live;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function writeStoredCooldown(
|
|
259
|
+
host: string,
|
|
260
|
+
cooldown: StoredCooldown,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
await mutate((data) => {
|
|
263
|
+
data.cooldowns[host] = cooldown;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function clearStoredCooldown(host: string): Promise<void> {
|
|
268
|
+
await mutate((data) => {
|
|
269
|
+
delete data.cooldowns[host];
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Community style checks ──────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every recorded upstream check, keyed by community source id.
|
|
277
|
+
*
|
|
278
|
+
* Deliberately NOT expired on read, unlike cooldowns. An expired cooldown means
|
|
279
|
+
* "you may call again"; an expired CHECK still carries the shas we compare
|
|
280
|
+
* against, and only its `checkedAt` has gone stale. Dropping it would throw away
|
|
281
|
+
* the one thing that makes the next check cheap, and would make a 25-hour-old
|
|
282
|
+
* result indistinguishable from never having looked. Freshness is judged by the
|
|
283
|
+
* reader, which is what lets an expired check present as `unknown` rather than
|
|
284
|
+
* as up to date.
|
|
285
|
+
*/
|
|
286
|
+
export async function readCommunityChecks(): Promise<
|
|
287
|
+
Record<string, StoredCommunityCheck>
|
|
288
|
+
> {
|
|
289
|
+
return { ...(await load()).communityStyles };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function writeCommunityCheck(
|
|
293
|
+
sourceId: string,
|
|
294
|
+
check: StoredCommunityCheck,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
await mutate((data) => {
|
|
297
|
+
data.communityStyles ??= {};
|
|
298
|
+
data.communityStyles[sourceId] = check;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Drop every recorded check. Backs an explicit refresh. */
|
|
303
|
+
export async function clearCommunityChecks(): Promise<void> {
|
|
304
|
+
await mutate((data) => {
|
|
305
|
+
data.communityStyles = {};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Test seam: forget this process's memo so the next read hits disk. */
|
|
310
|
+
export function resetCatalogCacheMemo(): void {
|
|
311
|
+
memo = null;
|
|
312
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* community-fetcher.ts — the one thing in the community-styles feature that
|
|
3
|
+
* knows `fetch` exists.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a port at all
|
|
6
|
+
*
|
|
7
|
+
* Exactly one boundary gets an interface: the network. The domain here is thin,
|
|
8
|
+
* and an interface-per-class reflex would be waste — so there is no Strategy
|
|
9
|
+
* registry, no adapter factory, and nothing else in the feature is abstracted.
|
|
10
|
+
* The gain is the one the constraints demand: no test can reach GitHub, because
|
|
11
|
+
* every exported function in `community-styles.ts` takes a `StyleFetcher` as a
|
|
12
|
+
* REQUIRED argument. Omitting it is a type error, not a forgotten mock.
|
|
13
|
+
*
|
|
14
|
+
* ## Why it does not speak `Response`
|
|
15
|
+
*
|
|
16
|
+
* The classic way a port leaks its adapter. A `Response` drags in streaming
|
|
17
|
+
* semantics, a body that can only be read once, and a `fetch`-shaped mental
|
|
18
|
+
* model into every test fake. `{ status, body, etag }` is what the caller
|
|
19
|
+
* actually uses.
|
|
20
|
+
*
|
|
21
|
+
* `headers` is the one deliberate exception. `github-budget.ts` parses
|
|
22
|
+
* `retry-after` / `x-ratelimit-reset` / `x-ratelimit-remaining` itself, and it
|
|
23
|
+
* is the module that knows the two hosts differ in what they report. Re-deriving
|
|
24
|
+
* that here would fork the rate-limit logic, which is precisely what §5.3 of the
|
|
25
|
+
* design forbids. A fake supplies `new Headers({...})`, which is a standard
|
|
26
|
+
* built-in, not a fetch import.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What the application needs from an HTTP response. Never a `Response`. */
|
|
30
|
+
export interface StyleFetchResponse {
|
|
31
|
+
status: number;
|
|
32
|
+
/** Empty for a 304 and for any status with no body. */
|
|
33
|
+
body: string;
|
|
34
|
+
etag: string | null;
|
|
35
|
+
contentType: string | null;
|
|
36
|
+
/** Handed to `github-budget` unchanged — see the header note. */
|
|
37
|
+
headers: Headers;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type StyleFetcher = (
|
|
41
|
+
url: string,
|
|
42
|
+
opts?: { etag?: string | null; timeoutMs?: number; accept?: string },
|
|
43
|
+
) => Promise<StyleFetchResponse>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 10s, matching `marketplace-fetcher.ts`. A style is a few kilobytes over a CDN
|
|
47
|
+
* — measured at ~340ms — so anything past ten seconds is a broken route rather
|
|
48
|
+
* than a slow one, and the user is waiting on a keypress they just made.
|
|
49
|
+
*/
|
|
50
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The real adapter. Wraps global `fetch` and nothing more: no retry, no
|
|
54
|
+
* classification, no budget check. Those are policy and belong in the service,
|
|
55
|
+
* where they can be tested without a network.
|
|
56
|
+
*
|
|
57
|
+
* Sends `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` to `api.github.com`
|
|
58
|
+
* ONLY — exactly as `skills-manager.ts` already does. The token raises that
|
|
59
|
+
* host's 60/hr unauthenticated budget to 5000/hr and is the documented escape
|
|
60
|
+
* hatch when the advisory says the budget is spent. It is never sent to
|
|
61
|
+
* `raw.githubusercontent.com`, which needs no auth and would receive a
|
|
62
|
+
* credential it has no business seeing.
|
|
63
|
+
*/
|
|
64
|
+
export const githubFetcher: StyleFetcher = async (url, opts = {}) => {
|
|
65
|
+
const headers: Record<string, string> = {
|
|
66
|
+
accept: opts.accept ?? "text/plain, */*",
|
|
67
|
+
"user-agent": "claudeup",
|
|
68
|
+
};
|
|
69
|
+
if (opts.etag) headers["if-none-match"] = opts.etag;
|
|
70
|
+
|
|
71
|
+
if (new URL(url).hostname === "api.github.com") {
|
|
72
|
+
const token =
|
|
73
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
74
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
headers,
|
|
79
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? FETCH_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
status: response.status,
|
|
84
|
+
// 304 carries no body; reading it is still safe and yields "".
|
|
85
|
+
body: response.status === 304 ? "" : await response.text(),
|
|
86
|
+
etag: response.headers.get("etag"),
|
|
87
|
+
contentType: response.headers.get("content-type"),
|
|
88
|
+
headers: response.headers,
|
|
89
|
+
};
|
|
90
|
+
};
|