@adea-ai/plugins 4.0.1 → 4.0.6

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 (142) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +0 -5
  17. package/dist/catalog-store.js +1 -25
  18. package/dist/catalog-store.js.map +1 -1
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +6 -28
  23. package/dist/consumer-index.js +7 -8
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +3 -2
  34. package/dist/icon-mirror.js +51 -63
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -315
  40. package/dist/index.js +12 -1905
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +6 -3
  64. package/dist/publication.js +25 -12
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +2 -3
  70. package/dist/site-icons.js +4 -3
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +2 -4
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.ts +234 -0
  101. package/src/build.ts +131 -0
  102. package/src/capabilities.ts +153 -0
  103. package/src/catalog-store.ts +1 -28
  104. package/src/concurrency.ts +19 -0
  105. package/src/consumer-index.test.ts +27 -21
  106. package/src/consumer-index.ts +12 -35
  107. package/src/digest.ts +55 -0
  108. package/src/fs-atomic.ts +32 -0
  109. package/src/harness.test.ts +4 -32
  110. package/src/harness.ts +2 -1
  111. package/src/icon-mirror.ts +57 -64
  112. package/src/icons.test.ts +0 -4
  113. package/src/icons.ts +4 -4
  114. package/src/index.test.ts +52 -48
  115. package/src/index.ts +50 -2583
  116. package/src/integrity-manifest.ts +41 -21
  117. package/src/json-values.ts +11 -0
  118. package/src/plugin-release.ts +457 -0
  119. package/src/plugin-skips.ts +110 -0
  120. package/src/policy.ts +39 -0
  121. package/src/portable-catalog.test.ts +0 -5
  122. package/src/portable-catalog.ts +16 -143
  123. package/src/portable-verify.ts +71 -0
  124. package/src/publication.test.ts +29 -1
  125. package/src/publication.ts +32 -13
  126. package/src/published-output.ts +60 -0
  127. package/src/site-icons.test.ts +0 -1
  128. package/src/site-icons.ts +4 -4
  129. package/src/snapshots.ts +287 -0
  130. package/src/source-digest.test.ts +3 -7
  131. package/src/source-resolution.ts +233 -0
  132. package/src/sources-json.test.ts +0 -1
  133. package/src/sources.ts +27 -33
  134. package/src/synchronize.ts +213 -0
  135. package/src/taxonomy.ts +87 -0
  136. package/src/upstream.ts +194 -0
  137. package/src/verify-artifacts.ts +365 -0
  138. package/src/write-artifacts.test.ts +0 -1
  139. package/dist/source-digest.d.ts +0 -2
  140. package/dist/source-digest.js +0 -12
  141. package/dist/source-digest.js.map +0 -1
  142. package/src/source-digest.ts +0 -14
@@ -0,0 +1,213 @@
1
+ import { createArtifacts, type ConsumerIndexOptions, type GeneratedArtifacts } from './artifacts.js'
2
+ import { buildCatalogInternal, type BuildCatalogInput } from './build.js'
3
+ import { mapWithConcurrency } from './concurrency.js'
4
+ import { digest } from './digest.js'
5
+ import type { SkippedPlugin } from './plugin-skips.js'
6
+ import type { CatalogPolicy } from './policy.js'
7
+ import type { Catalog, CatalogSource, SourceLockEntry, SourcesLock } from './schema.js'
8
+ import { FixtureSnapshotLoader, NetworkSnapshotLoader, type SnapshotLoader } from './snapshots.js'
9
+ import { createSourcesLock, resolveSource, sourceLockChanged } from './source-resolution.js'
10
+ import type { SourceConfig } from './sources.js'
11
+ import { verifyArtifacts } from './verify-artifacts.js'
12
+
13
+ export interface SyncInput
14
+ extends
15
+ ConsumerIndexOptions,
16
+ Omit<BuildCatalogInput, 'resolvedSources' | 'snapshotLoader' | 'resolveExternalRefs'> {
17
+ readonly sources: readonly SourceConfig[]
18
+ readonly policy: CatalogPolicy
19
+ readonly mode?: 'live' | 'offline'
20
+ readonly fixtureRoot?: string
21
+ readonly sourceId?: string
22
+ readonly dryRun?: boolean
23
+ readonly existingLock?: SourcesLock
24
+ readonly existingCatalog?: Catalog
25
+ readonly fromLock?: SourcesLock
26
+ readonly snapshotLoader?: SnapshotLoader
27
+ /** Rebuild even when source pins are unchanged (used for explicit format migrations). */
28
+ readonly forceRebuild?: boolean
29
+ }
30
+
31
+ export interface SyncResult {
32
+ readonly changed: boolean
33
+ readonly dryRun: boolean
34
+ readonly sourceHeads: readonly { sourceId: string; commitSha: string }[]
35
+ readonly catalog?: Catalog
36
+ readonly lock?: SourcesLock
37
+ readonly artifacts?: GeneratedArtifacts
38
+ readonly changeReport: ChangeReport
39
+ }
40
+
41
+ export interface ChangeReport {
42
+ readonly schemaVersion: 1
43
+ readonly changedSources: readonly string[]
44
+ readonly addedPlugins: readonly string[]
45
+ readonly removedPlugins: readonly string[]
46
+ readonly changedPlugins: readonly string[]
47
+ readonly permissionSensitiveChanges: readonly string[]
48
+ readonly skippedPlugins: readonly SkippedPlugin[]
49
+ readonly contentResolution: 'complete' | 'complete-with-skips' | 'metadata-only'
50
+ }
51
+
52
+ export async function synchronize(input: SyncInput): Promise<SyncResult> {
53
+ const mode = input.mode ?? 'live'
54
+ const configs = input.sources.filter(
55
+ (source) => !input.sourceId || source.sourceId === input.sourceId
56
+ )
57
+ if (configs.length === 0) throw new Error(`SOURCE_NOT_FOUND: ${input.sourceId ?? 'none'}`)
58
+ const lockToUse = input.fromLock ?? input.existingLock
59
+ const resolved = await mapWithConcurrency(configs, 4, (config) =>
60
+ resolveSource(config, mode, input.fixtureRoot, lockToUse, input.fromLock !== undefined)
61
+ )
62
+ const sourceHeads = resolved.map(({ config, commitSha }) => ({
63
+ sourceId: config.sourceId,
64
+ commitSha,
65
+ }))
66
+ const lock = createSourcesLock(resolved)
67
+ const unchanged =
68
+ input.fromLock === undefined &&
69
+ input.forceRebuild !== true &&
70
+ input.existingLock !== undefined &&
71
+ resolved.length === input.existingLock.sources.length &&
72
+ lock.sources.every(
73
+ (source) =>
74
+ !sourceChanged(
75
+ source,
76
+ resolved.find((entry) => entry.config.sourceId === source.sourceId)?.config,
77
+ input.existingLock,
78
+ input.existingCatalog
79
+ )
80
+ )
81
+ if (unchanged) {
82
+ return {
83
+ changed: false,
84
+ dryRun: input.dryRun ?? false,
85
+ sourceHeads,
86
+ changeReport: {
87
+ schemaVersion: 1,
88
+ changedSources: [],
89
+ addedPlugins: [],
90
+ removedPlugins: [],
91
+ changedPlugins: [],
92
+ permissionSensitiveChanges: [],
93
+ skippedPlugins: [],
94
+ contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
95
+ },
96
+ }
97
+ }
98
+
99
+ const { catalog, skippedPlugins } = await buildCatalogInternal({
100
+ ...input,
101
+ resolvedSources: resolved,
102
+ snapshotLoader:
103
+ input.snapshotLoader ??
104
+ (mode === 'offline'
105
+ ? new FixtureSnapshotLoader(input.fixtureRoot ?? 'fixtures', configs)
106
+ : new NetworkSnapshotLoader(input.policy)),
107
+ resolveExternalRefs: mode === 'live',
108
+ })
109
+ // An offline build replays fixtures, so its curation findings are noise.
110
+ const artifacts = createArtifacts(catalog, lock, {
111
+ ...input,
112
+ curationDiagnostics: mode !== 'offline',
113
+ })
114
+ verifyArtifacts(artifacts)
115
+ const report = createChangeReport(
116
+ catalog,
117
+ lock,
118
+ input.existingLock,
119
+ input.existingCatalog,
120
+ skippedPlugins
121
+ )
122
+ if (input.policy.publishRequiresCompleteContent && input.metadataOnly) {
123
+ throw new Error('CATALOG_CONTENT_INCOMPLETE: publication requires complete content')
124
+ }
125
+ return {
126
+ changed: true,
127
+ dryRun: input.dryRun ?? false,
128
+ sourceHeads,
129
+ catalog,
130
+ lock,
131
+ artifacts,
132
+ changeReport: report,
133
+ }
134
+ }
135
+
136
+ /** Whether a source differs from the last published lock and catalog. */
137
+ function sourceChanged(
138
+ source: SourceLockEntry,
139
+ current: Pick<CatalogSource, 'displayName' | 'trustClassification'> | undefined,
140
+ existingLock: SourcesLock | undefined,
141
+ existingCatalog: Catalog | undefined
142
+ ): boolean {
143
+ const previous = existingLock?.sources.find((entry) => entry.sourceId === source.sourceId)
144
+ if (!previous || sourceLockChanged(previous, source)) return true
145
+ if (!existingCatalog) return false
146
+ const prior = existingCatalog.sources.find((entry) => entry.sourceId === source.sourceId)
147
+ return (
148
+ !prior ||
149
+ !current ||
150
+ prior.displayName !== current.displayName ||
151
+ prior.trustClassification !== current.trustClassification
152
+ )
153
+ }
154
+
155
+ function createChangeReport(
156
+ catalog: Catalog,
157
+ lock: SourcesLock,
158
+ existingLock?: SourcesLock,
159
+ existingCatalog?: Catalog,
160
+ skippedPlugins: readonly SkippedPlugin[] = []
161
+ ): ChangeReport {
162
+ const changedSources = lock.sources
163
+ .filter(
164
+ (source) =>
165
+ existingLock === undefined ||
166
+ sourceChanged(
167
+ source,
168
+ catalog.sources.find((entry) => entry.sourceId === source.sourceId),
169
+ existingLock,
170
+ existingCatalog
171
+ )
172
+ )
173
+ .map((source) => source.sourceId)
174
+ const previousPlugins = new Map(
175
+ existingCatalog?.plugins.map((plugin) => [plugin.pluginId, plugin]) ?? []
176
+ )
177
+ const currentPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]))
178
+ const addedPlugins = catalog.plugins
179
+ .filter((plugin) => !previousPlugins.has(plugin.pluginId))
180
+ .map((plugin) => plugin.pluginId)
181
+ const removedPlugins = [...previousPlugins.keys()].filter(
182
+ (pluginId) => !currentPlugins.has(pluginId)
183
+ )
184
+ const changedPlugins = catalog.plugins
185
+ .filter((plugin) => {
186
+ const previous = previousPlugins.get(plugin.pluginId)
187
+ return previous !== undefined && digest(previous) !== digest(plugin)
188
+ })
189
+ .map((plugin) => plugin.pluginId)
190
+ return {
191
+ schemaVersion: 1,
192
+ changedSources: changedSources.toSorted(),
193
+ addedPlugins: addedPlugins.toSorted(),
194
+ removedPlugins: removedPlugins.toSorted(),
195
+ changedPlugins: changedPlugins.toSorted(),
196
+ skippedPlugins,
197
+ permissionSensitiveChanges: catalog.plugins
198
+ .flatMap((plugin) =>
199
+ plugin.securityClassification.permissionSensitiveChanges.map(
200
+ (type) => `${plugin.pluginId}:${type}`
201
+ )
202
+ )
203
+ .toSorted(),
204
+ contentResolution:
205
+ skippedPlugins.length > 0
206
+ ? 'complete-with-skips'
207
+ : catalog.plugins.some(
208
+ (plugin) => plugin.securityClassification.contentResolution === 'metadata-only'
209
+ )
210
+ ? 'metadata-only'
211
+ : 'complete',
212
+ }
213
+ }
@@ -0,0 +1,87 @@
1
+ import { contentId } from './digest.js'
2
+
3
+ export interface CategoryMap {
4
+ readonly aliases: Readonly<Record<string, string>>
5
+ readonly fallback: string
6
+ }
7
+
8
+ export interface ProductAliases {
9
+ readonly aliases: Readonly<Record<string, string>>
10
+ }
11
+
12
+ export function stablePluginId(sourceId: string, upstreamName: string): string {
13
+ return `plugin:${slug(sourceId)}:${slug(upstreamName)}`
14
+ }
15
+
16
+ export function stableReleaseId(
17
+ repositoryUrl: string,
18
+ subdirectory: string,
19
+ commitSha: string,
20
+ contentDigest: string
21
+ ): string {
22
+ return contentId('release', { repositoryUrl, subdirectory, commitSha, contentDigest })
23
+ }
24
+
25
+ export function normalizeCategory(value: string, map: CategoryMap): string {
26
+ const key = value.trim().toLocaleLowerCase()
27
+ return map.aliases[key] ?? (slug(key) || map.fallback)
28
+ }
29
+
30
+ export function productGroupingKey(name: string, aliases: ProductAliases): string {
31
+ const key = name.trim().toLocaleLowerCase()
32
+ return aliases.aliases[key] ?? slug(key)
33
+ }
34
+
35
+ /**
36
+ * Orders plugins so each category's curated leading products come first, in
37
+ * the configured order. Consumers preview the head of each category, so this
38
+ * ordering decides what users see before expanding.
39
+ */
40
+ export function orderLeadingPlugins<
41
+ T extends {
42
+ readonly plugins: readonly {
43
+ categories: readonly string[]
44
+ upstreamPluginName: string
45
+ }[]
46
+ },
47
+ >(catalog: T, leading?: Record<string, readonly string[]>): T {
48
+ if (!leading) return catalog
49
+ const leadRank = new Map<string, number>()
50
+ for (const [category, names] of Object.entries(leading)) {
51
+ names.forEach((name, index) => {
52
+ const key = `${category}::${name}`
53
+ if (!leadRank.has(key)) leadRank.set(key, index)
54
+ })
55
+ }
56
+ const rank = (plugin: T['plugins'][number]): number => {
57
+ let best = Number.POSITIVE_INFINITY
58
+ for (const category of plugin.categories) {
59
+ const value = leadRank.get(`${category}::${plugin.upstreamPluginName}`)
60
+ if (value !== undefined && value < best) best = value
61
+ }
62
+ return best
63
+ }
64
+ return {
65
+ ...catalog,
66
+ plugins: catalog.plugins.toSorted((left, right) => rank(left) - rank(right)),
67
+ }
68
+ }
69
+
70
+ function trimSlugEdges(value: string): string {
71
+ let start = 0
72
+ let end = value.length
73
+ while (start < end && value[start] === '-') start += 1
74
+ while (end > start && value[end - 1] === '-') end -= 1
75
+ return value.slice(start, end)
76
+ }
77
+
78
+ export function slug(value: string): string {
79
+ const result =
80
+ trimSlugEdges(
81
+ value
82
+ .trim()
83
+ .toLocaleLowerCase()
84
+ .replace(/[^a-z0-9]+/g, '-')
85
+ ).slice(0, 128) || 'unknown'
86
+ return result.length < 2 ? `${result}-plugin` : result
87
+ }
@@ -0,0 +1,194 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { canonicalRepositoryUrl } from './sources.js'
4
+ import type { SiteIconFetcher } from './site-icons.js'
5
+
6
+ export function externalGitRef(ref: string | undefined): string {
7
+ if (ref === undefined) return 'HEAD'
8
+ return ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
9
+ }
10
+
11
+ export async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string> {
12
+ const peeled = ref.startsWith('refs/tags/') && !ref.endsWith('^{}') ? `${ref}^{}` : undefined
13
+ const result = spawn(
14
+ 'git',
15
+ ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref, ...(peeled ? [peeled] : [])],
16
+ {
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ }
19
+ )
20
+ const [stdout, stderr, exitCode] = await Promise.all([
21
+ new Promise<string>((resolveOutput, reject) => {
22
+ const chunks: Buffer[] = []
23
+ result.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
24
+ result.stdout?.on('error', reject)
25
+ result.stdout?.on('end', () => resolveOutput(Buffer.concat(chunks).toString('utf8')))
26
+ }),
27
+ new Promise<string>((resolveError) => {
28
+ const chunks: Buffer[] = []
29
+ result.stderr?.on('data', (chunk: Buffer) => chunks.push(chunk))
30
+ result.stderr?.on('error', () => resolveError(''))
31
+ result.stderr?.on('end', () => resolveError(Buffer.concat(chunks).toString('utf8')))
32
+ }),
33
+ new Promise<number | null>((resolveExit, reject) => {
34
+ result.on('error', reject)
35
+ result.on('close', (code) => resolveExit(code ?? 1))
36
+ }),
37
+ ])
38
+ if (exitCode !== 0)
39
+ throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`)
40
+ const rows = stdout
41
+ .trim()
42
+ .split('\n')
43
+ .map((line) => line.split(/\s+/))
44
+ const sha =
45
+ (peeled && rows.find((row) => row[1] === peeled)?.[0]) ??
46
+ rows.find((row) => row[1] === ref)?.[0]
47
+ if (!sha || !/^[a-f0-9]{40}$/.test(sha))
48
+ throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`)
49
+ return sha
50
+ }
51
+
52
+ export async function fetchUpstreamBytes(url: string, signal?: AbortSignal): Promise<Uint8Array> {
53
+ const response = await fetchUpstream(url, {
54
+ headers: upstreamRequestHeaders(url),
55
+ ...(signal ? { signal } : {}),
56
+ })
57
+ if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}: ${url}`)
58
+ return new Uint8Array(await response.arrayBuffer())
59
+ }
60
+
61
+ export function rawContentUrl(repositoryUrl: string, commitSha: string, path: string): string {
62
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
63
+ return `https://raw.githubusercontent.com${parsed.pathname}/${commitSha}/${path.split('/').map(encodeURIComponent).join('/')}`
64
+ }
65
+
66
+ export async function fetchText(url: string): Promise<string> {
67
+ const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
68
+ if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`)
69
+ return response.text()
70
+ }
71
+
72
+ const UPSTREAM_USER_AGENT = 'agent-hq-plugin-marketplace'
73
+
74
+ /**
75
+ * Hosts that may receive the repository credential.
76
+ *
77
+ * Everything else is fetched anonymously. The token this build runs with
78
+ * authorises repository writes for the duration of the job, so a vendor
79
+ * homepage, its icon CDN or any other third party must never be handed it.
80
+ */
81
+ /** The hosts the network snapshot loader reads repositories from. */
82
+ export const GITHUB_REPOSITORY_HOSTS: readonly string[] = ['github.com', 'www.github.com']
83
+
84
+ const GITHUB_CREDENTIAL_HOSTS = /(^|\.)(github\.com|githubusercontent\.com)$/
85
+
86
+ /**
87
+ * Request headers for one upstream URL.
88
+ *
89
+ * The credential and GitHub's media type are attached only for GitHub hosts.
90
+ * Sending them elsewhere is not merely a needless disclosure: `appwrite.io`
91
+ * answers a page carrying an `Authorization` header with HTTP 500, and a CDN
92
+ * asked for an image with `accept: application/vnd.github+json` can answer with
93
+ * bytes that are not the mark. A build that did this dropped marks it had
94
+ * already curated, and the curation verifier — which used its own anonymous
95
+ * headers — could not see it.
96
+ */
97
+ export function upstreamRequestHeaders(url: string): Record<string, string> {
98
+ const anonymous = { 'user-agent': UPSTREAM_USER_AGENT }
99
+ let host: string
100
+ try {
101
+ host = new URL(url).hostname.toLowerCase()
102
+ } catch {
103
+ return anonymous
104
+ }
105
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
106
+ if (!token || !GITHUB_CREDENTIAL_HOSTS.test(host)) return anonymous
107
+ return {
108
+ accept: 'application/vnd.github+json',
109
+ authorization: `Bearer ${token}`,
110
+ 'user-agent': UPSTREAM_USER_AGENT,
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Fetches vendor-site documents exactly as a live build does.
116
+ *
117
+ * Shared with the curation verifier so both ask a vendor the same question: a
118
+ * verifier with its own headers reports sites healthy that the build then fails
119
+ * to resolve.
120
+ *
121
+ * `deadline` optionally bounds one product's whole resolution. The per-request
122
+ * ceiling is always created fresh inside each fetch, so a caller that passes a
123
+ * deadline — or shares one — can never hand later requests an already-expired
124
+ * signal.
125
+ */
126
+ export function createSiteIconFetcher(deadline?: AbortSignal): SiteIconFetcher {
127
+ const bounded = (): AbortSignal => {
128
+ const ceiling = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
129
+ return deadline ? AbortSignal.any([deadline, ceiling]) : ceiling
130
+ }
131
+ return {
132
+ fetchText: async (url) => new TextDecoder().decode(await fetchUpstreamBytes(url, bounded())),
133
+ fetchBytes: (url) => fetchUpstreamBytes(url, bounded()),
134
+ }
135
+ }
136
+
137
+ export const SITE_FETCH_TIMEOUT_MS = 15_000
138
+
139
+ const UPSTREAM_FETCH_MAX_ATTEMPTS = 3
140
+ const UPSTREAM_FETCH_BASE_DELAY_MS = 1000
141
+ const UPSTREAM_FETCH_MAX_DELAY_MS = 30000
142
+
143
+ function upstreamRetryDelayMs(attempt: number, response?: Response): number {
144
+ const retryAfter = response?.headers.get('retry-after')
145
+ if (retryAfter !== null && retryAfter !== undefined) {
146
+ const seconds = Number(retryAfter)
147
+ if (Number.isFinite(seconds) && seconds >= 0)
148
+ return Math.min(seconds * 1000, UPSTREAM_FETCH_MAX_DELAY_MS)
149
+ const date = Date.parse(retryAfter)
150
+ if (!Number.isNaN(date))
151
+ return Math.min(Math.max(date - Date.now(), 0), UPSTREAM_FETCH_MAX_DELAY_MS)
152
+ }
153
+ return Math.min(UPSTREAM_FETCH_BASE_DELAY_MS * 2 ** attempt, UPSTREAM_FETCH_MAX_DELAY_MS)
154
+ }
155
+
156
+ function isRateLimitedUpstream(response: Response): boolean {
157
+ return (
158
+ response.headers.get('x-ratelimit-remaining') === '0' ||
159
+ response.headers.get('retry-after') !== null
160
+ )
161
+ }
162
+
163
+ function isRetryableUpstreamStatus(status: number, response?: Response): boolean {
164
+ if (status === 429) return true
165
+ if (status >= 500) return true
166
+ if (status === 403 && response !== undefined && isRateLimitedUpstream(response)) return true
167
+ return false
168
+ }
169
+
170
+ export async function fetchUpstream(url: string, init: RequestInit): Promise<Response> {
171
+ let attempt = 0
172
+ for (;;) {
173
+ let response: Response
174
+ try {
175
+ response = await fetch(url, init)
176
+ } catch (error) {
177
+ if (init.signal?.aborted || attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
178
+ await sleep(upstreamRetryDelayMs(attempt), undefined, { signal: init.signal ?? undefined })
179
+ attempt += 1
180
+ continue
181
+ }
182
+ if (
183
+ response.ok ||
184
+ !isRetryableUpstreamStatus(response.status, response) ||
185
+ attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1
186
+ )
187
+ return response
188
+ await response.body?.cancel().catch(() => {})
189
+ await sleep(upstreamRetryDelayMs(attempt, response), undefined, {
190
+ signal: init.signal ?? undefined,
191
+ })
192
+ attempt += 1
193
+ }
194
+ }