@adea-ai/plugins 4.0.1 → 4.0.7

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
package/src/index.ts CHANGED
@@ -1,2591 +1,58 @@
1
- import { spawn } from 'node:child_process'
2
- import { createHash } from 'node:crypto'
3
- import { promises as fs } from 'node:fs'
4
- import { basename, join, relative, resolve } from 'node:path'
5
- import { Readable } from 'node:stream'
6
- import { setTimeout as sleep } from 'node:timers/promises'
7
- import { createGunzip } from 'node:zlib'
8
- import { extract } from 'tar-stream'
9
- import { publishCatalog, type PublishOptions } from './catalog-store.js'
10
- import {
11
- CatalogSchema,
12
- type Catalog,
13
- type CatalogSource,
14
- type Capability,
15
- type Harness,
16
- type HarnessCompatibility,
17
- type LicenseMetadata,
18
- type Plugin,
19
- type PluginRelease,
20
- type SecurityClassification,
21
- type SourceLockEntry,
22
- type SourcesLock,
23
- type PluginProvenance,
24
- HarnessSchema,
25
- SourcesLockSchema,
26
- PluginSchema,
27
- } from './schema.js'
28
- import {
29
- canonicalRepositoryUrl,
30
- parseJsonDocument,
31
- parseMarketplaceManifest,
32
- safeRelativePath,
33
- type MarketplacePluginEntry,
34
- type ParsedMarketplace,
35
- type PluginSourceSpec,
36
- type SourceConfig,
37
- } from './sources.js'
38
- import {
39
- buildConsumerIndex,
40
- CATALOG_INDEX_ARTIFACT,
41
- iconCoverage,
42
- type ConsumerCategory,
43
- type ConsumerIndex,
44
- type ConsumerProduct,
45
- type LeadingDiagnostic,
46
- type ProductPreference,
47
- } from './consumer-index.js'
48
- import {
49
- iconAssetName,
50
- iconOverrideFor,
51
- inspectIconBytes,
52
- selectIconPath,
53
- type ProductIconOverrides,
54
- } from './icons.js'
55
- import { stageIconBytes } from './icon-mirror.js'
56
- import {
57
- isVendorHomepage,
58
- resolveSiteIcon,
59
- type SiteIcon,
60
- type SiteIconFetcher,
61
- } from './site-icons.js'
62
- import { normalizeDisplayName } from './display-name.js'
63
- import { immutableAssetUrl } from './publication.js'
64
- import { sourceDigest as bytesDigest } from './source-digest.js'
65
-
66
- export { bytesDigest }
67
1
  export {
68
2
  catalogFingerprint,
69
3
  publishCatalog,
70
4
  withCatalogRead,
71
- writeFileAtomically,
72
5
  type CatalogCoordinationOptions,
73
6
  type PublicationStep,
74
7
  type PublishOptions,
75
8
  } from './catalog-store.js'
76
-
77
- /**
78
- * Marks the catalog format this compiler emits.
79
- *
80
- * Bumping or first introducing this value makes a synchronization replay its
81
- * verified pins and rebuild, which is how a format change reaches the published
82
- * catalog when no upstream source has moved. It is deliberately separate from
83
- * the agent-package normalizer version, which describes package normalization
84
- * and would misreport a change here.
85
- */
86
- export const CATALOG_CONTRACT_VERSION = 'adea-catalog/3'
87
-
88
- export interface CatalogPolicy {
89
- readonly allowedRepositoryProtocols: readonly string[]
90
- readonly allowedRepositoryHosts: readonly string[]
91
- readonly maxFilesPerPlugin: number
92
- readonly maxBytesPerPlugin: number
93
- readonly maxFileBytes: number
94
- readonly maxMarketplacePlugins: number
95
- readonly denyExecutableLifecycleScripts: boolean
96
- readonly publishRequiresCompleteContent: boolean
97
- readonly sensitiveCapabilityTypes: readonly string[]
98
- }
99
-
100
- export interface CategoryMap {
101
- readonly aliases: Readonly<Record<string, string>>
102
- readonly fallback: string
103
- }
104
-
105
- export interface ProductAliases {
106
- readonly aliases: Readonly<Record<string, string>>
107
- }
108
-
109
- export interface Snapshot {
110
- readonly files: ReadonlyMap<string, Uint8Array>
111
- readonly symlinks: readonly string[]
112
- }
113
-
114
- /** Trusted compiler extension; never loaded from upstream plugin content. */
115
- export type ReleaseTransform = (
116
- release: PluginRelease,
117
- snapshot: Snapshot,
118
- pluginName: string
119
- ) => PluginRelease
120
-
121
- export interface SnapshotLoader {
122
- load(repositoryUrl: string, commitSha: string, pluginSubdirectory: string): Promise<Snapshot>
123
- }
124
-
125
- export interface ResolvedSource {
126
- readonly config: SourceConfig
127
- readonly commitSha: string
128
- readonly manifestText: string
129
- readonly parsed: ParsedMarketplace
130
- readonly pluginPins: readonly PluginSourcePin[]
131
- readonly retrievedAt: string
132
- readonly manifestDigest: string
133
- }
134
-
135
- export interface PluginSourcePin {
136
- readonly pluginName: string
137
- readonly repositoryUrl: string
138
- readonly pluginSubdirectory: string
139
- readonly resolvedCommitSha: string
140
- }
141
-
142
- export interface SyncInput extends ConsumerIndexOptions {
143
- readonly sources: readonly SourceConfig[]
144
- readonly categoryMap: CategoryMap
145
- readonly productAliases: ProductAliases
146
- readonly productCategories?: Record<string, string>
147
- readonly productIconOverrides?: ProductIconOverrides
148
- /** Where this catalog is published, so records carry absolute asset URLs. */
149
- readonly publicationRepositoryUrl?: string
150
- /** Directory that receives mirrored brand marks as the catalog is built. */
151
- readonly iconAssetsDirectory?: string
152
- readonly policy: CatalogPolicy
153
- readonly mode?: 'live' | 'offline'
154
- readonly fixtureRoot?: string
155
- readonly sourceId?: string
156
- readonly metadataOnly?: boolean
157
- readonly dryRun?: boolean
158
- readonly existingLock?: SourcesLock
159
- readonly existingCatalog?: Catalog
160
- readonly fromLock?: SourcesLock
161
- readonly snapshotLoader?: SnapshotLoader
162
- readonly resolveExternalRefs?: boolean
163
- /** Rebuild even when source pins are unchanged (used for explicit format migrations). */
164
- readonly forceRebuild?: boolean
165
- readonly transformRelease?: ReleaseTransform
166
- }
167
-
168
- export interface SyncResult {
169
- readonly changed: boolean
170
- readonly dryRun: boolean
171
- readonly sourceHeads: readonly { sourceId: string; commitSha: string }[]
172
- readonly catalog?: Catalog
173
- readonly lock?: SourcesLock
174
- readonly artifacts?: GeneratedArtifacts
175
- readonly changeReport: ChangeReport
176
- }
177
-
178
- export interface ChangeReport {
179
- readonly schemaVersion: 1
180
- readonly changedSources: readonly string[]
181
- readonly addedPlugins: readonly string[]
182
- readonly removedPlugins: readonly string[]
183
- readonly changedPlugins: readonly string[]
184
- readonly permissionSensitiveChanges: readonly string[]
185
- readonly skippedPlugins: readonly SkippedPlugin[]
186
- readonly contentResolution: 'complete' | 'complete-with-skips' | 'metadata-only'
187
- }
188
-
189
- export type PluginSkipReasonCode =
190
- | 'GIT_SUBMODULE_UNSUPPORTED'
191
- | 'PLUGIN_FILE_TOO_LARGE'
192
- | 'PLUGIN_FILE_UNAVAILABLE'
193
- | 'PLUGIN_MANIFEST_INVALID'
194
- | 'PLUGIN_PATH_NOT_FOUND'
195
- | 'PLUGIN_SCHEMA_INVALID'
196
- | 'PLUGIN_SIZE_POLICY'
197
- | 'PLUGIN_SOURCE_UNAVAILABLE'
198
- | 'PLUGIN_TOO_LARGE'
199
- | 'PLUGIN_TOO_MANY_FILES'
200
- | 'SNAPSHOT_PATH_INVALID'
201
- | 'SYMLINK_ESCAPE'
202
-
203
- export interface SkippedPlugin {
204
- readonly sourceId: string
205
- readonly pluginId: string
206
- readonly pluginName: string
207
- readonly reasonCode: PluginSkipReasonCode
208
- readonly securityReason: string
209
- readonly incompleteContent: true
210
- readonly paths: readonly string[]
211
- }
212
-
213
- /**
214
- * Published artifacts by file name. The core six are always present; the
215
- * consumer shards (`catalog-index.v1.json`, `shelf-<category>.v1.json` and
216
- * `category-<category>.v1.json`) follow the configured categories.
217
- */
218
- export interface GeneratedArtifacts {
219
- readonly [name: string]: string
220
- readonly 'catalog.v1.json': string
221
- readonly 'catalog-summary.v1.json': string
222
- readonly 'sources.lock.json': string
223
- readonly 'compatibility.v1.json': string
224
- readonly 'categories.v1.json': string
225
- readonly 'integrity.json': string
226
- }
227
-
228
- /** Result of checking a published consumer index against its catalog. */
229
- export interface ConsumerIndexVerification {
230
- readonly hasConsumerIndex: boolean
231
- readonly diagnostics: readonly LeadingDiagnostic[]
232
- readonly counts?: ConsumerIndex['counts']
233
- readonly topCount?: number
234
- readonly products?: Readonly<Record<string, ConsumerProduct>>
235
- readonly categories?: readonly ConsumerCategory[]
236
- /** Shards the published integrity file set must carry for this index. */
237
- readonly shards: readonly ShardArtifact[]
238
- }
239
-
240
- /** One consumer shard artifact and what it must contain. */
241
- export interface ShardArtifact {
242
- readonly name: string
243
- readonly kind: 'index' | 'shelf' | 'list'
244
- readonly category?: string
245
- }
246
-
247
- /** Consumer index inputs: curated shelves and canonical-variant placement. */
248
- export interface ConsumerIndexOptions {
249
- readonly leading?: Readonly<Record<string, readonly string[]>>
250
- readonly topCount?: number
251
- readonly productPreference?: ProductPreference
252
- /**
253
- * Where this catalog is published. When known, product records carry an
254
- * absolute, immutable URL for their brand mark so no client composes one.
255
- */
256
- readonly publicationRepositoryUrl?: string
257
- /**
258
- * Base URL of the committed asset directory that browsers fetch directly.
259
- *
260
- * GitHub release assets answer a cross-origin `fetch` with no CORS header and
261
- * redirect through a URL that cannot be cached, so a browser neither reads the
262
- * index nor reuses an icon. The same files served from the repository are
263
- * CORS-enabled, direct and cacheable.
264
- */
265
- readonly publicationAssetsBaseUrl?: string
266
- /**
267
- * Base URL of the content-addressed snapshot directory, one level below the
268
- * asset branch. Catalog artifacts are addressed by catalog identity, so they
269
- * live in `catalogs/<catalogId>/` while digest-addressed brand marks sit at
270
- * the branch root.
271
- */
272
- readonly publicationCatalogsBaseUrl?: string
273
- /**
274
- * Whether curated-name drift is published in the consumer index. An offline
275
- * build replays fixtures rather than the upstream catalogs, so its curation
276
- * findings are noise and are omitted by default.
277
- */
278
- readonly curationDiagnostics?: boolean
279
- }
280
-
281
- const DEFAULT_POLICY: CatalogPolicy = {
282
- allowedRepositoryProtocols: ['https:'],
283
- allowedRepositoryHosts: ['github.com', 'www.github.com'],
284
- maxFilesPerPlugin: 4096,
285
- maxBytesPerPlugin: 50 * 1024 * 1024,
286
- maxFileBytes: 5 * 1024 * 1024,
287
- maxMarketplacePlugins: 4096,
288
- denyExecutableLifecycleScripts: true,
289
- publishRequiresCompleteContent: false,
290
- sensitiveCapabilityTypes: [
291
- 'mcp-server',
292
- 'connector',
293
- 'hook',
294
- 'browser',
295
- 'scheduled-task',
296
- 'executable',
297
- ],
298
- }
299
-
300
- export function canonicalJson(value: unknown): string {
301
- if (value === null || typeof value !== 'object') return JSON.stringify(value)
302
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
303
- const object = value as Record<string, unknown>
304
- return `{${Object.keys(object)
305
- .toSorted()
306
- .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
307
- .join(',')}}`
308
- }
309
-
310
- export function digest(value: unknown): string {
311
- return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
312
- }
313
-
314
- export function stablePluginId(sourceId: string, upstreamName: string): string {
315
- return `plugin:${slug(sourceId)}:${slug(upstreamName)}`
316
- }
317
-
318
- export function stableReleaseId(
319
- repositoryUrl: string,
320
- subdirectory: string,
321
- commitSha: string,
322
- contentDigest: string
323
- ): string {
324
- return `release:${digest({ repositoryUrl, subdirectory, commitSha, contentDigest }).slice('sha256:'.length)}`
325
- }
326
-
327
- export function normalizeCategory(value: string, map: CategoryMap): string {
328
- const key = value.trim().toLocaleLowerCase()
329
- return map.aliases[key] ?? (slug(key) || map.fallback)
330
- }
331
-
332
- export function productGroupingKey(name: string, aliases: ProductAliases): string {
333
- const key = name.trim().toLocaleLowerCase()
334
- return aliases.aliases[key] ?? slug(key)
335
- }
336
-
337
- export async function synchronize(input: SyncInput): Promise<SyncResult> {
338
- const mode = input.mode ?? 'live'
339
- const configs = input.sources.filter(
340
- (source) => !input.sourceId || source.sourceId === input.sourceId
341
- )
342
- if (configs.length === 0) throw new Error(`SOURCE_NOT_FOUND: ${input.sourceId ?? 'none'}`)
343
- const lockToUse = input.fromLock ?? input.existingLock
344
- const resolved = await mapWithConcurrency(configs, 4, (config) =>
345
- resolveSource(config, mode, input.fixtureRoot, lockToUse, input.fromLock !== undefined)
346
- )
347
- const sourceHeads = resolved.map(({ config, commitSha }) => ({
348
- sourceId: config.sourceId,
349
- commitSha,
350
- }))
351
- const resolvedLock = createSourcesLock(resolved)
352
- const unchanged =
353
- input.fromLock === undefined &&
354
- input.forceRebuild !== true &&
355
- input.existingLock !== undefined &&
356
- resolved.length === input.existingLock.sources.length &&
357
- resolvedLock.sources.every((source) => {
358
- const previous = input.existingLock?.sources.find(
359
- (entry) => entry.sourceId === source.sourceId
360
- )
361
- if (!previous || sourceLockChanged(previous, source)) return false
362
- if (!input.existingCatalog) return true
363
- const priorCatalog = input.existingCatalog.sources.find(
364
- (entry) => entry.sourceId === source.sourceId
365
- )
366
- const config = resolved.find((entry) => entry.config.sourceId === source.sourceId)!.config
367
- return (
368
- !!priorCatalog &&
369
- priorCatalog.displayName === config.displayName &&
370
- priorCatalog.trustClassification === config.trustClassification
371
- )
372
- })
373
- if (unchanged) {
374
- return {
375
- changed: false,
376
- dryRun: input.dryRun ?? false,
377
- sourceHeads,
378
- changeReport: {
379
- schemaVersion: 1,
380
- changedSources: [],
381
- addedPlugins: [],
382
- removedPlugins: [],
383
- changedPlugins: [],
384
- permissionSensitiveChanges: [],
385
- skippedPlugins: [],
386
- contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
387
- },
388
- }
389
- }
390
-
391
- const buildResult = await buildCatalogInternal({
392
- resolvedSources: resolved,
393
- categoryMap: input.categoryMap,
394
- ...(input.productCategories ? { productCategories: input.productCategories } : {}),
395
- ...(input.leading ? { leading: input.leading } : {}),
396
- ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
397
- ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
398
- productAliases: input.productAliases,
399
- policy: input.policy,
400
- metadataOnly: input.metadataOnly ?? false,
401
- snapshotLoader:
402
- input.snapshotLoader ??
403
- (mode === 'offline'
404
- ? new FixtureSnapshotLoader(input.fixtureRoot ?? 'fixtures', configs)
405
- : new NetworkSnapshotLoader(input.policy)),
406
- resolveExternalRefs: mode === 'live',
407
- ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
408
- })
409
- const catalog = buildResult.catalog
410
- const lock = resolvedLock
411
- const artifacts = createArtifacts(orderLeadingPlugins(catalog, input.leading), lock, {
412
- ...(input.leading ? { leading: input.leading } : {}),
413
- ...(input.topCount !== undefined ? { topCount: input.topCount } : {}),
414
- ...(input.productPreference ? { productPreference: input.productPreference } : {}),
415
- ...(input.publicationRepositoryUrl
416
- ? { publicationRepositoryUrl: input.publicationRepositoryUrl }
417
- : {}),
418
- ...(input.publicationAssetsBaseUrl
419
- ? { publicationAssetsBaseUrl: input.publicationAssetsBaseUrl }
420
- : {}),
421
- ...(input.publicationCatalogsBaseUrl
422
- ? { publicationCatalogsBaseUrl: input.publicationCatalogsBaseUrl }
423
- : {}),
424
- curationDiagnostics: (input.mode ?? 'live') !== 'offline',
425
- })
426
- verifyArtifacts(artifacts)
427
- const report = createChangeReport(
428
- catalog,
429
- lock,
430
- input.existingLock,
431
- input.existingCatalog,
432
- buildResult.skippedPlugins
433
- )
434
- if (input.policy.publishRequiresCompleteContent && input.metadataOnly) {
435
- throw new Error('CATALOG_CONTENT_INCOMPLETE: publication requires complete content')
436
- }
437
- return {
438
- changed: true,
439
- dryRun: input.dryRun ?? false,
440
- sourceHeads,
441
- catalog,
442
- lock,
443
- artifacts,
444
- changeReport: report,
445
- }
446
- }
447
-
448
- export async function buildCatalog(input: {
449
- readonly resolvedSources: readonly ResolvedSource[]
450
- readonly categoryMap: CategoryMap
451
- readonly productAliases: ProductAliases
452
- readonly productCategories?: Record<string, string>
453
- readonly leading?: Record<string, readonly string[]>
454
- readonly productIconOverrides?: ProductIconOverrides
455
- readonly iconAssetsDirectory?: string
456
- readonly policy?: CatalogPolicy
457
- readonly metadataOnly?: boolean
458
- readonly snapshotLoader: SnapshotLoader
459
- readonly resolveExternalRefs?: boolean
460
- readonly transformRelease?: ReleaseTransform
461
- }): Promise<Catalog> {
462
- return (
463
- await buildCatalogInternal({
464
- resolvedSources: input.resolvedSources,
465
- categoryMap: input.categoryMap,
466
- productAliases: input.productAliases,
467
- policy: input.policy ?? DEFAULT_POLICY,
468
- metadataOnly: input.metadataOnly ?? false,
469
- snapshotLoader: input.snapshotLoader,
470
- resolveExternalRefs: input.resolveExternalRefs ?? false,
471
- ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
472
- ...(input.productCategories ? { productCategories: input.productCategories } : {}),
473
- ...(input.leading ? { leading: input.leading } : {}),
474
- ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
475
- ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
476
- })
477
- ).catalog
478
- }
479
-
480
- interface BuildCatalogInput {
481
- readonly resolvedSources: readonly ResolvedSource[]
482
- readonly categoryMap: CategoryMap
483
- readonly productAliases: ProductAliases
484
- readonly productCategories?: Record<string, string>
485
- readonly leading?: Record<string, readonly string[]>
486
- readonly productIconOverrides?: ProductIconOverrides
487
- readonly iconAssetsDirectory?: string
488
- readonly policy?: CatalogPolicy
489
- readonly metadataOnly?: boolean
490
- readonly snapshotLoader: SnapshotLoader
491
- readonly resolveExternalRefs?: boolean
492
- readonly transformRelease?: ReleaseTransform
493
- }
494
-
495
- interface BuildCatalogResult {
496
- readonly catalog: Catalog
497
- readonly skippedPlugins: readonly SkippedPlugin[]
498
- }
499
-
500
- async function buildCatalogInternal(input: BuildCatalogInput): Promise<BuildCatalogResult> {
501
- const policy = input.policy ?? DEFAULT_POLICY
502
- for (const source of input.resolvedSources) {
503
- assertRepositoryAllowed(source.config.repositoryUrl, policy)
504
- for (const entry of source.parsed.entries) {
505
- if (entry.source.kind === 'git') assertRepositoryAllowed(entry.source.repositoryUrl, policy)
506
- }
507
- }
508
- const sourceRecords: CatalogSource[] = input.resolvedSources
509
- .map(({ config, commitSha, retrievedAt, manifestDigest }) => ({
510
- sourceId: config.sourceId,
511
- displayName: config.displayName,
512
- repositoryUrl: canonicalRepositoryUrl(config.repositoryUrl),
513
- marketplaceDialect: config.marketplaceDialect,
514
- manifestPath: config.manifestPath,
515
- defaultBranch: config.defaultBranch,
516
- resolvedCommitSha: commitSha,
517
- retrievalTimestamp: retrievedAt,
518
- trustClassification: config.trustClassification,
519
- sourceManifestDigest: manifestDigest,
520
- synchronizationStatus: 'synchronized' as const,
521
- }))
522
- .toSorted((left, right) => left.sourceId.localeCompare(right.sourceId))
523
- let plugins: Plugin[] = []
524
- let skippedPlugins: SkippedPlugin[] = []
525
- for (const source of input.resolvedSources) {
526
- if (source.parsed.entries.length > policy.maxMarketplacePlugins)
527
- throw new Error('MARKETPLACE_TOO_MANY_PLUGINS')
528
- const normalized = await mapWithConcurrency(source.parsed.entries, 8, async (entry) => {
529
- try {
530
- const plugin = await normalizePlugin({
531
- source,
532
- entry,
533
- categoryMap: input.categoryMap,
534
- ...(input.productCategories ? { productCategories: input.productCategories } : {}),
535
- productAliases: input.productAliases,
536
- ...(input.productIconOverrides
537
- ? { productIconOverrides: input.productIconOverrides }
538
- : {}),
539
- ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
540
- policy,
541
- metadataOnly: input.metadataOnly ?? false,
542
- snapshotLoader: input.snapshotLoader,
543
- resolveExternalRefs: input.resolveExternalRefs ?? true,
544
- ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
545
- })
546
- try {
547
- return { plugin: PluginSchema.parse(plugin) }
548
- } catch {
549
- throw new PluginSafetyError('PLUGIN_SCHEMA_INVALID', [])
550
- }
551
- } catch (error) {
552
- const skipped = createSkippedPlugin(source, entry, error)
553
- if (!skipped) throw error
554
- return { skipped }
555
- }
556
- })
557
- const sourcePlugins: Plugin[] = []
558
- const sourceSkipped: SkippedPlugin[] = []
559
- for (const result of normalized) {
560
- if (result.plugin) sourcePlugins.push(result.plugin)
561
- if (result.skipped) sourceSkipped.push(result.skipped)
562
- }
563
- const weatherSkips = sourceSkipped.filter((skipped) =>
564
- UPSTREAM_WEATHER_SKIP_CODES.has(skipped.reasonCode)
565
- )
566
- if (source.parsed.entries.length > 0 && weatherSkips.length === source.parsed.entries.length)
567
- throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [
568
- canonicalRepositoryUrl(source.config.repositoryUrl),
569
- ])
570
- for (const plugin of sourcePlugins) plugins.push(plugin)
571
- for (const skipped of sourceSkipped) skippedPlugins.push(skipped)
572
- }
573
- plugins = plugins.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
574
- skippedPlugins = skippedPlugins.toSorted(
575
- (left, right) =>
576
- left.pluginId.localeCompare(right.pluginId) ||
577
- left.reasonCode.localeCompare(right.reasonCode) ||
578
- left.paths.join('\0').localeCompare(right.paths.join('\0'))
579
- )
580
- const body = {
581
- schemaVersion: 1 as const,
582
- generatedAt:
583
- sourceRecords
584
- .map((s) => s.retrievalTimestamp)
585
- .toSorted()
586
- .at(-1) ?? new Date(0).toISOString(),
587
- sources: sourceRecords,
588
- plugins,
589
- }
590
- return {
591
- catalog: CatalogSchema.parse({
592
- ...body,
593
- catalogId: `catalog:${digest(body).slice('sha256:'.length)}`,
594
- }),
595
- skippedPlugins,
596
- }
597
- }
598
-
599
- class PluginSafetyError extends Error {
600
- constructor(
601
- readonly reasonCode: PluginSkipReasonCode,
602
- readonly paths: readonly string[]
603
- ) {
604
- super(`${reasonCode}${paths.length > 0 ? `: ${paths.join(',')}` : ''}`)
605
- this.name = 'PluginSafetyError'
606
- }
607
- }
608
-
609
- const pluginSkipReasons: Readonly<Record<PluginSkipReasonCode, string>> = {
610
- GIT_SUBMODULE_UNSUPPORTED:
611
- 'The plugin contains a Git submodule, so the catalog excludes it rather than traversing an unpinned repository boundary.',
612
- PLUGIN_FILE_TOO_LARGE:
613
- 'The plugin contains a file over the configured size limit, so the catalog excludes the plugin before publication.',
614
- PLUGIN_FILE_UNAVAILABLE:
615
- 'A plugin file could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
616
- PLUGIN_MANIFEST_INVALID:
617
- 'The plugin manifest is malformed, so the catalog excludes the plugin before consuming its metadata.',
618
- PLUGIN_PATH_NOT_FOUND:
619
- 'The declared plugin path does not exist at the pinned commit, so the catalog excludes the plugin.',
620
- PLUGIN_SCHEMA_INVALID:
621
- 'The normalized plugin record exceeds the published catalog schema, so the catalog excludes it before publication.',
622
- PLUGIN_SIZE_POLICY:
623
- 'The plugin exceeds the configured size policy, so the catalog excludes it before publication.',
624
- PLUGIN_SOURCE_UNAVAILABLE:
625
- 'The plugin repository tree could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
626
- PLUGIN_TOO_LARGE:
627
- 'The plugin exceeds the configured aggregate size limit, so the catalog excludes it before publication.',
628
- PLUGIN_TOO_MANY_FILES:
629
- 'The plugin exceeds the configured file-count limit, so the catalog excludes it before publication.',
630
- SNAPSHOT_PATH_INVALID:
631
- 'The plugin snapshot contains an unsafe path, so the catalog excludes it rather than allowing repository escape.',
632
- SYMLINK_ESCAPE:
633
- 'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
634
- }
635
-
636
- function createSkippedPlugin(
637
- source: ResolvedSource,
638
- entry: MarketplacePluginEntry,
639
- error: unknown
640
- ): SkippedPlugin | undefined {
641
- const parsed =
642
- error instanceof PluginSafetyError
643
- ? { reasonCode: error.reasonCode, paths: error.paths }
644
- : parsePluginSafetyError(error)
645
- if (!parsed) return undefined
646
- return {
647
- sourceId: source.config.sourceId,
648
- pluginId: stablePluginId(source.config.sourceId, entry.name),
649
- pluginName: entry.name,
650
- reasonCode: parsed.reasonCode,
651
- securityReason: pluginSkipReasons[parsed.reasonCode],
652
- incompleteContent: true,
653
- paths: [...new Set(parsed.paths)].toSorted(),
654
- }
655
- }
656
-
657
- function parsePluginSafetyError(
658
- error: unknown
659
- ): { reasonCode: PluginSkipReasonCode; paths: readonly string[] } | undefined {
660
- const message = error instanceof Error ? error.message : String(error)
661
- const match = /^([A-Z_]+)(?::\s?(.*))?$/.exec(message)
662
- if (!match) return undefined
663
- const reasonCode = match[1] as PluginSkipReasonCode
664
- if (!(reasonCode in pluginSkipReasons)) return undefined
665
- return {
666
- reasonCode,
667
- paths: match[2]
668
- ? match[2]
669
- .split(',')
670
- .map((path) => path.trim())
671
- .filter(Boolean)
672
- : [],
673
- }
674
- }
675
-
676
- async function normalizePlugin(input: {
677
- readonly source: ResolvedSource
678
- readonly entry: MarketplacePluginEntry
679
- readonly categoryMap: CategoryMap
680
- readonly productCategories?: Record<string, string>
681
- readonly productAliases: ProductAliases
682
- readonly productIconOverrides?: ProductIconOverrides
683
- readonly iconAssetsDirectory?: string
684
- readonly policy: CatalogPolicy
685
- readonly metadataOnly: boolean
686
- readonly snapshotLoader: SnapshotLoader
687
- readonly resolveExternalRefs: boolean
688
- readonly transformRelease?: ReleaseTransform
689
- }): Promise<Plugin> {
690
- const { source, entry, policy } = input
691
- const resolved = await resolvePluginSource(
692
- entry.name,
693
- entry.source,
694
- source,
695
- input.resolveExternalRefs
696
- )
697
- const snapshot = input.metadataOnly
698
- ? { files: new Map<string, Uint8Array>(), symlinks: [] }
699
- : await input.snapshotLoader.load(
700
- resolved.repositoryUrl,
701
- resolved.commitSha,
702
- resolved.subdirectory
703
- )
704
- if (snapshot.symlinks.length > 0)
705
- throw new PluginSafetyError('SYMLINK_ESCAPE', [...snapshot.symlinks].toSorted())
706
- validateSnapshot(snapshot, policy, entry.name)
707
- const contentDigest = input.metadataOnly
708
- ? digest({
709
- repositoryUrl: resolved.repositoryUrl,
710
- subdirectory: resolved.subdirectory,
711
- commitSha: resolved.commitSha,
712
- entryDigest: entry.entryDigest,
713
- })
714
- : bytesDigest(snapshot.files)
715
- const pluginManifest = findPluginManifest(snapshot, input.transformRelease !== undefined)
716
- const manifestDigest = pluginManifest ? digest(pluginManifest.metadata) : entry.entryDigest
717
- const capabilities = classifyCapabilities(snapshot.files, pluginManifest?.metadata)
718
- const iconOverride = iconOverrideFor(
719
- input.productIconOverrides,
720
- productGroupingKey(entry.name, input.productAliases)
721
- )
722
- const contentIcon = resolveContentIcon(snapshot, iconOverride, input.metadataOnly ?? false)
723
- const favicon =
724
- contentIcon === undefined
725
- ? await resolveFavicon({
726
- entry,
727
- iconOverride,
728
- metadataOnly: input.metadataOnly ?? false,
729
- live: input.resolveExternalRefs === true,
730
- })
731
- : undefined
732
- // Stage the mark while its bytes are in hand. Publication then needs no
733
- // re-fetch, and a site icon cannot drift between resolution and release.
734
- if (input.iconAssetsDirectory) {
735
- const staged = contentIcon
736
- ? snapshot.files.get(contentIcon.kind === 'content' ? contentIcon.path : '')
737
- : favicon?.bytes
738
- if (staged)
739
- await stageIconBytes(
740
- input.iconAssetsDirectory,
741
- iconAssetName(
742
- contentIcon ? contentIcon.digest : favicon!.digest,
743
- contentIcon ? contentIcon.contentType : favicon!.contentType
744
- ),
745
- staged
746
- )
747
- }
748
- const releaseId = stableReleaseId(
749
- resolved.repositoryUrl,
750
- resolved.subdirectory,
751
- resolved.commitSha,
752
- contentDigest
753
- )
754
- const originalRelease = createRelease({
755
- source,
756
- entry,
757
- resolved,
758
- contentDigest,
759
- manifestDigest,
760
- capabilities,
761
- snapshot,
762
- ...(pluginManifest ? { pluginManifest } : {}),
763
- releaseId,
764
- ...(contentIcon ? { contentIcon } : {}),
765
- ...(favicon ? { favicon } : {}),
766
- metadataOnly: input.metadataOnly,
767
- })
768
- const release =
769
- input.transformRelease && !input.metadataOnly
770
- ? input.transformRelease(originalRelease, snapshot, entry.name)
771
- : originalRelease
772
- const compatibility = createCompatibility(source.config.marketplaceDialect, capabilities, policy)
773
- const security = createSecurity(capabilities, policy, input.metadataOnly)
774
- const normalizedCategories = entry.categories.map((category) =>
775
- normalizeCategory(category, input.categoryMap)
776
- )
777
- const productCategory = input.productCategories?.[entry.name]
778
- const categories = [
779
- ...new Set(
780
- (productCategory
781
- ? [productCategory, ...normalizedCategories.filter((category) => category !== 'other')]
782
- : normalizedCategories.length > 0
783
- ? normalizedCategories
784
- : [input.categoryMap.fallback]
785
- ).filter(Boolean)
786
- ),
787
- ].toSorted()
788
- // The name a list renders: marketplace manifests are inconsistent (`Gmail`
789
- // beside `zapier` beside `aws-agents-for-devsecops`), so the catalog
790
- // publishes the written form once and every consumer shows the same thing.
791
- const displayName = normalizeDisplayName(
792
- entry.displayName ?? pluginManifest?.displayName ?? entry.name
793
- )
794
- const authors =
795
- entry.authors.length > 0
796
- ? [...entry.authors]
797
- : source.parsed.owner.length > 0
798
- ? [...source.parsed.owner]
799
- : ['Unknown']
800
- const keywords = [...new Set([...entry.keywords, ...entry.name.split(/[-_\s]+/g), ...categories])]
801
- .filter(Boolean)
802
- .toSorted()
803
- const provenance: PluginProvenance = {
804
- sourceId: source.config.sourceId,
805
- repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
806
- manifestPath: source.config.manifestPath,
807
- pluginSubdirectory: resolved.subdirectory || '.',
808
- resolvedCommitSha: resolved.commitSha,
809
- sourceManifestDigest: source.manifestDigest,
810
- upstreamEntryDigest: entry.entryDigest,
811
- }
812
- return {
813
- pluginId: stablePluginId(source.config.sourceId, entry.name),
814
- displayName,
815
- description: entry.description || pluginManifest?.description || '',
816
- productGroupingKey: productGroupingKey(entry.name, input.productAliases),
817
- categories,
818
- keywords,
819
- authors,
820
- ...(entry.homepage ? { homepage: entry.homepage } : {}),
821
- icons: entry.icons.length > 0 ? [...entry.icons].toSorted() : (pluginManifest?.icons ?? []),
822
- sourceId: source.config.sourceId,
823
- upstreamPluginName: entry.name,
824
- currentReleaseId: releaseId,
825
- availableReleases: [release],
826
- capabilitySummary: capabilitySummary(capabilities),
827
- harnessCompatibility: compatibility,
828
- license: findLicense(entry, pluginManifest?.metadata),
829
- provenance,
830
- securityClassification: security,
831
- }
832
- }
833
-
834
- interface CreateReleaseInput {
835
- readonly source: ResolvedSource
836
- readonly entry: MarketplacePluginEntry
837
- readonly resolved: ResolvedPluginSource
838
- readonly contentDigest: string
839
- readonly manifestDigest: string
840
- readonly capabilities: readonly Capability[]
841
- readonly snapshot: Snapshot
842
- readonly pluginManifest?: PluginManifest
843
- readonly releaseId: string
844
- readonly contentIcon?: PluginRelease['icon'] | undefined
845
- readonly favicon?: SiteIcon | undefined
846
- readonly metadataOnly: boolean
847
- }
848
-
849
- function createRelease(input: CreateReleaseInput): PluginRelease {
850
- const requiredCredentials = new Set<string>()
851
- const auth = input.entry.policy.authentication
852
- if (typeof auth === 'string' && auth !== 'NONE' && auth !== 'DISABLED')
853
- requiredCredentials.add('authentication')
854
- for (const value of arrayValue(input.entry.raw.requiredCredentials))
855
- if (typeof value === 'string') requiredCredentials.add(value)
856
- const requiredConnectors = [
857
- ...new Set(arrayValue(input.entry.raw.connectors).filter(isString)),
858
- ].toSorted()
859
- const permissionSensitiveChanges = input.capabilities
860
- .filter((capability) => capability.securityImpact === 'sensitive')
861
- .map((capability) => capability.type)
862
- .toSorted()
863
- const metadata: Record<string, unknown> = {
864
- catalogContract: CATALOG_CONTRACT_VERSION,
865
- marketplaceEntry: input.entry.raw,
866
- ...(input.pluginManifest ? { pluginManifest: input.pluginManifest.metadata } : {}),
867
- sourceDialect: input.source.config.marketplaceDialect,
868
- }
869
- const version = input.pluginManifest?.version ?? stringValue(input.entry.raw.version)
870
- return {
871
- releaseId: input.releaseId,
872
- ...(version ? { upstreamVersion: version } : {}),
873
- resolvedRepositoryUrl: input.resolved.repositoryUrl,
874
- resolvedCommitSha: input.resolved.commitSha,
875
- pluginSubdirectory: input.resolved.subdirectory || '.',
876
- canonicalContentDigest: input.contentDigest,
877
- manifestDigest: input.manifestDigest,
878
- contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
879
- releaseMetadata: metadata,
880
- capabilities: [...input.capabilities].toSorted(
881
- (left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name)
882
- ),
883
- requiredConnectors,
884
- requiredCredentials: [...requiredCredentials].toSorted(),
885
- permissionSensitiveChanges: [...new Set(permissionSensitiveChanges)],
886
- fileIndex: [...input.snapshot.files.keys()].toSorted(),
887
- publicationTimestamp: input.source.retrievedAt,
888
- ...assembleIcon(input),
889
- }
890
- }
891
-
892
- /**
893
- * Resolves the product site's icon, for a plugin whose content ships no mark.
894
- *
895
- * Only a live build with complete content attempts this: a metadata-only or
896
- * offline run must not reach out to vendor sites, and neither may a product the
897
- * curator has already decided about.
898
- */
899
- async function resolveFavicon(input: {
900
- readonly entry: MarketplacePluginEntry
901
- readonly iconOverride?: IconOverride | undefined
902
- readonly metadataOnly: boolean
903
- readonly live: boolean
904
- }): Promise<SiteIcon | undefined> {
905
- if (!input.live || input.metadataOnly) return undefined
906
- if (input.iconOverride?.forced === null || input.iconOverride?.forced) return undefined
907
- const homepage = input.iconOverride?.site ?? input.entry.homepage
908
- if (!isVendorHomepage(homepage)) return undefined
909
- // Bound both fetches: a vendor site that never answers must not stall a
910
- // catalogue build. A timeout simply leaves the product on its monogram.
911
- const signal = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
912
- const resolved = await resolveSiteIcon(homepage!, createSiteIconFetcher(signal))
913
- return 'contentType' in resolved ? resolved : undefined
914
- }
915
-
916
- /**
917
- * Picks and vets the brand mark for a release.
918
- *
919
- * A file in the plugin's own content is preferred, because it is the mark the
920
- * vendor ships with the plugin. When the content has none, a live build falls
921
- * back to the declared homepage's site icon: many plugins are a thin wrapper
922
- * over a vendor API and point at the vendor's site rather than shipping a logo.
923
- * Whatever the source, an icon that fails inspection is absent rather than
924
- * advertised: the catalog never publishes content it would refuse to mirror.
925
- */
926
- function resolveContentIcon(
927
- snapshot: Snapshot,
928
- iconOverride: IconOverride | undefined,
929
- metadataOnly: boolean
930
- ): PluginRelease['icon'] | undefined {
931
- if (metadataOnly || snapshot.files.size === 0) return undefined
932
- // A curator override wins over every rule, including "publish no icon".
933
- if (iconOverride?.forced === null) return undefined
934
- const files = [...snapshot.files.keys()].toSorted()
935
- const forced = iconOverride?.forced
936
- if (forced && !files.includes(forced)) return undefined
937
- const path = forced ?? selectIconPath(files)
938
- if (!path) return undefined
939
- const bytes = snapshot.files.get(path)
940
- if (!bytes) return undefined
941
- const inspected = inspectIconBytes(bytes)
942
- if (!('contentType' in inspected)) return undefined
943
- return {
944
- kind: 'content',
945
- path,
946
- contentType: inspected.contentType,
947
- digest: byteDigest(bytes),
948
- bytes: bytes.byteLength,
949
- }
950
- }
951
-
952
- type IconOverride = ReturnType<typeof iconOverrideFor>
953
-
954
- /** Assembles the release icon from whichever source produced one. */
955
- function assembleIcon(
956
- input: Pick<CreateReleaseInput, 'contentIcon' | 'favicon'>
957
- ): { icon: NonNullable<PluginRelease['icon']> } | Record<string, never> {
958
- if (input.contentIcon) return { icon: input.contentIcon }
959
- if (input.favicon)
960
- return {
961
- icon: {
962
- kind: 'favicon',
963
- url: input.favicon.url,
964
- homepage: input.favicon.homepage,
965
- contentType: input.favicon.contentType,
966
- digest: input.favicon.digest,
967
- bytes: input.favicon.bytes.byteLength,
968
- },
969
- }
970
- return {}
971
- }
972
-
973
- /** SHA-256 of a single byte buffer, in the catalog's digest format. */
974
- export function byteDigest(bytes: Uint8Array): string {
975
- return `sha256:${createHash('sha256').update(bytes).digest('hex')}`
976
- }
977
-
978
- async function resolvePluginSource(
979
- pluginName: string,
980
- spec: PluginSourceSpec,
981
- source: ResolvedSource,
982
- resolveExternalRefs: boolean
983
- ): Promise<ResolvedPluginSource> {
984
- if (spec.kind === 'local')
985
- return {
986
- repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
987
- subdirectory: spec.path,
988
- commitSha: source.commitSha,
989
- }
990
- const canonicalUrl = canonicalRepositoryUrl(spec.repositoryUrl)
991
- const pin = source.pluginPins.find(
992
- (candidate) =>
993
- candidate.pluginName === pluginName &&
994
- candidate.repositoryUrl === canonicalUrl &&
995
- candidate.pluginSubdirectory === spec.subdirectory
996
- )
997
- const commitSha =
998
- pin?.resolvedCommitSha ??
999
- spec.sha ??
1000
- (resolveExternalRefs
1001
- ? await resolveGitRef(spec.repositoryUrl, externalGitRef(spec.ref))
1002
- : source.commitSha)
1003
- return {
1004
- repositoryUrl: canonicalUrl,
1005
- subdirectory: pin?.pluginSubdirectory ?? spec.subdirectory,
1006
- commitSha,
1007
- }
1008
- }
1009
-
1010
- interface ResolvedPluginSource {
1011
- readonly repositoryUrl: string
1012
- readonly subdirectory: string
1013
- readonly commitSha: string
1014
- }
1015
-
1016
- function validateSnapshot(snapshot: Snapshot, policy: CatalogPolicy, pluginName: string): void {
1017
- if (snapshot.files.size > policy.maxFilesPerPlugin)
1018
- throw new PluginSafetyError('PLUGIN_TOO_MANY_FILES', [pluginName])
1019
- let total = 0
1020
- for (const [path, bytes] of snapshot.files) {
1021
- try {
1022
- safeRelativePath(path, 'SNAPSHOT_PATH_INVALID')
1023
- } catch {
1024
- throw new PluginSafetyError('SNAPSHOT_PATH_INVALID', [path])
1025
- }
1026
- if (bytes.byteLength > policy.maxFileBytes)
1027
- throw new PluginSafetyError('PLUGIN_FILE_TOO_LARGE', [path])
1028
- total += bytes.byteLength
1029
- }
1030
- if (total > policy.maxBytesPerPlugin)
1031
- throw new PluginSafetyError('PLUGIN_TOO_LARGE', [pluginName])
1032
- }
1033
-
1034
- function assertRepositoryAllowed(repositoryUrl: string, policy: CatalogPolicy): void {
1035
- const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
1036
- if (!policy.allowedRepositoryProtocols.includes(parsed.protocol))
1037
- throw new Error(`GIT_PROTOCOL_POLICY: ${parsed.protocol}`)
1038
- if (!policy.allowedRepositoryHosts.includes(parsed.hostname.toLowerCase()))
1039
- throw new Error(`GIT_HOST_POLICY: ${parsed.hostname}`)
1040
- }
1041
-
1042
- interface PluginManifest {
1043
- readonly metadata: Record<string, unknown>
1044
- readonly displayName?: string | undefined
1045
- readonly description?: string | undefined
1046
- readonly version?: string | undefined
1047
- readonly icons: string[]
1048
- }
1049
-
1050
- function findPluginManifest(snapshot: Snapshot, preferRoot = false): PluginManifest | undefined {
1051
- const candidates = [...snapshot.files.keys()]
1052
- .filter((path) => {
1053
- const lower = path.toLocaleLowerCase()
1054
- return lower === 'plugin.json' || lower.endsWith('/plugin.json') || lower === 'package.json'
1055
- })
1056
- .toSorted(
1057
- (left, right) => manifestRank(left) - manifestRank(right) || left.localeCompare(right)
1058
- )
1059
- const path = preferRoot && snapshot.files.has('plugin.json') ? 'plugin.json' : candidates[0]
1060
- if (!path) return undefined
1061
- try {
1062
- const bytes = snapshot.files.get(path)
1063
- if (!bytes) return undefined
1064
- const metadata = parseJsonDocument(new TextDecoder().decode(bytes)) as Record<string, unknown>
1065
- return {
1066
- metadata,
1067
- ...((stringValue(metadata.displayName) ?? stringValue(metadata.name))
1068
- ? { displayName: stringValue(metadata.displayName) ?? stringValue(metadata.name) }
1069
- : {}),
1070
- ...(stringValue(metadata.description)
1071
- ? { description: stringValue(metadata.description) }
1072
- : {}),
1073
- ...(stringValue(metadata.version) ? { version: stringValue(metadata.version) } : {}),
1074
- icons: [metadata.icon, ...(Array.isArray(metadata.icons) ? metadata.icons : [])]
1075
- .filter(isString)
1076
- .toSorted(),
1077
- }
1078
- } catch {
1079
- throw new PluginSafetyError('PLUGIN_MANIFEST_INVALID', [path])
1080
- }
1081
- }
1082
-
1083
- function manifestRank(path: string): number {
1084
- const lower = path.toLocaleLowerCase()
1085
- if (lower.endsWith('/.claude-plugin/plugin.json')) return 0
1086
- if (lower.endsWith('/.cursor-plugin/plugin.json')) return 1
1087
- if (lower.endsWith('/.agents/plugin.json')) return 2
1088
- return lower === 'plugin.json' ? 3 : 4
1089
- }
1090
-
1091
- export function classifyCapabilities(
1092
- files: ReadonlyMap<string, Uint8Array>,
1093
- metadata?: Record<string, unknown>
1094
- ): Capability[] {
1095
- const groups = new Map<string, string[]>()
1096
- const add = (type: Capability['type'], path: string) =>
1097
- groups.set(type, [...(groups.get(type) ?? []), path])
1098
- for (const path of files.keys()) {
1099
- const lower = path.toLocaleLowerCase()
1100
- const file = basename(lower)
1101
- if (file === 'skill.md' || lower.includes('/skills/')) add('skill', path)
1102
- if (file === '.mcp.json' || file === 'mcp.json' || lower.includes('/mcp-servers/'))
1103
- add('mcp-server', path)
1104
- if (lower.includes('/commands/') || lower.startsWith('commands/')) add('command', path)
1105
- if (lower.includes('/agents/') || lower.startsWith('agents/') || lower.includes('/subagents/'))
1106
- add('agent', path)
1107
- if (lower.includes('/hooks/') || lower.startsWith('hooks/') || file === 'hooks.json')
1108
- add('hook', path)
1109
- if (
1110
- lower.includes('/rules/') ||
1111
- lower.startsWith('rules/') ||
1112
- file === 'agents.md' ||
1113
- file === 'claude.md' ||
1114
- lower.includes('.cursor/rules/')
1115
- )
1116
- add('rule', path)
1117
- if (lower.includes('browser') || lower.includes('playwright') || lower.includes('chrome'))
1118
- add('browser', path)
1119
- if (lower.includes('/schedule') || lower.includes('/cron') || file.includes('schedule'))
1120
- add('scheduled-task', path)
1121
- if (lower.includes('/ui/') || lower.startsWith('ui/') || lower.includes('/components/'))
1122
- add('ui-component', path)
1123
- if (
1124
- lower.startsWith('bin/') ||
1125
- lower.startsWith('scripts/') ||
1126
- /\.(sh|bash|py|exe|bin)$/.test(file)
1127
- )
1128
- add('executable', path)
1129
- }
1130
- if (metadata && (metadata.mcpServers !== undefined || metadata.mcp !== undefined))
1131
- add('mcp-server', 'plugin-manifest')
1132
- if (metadata && (metadata.hooks !== undefined || metadata.hook !== undefined))
1133
- add('hook', 'plugin-manifest')
1134
- if (groups.size === 0) groups.set('unknown', ['plugin-root'])
1135
- return [...groups.entries()].map(([type, paths]) => ({
1136
- type: type as Capability['type'],
1137
- name: type,
1138
- paths: [...new Set(paths)].toSorted(),
1139
- metadata: {},
1140
- securityImpact: [
1141
- 'mcp-server',
1142
- 'connector',
1143
- 'hook',
1144
- 'browser',
1145
- 'scheduled-task',
1146
- 'executable',
1147
- ].includes(type)
1148
- ? 'sensitive'
1149
- : 'none',
1150
- }))
1151
- }
1152
-
1153
- function capabilitySummary(
1154
- capabilities: readonly Capability[]
1155
- ): Record<Capability['type'], number> {
1156
- const summary = {} as Record<Capability['type'], number>
1157
- for (const capability of capabilities) summary[capability.type] = capability.paths.length
1158
- return summary
1159
- }
1160
-
1161
- export function createCompatibility(
1162
- dialect: MarketplaceDialectName,
1163
- capabilities: readonly Capability[],
1164
- policy: CatalogPolicy = DEFAULT_POLICY
1165
- ): Record<Harness, HarnessCompatibility> {
1166
- const types = [...new Set(capabilities.map((capability) => capability.type))].toSorted()
1167
- const sensitive = types.filter((type) => policy.sensitiveCapabilityTypes.includes(type))
1168
- const make = (
1169
- status: HarnessCompatibility['status'],
1170
- reasons: string[]
1171
- ): HarnessCompatibility => ({
1172
- status,
1173
- reasons: [...new Set(reasons)].toSorted(),
1174
- responsibleCapabilities: types,
1175
- })
1176
- const result = {} as Record<Harness, HarnessCompatibility>
1177
- for (const harness of HarnessSchema.options) {
1178
- const native =
1179
- (harness === 'claude-code' && dialect === 'claude') ||
1180
- (harness === 'cursor' && dialect === 'cursor') ||
1181
- (harness === 'codex' && dialect === 'openai')
1182
- if (types.includes('executable'))
1183
- result[harness] = make('unsupported', [
1184
- 'Executable components require harness-specific execution authority.',
1185
- ])
1186
- else if (types.includes('unknown'))
1187
- result[harness] = make('unknown', [
1188
- 'The plugin content was not classified into a supported component type.',
1189
- ])
1190
- else if (native && sensitive.length === 0)
1191
- result[harness] = make('native', [
1192
- `The ${dialect} marketplace dialect matches the ${harness} harness.`,
1193
- ])
1194
- else if (sensitive.length > 0 && ['pi', 'hermes', 'opencode'].includes(harness))
1195
- result[harness] = make('partially-supported', [
1196
- `Sensitive capabilities require ${harness} policy and runtime review.`,
1197
- ...sensitive.map((type) => `Capability ${type} is not granted by this catalog.`),
1198
- ])
1199
- else if (
1200
- types.every((type) => ['skill', 'rule', 'mcp-server', 'command', 'agent'].includes(type))
1201
- )
1202
- result[harness] = make(
1203
- native ? 'native' : 'portable',
1204
- native
1205
- ? [`The ${dialect} marketplace dialect matches the ${harness} harness.`]
1206
- : ['Files can be copied or translated into the harness layout.']
1207
- )
1208
- else
1209
- result[harness] = make('requires-translation', [
1210
- 'One or more capabilities need a harness-specific projection.',
1211
- ])
1212
- }
1213
- return result
1214
- }
1215
-
1216
- type MarketplaceDialectName = 'openai' | 'cursor' | 'claude'
1217
-
1218
- function createSecurity(
1219
- capabilities: readonly Capability[],
1220
- policy: CatalogPolicy,
1221
- metadataOnly: boolean
1222
- ): SecurityClassification {
1223
- const sensitive = [
1224
- ...new Set(
1225
- capabilities
1226
- .filter((capability) => policy.sensitiveCapabilityTypes.includes(capability.type))
1227
- .map((capability) => capability.type)
1228
- ),
1229
- ].toSorted()
1230
- const reasons =
1231
- sensitive.length > 0
1232
- ? sensitive.map((type) => `Contains ${type} capability metadata.`)
1233
- : metadataOnly
1234
- ? ['Content was not fetched in metadata-only mode.']
1235
- : ['No permission-sensitive executable capability was detected.']
1236
- return {
1237
- level: sensitive.length > 0 ? 'sensitive' : metadataOnly ? 'review' : 'low',
1238
- reasons,
1239
- permissionSensitiveChanges: sensitive,
1240
- contentResolution: metadataOnly ? 'metadata-only' : 'complete',
1241
- }
1242
- }
1243
-
1244
- function findLicense(
1245
- entry: MarketplacePluginEntry,
1246
- metadata?: Record<string, unknown>
1247
- ): LicenseMetadata {
1248
- const candidate = stringValue(metadata?.license) ?? stringValue(entry.raw.license)
1249
- if (!candidate) return { name: 'Unknown', source: 'unknown' }
1250
- return {
1251
- name: candidate,
1252
- ...(candidate.startsWith('http') ? { url: candidate } : {}),
1253
- source: metadata?.license !== undefined ? 'plugin-manifest' : 'marketplace-entry',
1254
- }
1255
- }
1256
-
1257
- function sourceLockChanged(previous: SourceLockEntry, current: SourceLockEntry): boolean {
1258
- return (
1259
- previous.repositoryUrl !== current.repositoryUrl ||
1260
- previous.defaultBranch !== current.defaultBranch ||
1261
- previous.manifestPath !== current.manifestPath ||
1262
- previous.marketplaceDialect !== current.marketplaceDialect ||
1263
- previous.resolvedCommitSha !== current.resolvedCommitSha ||
1264
- previous.sourceManifestDigest !== current.sourceManifestDigest ||
1265
- digest(previous.pluginPins) !== digest(current.pluginPins)
1266
- )
1267
- }
1268
-
1269
- function createSourcesLock(sources: readonly ResolvedSource[]): SourcesLock {
1270
- const entries: SourceLockEntry[] = sources
1271
- .map((source) => ({
1272
- sourceId: source.config.sourceId,
1273
- repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
1274
- defaultBranch: source.config.defaultBranch,
1275
- resolvedCommitSha: source.commitSha,
1276
- manifestPath: source.config.manifestPath,
1277
- marketplaceDialect: source.config.marketplaceDialect,
1278
- sourceManifestDigest: source.manifestDigest,
1279
- retrievedAt: source.retrievedAt,
1280
- synchronizationStatus: 'synchronized' as const,
1281
- pluginPins: source.pluginPins.map((pin) => ({ ...pin })),
1282
- }))
1283
- .toSorted((left, right) => left.sourceId.localeCompare(right.sourceId))
1284
- const body = { schemaVersion: 1 as const, sources: entries }
1285
- return SourcesLockSchema.parse({
1286
- ...body,
1287
- lockId: `lock:${digest(body).slice('sha256:'.length)}`,
1288
- })
1289
- }
1290
-
1291
- /**
1292
- * Orders plugins so each category's curated leading products come first, in
1293
- * the configured order. Consumers preview the head of each category, so this
1294
- * ordering decides what users see before expanding.
1295
- */
1296
- export function orderLeadingPlugins<
1297
- T extends {
1298
- readonly plugins: readonly {
1299
- categories: readonly string[]
1300
- upstreamPluginName: string
1301
- }[]
1302
- },
1303
- >(catalog: T, leading?: Record<string, readonly string[]>): T {
1304
- if (!leading) return catalog
1305
- const leadRank = new Map<string, number>()
1306
- for (const [category, names] of Object.entries(leading)) {
1307
- names.forEach((name, index) => {
1308
- const key = `${category}::${name}`
1309
- if (!leadRank.has(key)) leadRank.set(key, index)
1310
- })
1311
- }
1312
- const rank = (plugin: T['plugins'][number]): number => {
1313
- let best = Number.POSITIVE_INFINITY
1314
- for (const category of plugin.categories) {
1315
- const value = leadRank.get(`${category}::${plugin.upstreamPluginName}`)
1316
- if (value !== undefined && value < best) best = value
1317
- }
1318
- return best
1319
- }
1320
- return {
1321
- ...catalog,
1322
- plugins: catalog.plugins.toSorted((left, right) => rank(left) - rank(right)),
1323
- }
1324
- }
1325
-
1326
- export function createArtifacts(
1327
- catalog: Catalog,
1328
- lock: SourcesLock,
1329
- options: ConsumerIndexOptions = {}
1330
- ): GeneratedArtifacts {
1331
- const index = buildConsumerIndex({
1332
- catalog,
1333
- ...(options.leading ? { leading: options.leading } : {}),
1334
- ...(options.topCount !== undefined ? { topCount: options.topCount } : {}),
1335
- ...(options.productPreference ? { preference: options.productPreference } : {}),
1336
- ...(options.publicationRepositoryUrl
1337
- ? { publicationRepositoryUrl: options.publicationRepositoryUrl }
1338
- : {}),
1339
- ...(options.publicationAssetsBaseUrl
1340
- ? { publicationAssetsBaseUrl: options.publicationAssetsBaseUrl }
1341
- : {}),
1342
- ...(options.publicationCatalogsBaseUrl
1343
- ? { publicationCatalogsBaseUrl: options.publicationCatalogsBaseUrl }
1344
- : {}),
1345
- })
1346
- const summary = {
1347
- schemaVersion: 1,
1348
- catalogId: catalog.catalogId,
1349
- generatedAt: catalog.generatedAt,
1350
- sourceCount: catalog.sources.length,
1351
- pluginCount: catalog.plugins.length,
1352
- categories: [...new Set(catalog.plugins.flatMap((plugin) => plugin.categories))].toSorted(),
1353
- productGroups: [
1354
- ...new Set(catalog.plugins.map((plugin) => plugin.productGroupingKey)),
1355
- ].toSorted(),
1356
- search: catalog.plugins
1357
- .map((plugin) => ({
1358
- pluginId: plugin.pluginId,
1359
- text: `${plugin.displayName} ${plugin.description} ${plugin.keywords.join(' ')}`.trim(),
1360
- }))
1361
- .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)),
1362
- }
1363
- const compatibility = {
1364
- schemaVersion: 1,
1365
- catalogId: catalog.catalogId,
1366
- plugins: catalog.plugins.map((plugin) => ({
1367
- pluginId: plugin.pluginId,
1368
- harnessCompatibility: plugin.harnessCompatibility,
1369
- })),
1370
- }
1371
- // Navigation stays small and points at the shards a client then fetches:
1372
- // `shelf-*` for a default view, `category-*` for "view more", and
1373
- // `catalog-index.v1.json` for the complete product list.
1374
- const navigation = {
1375
- schemaVersion: 1,
1376
- catalogId: catalog.catalogId,
1377
- generatedAt: catalog.generatedAt,
1378
- topCount: index.topCount,
1379
- sourcePreference: index.sourcePreference,
1380
- counts: index.counts,
1381
- catalogIndexArtifact: CATALOG_INDEX_ARTIFACT,
1382
- iconCoverage: iconCoverage(index),
1383
- // The index URL rides in the navigation artifact so a client can fetch the
1384
- // browsing index from the publication it was told about, verify it against
1385
- // the digest in integrity.json, and never compose a URL or need this
1386
- // service to proxy it. Absent for an unpublished build.
1387
- ...(options.publicationCatalogsBaseUrl
1388
- ? {
1389
- catalogIndexUrl: `${options.publicationCatalogsBaseUrl}/${catalog.catalogId.slice('catalog:'.length)}/${CATALOG_INDEX_ARTIFACT}`,
1390
- }
1391
- : options.publicationAssetsBaseUrl
1392
- ? { catalogIndexUrl: `${options.publicationAssetsBaseUrl}/${CATALOG_INDEX_ARTIFACT}` }
1393
- : options.publicationRepositoryUrl
1394
- ? {
1395
- catalogIndexUrl: immutableAssetUrl(
1396
- options.publicationRepositoryUrl,
1397
- catalog.catalogId,
1398
- CATALOG_INDEX_ARTIFACT
1399
- ),
1400
- }
1401
- : {}),
1402
- // Marks ride in the navigation artifact so a client that already receives it
1403
- // renders compiled brand marks without fetching the browsing index. Keys are
1404
- // sorted for a stable artifact.
1405
- brandMarks: Object.fromEntries(
1406
- Object.keys(index.products)
1407
- .toSorted()
1408
- .flatMap((productKey) => {
1409
- const assetUrl = index.products[productKey]?.icon?.assetUrl
1410
- return assetUrl ? [[productKey, assetUrl] as const] : []
1411
- })
1412
- ),
1413
- categories: index.categories,
1414
- diagnostics: options.curationDiagnostics === false ? [] : index.diagnostics,
1415
- }
1416
- const shards: Record<string, string> = {
1417
- [CATALOG_INDEX_ARTIFACT]: json({
1418
- schemaVersion: 1,
1419
- catalogId: catalog.catalogId,
1420
- generatedAt: catalog.generatedAt,
1421
- topCount: index.topCount,
1422
- sourcePreference: index.sourcePreference,
1423
- counts: index.counts,
1424
- products: index.products,
1425
- categories: index.categories.map((category) => ({
1426
- category: category.category,
1427
- productCount: category.productCount,
1428
- productKeys: category.productKeys,
1429
- })),
1430
- }),
1431
- }
1432
- for (const category of index.categories) {
1433
- shards[category.shelfArtifact] = json({
1434
- schemaVersion: 1,
1435
- catalogId: catalog.catalogId,
1436
- category: category.category,
1437
- topCount: index.topCount,
1438
- products: category.topProductKeys.map((productKey) => index.products[productKey]),
1439
- })
1440
- shards[category.listArtifact] = json({
1441
- schemaVersion: 1,
1442
- catalogId: catalog.catalogId,
1443
- category: category.category,
1444
- productCount: category.productCount,
1445
- pluginCount: category.pluginCount,
1446
- products: category.productKeys.map((productKey) => index.products[productKey]),
1447
- })
1448
- }
1449
- const files: Record<string, string> = {
1450
- 'catalog.v1.json': json(catalog),
1451
- 'catalog-summary.v1.json': json(summary),
1452
- 'sources.lock.json': json(lock),
1453
- 'compatibility.v1.json': json(compatibility),
1454
- 'categories.v1.json': json(navigation),
1455
- ...shards,
1456
- }
1457
- const integrityBody = {
1458
- schemaVersion: 1,
1459
- catalogId: catalog.catalogId,
1460
- // An artifact's digest is the canonical digest of its text, not a hash of
1461
- // the file bytes: consumers read the artifact as a string and compare it
1462
- // with `canonicalDigest`, so a checker hashing the raw bytes will disagree
1463
- // with every published release. Mirrored marks below are the byte case,
1464
- // because a client stores those bytes.
1465
- files: Object.fromEntries(
1466
- Object.entries(files)
1467
- .toSorted()
1468
- .map(([name, content]) => [name, digest(content)])
1469
- ),
1470
- // Mirrored brand marks are published next to the artifacts, never inside
1471
- // them: the digest and size let a consumer or the release check verify the
1472
- // bytes it fetched without trusting the release listing.
1473
- assets: iconAssets(index),
1474
- }
1475
- return { ...files, 'integrity.json': json(integrityBody) } as GeneratedArtifacts
1476
- }
1477
-
1478
- export interface PublishedAsset {
1479
- readonly name: string
1480
- readonly digest: string
1481
- readonly bytes: number
1482
- readonly contentType: string
1483
- /** `content` mirrors a plugin file; `favicon` mirrors the product site's icon. */
1484
- readonly kind: 'content' | 'favicon'
1485
- /** Upstream path or URL the bytes came from, for provenance. */
1486
- readonly origin: string
1487
- }
1488
-
1489
- /** Every mirrored icon in the catalog, deduplicated by asset name. */
1490
- export function iconAssets(index: ConsumerIndex): readonly PublishedAsset[] {
1491
- const assets = new Map<string, PublishedAsset>()
1492
- for (const product of Object.values(index.products)) {
1493
- if (!product.icon) continue
1494
- assets.set(product.icon.asset, {
1495
- name: product.icon.asset,
1496
- digest: product.icon.digest,
1497
- bytes: product.icon.bytes,
1498
- contentType: product.icon.contentType,
1499
- kind: product.icon.kind,
1500
- origin:
1501
- product.icon.kind === 'content' ? (product.icon.path ?? '') : (product.icon.url ?? ''),
1502
- })
1503
- }
1504
- return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name))
1505
- }
1506
-
1507
- /**
1508
- * Publishes a verified artifact set to `generatedDirectory` under the output's
1509
- * coordination lock. See `catalog-store.ts` for the publication contract.
1510
- */
1511
- export async function writeArtifacts(
1512
- generatedDirectory: string,
1513
- artifacts: GeneratedArtifacts,
1514
- options: PublishOptions = {}
1515
- ): Promise<void> {
1516
- await publishCatalog(generatedDirectory, artifacts as Record<string, string>, options)
1517
- }
1518
-
1519
- export function verifyArtifacts(artifacts: GeneratedArtifacts): void {
1520
- const parsedCatalog = CatalogSchema.parse(JSON.parse(artifacts['catalog.v1.json']))
1521
- verifyCatalogReferences(parsedCatalog)
1522
- const lock = SourcesLockSchema.parse(JSON.parse(artifacts['sources.lock.json']))
1523
- const summary = JSON.parse(artifacts['catalog-summary.v1.json']) as {
1524
- schemaVersion?: unknown
1525
- catalogId?: unknown
1526
- sourceCount?: unknown
1527
- pluginCount?: unknown
1528
- search?: unknown
1529
- }
1530
- const compatibility = JSON.parse(artifacts['compatibility.v1.json']) as {
1531
- schemaVersion?: unknown
1532
- catalogId?: unknown
1533
- plugins?: unknown
1534
- }
1535
- const categories = JSON.parse(artifacts['categories.v1.json']) as {
1536
- schemaVersion?: unknown
1537
- catalogId?: unknown
1538
- topCount?: unknown
1539
- counts?: { plugins?: unknown; products?: unknown; redundantPlugins?: unknown }
1540
- categories?: unknown
1541
- products?: unknown
1542
- diagnostics?: unknown
1543
- }
1544
- const integrity = JSON.parse(artifacts['integrity.json']) as {
1545
- schemaVersion?: unknown
1546
- catalogId?: unknown
1547
- files?: Record<string, string>
1548
- assets?: unknown
1549
- }
1550
- if (parsedCatalog.sources.length !== lock.sources.length)
1551
- throw new Error('INTEGRITY_SOURCE_COUNT_MISMATCH')
1552
- if (
1553
- summary.schemaVersion !== 1 ||
1554
- summary.catalogId !== parsedCatalog.catalogId ||
1555
- summary.sourceCount !== parsedCatalog.sources.length ||
1556
- summary.pluginCount !== parsedCatalog.plugins.length ||
1557
- !Array.isArray(summary.search) ||
1558
- compatibility.schemaVersion !== 1 ||
1559
- compatibility.catalogId !== parsedCatalog.catalogId ||
1560
- !Array.isArray(compatibility.plugins) ||
1561
- compatibility.plugins.length !== parsedCatalog.plugins.length ||
1562
- categories.schemaVersion !== 1 ||
1563
- categories.catalogId !== parsedCatalog.catalogId ||
1564
- !Array.isArray(categories.categories) ||
1565
- (categories.products !== undefined &&
1566
- (typeof categories.products !== 'object' || categories.products === null)) ||
1567
- (categories.diagnostics !== undefined && !Array.isArray(categories.diagnostics)) ||
1568
- integrity.schemaVersion !== 1 ||
1569
- integrity.catalogId !== parsedCatalog.catalogId ||
1570
- !integrity.files ||
1571
- (integrity.assets !== undefined && !Array.isArray(integrity.assets))
1572
- ) {
1573
- throw new Error('INTEGRITY_ARTIFACT_METADATA_MISMATCH')
1574
- }
1575
- // The published file set is exactly what integrity.json enumerates, so the
1576
- // shards can grow with the category configuration without a fixed list here.
1577
- for (const name of Object.keys(artifacts))
1578
- if (name !== 'integrity.json' && integrity.files?.[name] === undefined)
1579
- throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`)
1580
- for (const [name, expected] of Object.entries(integrity.files ?? {})) {
1581
- const content = artifacts[name]
1582
- if (content === undefined) throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`)
1583
- if (digest(content) !== expected) throw new Error(`INTEGRITY_DIGEST_MISMATCH: ${name}`)
1584
- }
1585
- const indexArtifact = artifacts[CATALOG_INDEX_ARTIFACT]
1586
- const verification = verifyConsumerIndex(
1587
- artifacts['categories.v1.json'],
1588
- parsedCatalog,
1589
- indexArtifact
1590
- )
1591
- if (verification.hasConsumerIndex && indexArtifact === undefined)
1592
- throw new Error(`SHARD_MISSING: ${CATALOG_INDEX_ARTIFACT}`)
1593
- for (const shard of verification.shards)
1594
- verifyShardArtifact(shard, artifacts[shard.name], verification, parsedCatalog.catalogId)
1595
- verifyIconAssets(integrity.assets, verification)
1596
- }
1597
-
1598
- /**
1599
- * Re-checks one consumer shard against the navigation index: a shelf carries
1600
- * exactly the curated products in order, and a category list carries exactly
1601
- * the category's products in order. The expected catalog ID must come from a
1602
- * separately verified catalog, not from the shard being checked.
1603
- */
1604
- export function verifyShardArtifact(
1605
- shard: ShardArtifact,
1606
- content: string | undefined,
1607
- verification: ConsumerIndexVerification,
1608
- catalogId: string
1609
- ): void {
1610
- if (typeof catalogId !== 'string' || !/^catalog:[a-f0-9]{64}$/.test(catalogId))
1611
- throw new Error('SHARD_CATALOG_ID_REQUIRED')
1612
- if (content === undefined) throw new Error(`SHARD_MISSING: ${shard.name}`)
1613
- const parsed = JSON.parse(content) as {
1614
- schemaVersion?: unknown
1615
- catalogId?: unknown
1616
- category?: unknown
1617
- topCount?: unknown
1618
- products?: unknown
1619
- }
1620
- if (
1621
- parsed.schemaVersion !== 1 ||
1622
- parsed.catalogId !== catalogId ||
1623
- (shard.kind !== 'index' && parsed.category !== shard.category)
1624
- )
1625
- throw new Error(`SHARD_IDENTITY_MISMATCH: ${shard.name}`)
1626
- // The index shard carries products keyed by productKey; the shelf and list
1627
- // shards carry them as ordered arrays.
1628
- const products: readonly ConsumerProduct[] =
1629
- shard.kind === 'index'
1630
- ? Object.values((parsed.products ?? {}) as Record<string, ConsumerProduct>)
1631
- : (parsed.products as readonly ConsumerProduct[])
1632
- if (!Array.isArray(products)) throw new Error(`SHARD_PRODUCTS_INVALID: ${shard.name}`)
1633
- const keys = products.map((product) => product.productKey)
1634
- if (new Set(keys).size !== keys.length) throw new Error(`SHARD_DUPLICATE_PRODUCT: ${shard.name}`)
1635
- if (shard.kind === 'index') {
1636
- const expected = Object.keys(verification.products ?? {}).toSorted()
1637
- if (keys.toSorted().join('\n') !== expected.join('\n'))
1638
- throw new Error('SHARD_INDEX_MEMBERSHIP_MISMATCH')
1639
- return
1640
- }
1641
- const category = verification.categories?.find((item) => item.category === shard.category)
1642
- if (!category) throw new Error(`SHARD_CATEGORY_UNKNOWN: ${shard.name}`)
1643
- const expected = shard.kind === 'shelf' ? category.topProductKeys : category.productKeys
1644
- if (keys.join('\n') !== expected.join('\n'))
1645
- throw new Error(`SHARD_ORDER_MISMATCH: ${shard.name}`)
1646
- for (const product of products) {
1647
- const index = verification.products?.[product.productKey]
1648
- if (!index || digest(index) !== digest(product))
1649
- throw new Error(`SHARD_PRODUCT_MISMATCH: ${shard.name}: ${product.productKey}`)
1650
- }
1651
- }
1652
-
1653
- /** Every mirrored icon is declared once, with the digest the index advertises. */
1654
- function verifyIconAssets(assets: unknown, verification: ConsumerIndexVerification): void {
1655
- const declared = (assets ?? []) as readonly PublishedAsset[]
1656
- if (!Array.isArray(declared)) throw new Error('INTEGRITY_ASSETS_INVALID')
1657
- const referenced = new Map<string, PublishedAsset>()
1658
- for (const product of Object.values(verification.products ?? {})) {
1659
- if (!product.icon) continue
1660
- const existing = referenced.get(product.icon.asset)
1661
- if (existing && existing.digest !== product.icon.digest)
1662
- throw new Error(`ICON_ASSET_DIGEST_CONFLICT: ${product.icon.asset}`)
1663
- referenced.set(product.icon.asset, {
1664
- name: product.icon.asset,
1665
- digest: product.icon.digest,
1666
- bytes: product.icon.bytes,
1667
- contentType: product.icon.contentType,
1668
- kind: product.icon.kind,
1669
- origin:
1670
- product.icon.kind === 'content' ? (product.icon.path ?? '') : (product.icon.url ?? ''),
1671
- })
1672
- }
1673
- if (declared.length !== referenced.size) throw new Error('INTEGRITY_ASSET_SET_MISMATCH')
1674
- for (const asset of declared) {
1675
- const expected = referenced.get(asset.name)
1676
- if (!expected) throw new Error(`INTEGRITY_ASSET_UNDECLARED: ${asset.name}`)
1677
- if (
1678
- expected.digest !== asset.digest ||
1679
- expected.bytes !== asset.bytes ||
1680
- expected.contentType !== asset.contentType ||
1681
- expected.kind !== asset.kind ||
1682
- expected.origin !== asset.origin
1683
- )
1684
- throw new Error(`INTEGRITY_ASSET_MISMATCH: ${asset.name}`)
1685
- }
1686
- }
1687
-
1688
- /**
1689
- * Re-checks the published consumer index against the catalog it indexes. The
1690
- * index is what Adea renders directly, so a contradiction between the two
1691
- * artifacts must never reach a release. Snapshots published before the index
1692
- * existed carry membership only; those are still membership-checked and
1693
- * reported as `hasConsumerIndex: false`.
1694
- */
1695
- export function verifyConsumerIndex(
1696
- content: string,
1697
- catalog: Pick<Catalog, 'plugins'>,
1698
- indexContent?: string
1699
- ): ConsumerIndexVerification {
1700
- const index = JSON.parse(content) as Partial<ConsumerIndex>
1701
- const knownPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]))
1702
- // Products live in the index shard; the navigation artifact only points at it.
1703
- const shard = indexContent ? (JSON.parse(indexContent) as Partial<ConsumerIndex>) : undefined
1704
- const products =
1705
- shard && typeof shard.products === 'object' && shard.products !== null
1706
- ? shard.products
1707
- : typeof index.products === 'object' && index.products !== null
1708
- ? index.products
1709
- : undefined
1710
- const diagnostics = Array.isArray(index.diagnostics) ? index.diagnostics : []
1711
- for (const category of index.categories ?? []) {
1712
- const members = catalog.plugins.filter((plugin) =>
1713
- plugin.categories.includes(category.category)
1714
- )
1715
- if (category.pluginCount !== undefined && category.pluginCount !== members.length)
1716
- throw new Error(`CONSUMER_INDEX_PLUGIN_COUNT_MISMATCH: ${category.category}`)
1717
- if (category.pluginIds.length !== members.length)
1718
- throw new Error(`CONSUMER_INDEX_PLUGIN_LIST_MISMATCH: ${category.category}`)
1719
- if (new Set(category.pluginIds).size !== category.pluginIds.length)
1720
- throw new Error(`CONSUMER_INDEX_DUPLICATE_PLUGIN: ${category.category}`)
1721
- for (const pluginId of category.pluginIds) {
1722
- const plugin = knownPlugins.get(pluginId)
1723
- if (!plugin || !plugin.categories.includes(category.category))
1724
- throw new Error(`CONSUMER_INDEX_FOREIGN_PLUGIN: ${category.category}: ${pluginId}`)
1725
- }
1726
- if (!products) continue
1727
- if (category.productKeys === undefined || category.topProductKeys === undefined)
1728
- throw new Error(`CONSUMER_INDEX_PRODUCTS_INCOMPLETE: ${category.category}`)
1729
- if (new Set(category.productKeys).size !== category.productKeys.length)
1730
- throw new Error(`CONSUMER_INDEX_DUPLICATE_PRODUCT: ${category.category}`)
1731
- for (const productKey of category.productKeys) {
1732
- const product = products[productKey]
1733
- if (!product) throw new Error(`CONSUMER_INDEX_UNKNOWN_PRODUCT: ${productKey}`)
1734
- if (!product.categories.includes(category.category))
1735
- throw new Error(`CONSUMER_INDEX_PRODUCT_CATEGORY_MISMATCH: ${productKey}`)
1736
- }
1737
- if (category.productCount !== category.productKeys.length)
1738
- throw new Error(`CONSUMER_INDEX_PRODUCT_COUNT_MISMATCH: ${category.category}`)
1739
- const topCount = index.topCount
1740
- if (topCount === undefined || !Number.isInteger(topCount) || topCount < 0)
1741
- throw new Error('CONSUMER_INDEX_TOP_COUNT_INVALID')
1742
- if (category.topProductKeys.length > topCount)
1743
- throw new Error(`CONSUMER_INDEX_TOP_OVERFLOW: ${category.category}`)
1744
- const shelved = new Set<string>()
1745
- for (const product of Object.values(products)) {
1746
- // Indexes published before the install facts existed carry no `release`,
1747
- // and are still valid; a record that does carry one must agree with the
1748
- // catalog it claims to describe.
1749
- if (product.release) {
1750
- const plugin = knownPlugins.get(product.pluginId)
1751
- const release = plugin?.availableReleases.find(
1752
- (candidate) => candidate.releaseId === plugin.currentReleaseId
1753
- )
1754
- if (
1755
- !plugin ||
1756
- !release ||
1757
- product.release.releaseId !== release.releaseId ||
1758
- product.release.canonicalContentDigest !== release.canonicalContentDigest ||
1759
- product.release.sourceRevision !== release.resolvedCommitSha ||
1760
- product.provenance?.resolvedCommitSha !== release.resolvedCommitSha
1761
- )
1762
- throw new Error(`CONSUMER_INDEX_RELEASE_MISMATCH: ${product.productKey}`)
1763
- }
1764
- const icon = product.icon
1765
- if (!icon?.assetUrl) continue
1766
- if (!icon.assetUrl.startsWith('https://') || !icon.assetUrl.endsWith(`/${icon.asset}`))
1767
- throw new Error(`CONSUMER_INDEX_ASSET_URL_INVALID: ${icon.asset}`)
1768
- }
1769
- for (const productKey of category.topProductKeys) {
1770
- if (!category.productKeys.includes(productKey))
1771
- throw new Error(`CONSUMER_INDEX_TOP_NOT_MEMBER: ${category.category}: ${productKey}`)
1772
- if (shelved.has(productKey)) throw new Error(`CONSUMER_INDEX_TOP_REPEATED: ${productKey}`)
1773
- shelved.add(productKey)
1774
- }
1775
- }
1776
- if (products) {
1777
- // A product headlines one category only, so no product is shelved twice.
1778
- const claimed = new Set<string>()
1779
- for (const category of index.categories ?? [])
1780
- for (const productKey of category.topProductKeys ?? []) {
1781
- if (claimed.has(productKey))
1782
- throw new Error(`CONSUMER_INDEX_TOP_CROSS_CATEGORY: ${productKey}`)
1783
- claimed.add(productKey)
1784
- }
1785
- for (const [productKey, product] of Object.entries(products)) {
1786
- if (productKey !== product.productKey) throw new Error('CONSUMER_INDEX_PRODUCT_KEY_MISMATCH')
1787
- if (product.variantPluginIds[0] !== product.pluginId)
1788
- throw new Error(`CONSUMER_INDEX_CANONICAL_NOT_FIRST: ${productKey}`)
1789
- const variants = new Set(
1790
- product.variantPluginIds.map((pluginId) => {
1791
- const plugin = knownPlugins.get(pluginId)
1792
- if (!plugin) throw new Error(`CONSUMER_INDEX_UNKNOWN_PLUGIN: ${pluginId}`)
1793
- if (plugin.productGroupingKey !== productKey)
1794
- throw new Error(`CONSUMER_INDEX_VARIANT_PRODUCT_MISMATCH: ${pluginId}`)
1795
- return pluginId
1796
- })
1797
- )
1798
- if (variants.size !== product.variantPluginIds.length)
1799
- throw new Error(`CONSUMER_INDEX_DUPLICATE_VARIANT: ${productKey}`)
1800
- }
1801
- const counts = shard?.counts ?? index.counts
1802
- if (counts?.plugins !== catalog.plugins.length) throw new Error('CONSUMER_INDEX_TOTAL_MISMATCH')
1803
- if (counts?.products !== Object.keys(products).length)
1804
- throw new Error('CONSUMER_INDEX_PRODUCT_TOTAL_MISMATCH')
1805
- if (counts?.redundantPlugins !== catalog.plugins.length - counts.products)
1806
- throw new Error('CONSUMER_INDEX_REDUNDANCY_MISMATCH')
1807
- if (index.counts && shard?.counts && digest(index.counts) !== digest(shard.counts))
1808
- throw new Error('CONSUMER_INDEX_COUNTS_MISMATCH')
1809
- if (shard?.topCount !== undefined && index.topCount !== shard.topCount)
1810
- throw new Error('CONSUMER_INDEX_TOP_COUNT_MISMATCH')
1811
- }
1812
- if (products && index.iconCoverage) {
1813
- const expected = iconCoverage({ products })
1814
- if (
1815
- index.iconCoverage.total !== expected.total ||
1816
- index.iconCoverage.content !== expected.content ||
1817
- index.iconCoverage.favicon !== expected.favicon ||
1818
- index.iconCoverage.monogramOnly !== expected.monogramOnly ||
1819
- index.iconCoverage.monogramProductKeys.join('\n') !== expected.monogramProductKeys.join('\n')
1820
- )
1821
- throw new Error('CONSUMER_INDEX_ICON_COVERAGE_MISMATCH')
1822
- }
1823
- const shards: ShardArtifact[] = [
1824
- ...(products ? [{ name: CATALOG_INDEX_ARTIFACT, kind: 'index' as const }] : []),
1825
- ...(index.categories ?? []).flatMap((category) =>
1826
- products && category.shelfArtifact && category.listArtifact
1827
- ? [
1828
- { name: category.shelfArtifact, kind: 'shelf' as const, category: category.category },
1829
- { name: category.listArtifact, kind: 'list' as const, category: category.category },
1830
- ]
1831
- : []
1832
- ),
1833
- ]
1834
- return {
1835
- hasConsumerIndex: products !== undefined,
1836
- diagnostics,
1837
- ...(products ? { counts: index.counts as ConsumerIndex['counts'] } : {}),
1838
- ...(products && index.topCount !== undefined ? { topCount: index.topCount } : {}),
1839
- ...(products ? { products, categories: index.categories ?? [] } : {}),
1840
- shards: products ? shards : [],
1841
- }
1842
- }
1843
-
1844
- export function verifyCatalogReferences(
1845
- catalog: Catalog,
1846
- policy: CatalogPolicy = DEFAULT_POLICY
1847
- ): void {
1848
- for (const source of catalog.sources) {
1849
- assertRepositoryAllowed(source.repositoryUrl, policy)
1850
- validateCatalogPath(source.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID')
1851
- }
1852
- for (const plugin of catalog.plugins) {
1853
- assertRepositoryAllowed(plugin.provenance.repositoryUrl, policy)
1854
- validateCatalogPath(plugin.provenance.pluginSubdirectory, 'PROVENANCE_PATH_INVALID')
1855
- for (const release of plugin.availableReleases) {
1856
- assertRepositoryAllowed(release.resolvedRepositoryUrl, policy)
1857
- validateCatalogPath(release.pluginSubdirectory, 'RELEASE_PATH_INVALID')
1858
- for (const path of release.fileIndex) validateCatalogPath(path, 'RELEASE_FILE_PATH_INVALID')
1859
- }
1860
- }
1861
- }
1862
-
1863
- function validateCatalogPath(path: string, code: string): void {
1864
- if (path !== '.') safeRelativePath(path, code)
1865
- }
1866
-
1867
- function createChangeReport(
1868
- catalog: Catalog,
1869
- lock: SourcesLock,
1870
- existingLock?: SourcesLock,
1871
- existingCatalog?: Catalog,
1872
- skippedPlugins: readonly SkippedPlugin[] = []
1873
- ): ChangeReport {
1874
- const changedSources = existingLock
1875
- ? lock.sources
1876
- .filter((source) =>
1877
- (() => {
1878
- const previous = existingLock.sources.find(
1879
- (candidate) => candidate.sourceId === source.sourceId
1880
- )
1881
- const priorCatalog = existingCatalog?.sources.find(
1882
- (entry) => entry.sourceId === source.sourceId
1883
- )
1884
- const currentCatalog = catalog.sources.find(
1885
- (entry) => entry.sourceId === source.sourceId
1886
- )
1887
- return (
1888
- !previous ||
1889
- sourceLockChanged(previous, source) ||
1890
- (existingCatalog !== undefined &&
1891
- (!priorCatalog ||
1892
- !currentCatalog ||
1893
- priorCatalog.displayName !== currentCatalog.displayName ||
1894
- priorCatalog.trustClassification !== currentCatalog.trustClassification))
1895
- )
1896
- })()
1897
- )
1898
- .map((source) => source.sourceId)
1899
- : lock.sources.map((source) => source.sourceId)
1900
- const previousPlugins = new Map(
1901
- existingCatalog?.plugins.map((plugin) => [plugin.pluginId, plugin]) ?? []
1902
- )
1903
- const currentPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]))
1904
- const addedPlugins = catalog.plugins
1905
- .filter((plugin) => !previousPlugins.has(plugin.pluginId))
1906
- .map((plugin) => plugin.pluginId)
1907
- const removedPlugins = [...previousPlugins.keys()].filter(
1908
- (pluginId) => !currentPlugins.has(pluginId)
1909
- )
1910
- const changedPlugins = catalog.plugins
1911
- .filter(
1912
- (plugin) =>
1913
- previousPlugins.get(plugin.pluginId)?.currentReleaseId !== plugin.currentReleaseId &&
1914
- previousPlugins.has(plugin.pluginId)
1915
- )
1916
- .map((plugin) => plugin.pluginId)
1917
- return {
1918
- schemaVersion: 1,
1919
- changedSources: changedSources.toSorted(),
1920
- addedPlugins: addedPlugins.toSorted(),
1921
- removedPlugins: removedPlugins.toSorted(),
1922
- changedPlugins: changedPlugins.toSorted(),
1923
- skippedPlugins,
1924
- permissionSensitiveChanges: catalog.plugins
1925
- .flatMap((plugin) =>
1926
- plugin.securityClassification.permissionSensitiveChanges.map(
1927
- (type) => `${plugin.pluginId}:${type}`
1928
- )
1929
- )
1930
- .toSorted(),
1931
- contentResolution:
1932
- skippedPlugins.length > 0
1933
- ? 'complete-with-skips'
1934
- : catalog.plugins.some(
1935
- (plugin) => plugin.securityClassification.contentResolution === 'metadata-only'
1936
- )
1937
- ? 'metadata-only'
1938
- : 'complete',
1939
- }
1940
- }
1941
-
1942
- async function resolveSource(
1943
- config: SourceConfig,
1944
- mode: 'live' | 'offline',
1945
- fixtureRoot: string | undefined,
1946
- lock: SourcesLock | undefined,
1947
- fromLock: boolean
1948
- ): Promise<ResolvedSource> {
1949
- const manifestPath = safeRelativePath(config.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID')
1950
- const locked = lock?.sources.find((entry) => entry.sourceId === config.sourceId)
1951
- if (fromLock && !locked) throw new Error(`SOURCE_LOCK_ENTRY_MISSING: ${config.sourceId}`)
1952
- const commitSha =
1953
- fromLock && locked
1954
- ? locked.resolvedCommitSha
1955
- : mode === 'offline'
1956
- ? (config.fixtureCommitSha ?? locked?.resolvedCommitSha)
1957
- : await resolveGitRef(config.repositoryUrl, `refs/heads/${config.defaultBranch}`)
1958
- if (!commitSha || !/^[a-f0-9]{40}$/.test(commitSha))
1959
- throw new Error(`SOURCE_COMMIT_UNRESOLVED: ${config.sourceId}`)
1960
- const manifestText =
1961
- mode === 'offline'
1962
- ? await readFixtureManifest({ ...config, manifestPath }, fixtureRoot)
1963
- : await fetchText(rawUrl(config.repositoryUrl, commitSha, manifestPath))
1964
- const parsed = parseMarketplaceManifest(parseJsonDocument(manifestText), config)
1965
- if (fromLock && locked && digest(manifestText) !== locked.sourceManifestDigest)
1966
- throw new Error(`SOURCE_LOCK_MANIFEST_MISMATCH: ${config.sourceId}`)
1967
- const pluginPins = await resolvePluginPins({
1968
- config,
1969
- parsed,
1970
- sourceCommitSha: commitSha,
1971
- lockedPins: locked?.pluginPins,
1972
- fromLock,
1973
- resolveExternalRefs: mode === 'live',
1974
- })
1975
- return {
1976
- config,
1977
- commitSha,
1978
- manifestText,
1979
- parsed,
1980
- pluginPins,
1981
- retrievedAt:
1982
- fromLock && locked
1983
- ? locked.retrievedAt
1984
- : mode === 'offline'
1985
- ? '2026-01-01T00:00:00.000Z'
1986
- : new Date().toISOString(),
1987
- manifestDigest: digest(manifestText),
1988
- }
1989
- }
1990
-
1991
- async function resolvePluginPins(input: {
1992
- readonly config: SourceConfig
1993
- readonly parsed: ParsedMarketplace
1994
- readonly sourceCommitSha: string
1995
- readonly lockedPins: readonly PluginSourcePin[] | undefined
1996
- readonly fromLock: boolean
1997
- readonly resolveExternalRefs: boolean
1998
- }): Promise<PluginSourcePin[]> {
1999
- const pins = await mapWithConcurrency(
2000
- input.parsed.entries,
2001
- 8,
2002
- async (entry): Promise<PluginSourcePin> => {
2003
- if (entry.source.kind === 'local') {
2004
- return {
2005
- pluginName: entry.name,
2006
- repositoryUrl: canonicalRepositoryUrl(input.config.repositoryUrl),
2007
- pluginSubdirectory: entry.source.path,
2008
- resolvedCommitSha: input.sourceCommitSha,
2009
- }
2010
- }
2011
-
2012
- const repositoryUrl = canonicalRepositoryUrl(entry.source.repositoryUrl)
2013
- const pluginSubdirectory = entry.source.subdirectory
2014
- // Existing locks describe the prior sync; only explicit replay freezes revisions.
2015
- const locked = input.fromLock
2016
- ? input.lockedPins?.find((pin) => pin.pluginName === entry.name)
2017
- : undefined
2018
- if (input.fromLock && !locked)
2019
- throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISSING: ${input.config.sourceId}:${entry.name}`)
2020
- if (locked) {
2021
- if (
2022
- locked.repositoryUrl !== repositoryUrl ||
2023
- locked.pluginSubdirectory !== pluginSubdirectory
2024
- ) {
2025
- throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISMATCH: ${input.config.sourceId}:${entry.name}`)
2026
- }
2027
- return locked
2028
- }
2029
-
2030
- const resolvedCommitSha =
2031
- entry.source.sha ??
2032
- (input.resolveExternalRefs
2033
- ? await resolveGitRef(repositoryUrl, externalGitRef(entry.source.ref))
2034
- : input.sourceCommitSha)
2035
- return { pluginName: entry.name, repositoryUrl, pluginSubdirectory, resolvedCommitSha }
2036
- }
2037
- )
2038
- return pins.toSorted((left, right) =>
2039
- left.pluginName < right.pluginName ? -1 : left.pluginName > right.pluginName ? 1 : 0
2040
- )
2041
- }
2042
-
2043
- function externalGitRef(ref: string | undefined): string {
2044
- if (ref === undefined) return 'HEAD'
2045
- return ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
2046
- }
2047
-
2048
- async function mapWithConcurrency<T, U>(
2049
- values: readonly T[],
2050
- concurrency: number,
2051
- mapper: (value: T, index: number) => Promise<U>
2052
- ): Promise<U[]> {
2053
- const results = Array.from<U>({ length: values.length })
2054
- let nextIndex = 0
2055
- const worker = async (): Promise<void> => {
2056
- while (true) {
2057
- const index = nextIndex++
2058
- if (index >= values.length) return
2059
- results[index] = await mapper(values[index]!, index)
2060
- }
2061
- }
2062
- await Promise.all(
2063
- Array.from({ length: Math.min(Math.max(concurrency, 1), values.length) }, () => worker())
2064
- )
2065
- return results
2066
- }
2067
-
2068
- async function readFixtureManifest(
2069
- config: SourceConfig,
2070
- fixtureRoot: string | undefined
2071
- ): Promise<string> {
2072
- if (!fixtureRoot || !config.fixturePath)
2073
- throw new Error(`FIXTURE_ROOT_REQUIRED: ${config.sourceId}`)
2074
- return fs.readFile(join(resolve(fixtureRoot), config.fixturePath, config.manifestPath), 'utf8')
2075
- }
2076
-
2077
- async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string> {
2078
- const peeled = ref.startsWith('refs/tags/') && !ref.endsWith('^{}') ? `${ref}^{}` : undefined
2079
- const result = spawn(
2080
- 'git',
2081
- ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref, ...(peeled ? [peeled] : [])],
2082
- {
2083
- stdio: ['ignore', 'pipe', 'pipe'],
2084
- }
2085
- )
2086
- const [stdout, stderr, exitCode] = await Promise.all([
2087
- new Promise<string>((resolveOutput, reject) => {
2088
- const chunks: Buffer[] = []
2089
- result.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
2090
- result.stdout?.on('error', reject)
2091
- result.stdout?.on('end', () => resolveOutput(Buffer.concat(chunks).toString('utf8')))
2092
- }),
2093
- new Promise<string>((resolveError) => {
2094
- const chunks: Buffer[] = []
2095
- result.stderr?.on('data', (chunk: Buffer) => chunks.push(chunk))
2096
- result.stderr?.on('error', () => resolveError(''))
2097
- result.stderr?.on('end', () => resolveError(Buffer.concat(chunks).toString('utf8')))
2098
- }),
2099
- new Promise<number | null>((resolveExit, reject) => {
2100
- result.on('error', reject)
2101
- result.on('close', (code) => resolveExit(code ?? 1))
2102
- }),
2103
- ])
2104
- if (exitCode !== 0)
2105
- throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`)
2106
- const rows = stdout
2107
- .trim()
2108
- .split('\n')
2109
- .map((line) => line.split(/\s+/))
2110
- const sha =
2111
- (peeled && rows.find((row) => row[1] === peeled)?.[0]) ??
2112
- rows.find((row) => row[1] === ref)?.[0]
2113
- if (!sha || !/^[a-f0-9]{40}$/.test(sha))
2114
- throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`)
2115
- return sha
2116
- }
2117
-
2118
- /** Raw content URL for one file at an immutable commit. */
2119
- export function rawContentUrl(repositoryUrl: string, commitSha: string, path: string): string {
2120
- return rawUrl(repositoryUrl, commitSha, path)
2121
- }
2122
-
2123
- /** Fetches bytes from an allow-listed upstream URL with the shared retry policy. */
2124
- export async function fetchUpstreamBytes(url: string, signal?: AbortSignal): Promise<Uint8Array> {
2125
- const response = await fetchUpstream(url, {
2126
- headers: upstreamRequestHeaders(url),
2127
- ...(signal ? { signal } : {}),
2128
- })
2129
- if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}: ${url}`)
2130
- return new Uint8Array(await response.arrayBuffer())
2131
- }
2132
-
2133
- function rawUrl(repositoryUrl: string, commitSha: string, path: string): string {
2134
- const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
2135
- return `https://raw.githubusercontent.com${parsed.pathname}/${commitSha}/${path.split('/').map(encodeURIComponent).join('/')}`
2136
- }
2137
-
2138
- async function fetchText(url: string): Promise<string> {
2139
- // Manifest requests use the same bounded retry policy as the tree and archive.
2140
- const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2141
- if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`)
2142
- return response.text()
2143
- }
2144
-
2145
- const UPSTREAM_USER_AGENT = 'agent-hq-plugin-marketplace'
2146
-
2147
- /**
2148
- * Hosts that may receive the repository credential.
2149
- *
2150
- * Everything else is fetched anonymously. The token this build runs with
2151
- * authorises repository writes for the duration of the job, so a vendor
2152
- * homepage, its icon CDN or any other third party must never be handed it.
2153
- */
2154
- const GITHUB_CREDENTIAL_HOSTS = /(^|\.)(github\.com|githubusercontent\.com)$/
2155
-
2156
- /**
2157
- * Request headers for one upstream URL.
2158
- *
2159
- * The credential and GitHub's media type are attached only for GitHub hosts.
2160
- * Sending them elsewhere is not merely a needless disclosure: `appwrite.io`
2161
- * answers a page carrying an `Authorization` header with HTTP 500, and a CDN
2162
- * asked for an image with `accept: application/vnd.github+json` can answer with
2163
- * bytes that are not the mark. A build that did this dropped marks it had
2164
- * already curated, and the curation verifier — which used its own anonymous
2165
- * headers — could not see it.
2166
- */
2167
- export function upstreamRequestHeaders(url: string): Record<string, string> {
2168
- const anonymous = { 'user-agent': UPSTREAM_USER_AGENT }
2169
- let host: string
2170
- try {
2171
- host = new URL(url).hostname.toLowerCase()
2172
- } catch {
2173
- return anonymous
2174
- }
2175
- const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
2176
- if (!token || !GITHUB_CREDENTIAL_HOSTS.test(host)) return anonymous
2177
- return {
2178
- accept: 'application/vnd.github+json',
2179
- authorization: `Bearer ${token}`,
2180
- 'user-agent': UPSTREAM_USER_AGENT,
2181
- }
2182
- }
2183
-
2184
- /**
2185
- * Fetches vendor-site documents exactly as a live build does.
2186
- *
2187
- * Shared with the curation verifier so both ask a vendor the same question: a
2188
- * verifier with its own headers reports sites healthy that the build then fails
2189
- * to resolve.
2190
- *
2191
- * `deadline` optionally bounds one product's whole resolution. The per-request
2192
- * ceiling is always created fresh inside each fetch, so a caller that passes a
2193
- * deadline — or shares one — can never hand later requests an already-expired
2194
- * signal.
2195
- */
2196
- export function createSiteIconFetcher(deadline?: AbortSignal): SiteIconFetcher {
2197
- const bounded = (): AbortSignal => {
2198
- const ceiling = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
2199
- return deadline ? AbortSignal.any([deadline, ceiling]) : ceiling
2200
- }
2201
- return {
2202
- fetchText: async (url) => new TextDecoder().decode(await fetchUpstreamBytes(url, bounded())),
2203
- fetchBytes: (url) => fetchUpstreamBytes(url, bounded()),
2204
- digest: byteDigest,
2205
- }
2206
- }
2207
-
2208
- /** Ceiling for a single product-site request during icon discovery. */
2209
- export const SITE_FETCH_TIMEOUT_MS = 15_000
2210
-
2211
- const UPSTREAM_FETCH_MAX_ATTEMPTS = 3
2212
- const UPSTREAM_FETCH_BASE_DELAY_MS = 1000
2213
- const UPSTREAM_FETCH_MAX_DELAY_MS = 30000
2214
-
2215
- const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
2216
- 'PLUGIN_SOURCE_UNAVAILABLE',
2217
- 'PLUGIN_FILE_UNAVAILABLE',
2218
- 'PLUGIN_PATH_NOT_FOUND',
2219
- ])
2220
-
2221
- function upstreamRetryDelayMs(attempt: number, response?: Response): number {
2222
- const retryAfter = response?.headers.get('retry-after')
2223
- if (retryAfter !== null && retryAfter !== undefined) {
2224
- const seconds = Number(retryAfter)
2225
- if (Number.isFinite(seconds) && seconds >= 0)
2226
- return Math.min(seconds * 1000, UPSTREAM_FETCH_MAX_DELAY_MS)
2227
- const date = Date.parse(retryAfter)
2228
- if (!Number.isNaN(date))
2229
- return Math.min(Math.max(date - Date.now(), 0), UPSTREAM_FETCH_MAX_DELAY_MS)
2230
- }
2231
- return Math.min(UPSTREAM_FETCH_BASE_DELAY_MS * 2 ** attempt, UPSTREAM_FETCH_MAX_DELAY_MS)
2232
- }
2233
-
2234
- function isRateLimitedUpstream(response: Response): boolean {
2235
- return (
2236
- response.headers.get('x-ratelimit-remaining') === '0' ||
2237
- response.headers.get('retry-after') !== null
2238
- )
2239
- }
2240
-
2241
- function isRetryableUpstreamStatus(status: number, response?: Response): boolean {
2242
- if (status === 429) return true
2243
- if (status >= 500) return true
2244
- if (status === 403 && response !== undefined && isRateLimitedUpstream(response)) return true
2245
- return false
2246
- }
2247
-
2248
- async function fetchUpstream(url: string, init: RequestInit): Promise<Response> {
2249
- let attempt = 0
2250
- for (;;) {
2251
- let response: Response
2252
- try {
2253
- response = await fetch(url, init)
2254
- } catch (error) {
2255
- if (init.signal?.aborted || attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
2256
- await sleep(upstreamRetryDelayMs(attempt), undefined, { signal: init.signal ?? undefined })
2257
- attempt += 1
2258
- continue
2259
- }
2260
- if (
2261
- response.ok ||
2262
- !isRetryableUpstreamStatus(response.status, response) ||
2263
- attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1
2264
- )
2265
- return response
2266
- await response.body?.cancel().catch(() => {})
2267
- await sleep(upstreamRetryDelayMs(attempt, response), undefined, {
2268
- signal: init.signal ?? undefined,
2269
- })
2270
- attempt += 1
2271
- }
2272
- }
2273
-
2274
- /**
2275
- * Ceiling on bytes read out of one repository archive.
2276
- *
2277
- * The per-plugin policy still bounds what any single plugin may contain; this
2278
- * only stops a decompression bomb from exhausting the build host, and it is
2279
- * enforced while the stream is read rather than after it is buffered.
2280
- */
2281
- const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024
2282
-
2283
- function concatChunks(chunks: readonly Uint8Array[], length: number): Uint8Array {
2284
- const out = new Uint8Array(length)
2285
- let offset = 0
2286
- for (const chunk of chunks) {
2287
- out.set(chunk, offset)
2288
- offset += chunk.byteLength
2289
- }
2290
- return out
2291
- }
2292
-
2293
- /**
2294
- * Read one GitHub repository tarball into path -> bytes.
2295
- *
2296
- * GitHub wraps every entry in a single top-level `<repo>-<sha>/` directory,
2297
- * which is stripped so the keys match the tree API's repository-relative
2298
- * paths. Only regular files are taken: directories, symlinks, hardlinks and
2299
- * the `pax_global_header` record are skipped, so a link can never be
2300
- * materialized as content. Every remaining path goes through
2301
- * `safeRelativePath`, so an archive that tries to escape the repository is
2302
- * rejected rather than partially trusted. This is a whole-repository archive
2303
- * shared across plugins, so an oversized unrelated file is drained and
2304
- * counted against the archive budget but not retained. The selected plugin's
2305
- * files are checked against the tree before the archive is fetched and again
2306
- * when its snapshot is validated.
2307
- */
2308
- export async function readArchiveFiles(
2309
- archive: Uint8Array,
2310
- policy: CatalogPolicy
2311
- ): Promise<Map<string, Uint8Array>> {
2312
- const files = new Map<string, Uint8Array>()
2313
- let totalBytes = 0
2314
- const parser = extract()
2315
- const gunzip = createGunzip()
2316
- const settled = new Promise<void>((resolveDone, rejectDone) => {
2317
- const fail = (error: unknown): void => {
2318
- gunzip.destroy()
2319
- parser.destroy()
2320
- rejectDone(error)
2321
- }
2322
- parser.on('entry', (header, stream, next) => {
2323
- const skip = (): void => {
2324
- stream.on('end', next)
2325
- stream.resume()
2326
- }
2327
- if (header.type !== 'file') return skip()
2328
- const segments = header.name.split('/')
2329
- if (segments.length < 2) return skip()
2330
- let path: string
2331
- try {
2332
- path = safeRelativePath(segments.slice(1).join('/'), 'TREE_PATH_INVALID')
2333
- } catch (error) {
2334
- return fail(error)
2335
- }
2336
- const chunks: Uint8Array[] = []
2337
- let length = 0
2338
- stream.on('data', (chunk: unknown) => {
2339
- const bytes = chunk as Uint8Array
2340
- length += bytes.byteLength
2341
- if (totalBytes + length > MAX_ARCHIVE_BYTES) {
2342
- fail(new Error('PLUGIN_SIZE_POLICY: archive'))
2343
- return
2344
- }
2345
- if (length <= policy.maxFileBytes) chunks.push(bytes)
2346
- else chunks.length = 0
2347
- })
2348
- stream.on('error', fail)
2349
- stream.on('end', () => {
2350
- try {
2351
- totalBytes += length
2352
- if (length <= policy.maxFileBytes) files.set(path, concatChunks(chunks, length))
2353
- next()
2354
- } catch (error) {
2355
- fail(error)
2356
- }
2357
- })
2358
- stream.resume()
2359
- })
2360
- parser.on('error', fail)
2361
- gunzip.on('error', fail)
2362
- parser.on('finish', () => resolveDone())
2363
- })
2364
- Readable.from([archive]).pipe(gunzip).pipe(parser)
2365
- await settled
2366
- return files
2367
- }
2368
-
2369
- export class NetworkSnapshotLoader implements SnapshotLoader {
2370
- readonly #policy: CatalogPolicy
2371
- readonly #trees = new Map<string, Promise<readonly TreeEntry[]>>()
2372
- readonly #archives = new Map<string, Promise<ReadonlyMap<string, Uint8Array>>>()
2373
-
2374
- constructor(policy: CatalogPolicy) {
2375
- this.#policy = policy
2376
- }
2377
-
2378
- async load(
2379
- repositoryUrl: string,
2380
- commitSha: string,
2381
- pluginSubdirectory: string
2382
- ): Promise<Snapshot> {
2383
- const parsed = new URL(canonicalRepositoryUrl(repositoryUrl))
2384
- if (!['github.com', 'www.github.com'].includes(parsed.hostname))
2385
- throw new Error(`GIT_HOST_UNSUPPORTED: ${parsed.hostname}`)
2386
- const [owner, repo] = parsed.pathname.split('/').filter(Boolean)
2387
- if (!owner || !repo) throw new Error('GIT_REPOSITORY_INVALID')
2388
- const key = `${owner}/${repo}@${commitSha}`
2389
- let pendingTree = this.#trees.get(key)
2390
- if (!pendingTree) {
2391
- pendingTree = (async () => {
2392
- const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`
2393
- const response = await fetchUpstream(treeUrl, {
2394
- headers: upstreamRequestHeaders(treeUrl),
2395
- })
2396
- if (!response.ok)
2397
- throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2398
- const payload = (await response.json()) as { truncated?: boolean; tree?: TreeEntry[] }
2399
- if (payload.truncated) throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`)
2400
- return (payload.tree ?? []).map((entry) => ({
2401
- ...entry,
2402
- path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
2403
- }))
2404
- })().catch((error: unknown) => {
2405
- this.#trees.delete(key)
2406
- throw error
2407
- })
2408
- this.#trees.set(key, pendingTree)
2409
- }
2410
- const tree = await pendingTree
2411
- const prefix = pluginSubdirectory ? `${safeRelativePath(pluginSubdirectory)}/` : ''
2412
- const submodules = tree.filter(
2413
- (entry) =>
2414
- entry.path.startsWith(prefix) && (entry.type === 'commit' || entry.mode === '160000')
2415
- )
2416
- if (submodules.length > 0)
2417
- throw new Error(`GIT_SUBMODULE_UNSUPPORTED: ${repositoryUrl}:${pluginSubdirectory}`)
2418
- const selected = tree.filter((entry) => entry.type === 'blob' && entry.path.startsWith(prefix))
2419
- if (selected.length === 0)
2420
- throw new Error(`PLUGIN_PATH_NOT_FOUND: ${repositoryUrl}:${pluginSubdirectory}`)
2421
- const files = new Map<string, Uint8Array>()
2422
- const symlinks = tree
2423
- .filter((entry) => entry.mode === '120000' && entry.path.startsWith(prefix))
2424
- .map((entry) => entry.path)
2425
- if (selected.length > this.#policy.maxFilesPerPlugin) throw new Error('PLUGIN_TOO_MANY_FILES')
2426
- let totalSize = 0
2427
- for (const entry of selected) {
2428
- const size = entry.size ?? 0
2429
- if (size > this.#policy.maxFileBytes || totalSize + size > this.#policy.maxBytesPerPlugin)
2430
- throw new Error(`PLUGIN_SIZE_POLICY: ${entry.path}`)
2431
- totalSize += size
2432
- }
2433
- // The tree above already bounded this plugin before a byte was pulled, so
2434
- // the archive is the only unbounded read left and it is capped while it
2435
- // streams. One archive per repository replaces one request per file.
2436
- const archive = await this.#archiveFiles(key, owner, repo, commitSha)
2437
- for (const entry of selected) {
2438
- const bytes = archive.get(entry.path)
2439
- if (!bytes) throw new PluginSafetyError('PLUGIN_FILE_UNAVAILABLE', [entry.path])
2440
- if (bytes.byteLength > this.#policy.maxFileBytes)
2441
- throw new Error(`PLUGIN_FILE_TOO_LARGE: ${entry.path}`)
2442
- files.set(
2443
- prefix ? relative(prefix.slice(0, -1), entry.path).replaceAll('\\', '/') : entry.path,
2444
- bytes
2445
- )
2446
- }
2447
- return { files, symlinks }
2448
- }
2449
-
2450
- /**
2451
- * One repository archive per (repository, commit), shared by every plugin
2452
- * that repository hosts.
2453
- */
2454
- async #archiveFiles(
2455
- key: string,
2456
- owner: string,
2457
- repo: string,
2458
- commitSha: string
2459
- ): Promise<ReadonlyMap<string, Uint8Array>> {
2460
- const cached = this.#archives.get(key)
2461
- if (cached) return cached
2462
- const pending = (async () => {
2463
- const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`
2464
- const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2465
- if (!response.ok)
2466
- throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2467
- return readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy)
2468
- })().catch((error: unknown) => {
2469
- this.#archives.delete(key)
2470
- throw error
2471
- })
2472
- this.#archives.set(key, pending)
2473
- return pending
2474
- }
2475
- }
2476
-
2477
- interface TreeEntry {
2478
- readonly path: string
2479
- readonly mode?: string
2480
- readonly type?: string
2481
- readonly size?: number
2482
- }
2483
-
2484
- export class FixtureSnapshotLoader implements SnapshotLoader {
2485
- readonly #root: string
2486
- readonly #sources: ReadonlyMap<string, SourceConfig>
2487
-
2488
- constructor(root: string, sources: readonly SourceConfig[]) {
2489
- this.#root = resolve(root)
2490
- this.#sources = new Map(
2491
- sources.map((source) => [canonicalRepositoryUrl(source.repositoryUrl), source])
2492
- )
2493
- }
2494
-
2495
- async load(
2496
- repositoryUrl: string,
2497
- _commitSha: string,
2498
- pluginSubdirectory: string
2499
- ): Promise<Snapshot> {
2500
- const source = this.#sources.get(canonicalRepositoryUrl(repositoryUrl))
2501
- if (!source?.fixturePath) throw new Error(`FIXTURE_REPOSITORY_NOT_FOUND: ${repositoryUrl}`)
2502
- return snapshotFromDirectory(join(this.#root, source.fixturePath), pluginSubdirectory)
2503
- }
2504
- }
2505
-
2506
- export class DirectorySnapshotLoader implements SnapshotLoader {
2507
- async load(
2508
- _repositoryUrl: string,
2509
- _commitSha: string,
2510
- pluginSubdirectory: string
2511
- ): Promise<Snapshot> {
2512
- throw new Error(`DIRECTORY_LOADER_REQUIRES_ROOT: ${pluginSubdirectory}`)
2513
- }
2514
- }
2515
-
2516
- export async function snapshotFromDirectory(
2517
- root: string,
2518
- pluginSubdirectory: string
2519
- ): Promise<Snapshot> {
2520
- const directory = resolve(root, pluginSubdirectory)
2521
- const files = new Map<string, Uint8Array>()
2522
- const symlinks: string[] = []
2523
- const rootStat = await fs.lstat(directory)
2524
- if (rootStat.isSymbolicLink()) return { files, symlinks: [pluginSubdirectory || '.'] }
2525
- await walk(directory, directory, files, symlinks)
2526
- return { files, symlinks }
2527
- }
2528
-
2529
- async function walk(
2530
- root: string,
2531
- current: string,
2532
- files: Map<string, Uint8Array>,
2533
- symlinks: string[]
2534
- ): Promise<void> {
2535
- for (const entry of await fs.readdir(current, { withFileTypes: true })) {
2536
- const absolute = join(current, entry.name)
2537
- const relativePath = safeRelativePath(relative(root, absolute), 'DIRECTORY_PATH_INVALID')
2538
- if (entry.isSymbolicLink()) {
2539
- symlinks.push(relativePath)
2540
- continue
2541
- }
2542
- if (entry.isDirectory()) await walk(root, absolute, files, symlinks)
2543
- else if (entry.isFile()) files.set(relativePath, await fs.readFile(absolute))
2544
- }
2545
- }
2546
-
2547
- function sortArtifactKeys(value: unknown): unknown {
2548
- if (Array.isArray(value)) return value.map(sortArtifactKeys)
2549
- if (value !== null && typeof value === 'object')
2550
- return Object.fromEntries(
2551
- Object.keys(value as Record<string, unknown>)
2552
- .toSorted()
2553
- .map((key) => [key, sortArtifactKeys((value as Record<string, unknown>)[key])])
2554
- )
2555
- return value
2556
- }
2557
-
2558
- function json(value: unknown): string {
2559
- return `${JSON.stringify(sortArtifactKeys(value), null, 2)}\n`
2560
- }
2561
-
2562
- function trimSlugEdges(value: string): string {
2563
- let start = 0
2564
- let end = value.length
2565
- while (start < end && value[start] === '-') start += 1
2566
- while (end > start && value[end - 1] === '-') end -= 1
2567
- return value.slice(start, end)
2568
- }
2569
-
2570
- function slug(value: string): string {
2571
- const result =
2572
- trimSlugEdges(
2573
- value
2574
- .trim()
2575
- .toLocaleLowerCase()
2576
- .replace(/[^a-z0-9]+/g, '-')
2577
- ).slice(0, 128) || 'unknown'
2578
- return result.length < 2 ? `${result}-plugin` : result
2579
- }
2580
-
2581
- function stringValue(input: unknown): string | undefined {
2582
- return typeof input === 'string' && input.trim() ? input.trim() : undefined
2583
- }
2584
-
2585
- function isString(input: unknown): input is string {
2586
- return typeof input === 'string' && input.trim().length > 0
2587
- }
2588
-
2589
- function arrayValue(input: unknown): unknown[] {
2590
- return Array.isArray(input) ? input : []
2591
- }
9
+ export {
10
+ createArtifacts,
11
+ iconAssets,
12
+ writeArtifacts,
13
+ type ConsumerIndexOptions,
14
+ type GeneratedArtifacts,
15
+ } from './artifacts.js'
16
+ export { writeFileAtomically } from './fs-atomic.js'
17
+ export type { PublishedAsset } from './integrity-manifest.js'
18
+ export { buildCatalog } from './build.js'
19
+ export { classifyCapabilities, createCompatibility } from './capabilities.js'
20
+ export { byteDigest, canonicalJson, digest, sourceDigest as bytesDigest } from './digest.js'
21
+ export { CATALOG_CONTRACT_VERSION, type ReleaseTransform } from './plugin-release.js'
22
+ export type { PluginSkipReasonCode, SkippedPlugin } from './plugin-skips.js'
23
+ export type { CatalogPolicy } from './policy.js'
24
+ export {
25
+ DirectorySnapshotLoader,
26
+ FixtureSnapshotLoader,
27
+ NetworkSnapshotLoader,
28
+ readArchiveFiles,
29
+ snapshotFromDirectory,
30
+ type Snapshot,
31
+ type SnapshotLoader,
32
+ } from './snapshots.js'
33
+ export type { PluginSourcePin, ResolvedSource } from './source-resolution.js'
34
+ export { synchronize, type ChangeReport, type SyncInput, type SyncResult } from './synchronize.js'
35
+ export {
36
+ normalizeCategory,
37
+ orderLeadingPlugins,
38
+ productGroupingKey,
39
+ stablePluginId,
40
+ stableReleaseId,
41
+ type CategoryMap,
42
+ type ProductAliases,
43
+ } from './taxonomy.js'
44
+ export {
45
+ createSiteIconFetcher,
46
+ fetchUpstreamBytes,
47
+ rawContentUrl,
48
+ SITE_FETCH_TIMEOUT_MS,
49
+ upstreamRequestHeaders,
50
+ } from './upstream.js'
51
+ export {
52
+ verifyArtifacts,
53
+ verifyCatalogReferences,
54
+ verifyConsumerIndex,
55
+ verifyShardArtifact,
56
+ type ConsumerIndexVerification,
57
+ type ShardArtifact,
58
+ } from './verify-artifacts.js'