@chatcode/cco-market 1.45.1

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
@@ -0,0 +1,84 @@
1
+ /** Versioned, read-only diagnostics shared by the host route and client. */
2
+ export const DIAGNOSTIC_SCHEMA = 'dsh-market/diagnostics/v1' as const
3
+
4
+ /** Conservative first set of known, identity-sensitive host contracts. */
5
+ export const KNOWN_SHARED_HOST_PACKAGES = [
6
+ '@deepseek-ai/cordis',
7
+ '@deepseek-ai/dsh-attachment',
8
+ '@deepseek-ai/dsh-llm',
9
+ '@deepseek-ai/dsh-system-prompt',
10
+ '@deepseek-ai/dsh-tools',
11
+ ] as const
12
+
13
+ const knownSharedHostPackages = new Set<string>(KNOWN_SHARED_HOST_PACKAGES)
14
+
15
+ export interface PackageManifestFact {
16
+ packageName: string
17
+ manifest: unknown
18
+ }
19
+
20
+ export interface SharedHostPackageDependencyFinding {
21
+ code: 'shared-host-package-dependency'
22
+ severity: 'warning'
23
+ subject: {
24
+ kind: 'package'
25
+ name: string
26
+ }
27
+ evidence: {
28
+ basis: 'manifest-declaration'
29
+ dependency: string
30
+ declaredRange: string
31
+ declaredIn: 'dependencies'
32
+ }
33
+ }
34
+
35
+ export type DiagnosticFinding = SharedHostPackageDependencyFinding
36
+
37
+ export interface DiagnosticReportV1 {
38
+ schema: typeof DIAGNOSTIC_SCHEMA
39
+ findings: DiagnosticFinding[]
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
44
+ }
45
+
46
+ /** Report exact manifest declarations; the resolved dependency tree is not inspected. */
47
+ function inspectKnownHostDependencyDeclarations(
48
+ packageName: string,
49
+ manifest: unknown,
50
+ ): SharedHostPackageDependencyFinding[] {
51
+ if (!isRecord(manifest) || !isRecord(manifest.dependencies)) return []
52
+
53
+ const findings: SharedHostPackageDependencyFinding[] = []
54
+ for (const dependency of Object.keys(manifest.dependencies).sort()) {
55
+ const declaredRange = manifest.dependencies[dependency]
56
+ if (!knownSharedHostPackages.has(dependency) || typeof declaredRange !== 'string') continue
57
+ findings.push({
58
+ code: 'shared-host-package-dependency',
59
+ severity: 'warning',
60
+ subject: { kind: 'package', name: packageName },
61
+ evidence: {
62
+ basis: 'manifest-declaration',
63
+ dependency,
64
+ declaredRange,
65
+ declaredIn: 'dependencies',
66
+ },
67
+ })
68
+ }
69
+ return findings
70
+ }
71
+
72
+ /** Build a stable diagnostic envelope from installed package manifests. */
73
+ export function diagnosePackageManifests(packages: readonly PackageManifestFact[]): DiagnosticReportV1 {
74
+ const sortedPackages = [...packages].sort((a, b) =>
75
+ a.packageName < b.packageName ? -1 : a.packageName > b.packageName ? 1 : 0,
76
+ )
77
+ return {
78
+ schema: DIAGNOSTIC_SCHEMA,
79
+ findings: sortedPackages.flatMap(({ packageName, manifest }) => {
80
+ if (!isRecord(manifest) || manifest.dsh === undefined) return []
81
+ return inspectKnownHostDependencyDeclarations(packageName, manifest)
82
+ }),
83
+ }
84
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Discovery-time DSH compatibility metadata.
3
+ *
4
+ * The public catalog does not carry npm manifests. Fetching every manifest
5
+ * while the market opens would turn one catalog request into more than a
6
+ * thousand registry requests, so this module supplies a bounded, on-demand
7
+ * index. Successful public manifest facts are cached beside the market's
8
+ * profile state; conclusions are never cached because they depend on the DSH
9
+ * version of the process serving the page.
10
+ */
11
+
12
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
13
+ import { dirname } from 'node:path'
14
+ import { satisfiesRange } from './check.ts'
15
+ import { classifyPeer } from './compatibility.ts'
16
+ import { marketFetch } from './net.ts'
17
+
18
+ export type HostCompatibilityStatus = 'compatible' | 'incompatible' | 'unknown'
19
+ export type HostCompatibilityBasis = 'manifest' | 'undeclared' | 'unavailable'
20
+
21
+ export interface HostRequirementDeclaration {
22
+ kind: 'engine' | 'peer'
23
+ /** Present only for a peer-derived declaration. */
24
+ package?: string
25
+ range: string
26
+ }
27
+
28
+ export interface HostCompatibility {
29
+ status: HostCompatibilityStatus
30
+ basis: HostCompatibilityBasis
31
+ /** Human-readable intersection of every raw declaration. */
32
+ requirement: string | null
33
+ declarations: HostRequirementDeclaration[]
34
+ }
35
+
36
+ export interface NpmManifestFacts {
37
+ version: string | null
38
+ enginesDsh: string | null
39
+ peerDependencies: Record<string, string>
40
+ }
41
+
42
+ interface CacheEntry {
43
+ checkedAt: number
44
+ facts: NpmManifestFacts
45
+ }
46
+
47
+ interface CacheFile {
48
+ schema: 'dsh-market/discovery-compatibility-cache/v1'
49
+ entries: Record<string, CacheEntry>
50
+ }
51
+
52
+ type FetchLike = (
53
+ url: string,
54
+ init?: { signal?: AbortSignal; headers?: Record<string, string> },
55
+ ) => Promise<Response>
56
+
57
+ const CACHE_SCHEMA = 'dsh-market/discovery-compatibility-cache/v1' as const
58
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
59
+ const FAILURE_COOLDOWN_MS = 5 * 60 * 1000
60
+ const OUTAGE_COOLDOWN_MS = 30 * 1000
61
+ const FETCH_TIMEOUT_MS = 8_000
62
+ const DEFAULT_CONCURRENCY = 8
63
+ const MAX_CACHE_ENTRIES = 5_000
64
+ const MAX_RANGE_LENGTH = 256
65
+
66
+ function range(value: unknown): string | null {
67
+ if (typeof value !== 'string') return null
68
+ const trimmed = value.trim()
69
+ return trimmed !== '' && trimmed.length <= MAX_RANGE_LENGTH ? trimmed : null
70
+ }
71
+
72
+ function record(value: unknown): Record<string, unknown> | null {
73
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
74
+ ? value as Record<string, unknown>
75
+ : null
76
+ }
77
+
78
+ /** Keep only the small public subset of an npm manifest needed by discovery. */
79
+ export function manifestFacts(value: unknown): NpmManifestFacts {
80
+ const manifest = record(value) ?? {}
81
+ const engines = record(manifest.engines)
82
+ const peers = record(manifest.peerDependencies)
83
+ const peerDependencies: Record<string, string> = {}
84
+ for (const [name, raw] of Object.entries(peers ?? {})) {
85
+ if (!name.startsWith('@deepseek-ai/')) continue
86
+ const declared = range(raw)
87
+ if (declared !== null) peerDependencies[name] = declared
88
+ }
89
+ return {
90
+ version: range(manifest.version),
91
+ enginesDsh: range(engines?.dsh),
92
+ peerDependencies,
93
+ }
94
+ }
95
+
96
+ function validFacts(value: unknown): value is NpmManifestFacts {
97
+ const facts = record(value)
98
+ const peers = record(facts?.peerDependencies)
99
+ return facts !== null
100
+ && (facts.version === null || range(facts.version) === facts.version)
101
+ && (facts.enginesDsh === null || range(facts.enginesDsh) === facts.enginesDsh)
102
+ && peers !== null
103
+ && Object.entries(peers).every(([name, item]) =>
104
+ name.startsWith('@deepseek-ai/') && range(item) === item)
105
+ }
106
+
107
+ function displayRequirement(declarations: HostRequirementDeclaration[]): string | null {
108
+ const unique = [...new Set(declarations.map(item => item.range))]
109
+ return unique.length === 0 ? null : unique.join(' ∩ ')
110
+ }
111
+
112
+ function rangeResult(hostVersion: string, declared: string): boolean | null {
113
+ return satisfiesRange(hostVersion, declared, { includePrerelease: true })
114
+ }
115
+
116
+ /**
117
+ * Host peers imply the DSH release-line floor, but old ecosystem packages
118
+ * often carry caret ranges such as `^0.0.1` whose computed 0.x upper bound
119
+ * was never intended as a host ceiling. Reuse install preflight's directional
120
+ * policy: below-min and explicit upper/exact violations are definite; a
121
+ * newer host above an implicit caret/tilde ceiling remains compatible.
122
+ */
123
+ function declarationResult(
124
+ declaration: HostRequirementDeclaration,
125
+ hostVersion: string,
126
+ ): boolean | null {
127
+ const satisfied = rangeResult(hostVersion, declaration.range)
128
+ if (declaration.kind === 'engine' || satisfied !== false) return satisfied
129
+ const verdict = classifyPeer(
130
+ 'discovery',
131
+ declaration.package ?? '@deepseek-ai/dsh',
132
+ declaration.range,
133
+ hostVersion,
134
+ false,
135
+ )
136
+ if (verdict.kind === 'risk') return false
137
+ if (verdict.kind === 'warning' && verdict.warning.reason === 'aboveMax') return true
138
+ return null
139
+ }
140
+
141
+ /**
142
+ * Derive the current host verdict from raw manifest facts.
143
+ *
144
+ * Every valid declaration is conjunctive: an explicit `engines.dsh` and all
145
+ * host peers must agree. A malformed declaration keeps a passing result
146
+ * unknown, but cannot erase a definite mismatch from another declaration.
147
+ */
148
+ export function deriveHostCompatibility(
149
+ facts: NpmManifestFacts | null,
150
+ hostVersion: string | null,
151
+ hostPackages: ReadonlySet<string>,
152
+ ): HostCompatibility {
153
+ if (facts === null) {
154
+ return { status: 'unknown', basis: 'unavailable', requirement: null, declarations: [] }
155
+ }
156
+ const declarations: HostRequirementDeclaration[] = []
157
+ if (facts.enginesDsh !== null) {
158
+ declarations.push({ kind: 'engine', range: facts.enginesDsh })
159
+ }
160
+ for (const [name, declared] of Object.entries(facts.peerDependencies)) {
161
+ // Cordis (4.x) and schemastery (3.x) are host packages, but are not on
162
+ // DSH's lockstep 0.x release line and therefore say nothing about the
163
+ // DSH version. `hostPackages` is the local install inventory plus its
164
+ // curated fallback, so future lockstep DSH packages are picked up too.
165
+ if (!hostPackages.has(name) || !/^@deepseek-ai\/dsh(?:-|$)/.test(name)) continue
166
+ declarations.push({ kind: 'peer', package: name, range: declared })
167
+ }
168
+ const requirement = displayRequirement(declarations)
169
+ if (declarations.length === 0) {
170
+ return { status: 'unknown', basis: 'undeclared', requirement: null, declarations: [] }
171
+ }
172
+ if (hostVersion === null) {
173
+ return { status: 'unknown', basis: 'manifest', requirement, declarations }
174
+ }
175
+ const outcomes = declarations.map(item => declarationResult(item, hostVersion))
176
+ const status: HostCompatibilityStatus = outcomes.some(item => item === false)
177
+ ? 'incompatible'
178
+ : outcomes.every(item => item === true)
179
+ ? 'compatible'
180
+ : 'unknown'
181
+ return { status, basis: 'manifest', requirement, declarations }
182
+ }
183
+
184
+ /**
185
+ * Durable, bounded lookup of npm `latest` manifests.
186
+ *
187
+ * Failed requests are intentionally memory-only and short-lived: a mirror
188
+ * outage must not become a day-long false "undeclared" result on disk.
189
+ */
190
+ export class DiscoveryManifestIndex {
191
+ private readonly entries = new Map<string, CacheEntry>()
192
+ private readonly failures = new Map<string, number>()
193
+ private readonly inflight = new Map<string, Promise<NpmManifestFacts | null>>()
194
+ private readonly fetcher: FetchLike
195
+ private readonly now: () => number
196
+ private readonly ttlMs: number
197
+ private readonly concurrency: number
198
+ private loaded = false
199
+ private consecutiveFailures = 0
200
+ private unavailableUntil = 0
201
+ private dirty = false
202
+ private activeFetches = 0
203
+ private readonly fetchWaiters: Array<() => void> = []
204
+
205
+ constructor(
206
+ private readonly cacheFile: string,
207
+ options: { fetcher?: FetchLike; now?: () => number; ttlMs?: number; concurrency?: number } = {},
208
+ ) {
209
+ this.fetcher = options.fetcher ?? marketFetch
210
+ this.now = options.now ?? Date.now
211
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
212
+ this.concurrency = Math.max(1, Math.floor(options.concurrency ?? DEFAULT_CONCURRENCY))
213
+ }
214
+
215
+ private load(): void {
216
+ if (this.loaded) return
217
+ this.loaded = true
218
+ try {
219
+ const parsed = JSON.parse(readFileSync(this.cacheFile, 'utf8')) as Partial<CacheFile>
220
+ if (parsed.schema !== CACHE_SCHEMA || record(parsed.entries) === null) return
221
+ for (const [name, raw] of Object.entries(parsed.entries as Record<string, unknown>)) {
222
+ const entry = record(raw)
223
+ if (entry === null || typeof entry.checkedAt !== 'number' || !validFacts(entry.facts)) continue
224
+ this.entries.set(name, { checkedAt: entry.checkedAt, facts: entry.facts })
225
+ }
226
+ } catch { /* first run or a truncated cache — fetch fresh */ }
227
+ }
228
+
229
+ private persist(): void {
230
+ try {
231
+ mkdirSync(dirname(this.cacheFile), { recursive: true, mode: 0o700 })
232
+ const newest = [...this.entries.entries()]
233
+ .sort(([, a], [, b]) => b.checkedAt - a.checkedAt)
234
+ .slice(0, MAX_CACHE_ENTRIES)
235
+ this.entries.clear()
236
+ for (const [name, entry] of newest) this.entries.set(name, entry)
237
+ const data: CacheFile = { schema: CACHE_SCHEMA, entries: Object.fromEntries(newest) }
238
+ writeFileSync(this.cacheFile, JSON.stringify(data), { mode: 0o600 })
239
+ } catch { /* cache failure degrades to in-memory lookups */ }
240
+ }
241
+
242
+ /** One semaphore for the whole index, including overlapping HTTP batches. */
243
+ private async withFetchPermit<T>(operation: () => Promise<T>): Promise<T> {
244
+ if (this.activeFetches >= this.concurrency) {
245
+ await new Promise<void>(resolve => this.fetchWaiters.push(resolve))
246
+ }
247
+ this.activeFetches += 1
248
+ try {
249
+ return await operation()
250
+ } finally {
251
+ this.activeFetches -= 1
252
+ this.fetchWaiters.shift()?.()
253
+ }
254
+ }
255
+
256
+ private async fetchOne(name: string, registry: string): Promise<NpmManifestFacts | null> {
257
+ this.load()
258
+ const now = this.now()
259
+ const cached = this.entries.get(name)
260
+ if (cached !== undefined && now - cached.checkedAt < this.ttlMs) return cached.facts
261
+ if ((this.failures.get(name) ?? 0) > now || this.unavailableUntil > now) return null
262
+ const pending = this.inflight.get(name)
263
+ if (pending !== undefined) return await pending
264
+
265
+ const request = (async (): Promise<NpmManifestFacts | null> => {
266
+ try {
267
+ const response = await this.withFetchPermit(async () => await this.fetcher(
268
+ `${registry}/${encodeURIComponent(name)}/latest`,
269
+ {
270
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
271
+ headers: { accept: 'application/json', 'user-agent': 'dsh-market' },
272
+ },
273
+ ))
274
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`)
275
+ const facts = manifestFacts(await response.json())
276
+ this.entries.set(name, { checkedAt: this.now(), facts })
277
+ this.dirty = true
278
+ this.failures.delete(name)
279
+ this.consecutiveFailures = 0
280
+ return facts
281
+ } catch {
282
+ const failedAt = this.now()
283
+ this.failures.set(name, failedAt + FAILURE_COOLDOWN_MS)
284
+ this.consecutiveFailures += 1
285
+ if (this.consecutiveFailures >= this.concurrency) {
286
+ this.unavailableUntil = failedAt + OUTAGE_COOLDOWN_MS
287
+ }
288
+ return null
289
+ } finally {
290
+ this.inflight.delete(name)
291
+ }
292
+ })()
293
+ this.inflight.set(name, request)
294
+ return await request
295
+ }
296
+
297
+ /** Look up a bounded batch while never exceeding the configured fan-out. */
298
+ async lookup(names: readonly string[], registry: string): Promise<Record<string, NpmManifestFacts | null>> {
299
+ const unique = [...new Set(names)]
300
+ const result: Record<string, NpmManifestFacts | null> = {}
301
+ let next = 0
302
+ const worker = async (): Promise<void> => {
303
+ while (next < unique.length) {
304
+ const name = unique[next++]!
305
+ result[name] = await this.fetchOne(name, registry)
306
+ }
307
+ }
308
+ await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker))
309
+ if (this.dirty) {
310
+ this.dirty = false
311
+ this.persist()
312
+ }
313
+ return result
314
+ }
315
+ }