@adea-ai/plugins 4.0.1 → 4.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +0 -5
  17. package/dist/catalog-store.js +1 -25
  18. package/dist/catalog-store.js.map +1 -1
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +6 -28
  23. package/dist/consumer-index.js +7 -8
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +3 -2
  34. package/dist/icon-mirror.js +51 -63
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -315
  40. package/dist/index.js +12 -1905
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +6 -3
  64. package/dist/publication.js +25 -12
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +2 -3
  70. package/dist/site-icons.js +4 -3
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +2 -4
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.ts +234 -0
  101. package/src/build.ts +131 -0
  102. package/src/capabilities.ts +153 -0
  103. package/src/catalog-store.ts +1 -28
  104. package/src/concurrency.ts +19 -0
  105. package/src/consumer-index.test.ts +27 -21
  106. package/src/consumer-index.ts +12 -35
  107. package/src/digest.ts +55 -0
  108. package/src/fs-atomic.ts +32 -0
  109. package/src/harness.test.ts +4 -32
  110. package/src/harness.ts +2 -1
  111. package/src/icon-mirror.ts +57 -64
  112. package/src/icons.test.ts +0 -4
  113. package/src/icons.ts +4 -4
  114. package/src/index.test.ts +52 -48
  115. package/src/index.ts +50 -2583
  116. package/src/integrity-manifest.ts +41 -21
  117. package/src/json-values.ts +11 -0
  118. package/src/plugin-release.ts +457 -0
  119. package/src/plugin-skips.ts +110 -0
  120. package/src/policy.ts +39 -0
  121. package/src/portable-catalog.test.ts +0 -5
  122. package/src/portable-catalog.ts +16 -143
  123. package/src/portable-verify.ts +71 -0
  124. package/src/publication.test.ts +29 -1
  125. package/src/publication.ts +32 -13
  126. package/src/published-output.ts +60 -0
  127. package/src/site-icons.test.ts +0 -1
  128. package/src/site-icons.ts +4 -4
  129. package/src/snapshots.ts +287 -0
  130. package/src/source-digest.test.ts +3 -7
  131. package/src/source-resolution.ts +233 -0
  132. package/src/sources-json.test.ts +0 -1
  133. package/src/sources.ts +27 -33
  134. package/src/synchronize.ts +213 -0
  135. package/src/taxonomy.ts +87 -0
  136. package/src/upstream.ts +194 -0
  137. package/src/verify-artifacts.ts +365 -0
  138. package/src/write-artifacts.test.ts +0 -1
  139. package/dist/source-digest.d.ts +0 -2
  140. package/dist/source-digest.js +0 -12
  141. package/dist/source-digest.js.map +0 -1
  142. package/src/source-digest.ts +0 -14
@@ -1,44 +1,64 @@
1
1
  import { z } from 'zod'
2
- import { DIGEST_RE } from './schema.js'
2
+ import { isPublishedName } from './publication.js'
3
+ import { CatalogSchema, DIGEST_RE } from './schema.js'
4
+
5
+ /** A mirrored brand mark published beside the artifacts. */
6
+ export const PublishedAssetSchema = z.object({
7
+ name: z.string(),
8
+ digest: z.string().regex(DIGEST_RE),
9
+ bytes: z.number().int().positive(),
10
+ contentType: z.string(),
11
+ /** `content` mirrors a plugin file; `favicon` mirrors the product site's icon. */
12
+ kind: z.enum(['content', 'favicon']),
13
+ /** Upstream path or URL the bytes came from, for provenance. */
14
+ origin: z.string(),
15
+ })
16
+ export type PublishedAsset = Readonly<z.output<typeof PublishedAssetSchema>>
17
+
18
+ /** The integrity record for a mirrored mark, from its catalog-relative path or site URL. */
19
+ export function publishedAssetOf(icon: {
20
+ readonly asset: string
21
+ readonly digest: string
22
+ readonly bytes: number
23
+ readonly contentType: string
24
+ readonly kind: 'content' | 'favicon'
25
+ readonly path?: string | null
26
+ readonly url?: string | null
27
+ }): PublishedAsset {
28
+ return {
29
+ name: icon.asset,
30
+ digest: icon.digest,
31
+ bytes: icon.bytes,
32
+ contentType: icon.contentType,
33
+ kind: icon.kind,
34
+ origin: (icon.kind === 'content' ? icon.path : icon.url) ?? '',
35
+ }
36
+ }
3
37
 
4
38
  // Assets were added after the first v1 snapshots, so their absence remains valid.
5
39
  const IntegrityManifestSchema = z.object({
6
40
  schemaVersion: z.literal(1),
7
- catalogId: z.string().regex(/^catalog:[a-f0-9]{64}$/),
41
+ catalogId: CatalogSchema.shape.catalogId,
8
42
  files: z.record(z.string(), z.string().regex(DIGEST_RE)),
9
- assets: z
10
- .array(
11
- z.object({
12
- name: z.string(),
13
- digest: z.string().regex(DIGEST_RE),
14
- bytes: z.number().int().positive(),
15
- contentType: z.string(),
16
- kind: z.enum(['content', 'favicon']),
17
- origin: z.string(),
18
- })
19
- )
20
- .optional(),
43
+ assets: z.array(PublishedAssetSchema).optional(),
21
44
  })
45
+ type IntegrityManifest = z.output<typeof IntegrityManifestSchema>
22
46
 
23
47
  /** Validate all names before using a manifest to address local files or URLs. */
24
- export function parseIntegrityManifest(input: unknown) {
48
+ export function parseIntegrityManifest(input: unknown): IntegrityManifest {
25
49
  const parsed = IntegrityManifestSchema.safeParse(input)
26
50
  if (!parsed.success || Object.keys(parsed.data.files).length === 0)
27
51
  throw new Error('INTEGRITY_MANIFEST_INVALID')
28
52
  const manifest = parsed.data
29
53
  for (const name of Object.keys(manifest.files)) {
30
- if (!safeName(name) || name === 'integrity.json')
54
+ if (!isPublishedName(name) || name === 'integrity.json')
31
55
  throw new Error(`INTEGRITY_FILE_NAME_INVALID: ${name}`)
32
56
  }
33
57
  const assetNames = new Set<string>()
34
58
  for (const asset of manifest.assets ?? []) {
35
- if (!safeName(asset.name)) throw new Error(`INTEGRITY_ASSET_NAME_INVALID: ${asset.name}`)
59
+ if (!isPublishedName(asset.name)) throw new Error(`INTEGRITY_ASSET_NAME_INVALID: ${asset.name}`)
36
60
  if (assetNames.has(asset.name)) throw new Error(`INTEGRITY_ASSET_DUPLICATE: ${asset.name}`)
37
61
  assetNames.add(asset.name)
38
62
  }
39
63
  return manifest
40
64
  }
41
-
42
- function safeName(name: string): boolean {
43
- return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)
44
- }
@@ -0,0 +1,11 @@
1
+ export function stringValue(input: unknown): string | undefined {
2
+ return typeof input === 'string' && input.trim() ? input.trim() : undefined
3
+ }
4
+
5
+ export function isString(input: unknown): input is string {
6
+ return typeof input === 'string' && input.trim().length > 0
7
+ }
8
+
9
+ export function arrayValue(input: unknown): unknown[] {
10
+ return Array.isArray(input) ? input : []
11
+ }
@@ -0,0 +1,457 @@
1
+ import { byteDigest, digest, sourceDigest } from './digest.js'
2
+ import {
3
+ capabilitySummary,
4
+ classifyCapabilities,
5
+ createCompatibility,
6
+ createSecurity,
7
+ } from './capabilities.js'
8
+ import { normalizeDisplayName } from './display-name.js'
9
+ import { stageIconBytes } from './icon-mirror.js'
10
+ import {
11
+ iconAssetName,
12
+ iconOverrideFor,
13
+ inspectIconBytes,
14
+ selectIconPath,
15
+ type ProductIconOverrides,
16
+ } from './icons.js'
17
+ import { arrayValue, isString, stringValue } from './json-values.js'
18
+ import { PluginSafetyError } from './plugin-skips.js'
19
+ import type { CatalogPolicy } from './policy.js'
20
+ import type {
21
+ Capability,
22
+ Harness,
23
+ HarnessCompatibility,
24
+ LicenseMetadata,
25
+ Plugin,
26
+ PluginProvenance,
27
+ PluginRelease,
28
+ } from './schema.js'
29
+ import { isVendorHomepage, resolveSiteIcon, type SiteIcon } from './site-icons.js'
30
+ import type { Snapshot, SnapshotLoader } from './snapshots.js'
31
+ import {
32
+ resolvePluginSource,
33
+ type ResolvedPluginSource,
34
+ type ResolvedSource,
35
+ } from './source-resolution.js'
36
+ import {
37
+ canonicalRepositoryUrl,
38
+ parseJsonDocument,
39
+ safeRelativePath,
40
+ type MarketplacePluginEntry,
41
+ } from './sources.js'
42
+ import {
43
+ normalizeCategory,
44
+ productGroupingKey,
45
+ stablePluginId,
46
+ stableReleaseId,
47
+ type CategoryMap,
48
+ type ProductAliases,
49
+ } from './taxonomy.js'
50
+ import { createSiteIconFetcher, SITE_FETCH_TIMEOUT_MS } from './upstream.js'
51
+
52
+ /**
53
+ * Marks the catalog format this compiler emits.
54
+ *
55
+ * Bumping or first introducing this value makes a synchronization replay its
56
+ * verified pins and rebuild, which is how a format change reaches the published
57
+ * catalog when no upstream source has moved. It is deliberately separate from
58
+ * the agent-package normalizer version, which describes package normalization
59
+ * and would misreport a change here.
60
+ */
61
+ export const CATALOG_CONTRACT_VERSION = 'adea-catalog/3'
62
+
63
+ /** Trusted compiler extension; never loaded from upstream plugin content. */
64
+ export type ReleaseTransform = (
65
+ release: PluginRelease,
66
+ snapshot: Snapshot,
67
+ pluginName: string
68
+ ) => PluginRelease
69
+
70
+ /** Everything that shapes a release besides the marketplace entry itself. */
71
+ export interface ReleaseOptions {
72
+ readonly categoryMap: CategoryMap
73
+ readonly productCategories?: Record<string, string>
74
+ readonly productAliases: ProductAliases
75
+ readonly productIconOverrides?: ProductIconOverrides
76
+ /** Directory that receives mirrored brand marks as the catalog is built. */
77
+ readonly iconAssetsDirectory?: string
78
+ readonly policy: CatalogPolicy
79
+ readonly metadataOnly: boolean
80
+ readonly snapshotLoader: SnapshotLoader
81
+ /** Live builds resolve unpinned refs and may fetch vendor site icons. */
82
+ readonly resolveExternalRefs: boolean
83
+ readonly transformRelease?: ReleaseTransform
84
+ /** Compatibility derived from the final release, replacing the dialect heuristic. */
85
+ readonly releaseCompatibility?: ReleaseCompatibility
86
+ }
87
+
88
+ export type ReleaseCompatibility = (
89
+ release: PluginRelease
90
+ ) => Record<Harness, HarnessCompatibility> | undefined
91
+
92
+ export async function normalizePlugin(
93
+ source: ResolvedSource,
94
+ entry: MarketplacePluginEntry,
95
+ input: ReleaseOptions
96
+ ): Promise<Plugin> {
97
+ const { policy } = input
98
+ const resolved = await resolvePluginSource(
99
+ entry.name,
100
+ entry.source,
101
+ source,
102
+ input.resolveExternalRefs
103
+ )
104
+ const snapshot = input.metadataOnly
105
+ ? { files: new Map<string, Uint8Array>(), symlinks: [] }
106
+ : await input.snapshotLoader.load(
107
+ resolved.repositoryUrl,
108
+ resolved.commitSha,
109
+ resolved.subdirectory
110
+ )
111
+ if (snapshot.symlinks.length > 0)
112
+ throw new PluginSafetyError('SYMLINK_ESCAPE', [...snapshot.symlinks].toSorted())
113
+ validateSnapshot(snapshot, policy, entry.name)
114
+ const contentDigest = input.metadataOnly
115
+ ? digest({
116
+ repositoryUrl: resolved.repositoryUrl,
117
+ subdirectory: resolved.subdirectory,
118
+ commitSha: resolved.commitSha,
119
+ entryDigest: entry.entryDigest,
120
+ })
121
+ : sourceDigest(snapshot.files)
122
+ const pluginManifest = findPluginManifest(snapshot)
123
+ const manifestDigest = pluginManifest ? digest(pluginManifest.metadata) : entry.entryDigest
124
+ const capabilities = classifyCapabilities(snapshot.files, pluginManifest?.metadata, policy)
125
+ const iconOverride = iconOverrideFor(
126
+ input.productIconOverrides,
127
+ productGroupingKey(entry.name, input.productAliases)
128
+ )
129
+ const contentIcon = resolveContentIcon(snapshot, iconOverride, input.metadataOnly)
130
+ const favicon =
131
+ contentIcon === undefined
132
+ ? await resolveFavicon({
133
+ entry,
134
+ iconOverride,
135
+ metadataOnly: input.metadataOnly,
136
+ live: input.resolveExternalRefs,
137
+ })
138
+ : undefined
139
+ // Stage the mark while its bytes are in hand. Publication then needs no
140
+ // re-fetch, and a site icon cannot drift between resolution and release.
141
+ const mark = contentIcon
142
+ ? { ...contentIcon, bytes: snapshot.files.get(contentIcon.path) }
143
+ : favicon
144
+ if (input.iconAssetsDirectory && mark?.bytes)
145
+ await stageIconBytes(
146
+ input.iconAssetsDirectory,
147
+ iconAssetName(mark.digest, mark.contentType),
148
+ mark.bytes
149
+ )
150
+ const releaseId = stableReleaseId(
151
+ resolved.repositoryUrl,
152
+ resolved.subdirectory,
153
+ resolved.commitSha,
154
+ contentDigest
155
+ )
156
+ const originalRelease = createRelease({
157
+ source,
158
+ entry,
159
+ resolved,
160
+ contentDigest,
161
+ manifestDigest,
162
+ capabilities,
163
+ snapshot,
164
+ ...(pluginManifest ? { pluginManifest } : {}),
165
+ releaseId,
166
+ ...(contentIcon ? { contentIcon } : {}),
167
+ ...(favicon ? { favicon } : {}),
168
+ metadataOnly: input.metadataOnly,
169
+ })
170
+ const release =
171
+ input.transformRelease && !input.metadataOnly
172
+ ? input.transformRelease(originalRelease, snapshot, entry.name)
173
+ : originalRelease
174
+ const compatibility =
175
+ input.releaseCompatibility?.(release) ??
176
+ createCompatibility(source.config.marketplaceDialect, capabilities, policy)
177
+ const security = createSecurity(capabilities, policy, input.metadataOnly)
178
+ const normalizedCategories = entry.categories.map((category) =>
179
+ normalizeCategory(category, input.categoryMap)
180
+ )
181
+ const productCategory = input.productCategories?.[entry.name]
182
+ const categories = [
183
+ ...new Set(
184
+ (productCategory
185
+ ? [productCategory, ...normalizedCategories.filter((category) => category !== 'other')]
186
+ : normalizedCategories.length > 0
187
+ ? normalizedCategories
188
+ : [input.categoryMap.fallback]
189
+ ).filter(Boolean)
190
+ ),
191
+ ].toSorted()
192
+ // The name a list renders: marketplace manifests are inconsistent (`Gmail`
193
+ // beside `zapier` beside `aws-agents-for-devsecops`), so the catalog
194
+ // publishes the written form once and every consumer shows the same thing.
195
+ const displayName = normalizeDisplayName(
196
+ entry.displayName ?? pluginManifest?.displayName ?? entry.name
197
+ )
198
+ const authors =
199
+ entry.authors.length > 0
200
+ ? [...entry.authors]
201
+ : source.parsed.owner.length > 0
202
+ ? [...source.parsed.owner]
203
+ : ['Unknown']
204
+ const keywords = [...new Set([...entry.keywords, ...entry.name.split(/[-_\s]+/g), ...categories])]
205
+ .filter(Boolean)
206
+ .toSorted()
207
+ const provenance: PluginProvenance = {
208
+ sourceId: source.config.sourceId,
209
+ repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
210
+ manifestPath: source.config.manifestPath,
211
+ pluginSubdirectory: resolved.subdirectory || '.',
212
+ resolvedCommitSha: resolved.commitSha,
213
+ sourceManifestDigest: source.manifestDigest,
214
+ upstreamEntryDigest: entry.entryDigest,
215
+ }
216
+ return {
217
+ pluginId: stablePluginId(source.config.sourceId, entry.name),
218
+ displayName,
219
+ description: entry.description || pluginManifest?.description || '',
220
+ productGroupingKey: productGroupingKey(entry.name, input.productAliases),
221
+ categories,
222
+ keywords,
223
+ authors,
224
+ ...(entry.homepage ? { homepage: entry.homepage } : {}),
225
+ icons: entry.icons.length > 0 ? [...entry.icons].toSorted() : (pluginManifest?.icons ?? []),
226
+ sourceId: source.config.sourceId,
227
+ upstreamPluginName: entry.name,
228
+ currentReleaseId: releaseId,
229
+ availableReleases: [release],
230
+ capabilitySummary: capabilitySummary(capabilities),
231
+ harnessCompatibility: compatibility,
232
+ license: findLicense(entry, pluginManifest?.metadata),
233
+ provenance,
234
+ securityClassification: security,
235
+ }
236
+ }
237
+
238
+ interface CreateReleaseInput {
239
+ readonly source: ResolvedSource
240
+ readonly entry: MarketplacePluginEntry
241
+ readonly resolved: ResolvedPluginSource
242
+ readonly contentDigest: string
243
+ readonly manifestDigest: string
244
+ readonly capabilities: readonly Capability[]
245
+ readonly snapshot: Snapshot
246
+ readonly pluginManifest?: PluginManifest
247
+ readonly releaseId: string
248
+ readonly contentIcon?: PluginRelease['icon'] | undefined
249
+ readonly favicon?: SiteIcon | undefined
250
+ readonly metadataOnly: boolean
251
+ }
252
+
253
+ function createRelease(input: CreateReleaseInput): PluginRelease {
254
+ const requiredCredentials = new Set<string>()
255
+ const auth = input.entry.policy.authentication
256
+ if (typeof auth === 'string' && auth !== 'NONE' && auth !== 'DISABLED')
257
+ requiredCredentials.add('authentication')
258
+ for (const value of arrayValue(input.entry.raw.requiredCredentials))
259
+ if (typeof value === 'string') requiredCredentials.add(value)
260
+ const requiredConnectors = [
261
+ ...new Set(arrayValue(input.entry.raw.connectors).filter(isString)),
262
+ ].toSorted()
263
+ const permissionSensitiveChanges = input.capabilities
264
+ .filter((capability) => capability.securityImpact === 'sensitive')
265
+ .map((capability) => capability.type)
266
+ .toSorted()
267
+ const metadata: Record<string, unknown> = {
268
+ catalogContract: CATALOG_CONTRACT_VERSION,
269
+ marketplaceEntry: input.entry.raw,
270
+ ...(input.pluginManifest ? { pluginManifest: input.pluginManifest.metadata } : {}),
271
+ sourceDialect: input.source.config.marketplaceDialect,
272
+ }
273
+ const version = input.pluginManifest?.version ?? stringValue(input.entry.raw.version)
274
+ return {
275
+ releaseId: input.releaseId,
276
+ ...(version ? { upstreamVersion: version } : {}),
277
+ resolvedRepositoryUrl: input.resolved.repositoryUrl,
278
+ resolvedCommitSha: input.resolved.commitSha,
279
+ pluginSubdirectory: input.resolved.subdirectory || '.',
280
+ canonicalContentDigest: input.contentDigest,
281
+ manifestDigest: input.manifestDigest,
282
+ contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
283
+ releaseMetadata: metadata,
284
+ capabilities: [...input.capabilities].toSorted(
285
+ (left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name)
286
+ ),
287
+ requiredConnectors,
288
+ requiredCredentials: [...requiredCredentials].toSorted(),
289
+ permissionSensitiveChanges: [...new Set(permissionSensitiveChanges)],
290
+ fileIndex: [...input.snapshot.files.keys()].toSorted(),
291
+ publicationTimestamp: input.source.retrievedAt,
292
+ ...assembleIcon(input),
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Resolves the product site's icon, for a plugin whose content ships no mark.
298
+ *
299
+ * Only a live build with complete content attempts this: a metadata-only or
300
+ * offline run must not reach out to vendor sites, and neither may a product the
301
+ * curator has already decided about.
302
+ */
303
+ async function resolveFavicon(input: {
304
+ readonly entry: MarketplacePluginEntry
305
+ readonly iconOverride?: IconOverride | undefined
306
+ readonly metadataOnly: boolean
307
+ readonly live: boolean
308
+ }): Promise<SiteIcon | undefined> {
309
+ if (!input.live || input.metadataOnly) return undefined
310
+ if (input.iconOverride?.forced === null || input.iconOverride?.forced) return undefined
311
+ const homepage = input.iconOverride?.site ?? input.entry.homepage
312
+ if (!isVendorHomepage(homepage)) return undefined
313
+ // Bound both fetches: a vendor site that never answers must not stall a
314
+ // catalogue build. A timeout simply leaves the product on its monogram.
315
+ const signal = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
316
+ const resolved = await resolveSiteIcon(homepage!, createSiteIconFetcher(signal))
317
+ return 'contentType' in resolved ? resolved : undefined
318
+ }
319
+
320
+ /**
321
+ * Picks and vets the brand mark for a release.
322
+ *
323
+ * A file in the plugin's own content is preferred, because it is the mark the
324
+ * vendor ships with the plugin. When the content has none, a live build falls
325
+ * back to the declared homepage's site icon: many plugins are a thin wrapper
326
+ * over a vendor API and point at the vendor's site rather than shipping a logo.
327
+ * Whatever the source, an icon that fails inspection is absent rather than
328
+ * advertised: the catalog never publishes content it would refuse to mirror.
329
+ */
330
+ function resolveContentIcon(
331
+ snapshot: Snapshot,
332
+ iconOverride: IconOverride | undefined,
333
+ metadataOnly: boolean
334
+ ): ContentIcon | undefined {
335
+ if (metadataOnly || snapshot.files.size === 0) return undefined
336
+ // A curator override wins over every rule, including "publish no icon".
337
+ if (iconOverride?.forced === null) return undefined
338
+ const files = [...snapshot.files.keys()].toSorted()
339
+ const forced = iconOverride?.forced
340
+ if (forced && !files.includes(forced)) return undefined
341
+ const path = forced ?? selectIconPath(files)
342
+ if (!path) return undefined
343
+ const bytes = snapshot.files.get(path)
344
+ if (!bytes) return undefined
345
+ const inspected = inspectIconBytes(bytes)
346
+ if (!('contentType' in inspected)) return undefined
347
+ return {
348
+ kind: 'content',
349
+ path,
350
+ contentType: inspected.contentType,
351
+ digest: byteDigest(bytes),
352
+ bytes: bytes.byteLength,
353
+ }
354
+ }
355
+
356
+ type IconOverride = ReturnType<typeof iconOverrideFor>
357
+ type ContentIcon = Extract<NonNullable<PluginRelease['icon']>, { kind: 'content' }>
358
+
359
+ function assembleIcon(
360
+ input: Pick<CreateReleaseInput, 'contentIcon' | 'favicon'>
361
+ ): { icon: NonNullable<PluginRelease['icon']> } | Record<string, never> {
362
+ if (input.contentIcon) return { icon: input.contentIcon }
363
+ if (input.favicon)
364
+ return {
365
+ icon: {
366
+ kind: 'favicon',
367
+ url: input.favicon.url,
368
+ homepage: input.favicon.homepage,
369
+ contentType: input.favicon.contentType,
370
+ digest: input.favicon.digest,
371
+ bytes: input.favicon.bytes.byteLength,
372
+ },
373
+ }
374
+ return {}
375
+ }
376
+
377
+ function validateSnapshot(snapshot: Snapshot, policy: CatalogPolicy, pluginName: string): void {
378
+ if (snapshot.files.size > policy.maxFilesPerPlugin)
379
+ throw new PluginSafetyError('PLUGIN_TOO_MANY_FILES', [pluginName])
380
+ let total = 0
381
+ for (const [path, bytes] of snapshot.files) {
382
+ try {
383
+ safeRelativePath(path, 'SNAPSHOT_PATH_INVALID')
384
+ } catch {
385
+ throw new PluginSafetyError('SNAPSHOT_PATH_INVALID', [path])
386
+ }
387
+ if (bytes.byteLength > policy.maxFileBytes)
388
+ throw new PluginSafetyError('PLUGIN_FILE_TOO_LARGE', [path])
389
+ total += bytes.byteLength
390
+ }
391
+ if (total > policy.maxBytesPerPlugin)
392
+ throw new PluginSafetyError('PLUGIN_TOO_LARGE', [pluginName])
393
+ }
394
+
395
+ interface PluginManifest {
396
+ readonly metadata: Record<string, unknown>
397
+ readonly displayName?: string | undefined
398
+ readonly description?: string | undefined
399
+ readonly version?: string | undefined
400
+ readonly icons: string[]
401
+ }
402
+
403
+ function findPluginManifest(snapshot: Snapshot): PluginManifest | undefined {
404
+ const candidates = [...snapshot.files.keys()]
405
+ .filter((path) => {
406
+ const lower = path.toLocaleLowerCase()
407
+ return lower === 'plugin.json' || lower.endsWith('/plugin.json') || lower === 'package.json'
408
+ })
409
+ .toSorted(
410
+ (left, right) => manifestRank(left) - manifestRank(right) || left.localeCompare(right)
411
+ )
412
+ const path = candidates[0]
413
+ if (!path) return undefined
414
+ try {
415
+ const bytes = snapshot.files.get(path)
416
+ if (!bytes) return undefined
417
+ const metadata = parseJsonDocument(new TextDecoder().decode(bytes)) as Record<string, unknown>
418
+ return {
419
+ metadata,
420
+ ...((stringValue(metadata.displayName) ?? stringValue(metadata.name))
421
+ ? { displayName: stringValue(metadata.displayName) ?? stringValue(metadata.name) }
422
+ : {}),
423
+ ...(stringValue(metadata.description)
424
+ ? { description: stringValue(metadata.description) }
425
+ : {}),
426
+ ...(stringValue(metadata.version) ? { version: stringValue(metadata.version) } : {}),
427
+ icons: [metadata.icon, ...(Array.isArray(metadata.icons) ? metadata.icons : [])]
428
+ .filter(isString)
429
+ .toSorted(),
430
+ }
431
+ } catch {
432
+ throw new PluginSafetyError('PLUGIN_MANIFEST_INVALID', [path])
433
+ }
434
+ }
435
+
436
+ function manifestRank(path: string): number {
437
+ // A root manifest is the plugin's own declaration and outranks harness copies.
438
+ if (path === 'plugin.json') return -1
439
+ const lower = path.toLocaleLowerCase()
440
+ if (lower.endsWith('/.claude-plugin/plugin.json')) return 0
441
+ if (lower.endsWith('/.cursor-plugin/plugin.json')) return 1
442
+ if (lower.endsWith('/.agents/plugin.json')) return 2
443
+ return lower === 'plugin.json' ? 3 : 4
444
+ }
445
+
446
+ function findLicense(
447
+ entry: MarketplacePluginEntry,
448
+ metadata?: Record<string, unknown>
449
+ ): LicenseMetadata {
450
+ const candidate = stringValue(metadata?.license) ?? stringValue(entry.raw.license)
451
+ if (!candidate) return { name: 'Unknown', source: 'unknown' }
452
+ return {
453
+ name: candidate,
454
+ ...(candidate.startsWith('http') ? { url: candidate } : {}),
455
+ source: metadata?.license !== undefined ? 'plugin-manifest' : 'marketplace-entry',
456
+ }
457
+ }
@@ -0,0 +1,110 @@
1
+ import type { MarketplacePluginEntry } from './sources.js'
2
+ import type { ResolvedSource } from './source-resolution.js'
3
+ import { stablePluginId } from './taxonomy.js'
4
+
5
+ export type PluginSkipReasonCode =
6
+ | 'GIT_SUBMODULE_UNSUPPORTED'
7
+ | 'PLUGIN_FILE_TOO_LARGE'
8
+ | 'PLUGIN_FILE_UNAVAILABLE'
9
+ | 'PLUGIN_MANIFEST_INVALID'
10
+ | 'PLUGIN_PATH_NOT_FOUND'
11
+ | 'PLUGIN_SCHEMA_INVALID'
12
+ | 'PLUGIN_SIZE_POLICY'
13
+ | 'PLUGIN_SOURCE_UNAVAILABLE'
14
+ | 'PLUGIN_TOO_LARGE'
15
+ | 'PLUGIN_TOO_MANY_FILES'
16
+ | 'SNAPSHOT_PATH_INVALID'
17
+ | 'SYMLINK_ESCAPE'
18
+
19
+ export interface SkippedPlugin {
20
+ readonly sourceId: string
21
+ readonly pluginId: string
22
+ readonly pluginName: string
23
+ readonly reasonCode: PluginSkipReasonCode
24
+ readonly securityReason: string
25
+ readonly incompleteContent: true
26
+ readonly paths: readonly string[]
27
+ }
28
+
29
+ export class PluginSafetyError extends Error {
30
+ constructor(
31
+ readonly reasonCode: PluginSkipReasonCode,
32
+ readonly paths: readonly string[]
33
+ ) {
34
+ super(`${reasonCode}${paths.length > 0 ? `: ${paths.join(',')}` : ''}`)
35
+ this.name = 'PluginSafetyError'
36
+ }
37
+ }
38
+
39
+ const pluginSkipReasons: Readonly<Record<PluginSkipReasonCode, string>> = {
40
+ GIT_SUBMODULE_UNSUPPORTED:
41
+ 'The plugin contains a Git submodule, so the catalog excludes it rather than traversing an unpinned repository boundary.',
42
+ PLUGIN_FILE_TOO_LARGE:
43
+ 'The plugin contains a file over the configured size limit, so the catalog excludes the plugin before publication.',
44
+ PLUGIN_FILE_UNAVAILABLE:
45
+ 'A plugin file could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
46
+ PLUGIN_MANIFEST_INVALID:
47
+ 'The plugin manifest is malformed, so the catalog excludes the plugin before consuming its metadata.',
48
+ PLUGIN_PATH_NOT_FOUND:
49
+ 'The declared plugin path does not exist at the pinned commit, so the catalog excludes the plugin.',
50
+ PLUGIN_SCHEMA_INVALID:
51
+ 'The normalized plugin record exceeds the published catalog schema, so the catalog excludes it before publication.',
52
+ PLUGIN_SIZE_POLICY:
53
+ 'The plugin exceeds the configured size policy, so the catalog excludes it before publication.',
54
+ PLUGIN_SOURCE_UNAVAILABLE:
55
+ 'The plugin repository tree could not be fetched from upstream after retries, so the catalog excludes the plugin rather than publishing partial content.',
56
+ PLUGIN_TOO_LARGE:
57
+ 'The plugin exceeds the configured aggregate size limit, so the catalog excludes it before publication.',
58
+ PLUGIN_TOO_MANY_FILES:
59
+ 'The plugin exceeds the configured file-count limit, so the catalog excludes it before publication.',
60
+ SNAPSHOT_PATH_INVALID:
61
+ 'The plugin snapshot contains an unsafe path, so the catalog excludes it rather than allowing repository escape.',
62
+ SYMLINK_ESCAPE:
63
+ 'The plugin snapshot contains symlinks; symlinks are never followed, so the catalog excludes the plugin.',
64
+ }
65
+
66
+ export function createSkippedPlugin(
67
+ source: ResolvedSource,
68
+ entry: MarketplacePluginEntry,
69
+ error: unknown
70
+ ): SkippedPlugin | undefined {
71
+ const parsed =
72
+ error instanceof PluginSafetyError
73
+ ? { reasonCode: error.reasonCode, paths: error.paths }
74
+ : parsePluginSafetyError(error)
75
+ if (!parsed) return undefined
76
+ return {
77
+ sourceId: source.config.sourceId,
78
+ pluginId: stablePluginId(source.config.sourceId, entry.name),
79
+ pluginName: entry.name,
80
+ reasonCode: parsed.reasonCode,
81
+ securityReason: pluginSkipReasons[parsed.reasonCode],
82
+ incompleteContent: true,
83
+ paths: [...new Set(parsed.paths)].toSorted(),
84
+ }
85
+ }
86
+
87
+ function parsePluginSafetyError(
88
+ error: unknown
89
+ ): { reasonCode: PluginSkipReasonCode; paths: readonly string[] } | undefined {
90
+ const message = error instanceof Error ? error.message : String(error)
91
+ const match = /^([A-Z_]+)(?::\s?(.*))?$/.exec(message)
92
+ if (!match) return undefined
93
+ const reasonCode = match[1] as PluginSkipReasonCode
94
+ if (!(reasonCode in pluginSkipReasons)) return undefined
95
+ return {
96
+ reasonCode,
97
+ paths: match[2]
98
+ ? match[2]
99
+ .split(',')
100
+ .map((path) => path.trim())
101
+ .filter(Boolean)
102
+ : [],
103
+ }
104
+ }
105
+
106
+ export const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
107
+ 'PLUGIN_SOURCE_UNAVAILABLE',
108
+ 'PLUGIN_FILE_UNAVAILABLE',
109
+ 'PLUGIN_PATH_NOT_FOUND',
110
+ ])