@adea-ai/plugins 4.0.0 → 4.0.6

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