dsh-skill-hub 0.3.7 → 0.3.8

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 (109) hide show
  1. package/README.md +40 -50
  2. package/README.zh.md +38 -48
  3. package/lib/client.js +2209 -1820
  4. package/lib/client.js.map +1 -1
  5. package/lib/index.js +653 -419
  6. package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
  7. package/lib/types/client/api.d.ts +12 -6
  8. package/lib/types/client/helpers.d.ts +0 -1
  9. package/lib/types/client/locales/common.d.ts +52 -0
  10. package/lib/types/client/locales/detail.d.ts +16 -0
  11. package/lib/types/client/locales/market.d.ts +73 -0
  12. package/lib/types/client/locales/settings.d.ts +41 -0
  13. package/lib/types/client/locales/skills.d.ts +65 -0
  14. package/lib/types/client/locales/sources.d.ts +35 -0
  15. package/lib/types/client/locales.d.ts +124 -118
  16. package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
  17. package/lib/types/client/panel/MarketView.d.ts +1 -0
  18. package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
  19. package/lib/types/client/panel/hooks/shared.d.ts +72 -0
  20. package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
  21. package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
  22. package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
  23. package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
  24. package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
  25. package/lib/types/client/panel/market-format.d.ts +6 -0
  26. package/lib/types/client/panel/useSkillHub.d.ts +45 -73
  27. package/lib/types/client/settings-card.d.ts +20 -0
  28. package/lib/types/client/settings-form.d.ts +6 -0
  29. package/lib/types/protocol/api.d.ts +38 -0
  30. package/lib/types/protocol/catalog.d.ts +155 -0
  31. package/lib/types/protocol/config.d.ts +113 -0
  32. package/lib/types/protocol/groups.d.ts +95 -0
  33. package/lib/types/protocol/market.d.ts +94 -0
  34. package/lib/types/protocol/repo.d.ts +107 -0
  35. package/lib/types/protocol/sources.d.ts +139 -0
  36. package/lib/types/protocol/stats.d.ts +46 -0
  37. package/lib/types/protocol.d.ts +13 -743
  38. package/lib/types/repo/github-client.d.ts +42 -0
  39. package/lib/types/repo.d.ts +6 -17
  40. package/lib/types/routes/catalog.d.ts +7 -0
  41. package/lib/types/routes/config.d.ts +7 -0
  42. package/lib/types/routes/groups.d.ts +7 -0
  43. package/lib/types/routes/helpers.d.ts +145 -0
  44. package/lib/types/routes/market.d.ts +8 -0
  45. package/lib/types/routes/repo-import.d.ts +7 -0
  46. package/lib/types/routes/route-state.d.ts +55 -0
  47. package/lib/types/routes/sources.d.ts +8 -0
  48. package/lib/types/routes.d.ts +5 -34
  49. package/lib/types/stats.d.ts +3 -2
  50. package/lib/types/store/errors.d.ts +10 -0
  51. package/lib/types/store/migrate.d.ts +44 -0
  52. package/lib/types/store/paths.d.ts +32 -0
  53. package/lib/types/store/store.d.ts +133 -0
  54. package/lib/types/store.d.ts +7 -146
  55. package/package.json +28 -27
  56. package/src/client/SkillHubSettingsCard.tsx +17 -3
  57. package/src/client/api.ts +101 -195
  58. package/src/client/helpers.ts +10 -1
  59. package/src/client/locales/common.ts +102 -0
  60. package/src/client/locales/detail.ts +30 -0
  61. package/src/client/locales/market.ts +144 -0
  62. package/src/client/locales/settings.ts +80 -0
  63. package/src/client/locales/skills.ts +128 -0
  64. package/src/client/locales/sources.ts +68 -0
  65. package/src/client/locales.ts +26 -512
  66. package/src/client/panel/MarketSourceRow.tsx +108 -0
  67. package/src/client/panel/MarketView.tsx +12 -253
  68. package/src/client/panel/RepoScanCard.tsx +171 -0
  69. package/src/client/panel/hooks/shared.ts +81 -0
  70. package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
  71. package/src/client/panel/hooks/useGroupFlow.ts +240 -0
  72. package/src/client/panel/hooks/useMarketFlow.ts +421 -0
  73. package/src/client/panel/hooks/useMetaFlow.ts +58 -0
  74. package/src/client/panel/hooks/useSourceFlow.ts +193 -0
  75. package/src/client/panel/market-format.ts +22 -0
  76. package/src/client/panel/useSkillHub.ts +141 -1032
  77. package/src/client/settings-card.tsx +51 -0
  78. package/src/client/settings-form.ts +18 -0
  79. package/src/index.ts +14 -4
  80. package/src/protocol/api.ts +38 -0
  81. package/src/protocol/catalog.ts +168 -0
  82. package/src/protocol/config.ts +145 -0
  83. package/src/protocol/groups.ts +106 -0
  84. package/src/protocol/market.ts +100 -0
  85. package/src/protocol/repo.ts +104 -0
  86. package/src/protocol/sources.ts +146 -0
  87. package/src/protocol/stats.ts +42 -0
  88. package/src/protocol.ts +83 -809
  89. package/src/repo/github-client.ts +131 -0
  90. package/src/repo.test.ts +24 -1
  91. package/src/repo.ts +50 -170
  92. package/src/routes/catalog.ts +309 -0
  93. package/src/routes/config.ts +119 -0
  94. package/src/routes/groups.ts +133 -0
  95. package/src/routes/helpers.ts +489 -0
  96. package/src/routes/market.ts +255 -0
  97. package/src/routes/repo-import.ts +267 -0
  98. package/src/routes/route-state.ts +89 -0
  99. package/src/routes/sources.ts +318 -0
  100. package/src/routes.test.ts +20 -0
  101. package/src/routes.ts +21 -1699
  102. package/src/stats.test.ts +58 -3
  103. package/src/stats.ts +45 -16
  104. package/src/store/errors.ts +14 -0
  105. package/src/store/migrate.ts +272 -0
  106. package/src/store/paths.ts +43 -0
  107. package/src/store/store.ts +532 -0
  108. package/src/store.test.ts +18 -0
  109. package/src/store.ts +7 -737
@@ -0,0 +1,131 @@
1
+ /**
2
+ * GitHub API 统一请求层:token 管理、鉴权头、ETag 缓存、错误映射、
3
+ * 带缓存的 JSON GET。从 repo.ts 抽出,原 6 处重复的 try/catch +
4
+ * headers + json 解析收敛到 fetchJson / fetchJsonCached 两个入口。
5
+ */
6
+
7
+ /** Fetch failure carrying a useful HTTP status for the route layer. */
8
+ export class RepoFetchError extends Error {
9
+ readonly status: number
10
+ constructor(message: string, status = 502) {
11
+ super(message)
12
+ this.name = 'RepoFetchError'
13
+ this.status = status
14
+ }
15
+ }
16
+
17
+ /**
18
+ * GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
19
+ * GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
20
+ * host calls it from the settings sync whenever the card value changes;
21
+ * an absent value falls back to the env var).
22
+ */
23
+ let githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
24
+
25
+ /** Override the GitHub token at runtime ('' falls back to the env var). */
26
+ export function setGithubToken(token: string | undefined): void {
27
+ githubToken = token !== undefined && token !== '' ? token : (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '')
28
+ }
29
+
30
+ /** Authorization header for api.github.com / raw.githubusercontent.com calls. */
31
+ export function githubAuthHeaders(): Record<string, string> {
32
+ return githubToken === '' ? {} : { authorization: 'Bearer ' + githubToken }
33
+ }
34
+
35
+ /** JSON API 默认请求头(含鉴权)。 */
36
+ export function apiHeaders(): Record<string, string> {
37
+ return { accept: 'application/vnd.github+json', ...githubAuthHeaders() }
38
+ }
39
+
40
+ /** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
41
+ const etagCache = new Map<string, { etag: string; json: unknown }>()
42
+ const ETAG_MAX = 200
43
+ function etagCacheGet(url: string): { etag: string; json: unknown } | undefined {
44
+ return etagCache.get(url)
45
+ }
46
+ function etagCacheSet(url: string, etag: string, json: unknown): void {
47
+ if (etagCache.size >= ETAG_MAX) {
48
+ const first = etagCache.keys().next().value as string | undefined
49
+ if (first !== undefined) etagCache.delete(first)
50
+ }
51
+ etagCache.set(url, { etag, json })
52
+ }
53
+ export function clearEtagCache(): void {
54
+ etagCache.clear()
55
+ }
56
+
57
+ /**
58
+ * Build a RepoFetchError; when the response shows an exhausted rate limit,
59
+ * report the reset time instead of a bare HTTP status.
60
+ */
61
+ export function fetchError(context: string, response: Response, fallbackStatus = 502): RepoFetchError {
62
+ const remaining = response.headers.get('x-ratelimit-remaining')
63
+ const reset = response.headers.get('x-ratelimit-reset')
64
+ if (response.status === 403 || response.status === 429) {
65
+ if (remaining === '0' && reset !== null) {
66
+ const at = new Date(Number(reset) * 1000)
67
+ return new RepoFetchError(
68
+ 'github rate limit reached (anonymous quota exhausted); retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN',
69
+ 403,
70
+ )
71
+ }
72
+ }
73
+ return new RepoFetchError(context + ' (HTTP ' + response.status + ')', response.status === 404 ? 404 : fallbackStatus)
74
+ }
75
+
76
+ /** 是否限流/中断类错误(调用方可直接透出,不再包装)。 */
77
+ export function isAbortError(error: unknown): boolean {
78
+ return error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted') || error.message.includes('Abort'))
79
+ }
80
+
81
+ /**
82
+ * 不带缓存的统一 GET+JSON:网络异常、非 2xx、坏 JSON 全部映射为
83
+ * RepoFetchError,调用方无需重复写 try/catch。
84
+ */
85
+ export async function fetchJson(url: string, fetchImpl: typeof fetch, context: string, headers: Record<string, string> = apiHeaders()): Promise<{ json: unknown; response: Response }> {
86
+ let response: Response
87
+ try {
88
+ response = await fetchImpl(url, { headers })
89
+ } catch (error) {
90
+ if (isAbortError(error)) throw error
91
+ throw new RepoFetchError(context + ': ' + (error instanceof Error ? error.message : String(error)))
92
+ }
93
+ if (!response.ok) throw fetchError(context, response)
94
+ try {
95
+ return { json: await response.json(), response }
96
+ } catch {
97
+ throw new RepoFetchError('invalid github response for ' + url)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Load repo metadata and the recursive git tree at the given ref. The meta
103
+ * request always resolves the default branch (used when ref is absent); the
104
+ * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
105
+ * the exact same content a later download would fetch.
106
+ */
107
+ export async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
108
+ const cached = etagCacheGet(url)
109
+ const headers: Record<string, string> = { ...apiHeaders() }
110
+ if (cached !== undefined) headers['if-none-match'] = cached.etag
111
+ let response: Response
112
+ try {
113
+ response = await fetchImpl(url, { headers })
114
+ } catch (error) {
115
+ throw new RepoFetchError(context + ': ' + (error instanceof Error ? error.message : String(error)))
116
+ }
117
+ if (response.status === 304 && cached !== undefined) {
118
+ // Return cached JSON with a synthetic 200-like response for error mapping.
119
+ return { json: cached.json, response }
120
+ }
121
+ if (!response.ok) throw fetchError(context, response)
122
+ let json: unknown
123
+ try {
124
+ json = await response.json()
125
+ } catch {
126
+ throw new RepoFetchError('invalid github response for ' + url)
127
+ }
128
+ const etag = response.headers.get('etag')
129
+ if (etag !== null && etag !== '') etagCacheSet(url, etag, json)
130
+ return { json, response }
131
+ }
package/src/repo.test.ts CHANGED
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
2
2
  import { mkdtemp, readFile, rm } from 'node:fs/promises'
3
3
  import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
- import { collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, listRepoBranches, normalizeRepoInput, originForRoot, repoSlug, skillDirOf } from './repo.ts'
5
+ import { collectRepoSkillFiles, diffRemoteSkills, discoverRepoEntries, downloadGitHubFile, downloadRepoSkill, getLatestReleaseTag, getRepoStats, listRepoBranches, normalizeRepoInput, originForRoot, repoSlug, skillDirOf } from './repo.ts'
6
6
  import type { RepoTreeItem } from './repo.ts'
7
7
  import type { RepoSkillEntry } from './protocol.ts'
8
8
 
@@ -252,3 +252,26 @@ describe('listRepoBranches', () => {
252
252
  expect(await listRepoBranches('a/b', fetchImpl as typeof fetch)).toEqual(['main', 'dev'])
253
253
  })
254
254
  })
255
+
256
+ describe('getRepoStats', () => {
257
+ it('sums stars and release asset downloads', async () => {
258
+ const fetchImpl = async (url: string) => {
259
+ if (url.includes('/releases')) {
260
+ return new Response(JSON.stringify([
261
+ { tag_name: 'v2', assets: [{ download_count: 30 }, { download_count: 12 }] },
262
+ { tag_name: 'v1', assets: [{ download_count: 8 }] },
263
+ ]), { status: 200 })
264
+ }
265
+ return new Response(JSON.stringify({ stargazers_count: 1500 }), { status: 200 })
266
+ }
267
+ expect(await getRepoStats('a/b', fetchImpl as typeof fetch)).toEqual({ stars: 1500, downloads: 50 })
268
+ })
269
+
270
+ it('tolerates a failing releases endpoint', async () => {
271
+ const fetchImpl = async (url: string) => {
272
+ if (url.includes('/releases')) return new Response('boom', { status: 500 })
273
+ return new Response(JSON.stringify({ stargazers_count: 7 }), { status: 200 })
274
+ }
275
+ expect(await getRepoStats('a/b', fetchImpl as typeof fetch)).toEqual({ stars: 7, downloads: 0 })
276
+ })
277
+ })
package/src/repo.ts CHANGED
@@ -12,6 +12,18 @@ import { dirname, join } from 'node:path'
12
12
  import { isSkillName } from '@deepseek-ai/dsh-skill'
13
13
  import type { RepoRoot, RepoSkillEntry } from './protocol.ts'
14
14
  import { parseFrontmatter } from './skillfs.ts'
15
+ import {
16
+ RepoFetchError,
17
+ apiHeaders,
18
+ fetchError,
19
+ fetchJson,
20
+ fetchJsonCached,
21
+ githubAuthHeaders,
22
+ isAbortError,
23
+ } from './repo/github-client.ts'
24
+
25
+ // 后向兼容:老 `from './repo.ts'` 写法继续可用,新代码可直引 github-client。
26
+ export { RepoFetchError, clearEtagCache, fetchError, fetchJson, fetchJsonCached, githubAuthHeaders, isAbortError, setGithubToken } from './repo/github-client.ts'
15
27
 
16
28
  /** Preferred display order for known roots; unknown roots sort alphabetically after these. */
17
29
  export const REPO_ROOTS: readonly RepoRoot[] = ['skills', 'design-templates']
@@ -40,69 +52,6 @@ export interface RepoFile {
40
52
  size: number
41
53
  }
42
54
 
43
- /** Fetch failure carrying a useful HTTP status for the route layer. */
44
- export class RepoFetchError extends Error {
45
- readonly status: number
46
- constructor(message: string, status = 502) {
47
- super(message)
48
- this.name = 'RepoFetchError'
49
- this.status = status
50
- }
51
- }
52
-
53
- /**
54
- * GitHub token for authenticated API calls. Read from GITHUB_TOKEN /
55
- * GH_TOKEN at module load; setGithubToken() overrides it at runtime (the
56
- * plugin config route calls it when a saved token exists).
57
- */
58
- let githubToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
59
-
60
- /** Override the GitHub token at runtime ('' falls back to the env var). */
61
- export function setGithubToken(token: string | undefined): void {
62
- githubToken = token !== undefined && token !== '' ? token : (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? '')
63
- }
64
-
65
- /** Authorization header for api.github.com / raw.githubusercontent.com calls. */
66
- export function githubAuthHeaders(): Record<string, string> {
67
- return githubToken === '' ? {} : { authorization: 'Bearer ' + githubToken }
68
- }
69
-
70
- /** ETag cache for GitHub API JSON (mirrors codex lib.rs marker+fingerprint). In-memory only, saves 304 for daily checks. */
71
- const etagCache = new Map<string, { etag: string; json: unknown }>()
72
- const ETAG_MAX = 200
73
- function etagCacheGet(url: string): { etag: string; json: unknown } | undefined {
74
- return etagCache.get(url)
75
- }
76
- function etagCacheSet(url: string, etag: string, json: unknown): void {
77
- if (etagCache.size >= ETAG_MAX) {
78
- const first = etagCache.keys().next().value as string | undefined
79
- if (first !== undefined) etagCache.delete(first)
80
- }
81
- etagCache.set(url, { etag, json })
82
- }
83
- export function clearEtagCache(): void {
84
- etagCache.clear()
85
- }
86
-
87
- /**
88
- * Build a RepoFetchError; when the response shows an exhausted rate limit,
89
- * report the reset time instead of a bare HTTP status.
90
- */
91
- export function fetchError(context: string, response: Response, fallbackStatus = 502): RepoFetchError {
92
- const remaining = response.headers.get('x-ratelimit-remaining')
93
- const reset = response.headers.get('x-ratelimit-reset')
94
- if (response.status === 403 || response.status === 429) {
95
- if (remaining === '0' && reset !== null) {
96
- const at = new Date(Number(reset) * 1000)
97
- return new RepoFetchError(
98
- 'github rate limit reached (anonymous quota exhausted); retry after ' + at.toLocaleTimeString() + ' or set GITHUB_TOKEN',
99
- 403,
100
- )
101
- }
102
- }
103
- return new RepoFetchError(context + ' (HTTP ' + response.status + ')', response.status === 404 ? 404 : fallbackStatus)
104
- }
105
-
106
55
  /** `owner/repo` slug for a parsed reference. */
107
56
  export function repoSlug(ref: RepoRef): string {
108
57
  return `${ref.owner}/${ref.repo}`
@@ -139,38 +88,6 @@ export function normalizeRepoInput(input: string): RepoRef | null {
139
88
  return { owner, repo, ...(ref !== undefined && ref !== '' ? { ref } : {}) }
140
89
  }
141
90
 
142
- /**
143
- * Load repo metadata and the recursive git tree at the given ref. The meta
144
- * request always resolves the default branch (used when ref is absent); the
145
- * tree is fetched at `ref ?? default_branch` so an explicit branch/tag scans
146
- * the exact same content a later download would fetch.
147
- */
148
- async function fetchJsonCached(url: string, fetchImpl: typeof fetch, context: string): Promise<{ json: unknown; response: Response }> {
149
- const cached = etagCacheGet(url)
150
- const headers: Record<string, string> = { accept: 'application/vnd.github+json', ...githubAuthHeaders() }
151
- if (cached !== undefined) headers['if-none-match'] = cached.etag
152
- let response: Response
153
- try {
154
- response = await fetchImpl(url, { headers })
155
- } catch (error) {
156
- throw new RepoFetchError(context + ': ' + (error instanceof Error ? error.message : String(error)))
157
- }
158
- if (response.status === 304 && cached !== undefined) {
159
- // Return cached JSON with a synthetic 200-like response for error mapping.
160
- return { json: cached.json, response }
161
- }
162
- if (!response.ok) throw fetchError(context, response)
163
- let json: unknown
164
- try {
165
- json = await response.json()
166
- } catch {
167
- throw new RepoFetchError('invalid github response for ' + url)
168
- }
169
- const etag = response.headers.get('etag')
170
- if (etag !== null && etag !== '') etagCacheSet(url, etag, json)
171
- return { json, response }
172
- }
173
-
174
91
  export async function loadRepoTree(repo: string, ref?: string, fetchImpl: typeof fetch = fetch): Promise<{ ref: string; tree: RepoTreeItem[]; truncated: boolean }> {
175
92
  const metaUrl = `https://api.github.com/repos/${repo}`
176
93
  const { json: metaJson } = await fetchJsonCached(metaUrl, fetchImpl, 'github repo not found or unavailable')
@@ -257,11 +174,6 @@ export async function mapConcurrent<T, R>(items: readonly T[], limit: number, wo
257
174
  return results
258
175
  }
259
176
 
260
- /** Whether an error is an abort (AbortError / signal). */
261
- export function isAbortError(error: unknown): boolean {
262
- return error instanceof Error && (error.name === 'AbortError' || error.message.includes('aborted') || error.message.includes('Abort'))
263
- }
264
-
265
177
  /**
266
178
  * Download one GitHub file as a buffer. Tries raw.githubusercontent.com
267
179
  * first (no API quota); on any failure falls back to the api.github.com
@@ -407,43 +319,51 @@ export async function cleanupLeftoverImportDirs(targetRoot: string): Promise<num
407
319
  * repo has no releases (GitHub's /releases/latest skips prereleases/drafts).
408
320
  */
409
321
  export async function getLatestReleaseTag(repo: string, fetchImpl: typeof fetch = fetch): Promise<string | undefined> {
410
- let response: Response
411
- try {
412
- response = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, {
413
- headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
414
- })
415
- } catch (error) {
416
- throw new RepoFetchError('github release request failed: ' + (error instanceof Error ? error.message : String(error)))
417
- }
418
- if (response.status === 404) return undefined
419
- if (!response.ok) throw fetchError('github release lookup failed', response)
420
322
  let payload: unknown
421
323
  try {
422
- payload = await response.json()
423
- } catch {
424
- throw new RepoFetchError('invalid github release response')
324
+ ;({ json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`, fetchImpl, 'github release lookup failed'))
325
+ } catch (error) {
326
+ if (error instanceof RepoFetchError && error.status === 404) return undefined
327
+ throw error
425
328
  }
426
329
  const tag = typeof payload === 'object' && payload !== null ? (payload as Record<string, unknown>).tag_name : undefined
427
330
  return typeof tag === 'string' && tag !== '' ? tag : undefined
428
331
  }
429
332
 
430
- /** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
431
- export async function listRepoReleases(repo: string, fetchImpl: typeof fetch = fetch): Promise<string[]> {
432
- let response: Response
433
- try {
434
- response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, {
435
- headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
436
- })
437
- } catch (error) {
438
- throw new RepoFetchError('github releases request failed: ' + (error instanceof Error ? error.message : String(error)))
439
- }
440
- if (!response.ok) throw fetchError('github releases lookup failed', response)
441
- let payload: unknown
333
+ /** Stars + release-asset downloads of a repo (two GitHub API requests). */
334
+ export async function getRepoStats(repo: string, fetchImpl: typeof fetch = fetch): Promise<{ stars: number; downloads: number }> {
335
+ const headers = apiHeaders()
336
+ const { json: meta } = await fetchJson(`https://api.github.com/repos/${repo}`, fetchImpl, 'github repo not found or unavailable', headers)
337
+ const stars = typeof meta === 'object' && meta !== null && typeof (meta as Record<string, unknown>).stargazers_count === 'number'
338
+ ? (meta as Record<string, unknown>).stargazers_count as number
339
+ : 0
340
+ let downloads = 0
442
341
  try {
443
- payload = await response.json()
342
+ const relResponse = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=20`, { headers })
343
+ if (relResponse.ok) {
344
+ const releases: unknown = await relResponse.json()
345
+ if (Array.isArray(releases)) {
346
+ for (const item of releases) {
347
+ if (typeof item !== 'object' || item === null) continue
348
+ const assets = (item as Record<string, unknown>).assets
349
+ if (!Array.isArray(assets)) continue
350
+ for (const asset of assets) {
351
+ if (typeof asset === 'object' && asset !== null && typeof (asset as Record<string, unknown>).download_count === 'number') {
352
+ downloads += (asset as Record<string, unknown>).download_count as number
353
+ }
354
+ }
355
+ }
356
+ }
357
+ }
444
358
  } catch {
445
- throw new RepoFetchError('invalid github releases response')
359
+ // downloads stay 0; stars are the primary signal
446
360
  }
361
+ return { stars, downloads }
362
+ }
363
+
364
+ /** Release tags of a repo, newest first (skips drafts, keeps prereleases). */
365
+ export async function listRepoReleases(repo: string, fetchImpl: typeof fetch = fetch): Promise<string[]> {
366
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/releases?per_page=20`, fetchImpl, 'github releases lookup failed')
447
367
  if (!Array.isArray(payload)) return []
448
368
  return payload
449
369
  .filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null && (item as Record<string, unknown>).draft !== true)
@@ -453,21 +373,7 @@ export async function listRepoReleases(repo: string, fetchImpl: typeof fetch = f
453
373
 
454
374
  /** Branch names of a repo, default branch first (one GitHub API request). */
455
375
  export async function listRepoBranches(repo: string, fetchImpl: typeof fetch = fetch): Promise<string[]> {
456
- let response: Response
457
- try {
458
- response = await fetchImpl(`https://api.github.com/repos/${repo}/branches?per_page=100`, {
459
- headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
460
- })
461
- } catch (error) {
462
- throw new RepoFetchError('github branches request failed: ' + (error instanceof Error ? error.message : String(error)))
463
- }
464
- if (!response.ok) throw fetchError('github branches lookup failed', response)
465
- let payload: unknown
466
- try {
467
- payload = await response.json()
468
- } catch {
469
- throw new RepoFetchError('invalid github branches response')
470
- }
376
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/branches?per_page=100`, fetchImpl, 'github branches lookup failed')
471
377
  if (!Array.isArray(payload)) return []
472
378
  const names = payload
473
379
  .map((item) => (typeof item === 'object' && item !== null ? (item as Record<string, unknown>).name : undefined))
@@ -484,19 +390,7 @@ export async function getLatestCommit(repo: string, ref?: string, fetchImpl: typ
484
390
  const url = ref !== undefined && ref !== ''
485
391
  ? `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`
486
392
  : `https://api.github.com/repos/${repo}/commits?per_page=1`
487
- let response: Response
488
- try {
489
- response = await fetchImpl(url, { headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() } })
490
- } catch (error) {
491
- throw new RepoFetchError('github request failed: ' + (error instanceof Error ? error.message : String(error)))
492
- }
493
- if (!response.ok) throw fetchError('github commit lookup failed', response)
494
- let payload: unknown
495
- try {
496
- payload = await response.json()
497
- } catch {
498
- throw new RepoFetchError('invalid github commit response')
499
- }
393
+ const { json: payload } = await fetchJson(url, fetchImpl, 'github commit lookup failed')
500
394
  const record = Array.isArray(payload)
501
395
  ? (payload[0] ?? {}) as Record<string, unknown>
502
396
  : typeof payload === 'object' && payload !== null ? payload as Record<string, unknown> : {}
@@ -509,21 +403,7 @@ export async function getLatestCommit(repo: string, ref?: string, fetchImpl: typ
509
403
 
510
404
  /** Load the recursive git tree at an explicit tree SHA (one API request). */
511
405
  export async function loadRepoTreeAt(repo: string, treeSha: string, fetchImpl: typeof fetch = fetch): Promise<RepoTreeItem[]> {
512
- let response: Response
513
- try {
514
- response = await fetchImpl(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, {
515
- headers: { accept: 'application/vnd.github+json', ...githubAuthHeaders() },
516
- })
517
- } catch (error) {
518
- throw new RepoFetchError('github tree request failed: ' + (error instanceof Error ? error.message : String(error)))
519
- }
520
- if (!response.ok) throw fetchError('github tree lookup failed', response)
521
- let payload: unknown
522
- try {
523
- payload = await response.json()
524
- } catch {
525
- throw new RepoFetchError('invalid github tree response')
526
- }
406
+ const { json: payload } = await fetchJson(`https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(treeSha)}?recursive=1`, fetchImpl, 'github tree lookup failed')
527
407
  const record = typeof payload === 'object' && payload !== null ? payload as Record<string, unknown> : {}
528
408
  if (record.truncated === true) throw new RepoFetchError('repo tree is too large to scan')
529
409
  return Array.isArray(record.tree) ? record.tree as RepoTreeItem[] : []