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.
- package/README.md +40 -50
- package/README.zh.md +38 -48
- package/lib/client.js +2209 -1820
- package/lib/client.js.map +1 -1
- package/lib/index.js +653 -419
- package/lib/types/client/SkillHubSettingsCard.d.ts +1 -0
- package/lib/types/client/api.d.ts +12 -6
- package/lib/types/client/helpers.d.ts +0 -1
- package/lib/types/client/locales/common.d.ts +52 -0
- package/lib/types/client/locales/detail.d.ts +16 -0
- package/lib/types/client/locales/market.d.ts +73 -0
- package/lib/types/client/locales/settings.d.ts +41 -0
- package/lib/types/client/locales/skills.d.ts +65 -0
- package/lib/types/client/locales/sources.d.ts +35 -0
- package/lib/types/client/locales.d.ts +124 -118
- package/lib/types/client/panel/MarketSourceRow.d.ts +12 -0
- package/lib/types/client/panel/MarketView.d.ts +1 -0
- package/lib/types/client/panel/RepoScanCard.d.ts +10 -0
- package/lib/types/client/panel/hooks/shared.d.ts +72 -0
- package/lib/types/client/panel/hooks/useCatalogFlow.d.ts +62 -0
- package/lib/types/client/panel/hooks/useGroupFlow.d.ts +41 -0
- package/lib/types/client/panel/hooks/useMarketFlow.d.ts +73 -0
- package/lib/types/client/panel/hooks/useMetaFlow.d.ts +18 -0
- package/lib/types/client/panel/hooks/useSourceFlow.d.ts +44 -0
- package/lib/types/client/panel/market-format.d.ts +6 -0
- package/lib/types/client/panel/useSkillHub.d.ts +45 -73
- package/lib/types/client/settings-card.d.ts +20 -0
- package/lib/types/client/settings-form.d.ts +6 -0
- package/lib/types/protocol/api.d.ts +38 -0
- package/lib/types/protocol/catalog.d.ts +155 -0
- package/lib/types/protocol/config.d.ts +113 -0
- package/lib/types/protocol/groups.d.ts +95 -0
- package/lib/types/protocol/market.d.ts +94 -0
- package/lib/types/protocol/repo.d.ts +107 -0
- package/lib/types/protocol/sources.d.ts +139 -0
- package/lib/types/protocol/stats.d.ts +46 -0
- package/lib/types/protocol.d.ts +13 -743
- package/lib/types/repo/github-client.d.ts +42 -0
- package/lib/types/repo.d.ts +6 -17
- package/lib/types/routes/catalog.d.ts +7 -0
- package/lib/types/routes/config.d.ts +7 -0
- package/lib/types/routes/groups.d.ts +7 -0
- package/lib/types/routes/helpers.d.ts +145 -0
- package/lib/types/routes/market.d.ts +8 -0
- package/lib/types/routes/repo-import.d.ts +7 -0
- package/lib/types/routes/route-state.d.ts +55 -0
- package/lib/types/routes/sources.d.ts +8 -0
- package/lib/types/routes.d.ts +5 -34
- package/lib/types/stats.d.ts +3 -2
- package/lib/types/store/errors.d.ts +10 -0
- package/lib/types/store/migrate.d.ts +44 -0
- package/lib/types/store/paths.d.ts +32 -0
- package/lib/types/store/store.d.ts +133 -0
- package/lib/types/store.d.ts +7 -146
- package/package.json +28 -27
- package/src/client/SkillHubSettingsCard.tsx +17 -3
- package/src/client/api.ts +101 -195
- package/src/client/helpers.ts +10 -1
- package/src/client/locales/common.ts +102 -0
- package/src/client/locales/detail.ts +30 -0
- package/src/client/locales/market.ts +144 -0
- package/src/client/locales/settings.ts +80 -0
- package/src/client/locales/skills.ts +128 -0
- package/src/client/locales/sources.ts +68 -0
- package/src/client/locales.ts +26 -512
- package/src/client/panel/MarketSourceRow.tsx +108 -0
- package/src/client/panel/MarketView.tsx +12 -253
- package/src/client/panel/RepoScanCard.tsx +171 -0
- package/src/client/panel/hooks/shared.ts +81 -0
- package/src/client/panel/hooks/useCatalogFlow.ts +211 -0
- package/src/client/panel/hooks/useGroupFlow.ts +240 -0
- package/src/client/panel/hooks/useMarketFlow.ts +421 -0
- package/src/client/panel/hooks/useMetaFlow.ts +58 -0
- package/src/client/panel/hooks/useSourceFlow.ts +193 -0
- package/src/client/panel/market-format.ts +22 -0
- package/src/client/panel/useSkillHub.ts +141 -1032
- package/src/client/settings-card.tsx +51 -0
- package/src/client/settings-form.ts +18 -0
- package/src/index.ts +14 -4
- package/src/protocol/api.ts +38 -0
- package/src/protocol/catalog.ts +168 -0
- package/src/protocol/config.ts +145 -0
- package/src/protocol/groups.ts +106 -0
- package/src/protocol/market.ts +100 -0
- package/src/protocol/repo.ts +104 -0
- package/src/protocol/sources.ts +146 -0
- package/src/protocol/stats.ts +42 -0
- package/src/protocol.ts +83 -809
- package/src/repo/github-client.ts +131 -0
- package/src/repo.test.ts +24 -1
- package/src/repo.ts +50 -170
- package/src/routes/catalog.ts +309 -0
- package/src/routes/config.ts +119 -0
- package/src/routes/groups.ts +133 -0
- package/src/routes/helpers.ts +489 -0
- package/src/routes/market.ts +255 -0
- package/src/routes/repo-import.ts +267 -0
- package/src/routes/route-state.ts +89 -0
- package/src/routes/sources.ts +318 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +21 -1699
- package/src/stats.test.ts +58 -3
- package/src/stats.ts +45 -16
- package/src/store/errors.ts +14 -0
- package/src/store/migrate.ts +272 -0
- package/src/store/paths.ts +43 -0
- package/src/store/store.ts +532 -0
- package/src/store.test.ts +18 -0
- 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
|
|
423
|
-
} catch {
|
|
424
|
-
|
|
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
|
-
/**
|
|
431
|
-
export async function
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[] : []
|