@adea-ai/plugins 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Display-name normalization for catalog plugins.
3
+ *
4
+ * Marketplace manifests are inconsistent: a vendor names a plugin `Gmail`, the
5
+ * next one names it `zapier`, and a curated source names it
6
+ * `aws-agents-for-devsecops`. The UI renders those strings verbatim, so one
7
+ * list reads `Zapier` and the next reads `zapier` beside it. This turns a
8
+ * slug-shaped name into the title-cased form a person would write, in the
9
+ * catalog, so every consumer shows the same thing without its own guesswork.
10
+ *
11
+ * Two rules keep it from "correcting" names that are already right:
12
+ *
13
+ * - a token that carries any uppercase is left exactly as it is, which
14
+ * preserves vendor casing (`Gmail`, `iMessage`, `eToro`, `PDF Viewer`) and
15
+ * makes the function idempotent;
16
+ * - a token with a published non-title-case form comes from `BRAND_TOKENS`
17
+ * instead of the general rule, because title-casing `npm` → `Npm` or
18
+ * `github` → `Github` is exactly the inconsistency this exists to remove.
19
+ */
20
+ const BRAND_TOKENS: Readonly<Record<string, string>> = {
21
+ '42crunch': '42Crunch',
22
+ adlc: 'ADLC',
23
+ ai: 'AI',
24
+ bigquery: 'BigQuery',
25
+ brightdata: 'Bright Data',
26
+ amd: 'AMD',
27
+ alloydb: 'AlloyDB',
28
+ api: 'API',
29
+ apis: 'APIs',
30
+ aws: 'AWS',
31
+ cds: 'CDS',
32
+ ckeditor: 'CKEditor',
33
+ clickhouse: 'ClickHouse',
34
+ cli: 'CLI',
35
+ coderabbit: 'CodeRabbit',
36
+ crowdsec: 'CrowdSec',
37
+ crm: 'CRM',
38
+ crowdstrike: 'CrowdStrike',
39
+ css: 'CSS',
40
+ dak: 'DAK',
41
+ dominodatalab: 'Domino Data Lab',
42
+ datadog: 'Datadog',
43
+ fullstory: 'FullStory',
44
+ gitkraken: 'GitKraken',
45
+ devops: 'DevOps',
46
+ devsecops: 'DevSecOps',
47
+ gcp: 'GCP',
48
+ github: 'GitHub',
49
+ gitlab: 'GitLab',
50
+ graphql: 'GraphQL',
51
+ html: 'HTML',
52
+ huggingface: 'Hugging Face',
53
+ ibm: 'IBM',
54
+ idmp: 'IDMP',
55
+ imessage: 'iMessage',
56
+ ios: 'iOS',
57
+ javascript: 'JavaScript',
58
+ json: 'JSON',
59
+ k8s: 'Kubernetes',
60
+ langchain: 'LangChain',
61
+ llm: 'LLM',
62
+ llms: 'LLMs',
63
+ logrocket: 'LogRocket',
64
+ lsp: 'LSP',
65
+ lseg: 'LSEG',
66
+ macos: 'macOS',
67
+ md: 'MD',
68
+ mcp: 'MCP',
69
+ mongodb: 'MongoDB',
70
+ oracledb: 'Oracle Database',
71
+ pagerduty: 'PagerDuty',
72
+ planetscale: 'PlanetScale',
73
+ posthog: 'PostHog',
74
+ mysql: 'MySQL',
75
+ ngs: 'NGS',
76
+ nosql: 'NoSQL',
77
+ nvidia: 'NVIDIA',
78
+ ocr: 'OCR',
79
+ openai: 'OpenAI',
80
+ paypal: 'PayPal',
81
+ pdf: 'PDF',
82
+ postgres: 'Postgres',
83
+ postgresql: 'PostgreSQL',
84
+ pytorch: 'PyTorch',
85
+ sap: 'SAP',
86
+ sdk: 'SDK',
87
+ seo: 'SEO',
88
+ servicenow: 'ServiceNow',
89
+ sql: 'SQL',
90
+ sre: 'SRE',
91
+ tensorflow: 'TensorFlow',
92
+ terraform: 'Terraform',
93
+ twg: 'TWG',
94
+ typescript: 'TypeScript',
95
+ ui: 'UI',
96
+ ui5: 'UI5',
97
+ ux: 'UX',
98
+ whatsapp: 'WhatsApp',
99
+ wordpress: 'WordPress',
100
+ youtube: 'YouTube',
101
+ }
102
+
103
+ /** Articles, conjunctions and prepositions stay lowercase inside a name. */
104
+ const SMALL_WORDS = new Set([
105
+ 'a',
106
+ 'an',
107
+ 'and',
108
+ 'as',
109
+ 'at',
110
+ 'by',
111
+ 'for',
112
+ 'from',
113
+ 'in',
114
+ 'of',
115
+ 'on',
116
+ 'or',
117
+ 'the',
118
+ 'to',
119
+ 'with',
120
+ ])
121
+
122
+ /**
123
+ * Names whose slug form hides a domain or an initialism, which the token rules
124
+ * below cannot recover: `monday-com` is `monday.com`, not `Monday Com`.
125
+ */
126
+ const FULL_NAMES: Readonly<Record<string, string>> = {
127
+ 'bigdata-com': 'BigData.com',
128
+ 'incident-io': 'Incident.io',
129
+ 'monday-com': 'Monday.com',
130
+ vpai: 'VPAI',
131
+ }
132
+
133
+ /**
134
+ * Title-cases a slug-shaped name and leaves a properly cased one alone.
135
+ *
136
+ * Separators become spaces (`adobe-for-creativity` → `Adobe for Creativity`),
137
+ * but a dot does not, so domain-style names keep their shape
138
+ * (`monday.com` → `Monday.com`).
139
+ */
140
+ export function normalizeDisplayName(value: string): string {
141
+ const trimmed = value.trim()
142
+ const known = FULL_NAMES[trimmed.toLowerCase()]
143
+ if (known !== undefined) return known
144
+ const tokens = trimmed.split(/[-_\s]+/u).filter(Boolean)
145
+ if (tokens.length === 0) return value
146
+ return tokens
147
+ .map((token, index) => {
148
+ const key = token.toLowerCase()
149
+ const brand = BRAND_TOKENS[key]
150
+ if (brand !== undefined) return brand
151
+ // Any uppercase means the vendor already chose a casing.
152
+ if (/\p{Lu}/u.test(token)) return token
153
+ if (index > 0 && SMALL_WORDS.has(key)) return key
154
+ return `${token.slice(0, 1).toUpperCase()}${token.slice(1)}`
155
+ })
156
+ .join(' ')
157
+ }
package/src/harness.ts ADDED
@@ -0,0 +1,202 @@
1
+ import {
2
+ HarnessSchema,
3
+ MaterializationPlanSchema,
4
+ type Catalog,
5
+ type Harness,
6
+ type MaterializationPlan,
7
+ type Plugin,
8
+ type PluginRelease,
9
+ } from './schema.js'
10
+
11
+ export class MaterializationError extends Error {
12
+ constructor(
13
+ readonly code: string,
14
+ readonly details: readonly string[] = []
15
+ ) {
16
+ super(`${code}${details.length ? `: ${details.join(', ')}` : ''}`)
17
+ this.name = 'MaterializationError'
18
+ }
19
+ }
20
+
21
+ export function createMaterializationPlan(input: {
22
+ readonly catalog: Catalog
23
+ readonly pluginId: string
24
+ readonly harness: Harness
25
+ readonly releaseId?: string
26
+ }): MaterializationPlan {
27
+ const plugin = input.catalog.plugins.find((candidate) => candidate.pluginId === input.pluginId)
28
+ if (!plugin) throw new MaterializationError('PLUGIN_NOT_FOUND', [input.pluginId])
29
+ const release = plugin.availableReleases.find(
30
+ (candidate) => candidate.releaseId === (input.releaseId ?? plugin.currentReleaseId)
31
+ )
32
+ if (!release)
33
+ throw new MaterializationError('RELEASE_NOT_FOUND', [
34
+ input.releaseId ?? plugin.currentReleaseId,
35
+ ])
36
+ const compatibility = plugin.harnessCompatibility[input.harness]
37
+ if (!compatibility) throw new MaterializationError('HARNESS_UNSUPPORTED', [input.harness])
38
+ return MaterializationPlanSchema.parse({
39
+ planVersion: 1,
40
+ pluginId: plugin.pluginId,
41
+ releaseId: release.releaseId,
42
+ harness: input.harness,
43
+ source: {
44
+ repositoryUrl: release.resolvedRepositoryUrl,
45
+ commitSha: release.resolvedCommitSha,
46
+ pluginSubdirectory: release.pluginSubdirectory,
47
+ contentDigest: release.canonicalContentDigest,
48
+ },
49
+ targetLayout: targetLayout(input.harness),
50
+ files: release.fileIndex
51
+ .map((sourcePath) => filePlan(release, sourcePath, input.harness, compatibility.status))
52
+ .toSorted(
53
+ (left, right) =>
54
+ left.targetPath.localeCompare(right.targetPath) ||
55
+ left.sourcePath.localeCompare(right.sourcePath)
56
+ ),
57
+ configuration: {
58
+ compatibilityStatus: compatibility.status,
59
+ sourceDialect: release.releaseMetadata.sourceDialect ?? 'unknown',
60
+ executeInstallation: false,
61
+ },
62
+ requiredConnectors: release.requiredConnectors,
63
+ requiredCredentials: release.requiredCredentials,
64
+ policyConstraints: [
65
+ 'This plan describes files and translation only; it does not install or execute upstream code.',
66
+ ...release.permissionSensitiveChanges.map(
67
+ (type) => `Control Plane must authorize ${type} capability explicitly.`
68
+ ),
69
+ ],
70
+ })
71
+ }
72
+
73
+ function targetLayout(harness: Harness): { root: string; notes: string[] } {
74
+ switch (harness) {
75
+ case 'codex':
76
+ return {
77
+ root: '.agents',
78
+ notes: [
79
+ 'Skills and instructions map to the Codex .agents layout.',
80
+ 'MCP and executable capabilities require Control Plane policy.',
81
+ ],
82
+ }
83
+ case 'claude-code':
84
+ return {
85
+ root: '.claude',
86
+ notes: [
87
+ 'Claude Code-native files can be copied when the source dialect is Claude.',
88
+ 'Hooks and MCP configuration still require explicit runtime authorization.',
89
+ ],
90
+ }
91
+ case 'cursor':
92
+ return {
93
+ root: '.cursor',
94
+ notes: [
95
+ 'Cursor rules and skills map to the .cursor layout.',
96
+ 'MCP configuration is declarative and is not executed by this plan.',
97
+ ],
98
+ }
99
+ case 'pi':
100
+ return {
101
+ root: '.pi',
102
+ notes: [
103
+ 'Pi receives portable SKILL.md and MCP metadata only.',
104
+ 'No Pi process or package lifecycle is invoked.',
105
+ ],
106
+ }
107
+ case 'hermes':
108
+ return {
109
+ root: '.hermes',
110
+ notes: [
111
+ 'Hermes receives portable skill and MCP metadata only.',
112
+ 'Harness-specific translation remains explicit in each file action.',
113
+ ],
114
+ }
115
+ case 'opencode':
116
+ return {
117
+ root: '.opencode',
118
+ notes: [
119
+ 'OpenCode receives portable instruction and MCP metadata only.',
120
+ 'Executable capabilities are excluded.',
121
+ ],
122
+ }
123
+ case 'generic-skill-mcp':
124
+ return {
125
+ root: '.agent',
126
+ notes: ['Generic SKILL.md and MCP-compatible files are copied when recognized.'],
127
+ }
128
+ }
129
+ }
130
+
131
+ function filePlan(
132
+ release: PluginRelease,
133
+ sourcePath: string,
134
+ harness: Harness,
135
+ status: Plugin['harnessCompatibility'][Harness]['status']
136
+ ) {
137
+ const type = capabilityTypeForPath(release, sourcePath)
138
+ const directory = sourcePath.includes('/') ? sourcePath.slice(0, sourcePath.lastIndexOf('/')) : ''
139
+ if (status === 'unsupported' || type === 'executable' || type === 'unknown') {
140
+ return {
141
+ sourcePath,
142
+ targetPath: sourcePath,
143
+ action: 'unsupported' as const,
144
+ reason: `${type} capability is not executable through a materialization plan.`,
145
+ }
146
+ }
147
+ if (
148
+ type === 'hook' ||
149
+ type === 'browser' ||
150
+ type === 'scheduled-task' ||
151
+ type === 'ui-component'
152
+ ) {
153
+ return {
154
+ sourcePath,
155
+ targetPath: sourcePath,
156
+ action: 'ignore' as const,
157
+ reason: `${type} is cataloged but has no portable target layout for ${harness}.`,
158
+ }
159
+ }
160
+ const targetPath = targetForType(type, sourcePath, directory, harness)
161
+ const action =
162
+ type === 'skill' || type === 'rule' || (type === 'mcp-server' && status === 'native')
163
+ ? 'copy'
164
+ : 'translate'
165
+ return {
166
+ sourcePath,
167
+ targetPath,
168
+ action: action as 'copy' | 'translate',
169
+ reason:
170
+ action === 'copy'
171
+ ? `Copy recognized ${type} content.`
172
+ : `Translate ${type} metadata into the ${harness} layout.`,
173
+ }
174
+ }
175
+
176
+ function capabilityTypeForPath(release: PluginRelease, path: string): string {
177
+ const match = release.capabilities.find((capability) => capability.paths.includes(path))
178
+ return match?.type ?? 'unknown'
179
+ }
180
+
181
+ function targetForType(type: string, path: string, directory: string, harness: Harness): string {
182
+ if (type === 'skill')
183
+ return `${targetLayout(harness).root}/skills/${directory || basenameWithoutExtension(path)}/${path.endsWith('SKILL.md') ? 'SKILL.md' : basenameWithoutExtension(path)}`
184
+ if (type === 'rule')
185
+ return `${targetLayout(harness).root}/rules/${basenameWithoutExtension(path)}.md`
186
+ if (type === 'command')
187
+ return `${targetLayout(harness).root}/commands/${basenameWithoutExtension(path)}.md`
188
+ if (type === 'agent')
189
+ return `${targetLayout(harness).root}/agents/${basenameWithoutExtension(path)}.md`
190
+ if (type === 'mcp-server')
191
+ return `${targetLayout(harness).root}/mcp/${basenameWithoutExtension(path)}.json`
192
+ return `${targetLayout(harness).root}/${path}`
193
+ }
194
+
195
+ function basenameWithoutExtension(path: string): string {
196
+ const basename = path.split('/').at(-1) ?? path
197
+ return basename.replace(/\.[^.]+$/, '')
198
+ }
199
+
200
+ export function supportedHarnesses(): readonly Harness[] {
201
+ return HarnessSchema.options
202
+ }
@@ -0,0 +1,196 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import type { Catalog } from './schema.js'
4
+ import { byteDigest, type PublishedAsset } from './index.js'
5
+ import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
6
+ import type { ConsumerIndex } from './consumer-index.js'
7
+
8
+ /**
9
+ * Where an icon's bytes come from, so a mirror needs no tree walk and no
10
+ * rediscovery: `content` refetches a file at the pinned commit, `favicon`
11
+ * refetches the site icon the catalog recorded.
12
+ */
13
+ export type IconSource =
14
+ | {
15
+ readonly kind: 'content'
16
+ readonly asset: string
17
+ readonly digest: string
18
+ readonly bytes: number
19
+ readonly contentType: IconContentType
20
+ readonly repositoryUrl: string
21
+ readonly commitSha: string
22
+ readonly pluginSubdirectory: string
23
+ /** Path relative to `pluginSubdirectory`, as recorded in the catalog. */
24
+ readonly path: string
25
+ /** Path including the plugin subdirectory, for a raw content fetch. */
26
+ readonly sourcePath: string
27
+ }
28
+ | {
29
+ readonly kind: 'favicon'
30
+ readonly asset: string
31
+ readonly digest: string
32
+ readonly bytes: number
33
+ readonly contentType: IconContentType
34
+ readonly url: string
35
+ readonly homepage: string
36
+ }
37
+
38
+ export interface IconMirrorResult {
39
+ /** Icons whose verified bytes are present in the assets directory. */
40
+ readonly staged: readonly PublishedAsset[]
41
+ /** Icons that could not be staged, with the reason, never silently dropped. */
42
+ readonly skipped: readonly { readonly asset: string; readonly reason: string }[]
43
+ }
44
+
45
+ /**
46
+ * Maps every mirrored icon back to the exact commit it was resolved from, by
47
+ * matching the digest the product advertises. Canonical variants are inspected
48
+ * first, mirroring the order product records were built in.
49
+ */
50
+ export function resolveIconSources(
51
+ catalog: Pick<Catalog, 'plugins'>,
52
+ index: ConsumerIndex
53
+ ): readonly IconSource[] {
54
+ const byAsset = new Map<string, IconSource>()
55
+ for (const plugin of catalog.plugins) {
56
+ for (const release of plugin.availableReleases) {
57
+ const icon = release.icon
58
+ if (!icon) continue
59
+ const asset = iconAssetName(icon.digest, icon.contentType)
60
+ if (byAsset.has(asset)) continue
61
+ if (icon.kind === 'favicon') {
62
+ byAsset.set(asset, {
63
+ kind: 'favicon',
64
+ asset,
65
+ digest: icon.digest,
66
+ bytes: icon.bytes,
67
+ contentType: icon.contentType,
68
+ url: icon.url,
69
+ homepage: icon.homepage,
70
+ })
71
+ continue
72
+ }
73
+ const subdirectory =
74
+ release.pluginSubdirectory && release.pluginSubdirectory !== '.'
75
+ ? release.pluginSubdirectory
76
+ : ''
77
+ byAsset.set(asset, {
78
+ kind: 'content',
79
+ asset,
80
+ digest: icon.digest,
81
+ bytes: icon.bytes,
82
+ contentType: icon.contentType,
83
+ repositoryUrl: release.resolvedRepositoryUrl,
84
+ commitSha: release.resolvedCommitSha,
85
+ pluginSubdirectory: subdirectory,
86
+ path: icon.path,
87
+ sourcePath: subdirectory ? `${subdirectory}/${icon.path}` : icon.path,
88
+ })
89
+ }
90
+ }
91
+ const sources: IconSource[] = []
92
+ for (const product of Object.values(index.products)) {
93
+ const icon = product.icon
94
+ if (!icon) continue
95
+ const source = byAsset.get(icon.asset)
96
+ if (source) sources.push(source)
97
+ }
98
+ return sources.toSorted((left, right) => left.asset.localeCompare(right.asset))
99
+ }
100
+
101
+ /**
102
+ * Writes mirrored icon bytes beside the published artifacts.
103
+ *
104
+ * Bytes are verified against the digest the catalog advertises before they are
105
+ * staged, so a mirror can never publish content that differs from what the
106
+ * index promised. Staging never overwrites a differing file: content-addressed
107
+ * names make an existing file either identical or a build error.
108
+ */
109
+ export async function stageIconAssets(input: {
110
+ readonly sources: readonly IconSource[]
111
+ readonly assetsDirectory: string
112
+ /** Reads the icon bytes for a source; live fetches raw content, offline reads fixtures. */
113
+ readonly readBytes: (source: IconSource) => Promise<Uint8Array>
114
+ }): Promise<IconMirrorResult> {
115
+ const staged: PublishedAsset[] = []
116
+ const skipped: { asset: string; reason: string }[] = []
117
+ await fs.mkdir(input.assetsDirectory, { recursive: true })
118
+ for (const source of input.sources) {
119
+ const destination = join(input.assetsDirectory, source.asset)
120
+ const existing = await fs.readFile(destination).catch(() => undefined)
121
+ if (existing && byteDigest(existing) === source.digest) {
122
+ staged.push(publishedAssetOf(source))
123
+ continue
124
+ }
125
+ if (existing) {
126
+ skipped.push({ asset: source.asset, reason: 'ASSET_CONFLICT' })
127
+ continue
128
+ }
129
+ let bytes: Uint8Array
130
+ try {
131
+ bytes = await input.readBytes(source)
132
+ } catch (error) {
133
+ skipped.push({
134
+ asset: source.asset,
135
+ reason: `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
136
+ })
137
+ continue
138
+ }
139
+ const inspected = inspectIconBytes(bytes)
140
+ if (!('contentType' in inspected)) {
141
+ skipped.push({ asset: source.asset, reason: inspected.rejected })
142
+ continue
143
+ }
144
+ if (inspected.contentType !== source.contentType) {
145
+ skipped.push({ asset: source.asset, reason: 'CONTENT_TYPE_CHANGED' })
146
+ continue
147
+ }
148
+ if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
149
+ skipped.push({ asset: source.asset, reason: 'DIGEST_MISMATCH' })
150
+ continue
151
+ }
152
+ await fs.mkdir(dirname(destination), { recursive: true })
153
+ await fs.writeFile(destination, bytes)
154
+ staged.push(publishedAssetOf(source))
155
+ }
156
+ // Distinct assets: two products can share one mark, and the catalog declares
157
+ // each asset once.
158
+ return { staged: dedupeAssets(staged), skipped }
159
+ }
160
+
161
+ function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
162
+ const unique = new Map<string, PublishedAsset>()
163
+ for (const asset of assets) unique.set(asset.name, asset)
164
+ return [...unique.values()]
165
+ }
166
+
167
+ function publishedAssetOf(source: IconSource): PublishedAsset {
168
+ return {
169
+ name: source.asset,
170
+ digest: source.digest,
171
+ bytes: source.bytes,
172
+ contentType: source.contentType,
173
+ kind: source.kind,
174
+ origin: source.kind === 'content' ? source.sourcePath : source.url,
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Writes one verified icon into the assets directory.
180
+ *
181
+ * The build already holds the exact bytes it recorded a digest for, so staging
182
+ * them here removes the re-fetch a later mirror would need. That matters for
183
+ * site icons: their URL is not content-addressed, so a vendor can change the
184
+ * file between resolution and publication.
185
+ */
186
+ export async function stageIconBytes(
187
+ assetsDirectory: string,
188
+ asset: string,
189
+ bytes: Uint8Array
190
+ ): Promise<void> {
191
+ const destination = join(assetsDirectory, asset)
192
+ const existing = await fs.readFile(destination).catch(() => undefined)
193
+ if (existing && byteDigest(existing) === byteDigest(bytes)) return
194
+ await fs.mkdir(dirname(destination), { recursive: true })
195
+ await fs.writeFile(destination, bytes)
196
+ }