@adea-ai/plugins 1.0.0

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