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