@adea-ai/plugins 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
package/dist/index.js ADDED
@@ -0,0 +1,1877 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { promises as fs } from 'node:fs';
4
+ import { basename, dirname, join, relative, resolve } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { createGunzip } from 'node:zlib';
7
+ import { extract } from 'tar-stream';
8
+ import { CatalogSchema, HarnessSchema, SourcesLockSchema, PluginSchema, } from './schema.js';
9
+ import { canonicalRepositoryUrl, parseJsonDocument, parseMarketplaceManifest, safeRelativePath, } from './sources.js';
10
+ import { buildConsumerIndex, CATALOG_INDEX_ARTIFACT, iconCoverage, } from './consumer-index.js';
11
+ import { iconAssetName, iconOverrideFor, inspectIconBytes, selectIconPath, } from './icons.js';
12
+ import { stageIconBytes } from './icon-mirror.js';
13
+ import { isVendorHomepage, resolveSiteIcon, } from './site-icons.js';
14
+ import { normalizeDisplayName } from './display-name.js';
15
+ import { immutableAssetUrl } from './publication.js';
16
+ /**
17
+ * Marks the catalog format this compiler emits.
18
+ *
19
+ * Bumping or first introducing this value makes a synchronization replay its
20
+ * verified pins and rebuild, which is how a format change reaches the published
21
+ * catalog when no upstream source has moved. It is deliberately separate from
22
+ * the agent-package normalizer version, which describes package normalization
23
+ * and would misreport a change here.
24
+ */
25
+ export const CATALOG_CONTRACT_VERSION = 'adea-catalog/3';
26
+ const DEFAULT_POLICY = {
27
+ allowedRepositoryProtocols: ['https:'],
28
+ allowedRepositoryHosts: ['github.com', 'www.github.com'],
29
+ maxFilesPerPlugin: 4096,
30
+ maxBytesPerPlugin: 50 * 1024 * 1024,
31
+ maxFileBytes: 5 * 1024 * 1024,
32
+ maxMarketplacePlugins: 4096,
33
+ denyExecutableLifecycleScripts: true,
34
+ publishRequiresCompleteContent: false,
35
+ sensitiveCapabilityTypes: [
36
+ 'mcp-server',
37
+ 'connector',
38
+ 'hook',
39
+ 'browser',
40
+ 'scheduled-task',
41
+ 'executable',
42
+ ],
43
+ };
44
+ export function canonicalJson(value) {
45
+ if (value === null || typeof value !== 'object')
46
+ return JSON.stringify(value);
47
+ if (Array.isArray(value))
48
+ return `[${value.map(canonicalJson).join(',')}]`;
49
+ const object = value;
50
+ return `{${Object.keys(object)
51
+ .toSorted()
52
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
53
+ .join(',')}}`;
54
+ }
55
+ export function digest(value) {
56
+ return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
57
+ }
58
+ export function bytesDigest(files) {
59
+ const hash = createHash('sha256');
60
+ for (const [path, bytes] of [...files.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
61
+ hash.update(`${path.length}:${path}:${bytes.byteLength}:`);
62
+ hash.update(bytes);
63
+ }
64
+ return `sha256:${hash.digest('hex')}`;
65
+ }
66
+ export function stablePluginId(sourceId, upstreamName) {
67
+ return `plugin:${slug(sourceId)}:${slug(upstreamName)}`;
68
+ }
69
+ export function stableReleaseId(repositoryUrl, subdirectory, commitSha, contentDigest) {
70
+ return `release:${digest({ repositoryUrl, subdirectory, commitSha, contentDigest }).slice('sha256:'.length)}`;
71
+ }
72
+ export function normalizeCategory(value, map) {
73
+ const key = value.trim().toLocaleLowerCase();
74
+ return map.aliases[key] ?? (slug(key) || map.fallback);
75
+ }
76
+ export function productGroupingKey(name, aliases) {
77
+ const key = name.trim().toLocaleLowerCase();
78
+ return aliases.aliases[key] ?? slug(key);
79
+ }
80
+ export async function synchronize(input) {
81
+ const mode = input.mode ?? 'live';
82
+ const configs = input.sources.filter((source) => !input.sourceId || source.sourceId === input.sourceId);
83
+ if (configs.length === 0)
84
+ throw new Error(`SOURCE_NOT_FOUND: ${input.sourceId ?? 'none'}`);
85
+ const lockToUse = input.fromLock ?? input.existingLock;
86
+ const resolved = await mapWithConcurrency(configs, 4, (config) => resolveSource(config, mode, input.fixtureRoot, lockToUse, input.fromLock !== undefined));
87
+ const sourceHeads = resolved.map(({ config, commitSha }) => ({
88
+ sourceId: config.sourceId,
89
+ commitSha,
90
+ }));
91
+ const unchanged = input.fromLock === undefined &&
92
+ input.forceRebuild !== true &&
93
+ input.existingLock !== undefined &&
94
+ resolved.length === input.existingLock.sources.length &&
95
+ resolved.every((source) => {
96
+ const previous = input.existingLock?.sources.find((entry) => entry.sourceId === source.config.sourceId);
97
+ if (!previous)
98
+ return false;
99
+ return (previous.resolvedCommitSha === source.commitSha &&
100
+ previous.sourceManifestDigest === source.manifestDigest &&
101
+ digest(previous.pluginPins) === digest(source.pluginPins));
102
+ });
103
+ if (unchanged) {
104
+ return {
105
+ changed: false,
106
+ dryRun: input.dryRun ?? false,
107
+ sourceHeads,
108
+ changeReport: {
109
+ schemaVersion: 1,
110
+ changedSources: [],
111
+ addedPlugins: [],
112
+ removedPlugins: [],
113
+ changedPlugins: [],
114
+ permissionSensitiveChanges: [],
115
+ skippedPlugins: [],
116
+ contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
117
+ },
118
+ };
119
+ }
120
+ const buildResult = await buildCatalogInternal({
121
+ resolvedSources: resolved,
122
+ categoryMap: input.categoryMap,
123
+ ...(input.productCategories ? { productCategories: input.productCategories } : {}),
124
+ ...(input.leading ? { leading: input.leading } : {}),
125
+ ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
126
+ ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
127
+ productAliases: input.productAliases,
128
+ policy: input.policy,
129
+ metadataOnly: input.metadataOnly ?? false,
130
+ snapshotLoader: input.snapshotLoader ??
131
+ (mode === 'offline'
132
+ ? new FixtureSnapshotLoader(input.fixtureRoot ?? 'fixtures', configs)
133
+ : new NetworkSnapshotLoader(input.policy)),
134
+ resolveExternalRefs: mode === 'live',
135
+ ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
136
+ });
137
+ const catalog = buildResult.catalog;
138
+ const lock = createSourcesLock(resolved);
139
+ const artifacts = createArtifacts(orderLeadingPlugins(catalog, input.leading), lock, {
140
+ ...(input.leading ? { leading: input.leading } : {}),
141
+ ...(input.topCount !== undefined ? { topCount: input.topCount } : {}),
142
+ ...(input.productPreference ? { productPreference: input.productPreference } : {}),
143
+ ...(input.publicationRepositoryUrl
144
+ ? { publicationRepositoryUrl: input.publicationRepositoryUrl }
145
+ : {}),
146
+ ...(input.publicationAssetsBaseUrl
147
+ ? { publicationAssetsBaseUrl: input.publicationAssetsBaseUrl }
148
+ : {}),
149
+ ...(input.publicationCatalogsBaseUrl
150
+ ? { publicationCatalogsBaseUrl: input.publicationCatalogsBaseUrl }
151
+ : {}),
152
+ curationDiagnostics: (input.mode ?? 'live') !== 'offline',
153
+ });
154
+ verifyArtifacts(artifacts);
155
+ const report = createChangeReport(catalog, lock, input.existingLock, input.existingCatalog, buildResult.skippedPlugins);
156
+ if (input.policy.publishRequiresCompleteContent && input.metadataOnly) {
157
+ throw new Error('CATALOG_CONTENT_INCOMPLETE: publication requires complete content');
158
+ }
159
+ return {
160
+ changed: true,
161
+ dryRun: input.dryRun ?? false,
162
+ sourceHeads,
163
+ catalog,
164
+ lock,
165
+ artifacts,
166
+ changeReport: report,
167
+ };
168
+ }
169
+ export async function buildCatalog(input) {
170
+ return (await buildCatalogInternal({
171
+ resolvedSources: input.resolvedSources,
172
+ categoryMap: input.categoryMap,
173
+ productAliases: input.productAliases,
174
+ policy: input.policy ?? DEFAULT_POLICY,
175
+ metadataOnly: input.metadataOnly ?? false,
176
+ snapshotLoader: input.snapshotLoader,
177
+ resolveExternalRefs: input.resolveExternalRefs ?? false,
178
+ ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
179
+ ...(input.productCategories ? { productCategories: input.productCategories } : {}),
180
+ ...(input.leading ? { leading: input.leading } : {}),
181
+ ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
182
+ })).catalog;
183
+ }
184
+ async function buildCatalogInternal(input) {
185
+ const policy = input.policy ?? DEFAULT_POLICY;
186
+ for (const source of input.resolvedSources) {
187
+ assertRepositoryAllowed(source.config.repositoryUrl, policy);
188
+ for (const entry of source.parsed.entries) {
189
+ if (entry.source.kind === 'git')
190
+ assertRepositoryAllowed(entry.source.repositoryUrl, policy);
191
+ }
192
+ }
193
+ const sourceRecords = input.resolvedSources
194
+ .map(({ config, commitSha, retrievedAt, manifestDigest }) => ({
195
+ sourceId: config.sourceId,
196
+ displayName: config.displayName,
197
+ repositoryUrl: canonicalRepositoryUrl(config.repositoryUrl),
198
+ marketplaceDialect: config.marketplaceDialect,
199
+ manifestPath: config.manifestPath,
200
+ defaultBranch: config.defaultBranch,
201
+ resolvedCommitSha: commitSha,
202
+ retrievalTimestamp: retrievedAt,
203
+ trustClassification: config.trustClassification,
204
+ sourceManifestDigest: manifestDigest,
205
+ synchronizationStatus: 'synchronized',
206
+ }))
207
+ .toSorted((left, right) => left.sourceId.localeCompare(right.sourceId));
208
+ let plugins = [];
209
+ let skippedPlugins = [];
210
+ for (const source of input.resolvedSources) {
211
+ if (source.parsed.entries.length > policy.maxMarketplacePlugins)
212
+ throw new Error('MARKETPLACE_TOO_MANY_PLUGINS');
213
+ const normalized = await mapWithConcurrency(source.parsed.entries, 8, async (entry) => {
214
+ try {
215
+ const plugin = await normalizePlugin({
216
+ source,
217
+ entry,
218
+ categoryMap: input.categoryMap,
219
+ ...(input.productCategories ? { productCategories: input.productCategories } : {}),
220
+ productAliases: input.productAliases,
221
+ ...(input.productIconOverrides
222
+ ? { productIconOverrides: input.productIconOverrides }
223
+ : {}),
224
+ ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
225
+ policy,
226
+ metadataOnly: input.metadataOnly ?? false,
227
+ snapshotLoader: input.snapshotLoader,
228
+ resolveExternalRefs: input.resolveExternalRefs ?? true,
229
+ ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
230
+ });
231
+ try {
232
+ return { plugin: PluginSchema.parse(plugin) };
233
+ }
234
+ catch {
235
+ throw new PluginSafetyError('PLUGIN_SCHEMA_INVALID', []);
236
+ }
237
+ }
238
+ catch (error) {
239
+ const skipped = createSkippedPlugin(source, entry, error);
240
+ if (!skipped)
241
+ throw error;
242
+ return { skipped };
243
+ }
244
+ });
245
+ const sourcePlugins = [];
246
+ const sourceSkipped = [];
247
+ for (const result of normalized) {
248
+ if (result.plugin)
249
+ sourcePlugins.push(result.plugin);
250
+ if (result.skipped)
251
+ sourceSkipped.push(result.skipped);
252
+ }
253
+ const weatherSkips = sourceSkipped.filter((skipped) => UPSTREAM_WEATHER_SKIP_CODES.has(skipped.reasonCode));
254
+ if (source.parsed.entries.length > 0 && weatherSkips.length === source.parsed.entries.length)
255
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [
256
+ canonicalRepositoryUrl(source.config.repositoryUrl),
257
+ ]);
258
+ for (const plugin of sourcePlugins)
259
+ plugins.push(plugin);
260
+ for (const skipped of sourceSkipped)
261
+ skippedPlugins.push(skipped);
262
+ }
263
+ plugins = plugins.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
264
+ skippedPlugins = skippedPlugins.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId) ||
265
+ left.reasonCode.localeCompare(right.reasonCode) ||
266
+ left.paths.join('\0').localeCompare(right.paths.join('\0')));
267
+ const body = {
268
+ schemaVersion: 1,
269
+ generatedAt: sourceRecords
270
+ .map((s) => s.retrievalTimestamp)
271
+ .toSorted()
272
+ .at(-1) ?? new Date(0).toISOString(),
273
+ sources: sourceRecords,
274
+ plugins,
275
+ };
276
+ return {
277
+ catalog: CatalogSchema.parse({
278
+ ...body,
279
+ catalogId: `catalog:${digest(body).slice('sha256:'.length)}`,
280
+ }),
281
+ skippedPlugins,
282
+ };
283
+ }
284
+ class PluginSafetyError extends Error {
285
+ reasonCode;
286
+ paths;
287
+ constructor(reasonCode, paths) {
288
+ super(`${reasonCode}${paths.length > 0 ? `: ${paths.join(',')}` : ''}`);
289
+ this.reasonCode = reasonCode;
290
+ this.paths = paths;
291
+ this.name = 'PluginSafetyError';
292
+ }
293
+ }
294
+ const pluginSkipReasons = {
295
+ GIT_SUBMODULE_UNSUPPORTED: 'The plugin contains a Git submodule, so the catalog excludes it rather than traversing an unpinned repository boundary.',
296
+ PLUGIN_FILE_TOO_LARGE: 'The plugin contains a file over the configured size limit, so the catalog excludes the plugin before publication.',
297
+ PLUGIN_FILE_UNAVAILABLE: 'A plugin file could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
298
+ PLUGIN_MANIFEST_INVALID: 'The plugin manifest is malformed, so the catalog excludes the plugin before consuming its metadata.',
299
+ PLUGIN_PATH_NOT_FOUND: 'The declared plugin path does not exist at the pinned commit, so the catalog excludes the plugin.',
300
+ PLUGIN_SCHEMA_INVALID: 'The normalized plugin record exceeds the published catalog schema, so the catalog excludes it before publication.',
301
+ PLUGIN_SIZE_POLICY: 'The plugin exceeds the configured size policy, so the catalog excludes it before publication.',
302
+ PLUGIN_SOURCE_UNAVAILABLE: 'The plugin repository tree could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
303
+ PLUGIN_TOO_LARGE: 'The plugin exceeds the configured aggregate size limit, so the catalog excludes it before publication.',
304
+ PLUGIN_TOO_MANY_FILES: 'The plugin exceeds the configured file-count limit, so the catalog excludes it before publication.',
305
+ SNAPSHOT_PATH_INVALID: 'The plugin snapshot contains an unsafe path, so the catalog excludes it rather than allowing repository escape.',
306
+ SYMLINK_ESCAPE: 'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
307
+ };
308
+ function createSkippedPlugin(source, entry, error) {
309
+ const parsed = error instanceof PluginSafetyError
310
+ ? { reasonCode: error.reasonCode, paths: error.paths }
311
+ : parsePluginSafetyError(error);
312
+ if (!parsed)
313
+ return undefined;
314
+ return {
315
+ sourceId: source.config.sourceId,
316
+ pluginId: stablePluginId(source.config.sourceId, entry.name),
317
+ pluginName: entry.name,
318
+ reasonCode: parsed.reasonCode,
319
+ securityReason: pluginSkipReasons[parsed.reasonCode],
320
+ incompleteContent: true,
321
+ paths: [...new Set(parsed.paths)].toSorted(),
322
+ };
323
+ }
324
+ function parsePluginSafetyError(error) {
325
+ const message = error instanceof Error ? error.message : String(error);
326
+ const match = /^([A-Z_]+)(?::\s?(.*))?$/.exec(message);
327
+ if (!match)
328
+ return undefined;
329
+ const reasonCode = match[1];
330
+ if (!(reasonCode in pluginSkipReasons))
331
+ return undefined;
332
+ return {
333
+ reasonCode,
334
+ paths: match[2]
335
+ ? match[2]
336
+ .split(',')
337
+ .map((path) => path.trim())
338
+ .filter(Boolean)
339
+ : [],
340
+ };
341
+ }
342
+ async function normalizePlugin(input) {
343
+ const { source, entry, policy } = input;
344
+ const resolved = await resolvePluginSource(entry.name, entry.source, source, input.resolveExternalRefs);
345
+ const snapshot = input.metadataOnly
346
+ ? { files: new Map(), symlinks: [] }
347
+ : await input.snapshotLoader.load(resolved.repositoryUrl, resolved.commitSha, resolved.subdirectory);
348
+ if (snapshot.symlinks.length > 0)
349
+ throw new PluginSafetyError('SYMLINK_ESCAPE', [...snapshot.symlinks].toSorted());
350
+ validateSnapshot(snapshot, policy, entry.name);
351
+ const contentDigest = input.metadataOnly
352
+ ? digest({
353
+ repositoryUrl: resolved.repositoryUrl,
354
+ subdirectory: resolved.subdirectory,
355
+ commitSha: resolved.commitSha,
356
+ entryDigest: entry.entryDigest,
357
+ })
358
+ : bytesDigest(snapshot.files);
359
+ const pluginManifest = findPluginManifest(snapshot, input.transformRelease !== undefined);
360
+ const manifestDigest = pluginManifest ? digest(pluginManifest.metadata) : entry.entryDigest;
361
+ const capabilities = classifyCapabilities(snapshot.files, pluginManifest?.metadata);
362
+ const iconOverride = iconOverrideFor(input.productIconOverrides, productGroupingKey(entry.name, input.productAliases));
363
+ const contentIcon = resolveContentIcon(snapshot, iconOverride, input.metadataOnly ?? false);
364
+ const favicon = contentIcon === undefined
365
+ ? await resolveFavicon({
366
+ entry,
367
+ iconOverride,
368
+ metadataOnly: input.metadataOnly ?? false,
369
+ live: input.resolveExternalRefs === true,
370
+ })
371
+ : undefined;
372
+ // Stage the mark while its bytes are in hand. Publication then needs no
373
+ // re-fetch, and a site icon cannot drift between resolution and release.
374
+ if (input.iconAssetsDirectory) {
375
+ const staged = contentIcon
376
+ ? snapshot.files.get(contentIcon.kind === 'content' ? contentIcon.path : '')
377
+ : favicon?.bytes;
378
+ if (staged)
379
+ await stageIconBytes(input.iconAssetsDirectory, iconAssetName(contentIcon ? contentIcon.digest : favicon.digest, contentIcon ? contentIcon.contentType : favicon.contentType), staged);
380
+ }
381
+ const releaseId = stableReleaseId(resolved.repositoryUrl, resolved.subdirectory, resolved.commitSha, contentDigest);
382
+ const originalRelease = createRelease({
383
+ source,
384
+ entry,
385
+ resolved,
386
+ contentDigest,
387
+ manifestDigest,
388
+ capabilities,
389
+ snapshot,
390
+ ...(pluginManifest ? { pluginManifest } : {}),
391
+ releaseId,
392
+ ...(contentIcon ? { contentIcon } : {}),
393
+ ...(favicon ? { favicon } : {}),
394
+ metadataOnly: input.metadataOnly,
395
+ });
396
+ const release = input.transformRelease && !input.metadataOnly
397
+ ? input.transformRelease(originalRelease, snapshot, entry.name)
398
+ : originalRelease;
399
+ const compatibility = createCompatibility(source.config.marketplaceDialect, capabilities, policy);
400
+ const security = createSecurity(capabilities, policy, input.metadataOnly);
401
+ const normalizedCategories = entry.categories.map((category) => normalizeCategory(category, input.categoryMap));
402
+ const productCategory = input.productCategories?.[entry.name];
403
+ const categories = [
404
+ ...new Set((productCategory
405
+ ? [productCategory, ...normalizedCategories.filter((category) => category !== 'other')]
406
+ : normalizedCategories.length > 0
407
+ ? normalizedCategories
408
+ : [input.categoryMap.fallback]).filter(Boolean)),
409
+ ].toSorted();
410
+ // The name a list renders: marketplace manifests are inconsistent (`Gmail`
411
+ // beside `zapier` beside `aws-agents-for-devsecops`), so the catalog
412
+ // publishes the written form once and every consumer shows the same thing.
413
+ const displayName = normalizeDisplayName(entry.displayName ?? pluginManifest?.displayName ?? entry.name);
414
+ const authors = entry.authors.length > 0
415
+ ? [...entry.authors]
416
+ : source.parsed.owner.length > 0
417
+ ? [...source.parsed.owner]
418
+ : ['Unknown'];
419
+ const keywords = [...new Set([...entry.keywords, ...entry.name.split(/[-_\s]+/g), ...categories])]
420
+ .filter(Boolean)
421
+ .toSorted();
422
+ const provenance = {
423
+ sourceId: source.config.sourceId,
424
+ repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
425
+ manifestPath: source.config.manifestPath,
426
+ pluginSubdirectory: resolved.subdirectory || '.',
427
+ resolvedCommitSha: resolved.commitSha,
428
+ sourceManifestDigest: source.manifestDigest,
429
+ upstreamEntryDigest: entry.entryDigest,
430
+ };
431
+ return {
432
+ pluginId: stablePluginId(source.config.sourceId, entry.name),
433
+ displayName,
434
+ description: entry.description || pluginManifest?.description || '',
435
+ productGroupingKey: productGroupingKey(entry.name, input.productAliases),
436
+ categories,
437
+ keywords,
438
+ authors,
439
+ ...(entry.homepage ? { homepage: entry.homepage } : {}),
440
+ icons: entry.icons.length > 0 ? [...entry.icons].toSorted() : (pluginManifest?.icons ?? []),
441
+ sourceId: source.config.sourceId,
442
+ upstreamPluginName: entry.name,
443
+ currentReleaseId: releaseId,
444
+ availableReleases: [release],
445
+ capabilitySummary: capabilitySummary(capabilities),
446
+ harnessCompatibility: compatibility,
447
+ license: findLicense(entry, pluginManifest?.metadata),
448
+ provenance,
449
+ securityClassification: security,
450
+ };
451
+ }
452
+ function createRelease(input) {
453
+ const requiredCredentials = new Set();
454
+ const auth = input.entry.policy.authentication;
455
+ if (typeof auth === 'string' && auth !== 'NONE' && auth !== 'DISABLED')
456
+ requiredCredentials.add('authentication');
457
+ for (const value of arrayValue(input.entry.raw.requiredCredentials))
458
+ if (typeof value === 'string')
459
+ requiredCredentials.add(value);
460
+ const requiredConnectors = [
461
+ ...new Set(arrayValue(input.entry.raw.connectors).filter(isString)),
462
+ ].toSorted();
463
+ const permissionSensitiveChanges = input.capabilities
464
+ .filter((capability) => capability.securityImpact === 'sensitive')
465
+ .map((capability) => capability.type)
466
+ .toSorted();
467
+ const metadata = {
468
+ catalogContract: CATALOG_CONTRACT_VERSION,
469
+ marketplaceEntry: input.entry.raw,
470
+ ...(input.pluginManifest ? { pluginManifest: input.pluginManifest.metadata } : {}),
471
+ sourceDialect: input.source.config.marketplaceDialect,
472
+ };
473
+ const version = input.pluginManifest?.version ?? stringValue(input.entry.raw.version);
474
+ return {
475
+ releaseId: input.releaseId,
476
+ ...(version ? { upstreamVersion: version } : {}),
477
+ resolvedRepositoryUrl: input.resolved.repositoryUrl,
478
+ resolvedCommitSha: input.resolved.commitSha,
479
+ pluginSubdirectory: input.resolved.subdirectory || '.',
480
+ canonicalContentDigest: input.contentDigest,
481
+ manifestDigest: input.manifestDigest,
482
+ contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
483
+ releaseMetadata: metadata,
484
+ capabilities: [...input.capabilities].toSorted((left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name)),
485
+ requiredConnectors,
486
+ requiredCredentials: [...requiredCredentials].toSorted(),
487
+ permissionSensitiveChanges: [...new Set(permissionSensitiveChanges)],
488
+ fileIndex: [...input.snapshot.files.keys()].toSorted(),
489
+ publicationTimestamp: input.source.retrievedAt,
490
+ ...assembleIcon(input),
491
+ };
492
+ }
493
+ /**
494
+ * Resolves the product site's icon, for a plugin whose content ships no mark.
495
+ *
496
+ * Only a live build with complete content attempts this: a metadata-only or
497
+ * offline run must not reach out to vendor sites, and neither may a product the
498
+ * curator has already decided about.
499
+ */
500
+ async function resolveFavicon(input) {
501
+ if (!input.live || input.metadataOnly)
502
+ return undefined;
503
+ if (input.iconOverride?.forced === null || input.iconOverride?.forced)
504
+ return undefined;
505
+ const homepage = input.iconOverride?.site ?? input.entry.homepage;
506
+ if (!isVendorHomepage(homepage))
507
+ return undefined;
508
+ // Bound both fetches: a vendor site that never answers must not stall a
509
+ // catalogue build. A timeout simply leaves the product on its monogram.
510
+ const signal = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS);
511
+ const resolved = await resolveSiteIcon(homepage, createSiteIconFetcher(signal));
512
+ return 'contentType' in resolved ? resolved : undefined;
513
+ }
514
+ /**
515
+ * Picks and vets the brand mark for a release.
516
+ *
517
+ * A file in the plugin's own content is preferred, because it is the mark the
518
+ * vendor ships with the plugin. When the content has none, a live build falls
519
+ * back to the declared homepage's site icon: many plugins are a thin wrapper
520
+ * over a vendor API and point at the vendor's site rather than shipping a logo.
521
+ * Whatever the source, an icon that fails inspection is absent rather than
522
+ * advertised: the catalog never publishes content it would refuse to mirror.
523
+ */
524
+ function resolveContentIcon(snapshot, iconOverride, metadataOnly) {
525
+ if (metadataOnly || snapshot.files.size === 0)
526
+ return undefined;
527
+ // A curator override wins over every rule, including "publish no icon".
528
+ if (iconOverride?.forced === null)
529
+ return undefined;
530
+ const files = [...snapshot.files.keys()].toSorted();
531
+ const forced = iconOverride?.forced;
532
+ if (forced && !files.includes(forced))
533
+ return undefined;
534
+ const path = forced ?? selectIconPath(files);
535
+ if (!path)
536
+ return undefined;
537
+ const bytes = snapshot.files.get(path);
538
+ if (!bytes)
539
+ return undefined;
540
+ const inspected = inspectIconBytes(bytes);
541
+ if (!('contentType' in inspected))
542
+ return undefined;
543
+ return {
544
+ kind: 'content',
545
+ path,
546
+ contentType: inspected.contentType,
547
+ digest: byteDigest(bytes),
548
+ bytes: bytes.byteLength,
549
+ };
550
+ }
551
+ /** Assembles the release icon from whichever source produced one. */
552
+ function assembleIcon(input) {
553
+ if (input.contentIcon)
554
+ return { icon: input.contentIcon };
555
+ if (input.favicon)
556
+ return {
557
+ icon: {
558
+ kind: 'favicon',
559
+ url: input.favicon.url,
560
+ homepage: input.favicon.homepage,
561
+ contentType: input.favicon.contentType,
562
+ digest: input.favicon.digest,
563
+ bytes: input.favicon.bytes.byteLength,
564
+ },
565
+ };
566
+ return {};
567
+ }
568
+ /** SHA-256 of a single byte buffer, in the catalog's digest format. */
569
+ export function byteDigest(bytes) {
570
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
571
+ }
572
+ async function resolvePluginSource(pluginName, spec, source, resolveExternalRefs) {
573
+ if (spec.kind === 'local')
574
+ return {
575
+ repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
576
+ subdirectory: spec.path,
577
+ commitSha: source.commitSha,
578
+ };
579
+ const canonicalUrl = canonicalRepositoryUrl(spec.repositoryUrl);
580
+ const pin = source.pluginPins.find((candidate) => candidate.pluginName === pluginName &&
581
+ candidate.repositoryUrl === canonicalUrl &&
582
+ candidate.pluginSubdirectory === spec.subdirectory);
583
+ const commitSha = pin?.resolvedCommitSha ??
584
+ spec.sha ??
585
+ (resolveExternalRefs
586
+ ? await resolveGitRef(spec.repositoryUrl, spec.ref?.startsWith('refs/') ? spec.ref : `refs/heads/${spec.ref ?? 'main'}`)
587
+ : source.commitSha);
588
+ return {
589
+ repositoryUrl: canonicalUrl,
590
+ subdirectory: pin?.pluginSubdirectory ?? spec.subdirectory,
591
+ commitSha,
592
+ };
593
+ }
594
+ function validateSnapshot(snapshot, policy, pluginName) {
595
+ if (snapshot.files.size > policy.maxFilesPerPlugin)
596
+ throw new PluginSafetyError('PLUGIN_TOO_MANY_FILES', [pluginName]);
597
+ let total = 0;
598
+ for (const [path, bytes] of snapshot.files) {
599
+ try {
600
+ safeRelativePath(path, 'SNAPSHOT_PATH_INVALID');
601
+ }
602
+ catch {
603
+ throw new PluginSafetyError('SNAPSHOT_PATH_INVALID', [path]);
604
+ }
605
+ if (bytes.byteLength > policy.maxFileBytes)
606
+ throw new PluginSafetyError('PLUGIN_FILE_TOO_LARGE', [path]);
607
+ total += bytes.byteLength;
608
+ }
609
+ if (total > policy.maxBytesPerPlugin)
610
+ throw new PluginSafetyError('PLUGIN_TOO_LARGE', [pluginName]);
611
+ }
612
+ function assertRepositoryAllowed(repositoryUrl, policy) {
613
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl));
614
+ if (!policy.allowedRepositoryProtocols.includes(parsed.protocol))
615
+ throw new Error(`GIT_PROTOCOL_POLICY: ${parsed.protocol}`);
616
+ if (!policy.allowedRepositoryHosts.includes(parsed.hostname.toLowerCase()))
617
+ throw new Error(`GIT_HOST_POLICY: ${parsed.hostname}`);
618
+ }
619
+ function findPluginManifest(snapshot, preferRoot = false) {
620
+ const candidates = [...snapshot.files.keys()]
621
+ .filter((path) => {
622
+ const lower = path.toLocaleLowerCase();
623
+ return lower === 'plugin.json' || lower.endsWith('/plugin.json') || lower === 'package.json';
624
+ })
625
+ .toSorted((left, right) => manifestRank(left) - manifestRank(right) || left.localeCompare(right));
626
+ const path = preferRoot && snapshot.files.has('plugin.json') ? 'plugin.json' : candidates[0];
627
+ if (!path)
628
+ return undefined;
629
+ try {
630
+ const bytes = snapshot.files.get(path);
631
+ if (!bytes)
632
+ return undefined;
633
+ const metadata = parseJsonDocument(new TextDecoder().decode(bytes));
634
+ return {
635
+ metadata,
636
+ ...((stringValue(metadata.displayName) ?? stringValue(metadata.name))
637
+ ? { displayName: stringValue(metadata.displayName) ?? stringValue(metadata.name) }
638
+ : {}),
639
+ ...(stringValue(metadata.description)
640
+ ? { description: stringValue(metadata.description) }
641
+ : {}),
642
+ ...(stringValue(metadata.version) ? { version: stringValue(metadata.version) } : {}),
643
+ icons: [metadata.icon, ...(Array.isArray(metadata.icons) ? metadata.icons : [])]
644
+ .filter(isString)
645
+ .toSorted(),
646
+ };
647
+ }
648
+ catch {
649
+ throw new PluginSafetyError('PLUGIN_MANIFEST_INVALID', [path]);
650
+ }
651
+ }
652
+ function manifestRank(path) {
653
+ const lower = path.toLocaleLowerCase();
654
+ if (lower.endsWith('/.claude-plugin/plugin.json'))
655
+ return 0;
656
+ if (lower.endsWith('/.cursor-plugin/plugin.json'))
657
+ return 1;
658
+ if (lower.endsWith('/.agents/plugin.json'))
659
+ return 2;
660
+ return lower === 'plugin.json' ? 3 : 4;
661
+ }
662
+ export function classifyCapabilities(files, metadata) {
663
+ const groups = new Map();
664
+ const add = (type, path) => groups.set(type, [...(groups.get(type) ?? []), path]);
665
+ for (const path of files.keys()) {
666
+ const lower = path.toLocaleLowerCase();
667
+ const file = basename(lower);
668
+ if (file === 'skill.md' || lower.includes('/skills/'))
669
+ add('skill', path);
670
+ if (file === '.mcp.json' || file === 'mcp.json' || lower.includes('/mcp-servers/'))
671
+ add('mcp-server', path);
672
+ if (lower.includes('/commands/') || lower.startsWith('commands/'))
673
+ add('command', path);
674
+ if (lower.includes('/agents/') || lower.startsWith('agents/') || lower.includes('/subagents/'))
675
+ add('agent', path);
676
+ if (lower.includes('/hooks/') || lower.startsWith('hooks/') || file === 'hooks.json')
677
+ add('hook', path);
678
+ if (lower.includes('/rules/') ||
679
+ lower.startsWith('rules/') ||
680
+ file === 'agents.md' ||
681
+ file === 'claude.md' ||
682
+ lower.includes('.cursor/rules/'))
683
+ add('rule', path);
684
+ if (lower.includes('browser') || lower.includes('playwright') || lower.includes('chrome'))
685
+ add('browser', path);
686
+ if (lower.includes('/schedule') || lower.includes('/cron') || file.includes('schedule'))
687
+ add('scheduled-task', path);
688
+ if (lower.includes('/ui/') || lower.startsWith('ui/') || lower.includes('/components/'))
689
+ add('ui-component', path);
690
+ if (lower.startsWith('bin/') ||
691
+ lower.startsWith('scripts/') ||
692
+ /\.(sh|bash|py|exe|bin)$/.test(file))
693
+ add('executable', path);
694
+ }
695
+ if (metadata && (metadata.mcpServers !== undefined || metadata.mcp !== undefined))
696
+ add('mcp-server', 'plugin-manifest');
697
+ if (metadata && (metadata.hooks !== undefined || metadata.hook !== undefined))
698
+ add('hook', 'plugin-manifest');
699
+ if (groups.size === 0)
700
+ groups.set('unknown', ['plugin-root']);
701
+ return [...groups.entries()].map(([type, paths]) => ({
702
+ type: type,
703
+ name: type,
704
+ paths: [...new Set(paths)].toSorted(),
705
+ metadata: {},
706
+ securityImpact: [
707
+ 'mcp-server',
708
+ 'connector',
709
+ 'hook',
710
+ 'browser',
711
+ 'scheduled-task',
712
+ 'executable',
713
+ ].includes(type)
714
+ ? 'sensitive'
715
+ : 'none',
716
+ }));
717
+ }
718
+ function capabilitySummary(capabilities) {
719
+ const summary = {};
720
+ for (const capability of capabilities)
721
+ summary[capability.type] = capability.paths.length;
722
+ return summary;
723
+ }
724
+ export function createCompatibility(dialect, capabilities, policy = DEFAULT_POLICY) {
725
+ const types = [...new Set(capabilities.map((capability) => capability.type))].toSorted();
726
+ const sensitive = types.filter((type) => policy.sensitiveCapabilityTypes.includes(type));
727
+ const make = (status, reasons) => ({
728
+ status,
729
+ reasons: [...new Set(reasons)].toSorted(),
730
+ responsibleCapabilities: types,
731
+ });
732
+ const result = {};
733
+ for (const harness of HarnessSchema.options) {
734
+ const native = (harness === 'claude-code' && dialect === 'claude') ||
735
+ (harness === 'cursor' && dialect === 'cursor') ||
736
+ (harness === 'codex' && dialect === 'openai');
737
+ if (types.includes('executable'))
738
+ result[harness] = make('unsupported', [
739
+ 'Executable components require harness-specific execution authority.',
740
+ ]);
741
+ else if (types.includes('unknown'))
742
+ result[harness] = make('unknown', [
743
+ 'The plugin content was not classified into a supported component type.',
744
+ ]);
745
+ else if (native && sensitive.length === 0)
746
+ result[harness] = make('native', [
747
+ `The ${dialect} marketplace dialect matches the ${harness} harness.`,
748
+ ]);
749
+ else if (sensitive.length > 0 && ['pi', 'hermes', 'opencode'].includes(harness))
750
+ result[harness] = make('partially-supported', [
751
+ `Sensitive capabilities require ${harness} policy and runtime review.`,
752
+ ...sensitive.map((type) => `Capability ${type} is not granted by this catalog.`),
753
+ ]);
754
+ else if (types.every((type) => ['skill', 'rule', 'mcp-server', 'command', 'agent'].includes(type)))
755
+ result[harness] = make(native ? 'native' : 'portable', native
756
+ ? [`The ${dialect} marketplace dialect matches the ${harness} harness.`]
757
+ : ['Files can be copied or translated into the harness layout.']);
758
+ else
759
+ result[harness] = make('requires-translation', [
760
+ 'One or more capabilities need a harness-specific projection.',
761
+ ]);
762
+ }
763
+ return result;
764
+ }
765
+ function createSecurity(capabilities, policy, metadataOnly) {
766
+ const sensitive = [
767
+ ...new Set(capabilities
768
+ .filter((capability) => policy.sensitiveCapabilityTypes.includes(capability.type))
769
+ .map((capability) => capability.type)),
770
+ ].toSorted();
771
+ const reasons = sensitive.length > 0
772
+ ? sensitive.map((type) => `Contains ${type} capability metadata.`)
773
+ : metadataOnly
774
+ ? ['Content was not fetched in metadata-only mode.']
775
+ : ['No permission-sensitive executable capability was detected.'];
776
+ return {
777
+ level: sensitive.length > 0 ? 'sensitive' : metadataOnly ? 'review' : 'low',
778
+ reasons,
779
+ permissionSensitiveChanges: sensitive,
780
+ contentResolution: metadataOnly ? 'metadata-only' : 'complete',
781
+ };
782
+ }
783
+ function findLicense(entry, metadata) {
784
+ const candidate = stringValue(metadata?.license) ?? stringValue(entry.raw.license);
785
+ if (!candidate)
786
+ return { name: 'Unknown', source: 'unknown' };
787
+ return {
788
+ name: candidate,
789
+ ...(candidate.startsWith('http') ? { url: candidate } : {}),
790
+ source: metadata?.license !== undefined ? 'plugin-manifest' : 'marketplace-entry',
791
+ };
792
+ }
793
+ function createSourcesLock(sources) {
794
+ const entries = sources
795
+ .map((source) => ({
796
+ sourceId: source.config.sourceId,
797
+ repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
798
+ defaultBranch: source.config.defaultBranch,
799
+ resolvedCommitSha: source.commitSha,
800
+ manifestPath: source.config.manifestPath,
801
+ marketplaceDialect: source.config.marketplaceDialect,
802
+ sourceManifestDigest: source.manifestDigest,
803
+ retrievedAt: source.retrievedAt,
804
+ synchronizationStatus: 'synchronized',
805
+ pluginPins: source.pluginPins.map((pin) => ({ ...pin })),
806
+ }))
807
+ .toSorted((left, right) => left.sourceId.localeCompare(right.sourceId));
808
+ const body = { schemaVersion: 1, sources: entries };
809
+ return SourcesLockSchema.parse({
810
+ ...body,
811
+ lockId: `lock:${digest(body).slice('sha256:'.length)}`,
812
+ });
813
+ }
814
+ /**
815
+ * Orders plugins so each category's curated leading products come first, in
816
+ * the configured order. Consumers preview the head of each category, so this
817
+ * ordering decides what users see before expanding.
818
+ */
819
+ export function orderLeadingPlugins(catalog, leading) {
820
+ if (!leading)
821
+ return catalog;
822
+ const leadRank = new Map();
823
+ for (const [category, names] of Object.entries(leading)) {
824
+ names.forEach((name, index) => {
825
+ const key = `${category}::${name}`;
826
+ if (!leadRank.has(key))
827
+ leadRank.set(key, index);
828
+ });
829
+ }
830
+ const rank = (plugin) => {
831
+ let best = Number.POSITIVE_INFINITY;
832
+ for (const category of plugin.categories) {
833
+ const value = leadRank.get(`${category}::${plugin.upstreamPluginName}`);
834
+ if (value !== undefined && value < best)
835
+ best = value;
836
+ }
837
+ return best;
838
+ };
839
+ return {
840
+ ...catalog,
841
+ plugins: catalog.plugins.toSorted((left, right) => rank(left) - rank(right)),
842
+ };
843
+ }
844
+ export function createArtifacts(catalog, lock, options = {}) {
845
+ const index = buildConsumerIndex({
846
+ catalog,
847
+ ...(options.leading ? { leading: options.leading } : {}),
848
+ ...(options.topCount !== undefined ? { topCount: options.topCount } : {}),
849
+ ...(options.productPreference ? { preference: options.productPreference } : {}),
850
+ ...(options.publicationRepositoryUrl
851
+ ? { publicationRepositoryUrl: options.publicationRepositoryUrl }
852
+ : {}),
853
+ ...(options.publicationAssetsBaseUrl
854
+ ? { publicationAssetsBaseUrl: options.publicationAssetsBaseUrl }
855
+ : {}),
856
+ ...(options.publicationCatalogsBaseUrl
857
+ ? { publicationCatalogsBaseUrl: options.publicationCatalogsBaseUrl }
858
+ : {}),
859
+ });
860
+ const summary = {
861
+ schemaVersion: 1,
862
+ catalogId: catalog.catalogId,
863
+ generatedAt: catalog.generatedAt,
864
+ sourceCount: catalog.sources.length,
865
+ pluginCount: catalog.plugins.length,
866
+ categories: [...new Set(catalog.plugins.flatMap((plugin) => plugin.categories))].toSorted(),
867
+ productGroups: [
868
+ ...new Set(catalog.plugins.map((plugin) => plugin.productGroupingKey)),
869
+ ].toSorted(),
870
+ search: catalog.plugins
871
+ .map((plugin) => ({
872
+ pluginId: plugin.pluginId,
873
+ text: `${plugin.displayName} ${plugin.description} ${plugin.keywords.join(' ')}`.trim(),
874
+ }))
875
+ .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)),
876
+ };
877
+ const compatibility = {
878
+ schemaVersion: 1,
879
+ catalogId: catalog.catalogId,
880
+ plugins: catalog.plugins.map((plugin) => ({
881
+ pluginId: plugin.pluginId,
882
+ harnessCompatibility: plugin.harnessCompatibility,
883
+ })),
884
+ };
885
+ // Navigation stays small and points at the shards a client then fetches:
886
+ // `shelf-*` for a default view, `category-*` for "view more", and
887
+ // `catalog-index.v1.json` for the complete product list.
888
+ const navigation = {
889
+ schemaVersion: 1,
890
+ catalogId: catalog.catalogId,
891
+ generatedAt: catalog.generatedAt,
892
+ topCount: index.topCount,
893
+ sourcePreference: index.sourcePreference,
894
+ counts: index.counts,
895
+ catalogIndexArtifact: CATALOG_INDEX_ARTIFACT,
896
+ iconCoverage: iconCoverage(index),
897
+ // The index URL rides in the navigation artifact so a client can fetch the
898
+ // browsing index from the publication it was told about, verify it against
899
+ // the digest in integrity.json, and never compose a URL or need this
900
+ // service to proxy it. Absent for an unpublished build.
901
+ ...(options.publicationCatalogsBaseUrl
902
+ ? {
903
+ catalogIndexUrl: `${options.publicationCatalogsBaseUrl}/${catalog.catalogId.slice('catalog:'.length)}/${CATALOG_INDEX_ARTIFACT}`,
904
+ }
905
+ : options.publicationAssetsBaseUrl
906
+ ? { catalogIndexUrl: `${options.publicationAssetsBaseUrl}/${CATALOG_INDEX_ARTIFACT}` }
907
+ : options.publicationRepositoryUrl
908
+ ? {
909
+ catalogIndexUrl: immutableAssetUrl(options.publicationRepositoryUrl, catalog.catalogId, CATALOG_INDEX_ARTIFACT),
910
+ }
911
+ : {}),
912
+ // Marks ride in the navigation artifact so a client that already receives it
913
+ // renders compiled brand marks without fetching the browsing index. Keys are
914
+ // sorted for a stable artifact.
915
+ brandMarks: Object.fromEntries(Object.keys(index.products)
916
+ .toSorted()
917
+ .flatMap((productKey) => {
918
+ const assetUrl = index.products[productKey]?.icon?.assetUrl;
919
+ return assetUrl ? [[productKey, assetUrl]] : [];
920
+ })),
921
+ categories: index.categories,
922
+ diagnostics: options.curationDiagnostics === false ? [] : index.diagnostics,
923
+ };
924
+ const shards = {
925
+ [CATALOG_INDEX_ARTIFACT]: json({
926
+ schemaVersion: 1,
927
+ catalogId: catalog.catalogId,
928
+ generatedAt: catalog.generatedAt,
929
+ topCount: index.topCount,
930
+ sourcePreference: index.sourcePreference,
931
+ counts: index.counts,
932
+ products: index.products,
933
+ categories: index.categories.map((category) => ({
934
+ category: category.category,
935
+ productCount: category.productCount,
936
+ productKeys: category.productKeys,
937
+ })),
938
+ }),
939
+ };
940
+ for (const category of index.categories) {
941
+ shards[category.shelfArtifact] = json({
942
+ schemaVersion: 1,
943
+ catalogId: catalog.catalogId,
944
+ category: category.category,
945
+ topCount: index.topCount,
946
+ products: category.topProductKeys.map((productKey) => index.products[productKey]),
947
+ });
948
+ shards[category.listArtifact] = json({
949
+ schemaVersion: 1,
950
+ catalogId: catalog.catalogId,
951
+ category: category.category,
952
+ productCount: category.productCount,
953
+ pluginCount: category.pluginCount,
954
+ products: category.productKeys.map((productKey) => index.products[productKey]),
955
+ });
956
+ }
957
+ const files = {
958
+ 'catalog.v1.json': json(catalog),
959
+ 'catalog-summary.v1.json': json(summary),
960
+ 'sources.lock.json': json(lock),
961
+ 'compatibility.v1.json': json(compatibility),
962
+ 'categories.v1.json': json(navigation),
963
+ ...shards,
964
+ };
965
+ const integrityBody = {
966
+ schemaVersion: 1,
967
+ catalogId: catalog.catalogId,
968
+ // An artifact's digest is the canonical digest of its text, not a hash of
969
+ // the file bytes: consumers read the artifact as a string and compare it
970
+ // with `canonicalDigest`, so a checker hashing the raw bytes will disagree
971
+ // with every published release. Mirrored marks below are the byte case,
972
+ // because a client stores those bytes.
973
+ files: Object.fromEntries(Object.entries(files)
974
+ .toSorted()
975
+ .map(([name, content]) => [name, digest(content)])),
976
+ // Mirrored brand marks are published next to the artifacts, never inside
977
+ // them: the digest and size let a consumer or the release check verify the
978
+ // bytes it fetched without trusting the release listing.
979
+ assets: iconAssets(index),
980
+ };
981
+ return { ...files, 'integrity.json': json(integrityBody) };
982
+ }
983
+ /** Every mirrored icon in the catalog, deduplicated by asset name. */
984
+ export function iconAssets(index) {
985
+ const assets = new Map();
986
+ for (const product of Object.values(index.products)) {
987
+ if (!product.icon)
988
+ continue;
989
+ assets.set(product.icon.asset, {
990
+ name: product.icon.asset,
991
+ digest: product.icon.digest,
992
+ bytes: product.icon.bytes,
993
+ contentType: product.icon.contentType,
994
+ kind: product.icon.kind,
995
+ origin: product.icon.kind === 'content' ? (product.icon.path ?? '') : (product.icon.url ?? ''),
996
+ });
997
+ }
998
+ return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name));
999
+ }
1000
+ export async function writeArtifacts(generatedDirectory, artifacts) {
1001
+ const parent = dirname(generatedDirectory);
1002
+ await fs.mkdir(parent, { recursive: true });
1003
+ const temporary = await fs.mkdtemp(join(parent, '.catalog-'));
1004
+ try {
1005
+ for (const [name, content] of Object.entries(artifacts))
1006
+ await fs.writeFile(join(temporary, name), content, 'utf8');
1007
+ const backup = `${generatedDirectory}.previous`;
1008
+ await fs.rm(backup, { recursive: true, force: true });
1009
+ let movedExisting = false;
1010
+ try {
1011
+ await fs.rename(generatedDirectory, backup);
1012
+ movedExisting = true;
1013
+ }
1014
+ catch (error) {
1015
+ if (error.code !== 'ENOENT')
1016
+ throw error;
1017
+ }
1018
+ try {
1019
+ await fs.rename(temporary, generatedDirectory);
1020
+ }
1021
+ catch (error) {
1022
+ if (movedExisting)
1023
+ await fs.rename(backup, generatedDirectory);
1024
+ throw error;
1025
+ }
1026
+ if (movedExisting)
1027
+ await fs.rm(backup, { recursive: true, force: true });
1028
+ }
1029
+ finally {
1030
+ await fs.rm(temporary, { recursive: true, force: true });
1031
+ }
1032
+ }
1033
+ export function verifyArtifacts(artifacts) {
1034
+ const parsedCatalog = CatalogSchema.parse(JSON.parse(artifacts['catalog.v1.json']));
1035
+ verifyCatalogReferences(parsedCatalog);
1036
+ const lock = SourcesLockSchema.parse(JSON.parse(artifacts['sources.lock.json']));
1037
+ const summary = JSON.parse(artifacts['catalog-summary.v1.json']);
1038
+ const compatibility = JSON.parse(artifacts['compatibility.v1.json']);
1039
+ const categories = JSON.parse(artifacts['categories.v1.json']);
1040
+ const integrity = JSON.parse(artifacts['integrity.json']);
1041
+ if (parsedCatalog.sources.length !== lock.sources.length)
1042
+ throw new Error('INTEGRITY_SOURCE_COUNT_MISMATCH');
1043
+ if (summary.schemaVersion !== 1 ||
1044
+ summary.catalogId !== parsedCatalog.catalogId ||
1045
+ summary.sourceCount !== parsedCatalog.sources.length ||
1046
+ summary.pluginCount !== parsedCatalog.plugins.length ||
1047
+ !Array.isArray(summary.search) ||
1048
+ compatibility.schemaVersion !== 1 ||
1049
+ compatibility.catalogId !== parsedCatalog.catalogId ||
1050
+ !Array.isArray(compatibility.plugins) ||
1051
+ compatibility.plugins.length !== parsedCatalog.plugins.length ||
1052
+ categories.schemaVersion !== 1 ||
1053
+ categories.catalogId !== parsedCatalog.catalogId ||
1054
+ !Array.isArray(categories.categories) ||
1055
+ (categories.products !== undefined &&
1056
+ (typeof categories.products !== 'object' || categories.products === null)) ||
1057
+ (categories.diagnostics !== undefined && !Array.isArray(categories.diagnostics)) ||
1058
+ integrity.schemaVersion !== 1 ||
1059
+ integrity.catalogId !== parsedCatalog.catalogId ||
1060
+ !integrity.files ||
1061
+ (integrity.assets !== undefined && !Array.isArray(integrity.assets))) {
1062
+ throw new Error('INTEGRITY_ARTIFACT_METADATA_MISMATCH');
1063
+ }
1064
+ // The published file set is exactly what integrity.json enumerates, so the
1065
+ // shards can grow with the category configuration without a fixed list here.
1066
+ for (const name of Object.keys(artifacts))
1067
+ if (name !== 'integrity.json' && integrity.files?.[name] === undefined)
1068
+ throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`);
1069
+ for (const [name, expected] of Object.entries(integrity.files ?? {})) {
1070
+ const content = artifacts[name];
1071
+ if (content === undefined)
1072
+ throw new Error(`INTEGRITY_FILE_SET_MISMATCH: ${name}`);
1073
+ if (digest(content) !== expected)
1074
+ throw new Error(`INTEGRITY_DIGEST_MISMATCH: ${name}`);
1075
+ }
1076
+ const indexArtifact = artifacts[CATALOG_INDEX_ARTIFACT];
1077
+ const verification = verifyConsumerIndex(artifacts['categories.v1.json'], parsedCatalog, indexArtifact);
1078
+ if (verification.hasConsumerIndex && indexArtifact === undefined)
1079
+ throw new Error(`SHARD_MISSING: ${CATALOG_INDEX_ARTIFACT}`);
1080
+ for (const shard of verification.shards)
1081
+ verifyShardArtifact(shard, artifacts[shard.name], verification);
1082
+ verifyIconAssets(integrity.assets, verification);
1083
+ }
1084
+ /**
1085
+ * Re-checks one consumer shard against the navigation index: a shelf carries
1086
+ * exactly the curated products in order, and a category list carries exactly
1087
+ * the category's products in order.
1088
+ */
1089
+ export function verifyShardArtifact(shard, content, verification) {
1090
+ if (content === undefined)
1091
+ throw new Error(`SHARD_MISSING: ${shard.name}`);
1092
+ const parsed = JSON.parse(content);
1093
+ // The index shard carries products keyed by productKey; the shelf and list
1094
+ // shards carry them as ordered arrays.
1095
+ const products = shard.kind === 'index'
1096
+ ? Object.values((parsed.products ?? {}))
1097
+ : parsed.products;
1098
+ if (!Array.isArray(products))
1099
+ throw new Error(`SHARD_PRODUCTS_INVALID: ${shard.name}`);
1100
+ const keys = products.map((product) => product.productKey);
1101
+ if (new Set(keys).size !== keys.length)
1102
+ throw new Error(`SHARD_DUPLICATE_PRODUCT: ${shard.name}`);
1103
+ if (shard.kind === 'index') {
1104
+ const expected = Object.keys(verification.products ?? {}).toSorted();
1105
+ if (keys.toSorted().join('\n') !== expected.join('\n'))
1106
+ throw new Error('SHARD_INDEX_MEMBERSHIP_MISMATCH');
1107
+ return;
1108
+ }
1109
+ const category = verification.categories?.find((item) => item.category === shard.category);
1110
+ if (!category)
1111
+ throw new Error(`SHARD_CATEGORY_UNKNOWN: ${shard.name}`);
1112
+ const expected = shard.kind === 'shelf' ? category.topProductKeys : category.productKeys;
1113
+ if (keys.join('\n') !== expected.join('\n'))
1114
+ throw new Error(`SHARD_ORDER_MISMATCH: ${shard.name}`);
1115
+ for (const product of products) {
1116
+ const index = verification.products?.[product.productKey];
1117
+ if (!index || digest(index) !== digest(product))
1118
+ throw new Error(`SHARD_PRODUCT_MISMATCH: ${shard.name}: ${product.productKey}`);
1119
+ }
1120
+ }
1121
+ /** Every mirrored icon is declared once, with the digest the index advertises. */
1122
+ function verifyIconAssets(assets, verification) {
1123
+ const declared = (assets ?? []);
1124
+ if (!Array.isArray(declared))
1125
+ throw new Error('INTEGRITY_ASSETS_INVALID');
1126
+ const referenced = new Map();
1127
+ for (const product of Object.values(verification.products ?? {})) {
1128
+ if (!product.icon)
1129
+ continue;
1130
+ const existing = referenced.get(product.icon.asset);
1131
+ if (existing && existing.digest !== product.icon.digest)
1132
+ throw new Error(`ICON_ASSET_DIGEST_CONFLICT: ${product.icon.asset}`);
1133
+ referenced.set(product.icon.asset, {
1134
+ name: product.icon.asset,
1135
+ digest: product.icon.digest,
1136
+ bytes: product.icon.bytes,
1137
+ contentType: product.icon.contentType,
1138
+ kind: product.icon.kind,
1139
+ origin: product.icon.kind === 'content' ? (product.icon.path ?? '') : (product.icon.url ?? ''),
1140
+ });
1141
+ }
1142
+ if (declared.length !== referenced.size)
1143
+ throw new Error('INTEGRITY_ASSET_SET_MISMATCH');
1144
+ for (const asset of declared) {
1145
+ const expected = referenced.get(asset.name);
1146
+ if (!expected)
1147
+ throw new Error(`INTEGRITY_ASSET_UNDECLARED: ${asset.name}`);
1148
+ if (expected.digest !== asset.digest ||
1149
+ expected.bytes !== asset.bytes ||
1150
+ expected.contentType !== asset.contentType ||
1151
+ expected.kind !== asset.kind ||
1152
+ expected.origin !== asset.origin)
1153
+ throw new Error(`INTEGRITY_ASSET_MISMATCH: ${asset.name}`);
1154
+ }
1155
+ }
1156
+ /**
1157
+ * Re-checks the published consumer index against the catalog it indexes. The
1158
+ * index is what Adea renders directly, so a contradiction between the two
1159
+ * artifacts must never reach a release. Snapshots published before the index
1160
+ * existed carry membership only; those are still membership-checked and
1161
+ * reported as `hasConsumerIndex: false`.
1162
+ */
1163
+ export function verifyConsumerIndex(content, catalog, indexContent) {
1164
+ const index = JSON.parse(content);
1165
+ const knownPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]));
1166
+ // Products live in the index shard; the navigation artifact only points at it.
1167
+ const shard = indexContent ? JSON.parse(indexContent) : undefined;
1168
+ const products = shard && typeof shard.products === 'object' && shard.products !== null
1169
+ ? shard.products
1170
+ : typeof index.products === 'object' && index.products !== null
1171
+ ? index.products
1172
+ : undefined;
1173
+ const diagnostics = Array.isArray(index.diagnostics) ? index.diagnostics : [];
1174
+ for (const category of index.categories ?? []) {
1175
+ const members = catalog.plugins.filter((plugin) => plugin.categories.includes(category.category));
1176
+ if (category.pluginCount !== undefined && category.pluginCount !== members.length)
1177
+ throw new Error(`CONSUMER_INDEX_PLUGIN_COUNT_MISMATCH: ${category.category}`);
1178
+ if (category.pluginIds.length !== members.length)
1179
+ throw new Error(`CONSUMER_INDEX_PLUGIN_LIST_MISMATCH: ${category.category}`);
1180
+ if (new Set(category.pluginIds).size !== category.pluginIds.length)
1181
+ throw new Error(`CONSUMER_INDEX_DUPLICATE_PLUGIN: ${category.category}`);
1182
+ for (const pluginId of category.pluginIds) {
1183
+ const plugin = knownPlugins.get(pluginId);
1184
+ if (!plugin || !plugin.categories.includes(category.category))
1185
+ throw new Error(`CONSUMER_INDEX_FOREIGN_PLUGIN: ${category.category}: ${pluginId}`);
1186
+ }
1187
+ if (!products)
1188
+ continue;
1189
+ if (category.productKeys === undefined || category.topProductKeys === undefined)
1190
+ throw new Error(`CONSUMER_INDEX_PRODUCTS_INCOMPLETE: ${category.category}`);
1191
+ if (new Set(category.productKeys).size !== category.productKeys.length)
1192
+ throw new Error(`CONSUMER_INDEX_DUPLICATE_PRODUCT: ${category.category}`);
1193
+ for (const productKey of category.productKeys) {
1194
+ const product = products[productKey];
1195
+ if (!product)
1196
+ throw new Error(`CONSUMER_INDEX_UNKNOWN_PRODUCT: ${productKey}`);
1197
+ if (!product.categories.includes(category.category))
1198
+ throw new Error(`CONSUMER_INDEX_PRODUCT_CATEGORY_MISMATCH: ${productKey}`);
1199
+ }
1200
+ if (category.productCount !== category.productKeys.length)
1201
+ throw new Error(`CONSUMER_INDEX_PRODUCT_COUNT_MISMATCH: ${category.category}`);
1202
+ const topCount = index.topCount;
1203
+ if (topCount === undefined || !Number.isInteger(topCount) || topCount < 0)
1204
+ throw new Error('CONSUMER_INDEX_TOP_COUNT_INVALID');
1205
+ if (category.topProductKeys.length > topCount)
1206
+ throw new Error(`CONSUMER_INDEX_TOP_OVERFLOW: ${category.category}`);
1207
+ const shelved = new Set();
1208
+ for (const product of Object.values(products)) {
1209
+ // Indexes published before the install facts existed carry no `release`,
1210
+ // and are still valid; a record that does carry one must agree with the
1211
+ // catalog it claims to describe.
1212
+ if (product.release) {
1213
+ const plugin = knownPlugins.get(product.pluginId);
1214
+ const release = plugin?.availableReleases.find((candidate) => candidate.releaseId === plugin.currentReleaseId);
1215
+ if (!plugin ||
1216
+ !release ||
1217
+ product.release.releaseId !== release.releaseId ||
1218
+ product.release.canonicalContentDigest !== release.canonicalContentDigest ||
1219
+ product.release.sourceRevision !== release.resolvedCommitSha ||
1220
+ product.provenance?.resolvedCommitSha !== release.resolvedCommitSha)
1221
+ throw new Error(`CONSUMER_INDEX_RELEASE_MISMATCH: ${product.productKey}`);
1222
+ }
1223
+ const icon = product.icon;
1224
+ if (!icon?.assetUrl)
1225
+ continue;
1226
+ if (!icon.assetUrl.startsWith('https://') || !icon.assetUrl.endsWith(`/${icon.asset}`))
1227
+ throw new Error(`CONSUMER_INDEX_ASSET_URL_INVALID: ${icon.asset}`);
1228
+ }
1229
+ for (const productKey of category.topProductKeys) {
1230
+ if (!category.productKeys.includes(productKey))
1231
+ throw new Error(`CONSUMER_INDEX_TOP_NOT_MEMBER: ${category.category}: ${productKey}`);
1232
+ if (shelved.has(productKey))
1233
+ throw new Error(`CONSUMER_INDEX_TOP_REPEATED: ${productKey}`);
1234
+ shelved.add(productKey);
1235
+ }
1236
+ }
1237
+ if (products) {
1238
+ // A product headlines one category only, so no product is shelved twice.
1239
+ const claimed = new Set();
1240
+ for (const category of index.categories ?? [])
1241
+ for (const productKey of category.topProductKeys ?? []) {
1242
+ if (claimed.has(productKey))
1243
+ throw new Error(`CONSUMER_INDEX_TOP_CROSS_CATEGORY: ${productKey}`);
1244
+ claimed.add(productKey);
1245
+ }
1246
+ for (const [productKey, product] of Object.entries(products)) {
1247
+ if (productKey !== product.productKey)
1248
+ throw new Error('CONSUMER_INDEX_PRODUCT_KEY_MISMATCH');
1249
+ if (product.variantPluginIds[0] !== product.pluginId)
1250
+ throw new Error(`CONSUMER_INDEX_CANONICAL_NOT_FIRST: ${productKey}`);
1251
+ const variants = new Set(product.variantPluginIds.map((pluginId) => {
1252
+ const plugin = knownPlugins.get(pluginId);
1253
+ if (!plugin)
1254
+ throw new Error(`CONSUMER_INDEX_UNKNOWN_PLUGIN: ${pluginId}`);
1255
+ if (plugin.productGroupingKey !== productKey)
1256
+ throw new Error(`CONSUMER_INDEX_VARIANT_PRODUCT_MISMATCH: ${pluginId}`);
1257
+ return pluginId;
1258
+ }));
1259
+ if (variants.size !== product.variantPluginIds.length)
1260
+ throw new Error(`CONSUMER_INDEX_DUPLICATE_VARIANT: ${productKey}`);
1261
+ }
1262
+ const counts = shard?.counts ?? index.counts;
1263
+ if (counts?.plugins !== catalog.plugins.length)
1264
+ throw new Error('CONSUMER_INDEX_TOTAL_MISMATCH');
1265
+ if (counts?.products !== Object.keys(products).length)
1266
+ throw new Error('CONSUMER_INDEX_PRODUCT_TOTAL_MISMATCH');
1267
+ if (counts?.redundantPlugins !== catalog.plugins.length - counts.products)
1268
+ throw new Error('CONSUMER_INDEX_REDUNDANCY_MISMATCH');
1269
+ if (index.counts && shard?.counts && digest(index.counts) !== digest(shard.counts))
1270
+ throw new Error('CONSUMER_INDEX_COUNTS_MISMATCH');
1271
+ if (shard?.topCount !== undefined && index.topCount !== shard.topCount)
1272
+ throw new Error('CONSUMER_INDEX_TOP_COUNT_MISMATCH');
1273
+ }
1274
+ if (products && index.iconCoverage) {
1275
+ const expected = iconCoverage({ products });
1276
+ if (index.iconCoverage.total !== expected.total ||
1277
+ index.iconCoverage.content !== expected.content ||
1278
+ index.iconCoverage.favicon !== expected.favicon ||
1279
+ index.iconCoverage.monogramOnly !== expected.monogramOnly ||
1280
+ index.iconCoverage.monogramProductKeys.join('\n') !== expected.monogramProductKeys.join('\n'))
1281
+ throw new Error('CONSUMER_INDEX_ICON_COVERAGE_MISMATCH');
1282
+ }
1283
+ const shards = [
1284
+ ...(products ? [{ name: CATALOG_INDEX_ARTIFACT, kind: 'index' }] : []),
1285
+ ...(index.categories ?? []).flatMap((category) => products && category.shelfArtifact && category.listArtifact
1286
+ ? [
1287
+ { name: category.shelfArtifact, kind: 'shelf', category: category.category },
1288
+ { name: category.listArtifact, kind: 'list', category: category.category },
1289
+ ]
1290
+ : []),
1291
+ ];
1292
+ return {
1293
+ hasConsumerIndex: products !== undefined,
1294
+ diagnostics,
1295
+ ...(products ? { counts: index.counts } : {}),
1296
+ ...(products && index.topCount !== undefined ? { topCount: index.topCount } : {}),
1297
+ ...(products ? { products, categories: index.categories ?? [] } : {}),
1298
+ shards: products ? shards : [],
1299
+ };
1300
+ }
1301
+ export function verifyCatalogReferences(catalog, policy = DEFAULT_POLICY) {
1302
+ for (const source of catalog.sources) {
1303
+ assertRepositoryAllowed(source.repositoryUrl, policy);
1304
+ validateCatalogPath(source.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID');
1305
+ }
1306
+ for (const plugin of catalog.plugins) {
1307
+ assertRepositoryAllowed(plugin.provenance.repositoryUrl, policy);
1308
+ validateCatalogPath(plugin.provenance.pluginSubdirectory, 'PROVENANCE_PATH_INVALID');
1309
+ for (const release of plugin.availableReleases) {
1310
+ assertRepositoryAllowed(release.resolvedRepositoryUrl, policy);
1311
+ validateCatalogPath(release.pluginSubdirectory, 'RELEASE_PATH_INVALID');
1312
+ for (const path of release.fileIndex)
1313
+ validateCatalogPath(path, 'RELEASE_FILE_PATH_INVALID');
1314
+ }
1315
+ }
1316
+ }
1317
+ function validateCatalogPath(path, code) {
1318
+ if (path !== '.')
1319
+ safeRelativePath(path, code);
1320
+ }
1321
+ function createChangeReport(catalog, lock, existingLock, existingCatalog, skippedPlugins = []) {
1322
+ const changedSources = existingLock
1323
+ ? lock.sources
1324
+ .filter((source) => (() => {
1325
+ const previous = existingLock.sources.find((candidate) => candidate.sourceId === source.sourceId);
1326
+ return (!previous ||
1327
+ previous?.resolvedCommitSha !== source.resolvedCommitSha ||
1328
+ previous.sourceManifestDigest !== source.sourceManifestDigest ||
1329
+ digest(previous.pluginPins) !== digest(source.pluginPins));
1330
+ })())
1331
+ .map((source) => source.sourceId)
1332
+ : lock.sources.map((source) => source.sourceId);
1333
+ const previousPlugins = new Map(existingCatalog?.plugins.map((plugin) => [plugin.pluginId, plugin]) ?? []);
1334
+ const currentPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]));
1335
+ const addedPlugins = catalog.plugins
1336
+ .filter((plugin) => !previousPlugins.has(plugin.pluginId))
1337
+ .map((plugin) => plugin.pluginId);
1338
+ const removedPlugins = [...previousPlugins.keys()].filter((pluginId) => !currentPlugins.has(pluginId));
1339
+ const changedPlugins = catalog.plugins
1340
+ .filter((plugin) => previousPlugins.get(plugin.pluginId)?.currentReleaseId !== plugin.currentReleaseId &&
1341
+ previousPlugins.has(plugin.pluginId))
1342
+ .map((plugin) => plugin.pluginId);
1343
+ return {
1344
+ schemaVersion: 1,
1345
+ changedSources: changedSources.toSorted(),
1346
+ addedPlugins: addedPlugins.toSorted(),
1347
+ removedPlugins: removedPlugins.toSorted(),
1348
+ changedPlugins: changedPlugins.toSorted(),
1349
+ skippedPlugins,
1350
+ permissionSensitiveChanges: catalog.plugins
1351
+ .flatMap((plugin) => plugin.securityClassification.permissionSensitiveChanges.map((type) => `${plugin.pluginId}:${type}`))
1352
+ .toSorted(),
1353
+ contentResolution: skippedPlugins.length > 0
1354
+ ? 'complete-with-skips'
1355
+ : catalog.plugins.some((plugin) => plugin.securityClassification.contentResolution === 'metadata-only')
1356
+ ? 'metadata-only'
1357
+ : 'complete',
1358
+ };
1359
+ }
1360
+ async function resolveSource(config, mode, fixtureRoot, lock, fromLock) {
1361
+ const manifestPath = safeRelativePath(config.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID');
1362
+ const locked = lock?.sources.find((entry) => entry.sourceId === config.sourceId);
1363
+ if (fromLock && !locked)
1364
+ throw new Error(`SOURCE_LOCK_ENTRY_MISSING: ${config.sourceId}`);
1365
+ const commitSha = fromLock && locked
1366
+ ? locked.resolvedCommitSha
1367
+ : mode === 'offline'
1368
+ ? (config.fixtureCommitSha ?? locked?.resolvedCommitSha)
1369
+ : await resolveGitRef(config.repositoryUrl, `refs/heads/${config.defaultBranch}`);
1370
+ if (!commitSha || !/^[a-f0-9]{40}$/.test(commitSha))
1371
+ throw new Error(`SOURCE_COMMIT_UNRESOLVED: ${config.sourceId}`);
1372
+ const manifestText = mode === 'offline'
1373
+ ? await readFixtureManifest({ ...config, manifestPath }, fixtureRoot)
1374
+ : await fetchText(rawUrl(config.repositoryUrl, commitSha, manifestPath));
1375
+ const parsed = parseMarketplaceManifest(parseJsonDocument(manifestText), config);
1376
+ if (fromLock && locked && digest(manifestText) !== locked.sourceManifestDigest)
1377
+ throw new Error(`SOURCE_LOCK_MANIFEST_MISMATCH: ${config.sourceId}`);
1378
+ const pluginPins = await resolvePluginPins({
1379
+ config,
1380
+ parsed,
1381
+ sourceCommitSha: commitSha,
1382
+ lockedPins: locked?.pluginPins,
1383
+ fromLock,
1384
+ resolveExternalRefs: mode === 'live',
1385
+ });
1386
+ return {
1387
+ config,
1388
+ commitSha,
1389
+ manifestText,
1390
+ parsed,
1391
+ pluginPins,
1392
+ retrievedAt: fromLock && locked
1393
+ ? locked.retrievedAt
1394
+ : mode === 'offline'
1395
+ ? '2026-01-01T00:00:00.000Z'
1396
+ : new Date().toISOString(),
1397
+ manifestDigest: digest(manifestText),
1398
+ };
1399
+ }
1400
+ async function resolvePluginPins(input) {
1401
+ const pins = await mapWithConcurrency(input.parsed.entries, 8, async (entry) => {
1402
+ if (entry.source.kind === 'local') {
1403
+ return {
1404
+ pluginName: entry.name,
1405
+ repositoryUrl: canonicalRepositoryUrl(input.config.repositoryUrl),
1406
+ pluginSubdirectory: entry.source.path,
1407
+ resolvedCommitSha: input.sourceCommitSha,
1408
+ };
1409
+ }
1410
+ const repositoryUrl = canonicalRepositoryUrl(entry.source.repositoryUrl);
1411
+ const pluginSubdirectory = entry.source.subdirectory;
1412
+ const locked = input.lockedPins?.find((pin) => pin.pluginName === entry.name);
1413
+ if (input.fromLock && !locked)
1414
+ throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISSING: ${input.config.sourceId}:${entry.name}`);
1415
+ if (locked) {
1416
+ if (locked.repositoryUrl !== repositoryUrl ||
1417
+ locked.pluginSubdirectory !== pluginSubdirectory) {
1418
+ throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISMATCH: ${input.config.sourceId}:${entry.name}`);
1419
+ }
1420
+ return locked;
1421
+ }
1422
+ const resolvedCommitSha = entry.source.sha ??
1423
+ (input.resolveExternalRefs
1424
+ ? await resolveGitRef(repositoryUrl, entry.source.ref?.startsWith('refs/')
1425
+ ? entry.source.ref
1426
+ : `refs/heads/${entry.source.ref ?? 'main'}`)
1427
+ : input.sourceCommitSha);
1428
+ return { pluginName: entry.name, repositoryUrl, pluginSubdirectory, resolvedCommitSha };
1429
+ });
1430
+ return pins.toSorted((left, right) => left.pluginName.localeCompare(right.pluginName));
1431
+ }
1432
+ async function mapWithConcurrency(values, concurrency, mapper) {
1433
+ const results = Array.from({ length: values.length });
1434
+ let nextIndex = 0;
1435
+ const worker = async () => {
1436
+ while (true) {
1437
+ const index = nextIndex++;
1438
+ if (index >= values.length)
1439
+ return;
1440
+ results[index] = await mapper(values[index], index);
1441
+ }
1442
+ };
1443
+ await Promise.all(Array.from({ length: Math.min(Math.max(concurrency, 1), values.length) }, () => worker()));
1444
+ return results;
1445
+ }
1446
+ async function readFixtureManifest(config, fixtureRoot) {
1447
+ if (!fixtureRoot || !config.fixturePath)
1448
+ throw new Error(`FIXTURE_ROOT_REQUIRED: ${config.sourceId}`);
1449
+ return fs.readFile(join(resolve(fixtureRoot), config.fixturePath, config.manifestPath), 'utf8');
1450
+ }
1451
+ async function resolveGitRef(repositoryUrl, ref) {
1452
+ const result = spawn('git', ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref], {
1453
+ stdio: ['ignore', 'pipe', 'pipe'],
1454
+ });
1455
+ const [stdout, stderr, exitCode] = await Promise.all([
1456
+ new Promise((resolveOutput, reject) => {
1457
+ const chunks = [];
1458
+ result.stdout?.on('data', (chunk) => chunks.push(chunk));
1459
+ result.stdout?.on('error', reject);
1460
+ result.stdout?.on('end', () => resolveOutput(Buffer.concat(chunks).toString('utf8')));
1461
+ }),
1462
+ new Promise((resolveError) => {
1463
+ const chunks = [];
1464
+ result.stderr?.on('data', (chunk) => chunks.push(chunk));
1465
+ result.stderr?.on('error', () => resolveError(''));
1466
+ result.stderr?.on('end', () => resolveError(Buffer.concat(chunks).toString('utf8')));
1467
+ }),
1468
+ new Promise((resolveExit, reject) => {
1469
+ result.on('error', reject);
1470
+ result.on('close', (code) => resolveExit(code ?? 1));
1471
+ }),
1472
+ ]);
1473
+ if (exitCode !== 0)
1474
+ throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`);
1475
+ const sha = stdout.trim().split(/\s+/)[0];
1476
+ if (!sha || !/^[a-f0-9]{40}$/.test(sha))
1477
+ throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`);
1478
+ return sha;
1479
+ }
1480
+ /** Raw content URL for one file at an immutable commit. */
1481
+ export function rawContentUrl(repositoryUrl, commitSha, path) {
1482
+ return rawUrl(repositoryUrl, commitSha, path);
1483
+ }
1484
+ /** Fetches bytes from an allow-listed upstream URL with the shared retry policy. */
1485
+ export async function fetchUpstreamBytes(url, signal) {
1486
+ const response = await fetchUpstream(url, {
1487
+ headers: upstreamRequestHeaders(url),
1488
+ ...(signal ? { signal } : {}),
1489
+ });
1490
+ if (!response.ok)
1491
+ throw new Error(`HTTP_FETCH_FAILED: ${response.status}: ${url}`);
1492
+ return new Uint8Array(await response.arrayBuffer());
1493
+ }
1494
+ function rawUrl(repositoryUrl, commitSha, path) {
1495
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl));
1496
+ return `https://raw.githubusercontent.com${parsed.pathname}/${commitSha}/${path.split('/').map(encodeURIComponent).join('/')}`;
1497
+ }
1498
+ async function fetchText(url) {
1499
+ const response = await fetch(url, { headers: upstreamRequestHeaders(url) });
1500
+ if (!response.ok)
1501
+ throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`);
1502
+ return response.text();
1503
+ }
1504
+ const UPSTREAM_USER_AGENT = 'agent-hq-plugin-marketplace';
1505
+ /**
1506
+ * Hosts that may receive the repository credential.
1507
+ *
1508
+ * Everything else is fetched anonymously. The token this build runs with
1509
+ * authorises repository writes for the duration of the job, so a vendor
1510
+ * homepage, its icon CDN or any other third party must never be handed it.
1511
+ */
1512
+ const GITHUB_CREDENTIAL_HOSTS = /(^|\.)(github\.com|githubusercontent\.com)$/;
1513
+ /**
1514
+ * Request headers for one upstream URL.
1515
+ *
1516
+ * The credential and GitHub's media type are attached only for GitHub hosts.
1517
+ * Sending them elsewhere is not merely a needless disclosure: `appwrite.io`
1518
+ * answers a page carrying an `Authorization` header with HTTP 500, and a CDN
1519
+ * asked for an image with `accept: application/vnd.github+json` can answer with
1520
+ * bytes that are not the mark. A build that did this dropped marks it had
1521
+ * already curated, and the curation verifier — which used its own anonymous
1522
+ * headers — could not see it.
1523
+ */
1524
+ export function upstreamRequestHeaders(url) {
1525
+ const anonymous = { 'user-agent': UPSTREAM_USER_AGENT };
1526
+ let host;
1527
+ try {
1528
+ host = new URL(url).hostname.toLowerCase();
1529
+ }
1530
+ catch {
1531
+ return anonymous;
1532
+ }
1533
+ const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
1534
+ if (!token || !GITHUB_CREDENTIAL_HOSTS.test(host))
1535
+ return anonymous;
1536
+ return {
1537
+ accept: 'application/vnd.github+json',
1538
+ authorization: `Bearer ${token}`,
1539
+ 'user-agent': UPSTREAM_USER_AGENT,
1540
+ };
1541
+ }
1542
+ /**
1543
+ * Fetches vendor-site documents exactly as a live build does.
1544
+ *
1545
+ * Shared with the curation verifier so both ask a vendor the same question: a
1546
+ * verifier with its own headers reports sites healthy that the build then fails
1547
+ * to resolve.
1548
+ *
1549
+ * `deadline` optionally bounds one product's whole resolution. The per-request
1550
+ * ceiling is always created fresh inside each fetch, so a caller that passes a
1551
+ * deadline — or shares one — can never hand later requests an already-expired
1552
+ * signal.
1553
+ */
1554
+ export function createSiteIconFetcher(deadline) {
1555
+ const bounded = () => {
1556
+ const ceiling = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS);
1557
+ return deadline ? AbortSignal.any([deadline, ceiling]) : ceiling;
1558
+ };
1559
+ return {
1560
+ fetchText: async (url) => new TextDecoder().decode(await fetchUpstreamBytes(url, bounded())),
1561
+ fetchBytes: (url) => fetchUpstreamBytes(url, bounded()),
1562
+ digest: byteDigest,
1563
+ };
1564
+ }
1565
+ /** Ceiling for a single product-site request during icon discovery. */
1566
+ export const SITE_FETCH_TIMEOUT_MS = 15_000;
1567
+ const UPSTREAM_FETCH_MAX_ATTEMPTS = 3;
1568
+ const UPSTREAM_FETCH_BASE_DELAY_MS = 1000;
1569
+ const UPSTREAM_FETCH_MAX_DELAY_MS = 30000;
1570
+ const UPSTREAM_WEATHER_SKIP_CODES = new Set([
1571
+ 'PLUGIN_SOURCE_UNAVAILABLE',
1572
+ 'PLUGIN_FILE_UNAVAILABLE',
1573
+ 'PLUGIN_PATH_NOT_FOUND',
1574
+ ]);
1575
+ function sleep(ms) {
1576
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
1577
+ }
1578
+ function upstreamRetryDelayMs(attempt, response) {
1579
+ const retryAfter = response?.headers.get('retry-after');
1580
+ if (retryAfter !== null && retryAfter !== undefined) {
1581
+ const seconds = Number(retryAfter);
1582
+ if (Number.isFinite(seconds) && seconds >= 0)
1583
+ return Math.min(seconds * 1000, UPSTREAM_FETCH_MAX_DELAY_MS);
1584
+ const date = Date.parse(retryAfter);
1585
+ if (!Number.isNaN(date))
1586
+ return Math.min(Math.max(date - Date.now(), 0), UPSTREAM_FETCH_MAX_DELAY_MS);
1587
+ }
1588
+ return Math.min(UPSTREAM_FETCH_BASE_DELAY_MS * 2 ** attempt, UPSTREAM_FETCH_MAX_DELAY_MS);
1589
+ }
1590
+ function isRateLimitedUpstream(response) {
1591
+ return (response.headers.get('x-ratelimit-remaining') === '0' ||
1592
+ response.headers.get('retry-after') !== null);
1593
+ }
1594
+ function isRetryableUpstreamStatus(status, response) {
1595
+ if (status === 429)
1596
+ return true;
1597
+ if (status >= 500)
1598
+ return true;
1599
+ if (status === 403 && response !== undefined && isRateLimitedUpstream(response))
1600
+ return true;
1601
+ return false;
1602
+ }
1603
+ async function fetchUpstream(url, init) {
1604
+ let attempt = 0;
1605
+ for (;;) {
1606
+ let response;
1607
+ try {
1608
+ response = await fetch(url, init);
1609
+ }
1610
+ catch (error) {
1611
+ if (attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1)
1612
+ throw error;
1613
+ await sleep(upstreamRetryDelayMs(attempt));
1614
+ attempt += 1;
1615
+ continue;
1616
+ }
1617
+ if (response.ok ||
1618
+ !isRetryableUpstreamStatus(response.status, response) ||
1619
+ attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1)
1620
+ return response;
1621
+ await sleep(upstreamRetryDelayMs(attempt, response));
1622
+ attempt += 1;
1623
+ }
1624
+ }
1625
+ /**
1626
+ * Ceiling on bytes read out of one repository archive.
1627
+ *
1628
+ * The per-plugin policy still bounds what any single plugin may contain; this
1629
+ * only stops a decompression bomb from exhausting the build host, and it is
1630
+ * enforced while the stream is read rather than after it is buffered.
1631
+ */
1632
+ const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
1633
+ function concatChunks(chunks, length) {
1634
+ const out = new Uint8Array(length);
1635
+ let offset = 0;
1636
+ for (const chunk of chunks) {
1637
+ out.set(chunk, offset);
1638
+ offset += chunk.byteLength;
1639
+ }
1640
+ return out;
1641
+ }
1642
+ /**
1643
+ * Read one GitHub repository tarball into path -> bytes.
1644
+ *
1645
+ * GitHub wraps every entry in a single top-level `<repo>-<sha>/` directory,
1646
+ * which is stripped so the keys match the tree API's repository-relative
1647
+ * paths. Only regular files are taken: directories, symlinks, hardlinks and
1648
+ * the `pax_global_header` record are skipped, so a link can never be
1649
+ * materialized as content. Every remaining path goes through
1650
+ * `safeRelativePath`, so an archive that tries to escape the repository is
1651
+ * rejected rather than partially trusted.
1652
+ */
1653
+ export async function readArchiveFiles(archive, policy) {
1654
+ const files = new Map();
1655
+ let totalBytes = 0;
1656
+ const parser = extract();
1657
+ const gunzip = createGunzip();
1658
+ const settled = new Promise((resolveDone, rejectDone) => {
1659
+ const fail = (error) => {
1660
+ gunzip.destroy();
1661
+ parser.destroy();
1662
+ rejectDone(error);
1663
+ };
1664
+ parser.on('entry', (header, stream, next) => {
1665
+ const skip = () => {
1666
+ stream.on('end', next);
1667
+ stream.resume();
1668
+ };
1669
+ if (header.type !== 'file')
1670
+ return skip();
1671
+ const segments = header.name.split('/');
1672
+ if (segments.length < 2)
1673
+ return skip();
1674
+ let path;
1675
+ try {
1676
+ path = safeRelativePath(segments.slice(1).join('/'), 'TREE_PATH_INVALID');
1677
+ }
1678
+ catch (error) {
1679
+ return fail(error);
1680
+ }
1681
+ const chunks = [];
1682
+ let length = 0;
1683
+ stream.on('data', (chunk) => {
1684
+ const bytes = chunk;
1685
+ length += bytes.byteLength;
1686
+ chunks.push(bytes);
1687
+ });
1688
+ stream.on('error', fail);
1689
+ stream.on('end', () => {
1690
+ try {
1691
+ const bytes = concatChunks(chunks, length);
1692
+ if (bytes.byteLength > policy.maxFileBytes)
1693
+ throw new Error(`PLUGIN_FILE_TOO_LARGE: ${path}`);
1694
+ totalBytes += bytes.byteLength;
1695
+ if (totalBytes > MAX_ARCHIVE_BYTES)
1696
+ throw new Error('PLUGIN_SIZE_POLICY: archive');
1697
+ files.set(path, bytes);
1698
+ next();
1699
+ }
1700
+ catch (error) {
1701
+ fail(error);
1702
+ }
1703
+ });
1704
+ stream.resume();
1705
+ });
1706
+ parser.on('error', fail);
1707
+ gunzip.on('error', fail);
1708
+ parser.on('finish', () => resolveDone());
1709
+ });
1710
+ Readable.from([archive]).pipe(gunzip).pipe(parser);
1711
+ await settled;
1712
+ return files;
1713
+ }
1714
+ export class NetworkSnapshotLoader {
1715
+ #policy;
1716
+ #trees = new Map();
1717
+ #archives = new Map();
1718
+ constructor(policy) {
1719
+ this.#policy = policy;
1720
+ }
1721
+ async load(repositoryUrl, commitSha, pluginSubdirectory) {
1722
+ const parsed = new URL(canonicalRepositoryUrl(repositoryUrl));
1723
+ if (!['github.com', 'www.github.com'].includes(parsed.hostname))
1724
+ throw new Error(`GIT_HOST_UNSUPPORTED: ${parsed.hostname}`);
1725
+ const [owner, repo] = parsed.pathname.split('/').filter(Boolean);
1726
+ if (!owner || !repo)
1727
+ throw new Error('GIT_REPOSITORY_INVALID');
1728
+ const key = `${owner}/${repo}@${commitSha}`;
1729
+ let tree = this.#trees.get(key);
1730
+ if (!tree) {
1731
+ const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`;
1732
+ const response = await fetchUpstream(treeUrl, {
1733
+ headers: upstreamRequestHeaders(treeUrl),
1734
+ });
1735
+ if (!response.ok)
1736
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`]);
1737
+ const payload = (await response.json());
1738
+ if (payload.truncated)
1739
+ throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`);
1740
+ tree = (payload.tree ?? []).map((entry) => ({
1741
+ ...entry,
1742
+ path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
1743
+ }));
1744
+ this.#trees.set(key, tree);
1745
+ }
1746
+ const prefix = pluginSubdirectory ? `${safeRelativePath(pluginSubdirectory)}/` : '';
1747
+ const submodules = tree.filter((entry) => entry.path.startsWith(prefix) && (entry.type === 'commit' || entry.mode === '160000'));
1748
+ if (submodules.length > 0)
1749
+ throw new Error(`GIT_SUBMODULE_UNSUPPORTED: ${repositoryUrl}:${pluginSubdirectory}`);
1750
+ const selected = tree.filter((entry) => entry.type === 'blob' && entry.path.startsWith(prefix));
1751
+ if (selected.length === 0)
1752
+ throw new Error(`PLUGIN_PATH_NOT_FOUND: ${repositoryUrl}:${pluginSubdirectory}`);
1753
+ const files = new Map();
1754
+ const symlinks = tree
1755
+ .filter((entry) => entry.mode === '120000' && entry.path.startsWith(prefix))
1756
+ .map((entry) => entry.path);
1757
+ if (selected.length > this.#policy.maxFilesPerPlugin)
1758
+ throw new Error('PLUGIN_TOO_MANY_FILES');
1759
+ let totalSize = 0;
1760
+ for (const entry of selected) {
1761
+ const size = entry.size ?? 0;
1762
+ if (size > this.#policy.maxFileBytes || totalSize + size > this.#policy.maxBytesPerPlugin)
1763
+ throw new Error(`PLUGIN_SIZE_POLICY: ${entry.path}`);
1764
+ totalSize += size;
1765
+ }
1766
+ // The tree above already bounded this plugin before a byte was pulled, so
1767
+ // the archive is the only unbounded read left and it is capped while it
1768
+ // streams. One archive per repository replaces one request per file.
1769
+ const archive = await this.#archiveFiles(key, owner, repo, commitSha);
1770
+ for (const entry of selected) {
1771
+ const bytes = archive.get(entry.path);
1772
+ if (!bytes)
1773
+ throw new PluginSafetyError('PLUGIN_FILE_UNAVAILABLE', [entry.path]);
1774
+ if (bytes.byteLength > this.#policy.maxFileBytes)
1775
+ throw new Error(`PLUGIN_FILE_TOO_LARGE: ${entry.path}`);
1776
+ files.set(prefix ? relative(prefix.slice(0, -1), entry.path).replaceAll('\\', '/') : entry.path, bytes);
1777
+ }
1778
+ return { files, symlinks };
1779
+ }
1780
+ /**
1781
+ * One repository archive per (repository, commit), shared by every plugin
1782
+ * that repository hosts.
1783
+ */
1784
+ async #archiveFiles(key, owner, repo, commitSha) {
1785
+ const cached = this.#archives.get(key);
1786
+ if (cached)
1787
+ return cached;
1788
+ const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`;
1789
+ const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) });
1790
+ if (!response.ok)
1791
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`]);
1792
+ const files = await readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy);
1793
+ this.#archives.set(key, files);
1794
+ return files;
1795
+ }
1796
+ }
1797
+ export class FixtureSnapshotLoader {
1798
+ #root;
1799
+ #sources;
1800
+ constructor(root, sources) {
1801
+ this.#root = resolve(root);
1802
+ this.#sources = new Map(sources.map((source) => [canonicalRepositoryUrl(source.repositoryUrl), source]));
1803
+ }
1804
+ async load(repositoryUrl, _commitSha, pluginSubdirectory) {
1805
+ const source = this.#sources.get(canonicalRepositoryUrl(repositoryUrl));
1806
+ if (!source?.fixturePath)
1807
+ throw new Error(`FIXTURE_REPOSITORY_NOT_FOUND: ${repositoryUrl}`);
1808
+ return snapshotFromDirectory(join(this.#root, source.fixturePath), pluginSubdirectory);
1809
+ }
1810
+ }
1811
+ export class DirectorySnapshotLoader {
1812
+ async load(_repositoryUrl, _commitSha, pluginSubdirectory) {
1813
+ throw new Error(`DIRECTORY_LOADER_REQUIRES_ROOT: ${pluginSubdirectory}`);
1814
+ }
1815
+ }
1816
+ export async function snapshotFromDirectory(root, pluginSubdirectory) {
1817
+ const directory = resolve(root, pluginSubdirectory);
1818
+ const files = new Map();
1819
+ const symlinks = [];
1820
+ const rootStat = await fs.lstat(directory);
1821
+ if (rootStat.isSymbolicLink())
1822
+ return { files, symlinks: [pluginSubdirectory || '.'] };
1823
+ await walk(directory, directory, files, symlinks);
1824
+ return { files, symlinks };
1825
+ }
1826
+ async function walk(root, current, files, symlinks) {
1827
+ for (const entry of await fs.readdir(current, { withFileTypes: true })) {
1828
+ const absolute = join(current, entry.name);
1829
+ const relativePath = safeRelativePath(relative(root, absolute), 'DIRECTORY_PATH_INVALID');
1830
+ if (entry.isSymbolicLink()) {
1831
+ symlinks.push(relativePath);
1832
+ continue;
1833
+ }
1834
+ if (entry.isDirectory())
1835
+ await walk(root, absolute, files, symlinks);
1836
+ else if (entry.isFile())
1837
+ files.set(relativePath, await fs.readFile(absolute));
1838
+ }
1839
+ }
1840
+ function sortArtifactKeys(value) {
1841
+ if (Array.isArray(value))
1842
+ return value.map(sortArtifactKeys);
1843
+ if (value !== null && typeof value === 'object')
1844
+ return Object.fromEntries(Object.keys(value)
1845
+ .toSorted()
1846
+ .map((key) => [key, sortArtifactKeys(value[key])]));
1847
+ return value;
1848
+ }
1849
+ function json(value) {
1850
+ return `${JSON.stringify(sortArtifactKeys(value), null, 2)}\n`;
1851
+ }
1852
+ function trimSlugEdges(value) {
1853
+ let start = 0;
1854
+ let end = value.length;
1855
+ while (start < end && value[start] === '-')
1856
+ start += 1;
1857
+ while (end > start && value[end - 1] === '-')
1858
+ end -= 1;
1859
+ return value.slice(start, end);
1860
+ }
1861
+ function slug(value) {
1862
+ const result = trimSlugEdges(value
1863
+ .trim()
1864
+ .toLocaleLowerCase()
1865
+ .replace(/[^a-z0-9]+/g, '-')).slice(0, 128) || 'unknown';
1866
+ return result.length < 2 ? `${result}-plugin` : result;
1867
+ }
1868
+ function stringValue(input) {
1869
+ return typeof input === 'string' && input.trim() ? input.trim() : undefined;
1870
+ }
1871
+ function isString(input) {
1872
+ return typeof input === 'string' && input.trim().length > 0;
1873
+ }
1874
+ function arrayValue(input) {
1875
+ return Array.isArray(input) ? input : [];
1876
+ }
1877
+ //# sourceMappingURL=index.js.map