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