@adea-ai/plugins 3.0.10 → 4.0.1

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 (75) hide show
  1. package/README.md +18 -11
  2. package/dist/agent-plugins-catalog.d.ts +2 -3
  3. package/dist/agent-plugins-catalog.js +2 -10
  4. package/dist/agent-plugins-catalog.js.map +1 -1
  5. package/dist/catalog-store.d.ts +81 -0
  6. package/dist/catalog-store.js +487 -0
  7. package/dist/catalog-store.js.map +1 -0
  8. package/dist/consumer-index.d.ts +1 -1
  9. package/dist/consumer-index.js +17 -7
  10. package/dist/consumer-index.js.map +1 -1
  11. package/dist/display-name.js +3 -2
  12. package/dist/display-name.js.map +1 -1
  13. package/dist/harness.d.ts +50 -9
  14. package/dist/harness.js +190 -147
  15. package/dist/harness.js.map +1 -1
  16. package/dist/icon-mirror.d.ts +2 -2
  17. package/dist/icon-mirror.js +38 -25
  18. package/dist/icon-mirror.js.map +1 -1
  19. package/dist/icons.js +5 -27
  20. package/dist/icons.js.map +1 -1
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.js +103 -88
  23. package/dist/index.js.map +1 -1
  24. package/dist/publication.d.ts +8 -11
  25. package/dist/publication.js +8 -11
  26. package/dist/publication.js.map +1 -1
  27. package/dist/schema.d.ts +0 -40
  28. package/dist/schema.js +0 -36
  29. package/dist/schema.js.map +1 -1
  30. package/dist/site-icons.d.ts +2 -0
  31. package/dist/site-icons.js +46 -9
  32. package/dist/site-icons.js.map +1 -1
  33. package/dist/source-digest.d.ts +2 -0
  34. package/dist/source-digest.js +12 -0
  35. package/dist/source-digest.js.map +1 -0
  36. package/dist/sources.js +3 -3
  37. package/dist/sources.js.map +1 -1
  38. package/native/build-provenance.txt +5 -5
  39. package/native/staged-reader-win32-arm64.node +0 -0
  40. package/native/staged-reader-win32-x64.node +0 -0
  41. package/package.json +4 -9
  42. package/src/agent-plugins-catalog.ts +3 -10
  43. package/src/artifacts.test.ts +80 -0
  44. package/src/catalog-installation.test.ts +1 -1
  45. package/src/catalog-store.ts +546 -0
  46. package/src/consumer-index.test.ts +125 -0
  47. package/src/consumer-index.ts +23 -8
  48. package/src/default-head.test.ts +143 -0
  49. package/src/display-name.test.ts +8 -0
  50. package/src/display-name.ts +3 -2
  51. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  52. package/src/harness.ts +249 -170
  53. package/src/icon-mirror.ts +36 -23
  54. package/src/icons.test.ts +5 -0
  55. package/src/icons.ts +6 -29
  56. package/src/index.test.ts +272 -40
  57. package/src/index.ts +131 -94
  58. package/src/manifest-retry.test.ts +49 -0
  59. package/src/portable-catalog.test.ts +8 -5
  60. package/src/publication.ts +8 -11
  61. package/src/schema.ts +0 -40
  62. package/src/site-icon-fallback.test.ts +36 -19
  63. package/src/site-icons.test.ts +104 -0
  64. package/src/site-icons.ts +47 -9
  65. package/src/source-config-sync.test.ts +84 -0
  66. package/src/source-digest.test.ts +101 -0
  67. package/src/source-digest.ts +14 -0
  68. package/src/sources-json.test.ts +53 -0
  69. package/src/sources.ts +3 -3
  70. package/src/upstream-headers.test.ts +31 -7
  71. package/src/write-artifacts.test.ts +375 -0
  72. package/dist/agent-plugins-harness.d.ts +0 -54
  73. package/dist/agent-plugins-harness.js +0 -212
  74. package/dist/agent-plugins-harness.js.map +0 -1
  75. package/src/agent-plugins-harness.ts +0 -281
package/src/harness.ts CHANGED
@@ -1,202 +1,281 @@
1
+ import { isAbsolute, resolve, relative, sep } from 'node:path'
2
+ import { realpath } from 'node:fs/promises'
3
+ import { createHash } from 'node:crypto'
1
4
  import {
2
- HarnessSchema,
3
- MaterializationPlanSchema,
4
- type Catalog,
5
- type Harness,
6
- type MaterializationPlan,
7
- type Plugin,
8
- type PluginRelease,
9
- } from './schema.js'
5
+ HarnessProfileSchema,
6
+ InstallationPlanSchema,
7
+ type HarnessProfile,
8
+ type McpServer,
9
+ type InstallationPlan,
10
+ } from './agent-plugins-schema.js'
11
+ import type { Catalog } from './schema.js'
12
+ import { verifyPortableCatalog, PACKAGE_METADATA_KEY } from './portable-catalog.js'
13
+ import { verifyAgentPackage, validateServer } from './agent-plugins-catalog.js'
10
14
 
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
- }
15
+ export interface InstallationInput {
16
+ readonly pluginId: string
17
+ readonly releaseId: string
18
+ readonly instanceId: string
19
+ readonly source: InstallationPlan['source']
20
+ readonly package: unknown
21
+ readonly profile: HarnessProfile
22
+ readonly requiredCredentials?: readonly string[]
23
+ readonly requiredConnectors?: readonly string[]
24
+ readonly allowPartial?: boolean
19
25
  }
20
-
21
- export function createMaterializationPlan(input: {
26
+ export interface CatalogInstallationInput {
22
27
  readonly catalog: Catalog
23
28
  readonly pluginId: string
24
- readonly harness: Harness
25
29
  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])
30
+ readonly instanceId: string
31
+ readonly profile: HarnessProfile
32
+ readonly allowPartial?: boolean
33
+ }
34
+
35
+ /** Resolve the exact catalog release before planning. The caller must also authenticate
36
+ * the catalog publisher and verify its artifact integrity before invoking this API.
37
+ */
38
+ export function createCatalogInstallationPlan(input: CatalogInstallationInput): InstallationPlan {
39
+ verifyPortableCatalog(input.catalog)
40
+ const plugin = input.catalog.plugins.find((entry) => entry.pluginId === input.pluginId)
41
+ if (!plugin) throw new Error(`PLUGIN_NOT_FOUND: ${input.pluginId}`)
29
42
  const release = plugin.availableReleases.find(
30
- (candidate) => candidate.releaseId === (input.releaseId ?? plugin.currentReleaseId)
43
+ (entry) => entry.releaseId === (input.releaseId ?? plugin.currentReleaseId)
31
44
  )
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,
45
+ if (!release) throw new Error('RELEASE_NOT_FOUND')
46
+ if (release.contentResolution !== 'complete') throw new Error('COMPLETE_SOURCE_REQUIRED')
47
+ const pkg = release.releaseMetadata[PACKAGE_METADATA_KEY]
48
+ if (pkg === undefined)
49
+ throw new Error(
50
+ 'PORTABLE_RESYNC_REQUIRED: run a full catalog sync before planning portable installation'
51
+ )
52
+ return createInstallationPlan({
40
53
  pluginId: plugin.pluginId,
41
54
  releaseId: release.releaseId,
42
- harness: input.harness,
55
+ instanceId: input.instanceId,
56
+ profile: input.profile,
57
+ package: pkg,
58
+ allowPartial: input.allowPartial === true,
43
59
  source: {
44
60
  repositoryUrl: release.resolvedRepositoryUrl,
45
61
  commitSha: release.resolvedCommitSha,
46
62
  pluginSubdirectory: release.pluginSubdirectory,
47
63
  contentDigest: release.canonicalContentDigest,
48
64
  },
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
65
  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
- ],
66
+ requiredConnectors: release.requiredConnectors,
70
67
  })
71
68
  }
72
69
 
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
- }
70
+ function identity(...parts: string[]): string {
71
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex')
129
72
  }
73
+ /** A declarative proposal, never an execution grant. No brand-based support assumptions. */
74
+ export function createInstallationPlan(input: InstallationInput): InstallationPlan {
75
+ const profile = HarnessProfileSchema.parse(input.profile)
76
+ const pkg = verifyAgentPackage(input.package)
77
+ if (pkg.sourceDigest !== input.source.contentDigest)
78
+ throw new Error('Package recipe does not match the selected immutable release.')
79
+ if (!input.instanceId || input.instanceId.length > 256)
80
+ throw new Error('A stable installation instanceId is required.')
81
+ const disabled: InstallationPlan['disabled'] = []
82
+ for (const item of pkg.nonPortable) disabled.push({ component: item.kind, reason: item.reason })
83
+ for (const item of pkg.diagnostics.filter((entry) => entry.severity === 'error'))
84
+ disabled.push({ component: item.path, reason: item.message })
130
85
 
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}.`,
86
+ const nativeEligible =
87
+ profile.agentPlugins.versions.includes(pkg.specVersion) &&
88
+ pkg.nonPortable.length === 0 &&
89
+ !Object.keys(pkg.manifest?.extensions ?? {}).length
90
+ const nativeCoverage =
91
+ (profile.agentPlugins.skills ? pkg.skills.length : 0) +
92
+ Object.values(pkg.mcpServers).filter((server) =>
93
+ profile.agentPlugins.mcpTransports.includes(server.type)
94
+ ).length
95
+ const adapterCoverage =
96
+ (profile.components.skillDirectories ? pkg.skills.length : 0) +
97
+ Object.values(pkg.mcpServers).filter((server) =>
98
+ profile.components.mcpTransports.includes(server.type)
99
+ ).length
100
+ const native = nativeEligible && nativeCoverage > 0 && nativeCoverage >= adapterCoverage
101
+ const strategy =
102
+ pkg.status === 'unavailable'
103
+ ? 'unavailable'
104
+ : native
105
+ ? 'native-agent-plugin'
106
+ : 'component-adapter'
107
+ let skillDirectories: string[] = []
108
+ const servers = new Map<string, McpServer>()
109
+ if (strategy !== 'unavailable') {
110
+ if (native ? profile.agentPlugins.skills : profile.components.skillDirectories)
111
+ skillDirectories = pkg.skills.map((skill) => skill.path)
112
+ else
113
+ for (const skill of pkg.skills)
114
+ disabled.push({
115
+ component: `skill:${skill.name}`,
116
+ reason: 'The selected adapter cannot expose skill directories.',
117
+ })
118
+ for (const [name, server] of Object.entries(pkg.mcpServers)) {
119
+ if (
120
+ (native ? profile.agentPlugins.mcpTransports : profile.components.mcpTransports).includes(
121
+ server.type
122
+ )
123
+ )
124
+ servers.set(name, server)
125
+ else
126
+ disabled.push({
127
+ component: `mcp:${name}`,
128
+ reason: `No ${server.type} MCP binding on this harness adapter.`,
129
+ })
158
130
  }
159
131
  }
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
- }
132
+ const hasContent = skillDirectories.length > 0 || servers.size > 0
133
+ const compatibility = !hasContent ? 'unsupported' : disabled.length > 0 ? 'partial' : 'full'
134
+ const selectedStrategy =
135
+ !hasContent || (compatibility === 'partial' && !input.allowPartial) ? 'unavailable' : strategy
136
+ return InstallationPlanSchema.parse({
137
+ planVersion: 2,
138
+ pluginId: input.pluginId,
139
+ releaseId: input.releaseId,
140
+ instanceId: input.instanceId,
141
+ profile,
142
+ source: input.source,
143
+ package: pkg,
144
+ // Git source identity also pins executable modes, which the v1 byte snapshot omits.
145
+ packageKey: pkg.packageDigest
146
+ ? `packages/${identity(input.source.repositoryUrl, input.source.commitSha, input.source.pluginSubdirectory, pkg.packageDigest)}`
147
+ : '',
148
+ // Deliberately excludes release, package digest and harness. Switching harnesses or updating
149
+ // the same approved instance preserves data; distinct scopes use distinct instance IDs.
150
+ dataKey: `instances/${identity(input.pluginId, input.instanceId)}`,
151
+ preserveDataAcrossUpdates: true,
152
+ strategy: selectedStrategy,
153
+ compatibility,
154
+ allowedToActivate: false,
155
+ approvalRequired: true,
156
+ selection:
157
+ selectedStrategy === 'unavailable'
158
+ ? { skillDirectories: [], mcpServers: {} }
159
+ : { skillDirectories, mcpServers: Object.fromEntries(servers) },
160
+ disabled,
161
+ requiredCredentials: [...(input.requiredCredentials ?? [])],
162
+ requiredConnectors: [...(input.requiredConnectors ?? [])],
163
+ preconditions: [
164
+ 'Verify catalog integrity/provenance and exact source digest before applying the package recipe.',
165
+ 'Use an immutable package root; preserve source file modes and complete skill directories/resources.',
166
+ 'Create a writable data directory for this instance; never key it by release or copy credentials between instances.',
167
+ 'Control Plane must authorize activation, filesystem/network access, executables and credential bindings.',
168
+ 'Recheck the actual harness/runtime/adapter profile and realpath containment immediately before activation.',
169
+ 'Native mode registers exactly one package root. Component mode registers only the selected skill directories and MCP bindings, never legacy hooks/config files.',
170
+ 'Do not install dependencies or run lifecycle scripts implicitly; review required executables and skill compatibility requirements.',
171
+ 'Skill allowed-tools metadata is advisory and never grants permission.',
172
+ 'Namespace registrations by installation identity; detect skill/server name collisions instead of overwriting existing harness configuration.',
173
+ 'Remote authorization is client-managed. Do not expand remote URLs/headers or forward configured headers to another origin without authorization.',
174
+ ],
175
+ })
174
176
  }
175
177
 
176
- function capabilityTypeForPath(release: PluginRelease, path: string): string {
177
- const match = release.capabilities.find((capability) => capability.paths.includes(path))
178
- return match?.type ?? 'unknown'
178
+ export interface RuntimeRoots {
179
+ readonly pluginRoot: string
180
+ readonly pluginData: string
179
181
  }
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}`
182
+ /** Exact, single-pass expansion. Replacement text is never scanned recursively. */
183
+ export function expandPluginPaths(value: string, roots: RuntimeRoots): string {
184
+ return value.replace(/\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}/g, (_match, name: string) =>
185
+ name === 'PLUGIN_ROOT' ? roots.pluginRoot : roots.pluginData
186
+ )
187
+ }
188
+ function within(path: string, root: string): boolean {
189
+ const suffix = relative(root, path)
190
+ return suffix === '' || (suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix))
193
191
  }
192
+ export type McpBinding =
193
+ | {
194
+ type: 'stdio'
195
+ command: string
196
+ args: string[]
197
+ cwd: string
198
+ env: Record<string, string>
199
+ shell: false
200
+ }
201
+ | {
202
+ type: 'streamable-http' | 'sse'
203
+ url: string
204
+ headers: Record<string, string>
205
+ redirectPolicy: 'same-origin'
206
+ authorization: 'control-plane'
207
+ }
194
208
 
195
- function basenameWithoutExtension(path: string): string {
196
- const basename = path.split('/').at(-1) ?? path
197
- return basename.replace(/\.[^.]+$/, '')
209
+ /** Produce concrete Control Plane launch/connect descriptors, without launching or connecting. */
210
+ export function resolveMcpBinding(
211
+ input: McpServer,
212
+ roots: RuntimeRoots,
213
+ baseEnv: Readonly<Record<string, string>> = {},
214
+ platform: 'posix' | 'win32' = process.platform === 'win32' ? 'win32' : 'posix'
215
+ ): McpBinding {
216
+ const server = validateServer(input)
217
+ if (!isAbsolute(roots.pluginRoot) || !isAbsolute(roots.pluginData))
218
+ throw new Error('Runtime plugin roots must be absolute paths.')
219
+ if (server.type !== 'stdio')
220
+ return {
221
+ type: server.type,
222
+ url: server.url,
223
+ headers: { ...server.headers },
224
+ redirectPolicy: 'same-origin',
225
+ authorization: 'control-plane',
226
+ }
227
+ const command = server.command.startsWith('./')
228
+ ? resolve(roots.pluginRoot, server.command)
229
+ : server.command
230
+ if (server.command.startsWith('./') && !within(command, roots.pluginRoot))
231
+ throw new Error('Executable escapes the plugin root.')
232
+ const cwdInput = server.cwd ?? '${PLUGIN_ROOT}'
233
+ const cwdRoot = cwdInput.startsWith('${PLUGIN_DATA}') ? roots.pluginData : roots.pluginRoot
234
+ const expanded = expandPluginPaths(cwdInput, roots)
235
+ const cwd = resolve(roots.pluginRoot, expanded)
236
+ if (!within(cwd, cwdRoot)) throw new Error('Working directory escapes its declared root.')
237
+ const env: Record<string, string> = {}
238
+ const overlay = (name: string, value: string): void => {
239
+ if (platform === 'win32')
240
+ for (const existing of Object.keys(env))
241
+ if (existing.toUpperCase() === name.toUpperCase()) delete env[existing]
242
+ Object.defineProperty(env, name, {
243
+ value,
244
+ enumerable: true,
245
+ configurable: true,
246
+ writable: true,
247
+ })
248
+ }
249
+ for (const [key, value] of Object.entries(baseEnv)) overlay(key, value)
250
+ for (const [key, value] of Object.entries(server.env ?? {}))
251
+ overlay(key, expandPluginPaths(value, roots))
252
+ overlay('PLUGIN_ROOT', roots.pluginRoot)
253
+ overlay('PLUGIN_DATA', roots.pluginData)
254
+ return {
255
+ type: 'stdio',
256
+ command,
257
+ args: (server.args ?? []).map((arg) => expandPluginPaths(arg, roots)),
258
+ cwd,
259
+ env,
260
+ shell: false,
261
+ }
198
262
  }
199
263
 
200
- export function supportedHarnesses(): readonly Harness[] {
201
- return HarnessSchema.options
264
+ /** Mandatory filesystem preflight for Control Plane callers after safe materialization.
265
+ * Call immediately before activation inside the same protected installation boundary;
266
+ * a separate preflight is not a substitute for preventing concurrent filesystem mutation.
267
+ */
268
+ export async function verifyBindingPaths(input: McpServer, roots: RuntimeRoots): Promise<void> {
269
+ const binding = resolveMcpBinding(input, roots)
270
+ if (binding.type !== 'stdio' || input.type !== 'stdio') return
271
+ const root = await realpath(roots.pluginRoot)
272
+ const data = await realpath(roots.pluginData)
273
+ if (root !== roots.pluginRoot || data !== roots.pluginData)
274
+ throw new Error('Supply canonical filesystem-resolved runtime roots.')
275
+ const cwd = await realpath(binding.cwd)
276
+ const expected = input.cwd?.startsWith('${PLUGIN_DATA}') ? data : root
277
+ if (!within(cwd, expected))
278
+ throw new Error('Working directory resolves outside its declared root.')
279
+ if (input.command.startsWith('./') && !within(await realpath(binding.command), root))
280
+ throw new Error('Executable resolves outside the package root.')
202
281
  }
@@ -4,6 +4,7 @@ import type { Catalog } from './schema.js'
4
4
  import { byteDigest, type PublishedAsset } from './index.js'
5
5
  import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
6
6
  import type { ConsumerIndex } from './consumer-index.js'
7
+ import { writeFileAtomically } from './catalog-store.js'
7
8
 
8
9
  /**
9
10
  * Where an icon's bytes come from, so a mirror needs no tree walk and no
@@ -44,22 +45,27 @@ export interface IconMirrorResult {
44
45
 
45
46
  /**
46
47
  * 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.
48
+ * matching the digest the product advertises. Retain each distinct pinned
49
+ * origin so the mirror can try another source if one is unavailable.
49
50
  */
50
51
  export function resolveIconSources(
51
52
  catalog: Pick<Catalog, 'plugins'>,
52
53
  index: ConsumerIndex
53
54
  ): readonly IconSource[] {
54
- const byAsset = new Map<string, IconSource>()
55
+ const byAsset = new Map<string, IconSource[]>()
56
+ const add = (source: IconSource) => {
57
+ const candidates = byAsset.get(source.asset) ?? []
58
+ if (!candidates.some((candidate) => JSON.stringify(candidate) === JSON.stringify(source)))
59
+ candidates.push(source)
60
+ byAsset.set(source.asset, candidates)
61
+ }
55
62
  for (const plugin of catalog.plugins) {
56
63
  for (const release of plugin.availableReleases) {
57
64
  const icon = release.icon
58
65
  if (!icon) continue
59
66
  const asset = iconAssetName(icon.digest, icon.contentType)
60
- if (byAsset.has(asset)) continue
61
67
  if (icon.kind === 'favicon') {
62
- byAsset.set(asset, {
68
+ add({
63
69
  kind: 'favicon',
64
70
  asset,
65
71
  digest: icon.digest,
@@ -74,7 +80,7 @@ export function resolveIconSources(
74
80
  release.pluginSubdirectory && release.pluginSubdirectory !== '.'
75
81
  ? release.pluginSubdirectory
76
82
  : ''
77
- byAsset.set(asset, {
83
+ add({
78
84
  kind: 'content',
79
85
  asset,
80
86
  digest: icon.digest,
@@ -88,14 +94,12 @@ export function resolveIconSources(
88
94
  })
89
95
  }
90
96
  }
91
- const sources: IconSource[] = []
97
+ const selected = new Set<string>()
92
98
  for (const product of Object.values(index.products)) {
93
99
  const icon = product.icon
94
- if (!icon) continue
95
- const source = byAsset.get(icon.asset)
96
- if (source) sources.push(source)
100
+ if (icon) selected.add(icon.asset)
97
101
  }
98
- return sources.toSorted((left, right) => left.asset.localeCompare(right.asset))
102
+ return [...selected].toSorted().flatMap((asset) => byAsset.get(asset) ?? [])
99
103
  }
100
104
 
101
105
  /**
@@ -113,49 +117,58 @@ export async function stageIconAssets(input: {
113
117
  readonly readBytes: (source: IconSource) => Promise<Uint8Array>
114
118
  }): Promise<IconMirrorResult> {
115
119
  const staged: PublishedAsset[] = []
116
- const skipped: { asset: string; reason: string }[] = []
120
+ const skipped = new Map<string, string>()
121
+ const complete = new Set<string>()
122
+ const skip = (asset: string, reason: string) => {
123
+ if (!skipped.has(asset)) skipped.set(asset, reason)
124
+ }
117
125
  await fs.mkdir(input.assetsDirectory, { recursive: true })
118
126
  for (const source of input.sources) {
127
+ if (complete.has(source.asset)) continue
119
128
  const destination = join(input.assetsDirectory, source.asset)
120
129
  const existing = await fs.readFile(destination).catch(() => undefined)
121
130
  if (existing && byteDigest(existing) === source.digest) {
122
131
  staged.push(publishedAssetOf(source))
132
+ complete.add(source.asset)
133
+ skipped.delete(source.asset)
123
134
  continue
124
135
  }
125
136
  if (existing) {
126
- skipped.push({ asset: source.asset, reason: 'ASSET_CONFLICT' })
137
+ skip(source.asset, 'ASSET_CONFLICT')
127
138
  continue
128
139
  }
129
140
  let bytes: Uint8Array
130
141
  try {
131
142
  bytes = await input.readBytes(source)
132
143
  } catch (error) {
133
- skipped.push({
134
- asset: source.asset,
135
- reason: `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
136
- })
144
+ skip(source.asset, `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`)
137
145
  continue
138
146
  }
139
147
  const inspected = inspectIconBytes(bytes)
140
148
  if (!('contentType' in inspected)) {
141
- skipped.push({ asset: source.asset, reason: inspected.rejected })
149
+ skip(source.asset, inspected.rejected)
142
150
  continue
143
151
  }
144
152
  if (inspected.contentType !== source.contentType) {
145
- skipped.push({ asset: source.asset, reason: 'CONTENT_TYPE_CHANGED' })
153
+ skip(source.asset, 'CONTENT_TYPE_CHANGED')
146
154
  continue
147
155
  }
148
156
  if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
149
- skipped.push({ asset: source.asset, reason: 'DIGEST_MISMATCH' })
157
+ skip(source.asset, 'DIGEST_MISMATCH')
150
158
  continue
151
159
  }
152
160
  await fs.mkdir(dirname(destination), { recursive: true })
153
- await fs.writeFile(destination, bytes)
161
+ await writeFileAtomically(destination, bytes)
154
162
  staged.push(publishedAssetOf(source))
163
+ complete.add(source.asset)
164
+ skipped.delete(source.asset)
155
165
  }
156
166
  // Distinct assets: two products can share one mark, and the catalog declares
157
167
  // each asset once.
158
- return { staged: dedupeAssets(staged), skipped }
168
+ return {
169
+ staged: dedupeAssets(staged),
170
+ skipped: [...skipped].map(([asset, reason]) => ({ asset, reason })),
171
+ }
159
172
  }
160
173
 
161
174
  function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
@@ -192,5 +205,5 @@ export async function stageIconBytes(
192
205
  const existing = await fs.readFile(destination).catch(() => undefined)
193
206
  if (existing && byteDigest(existing) === byteDigest(bytes)) return
194
207
  await fs.mkdir(dirname(destination), { recursive: true })
195
- await fs.writeFile(destination, bytes)
208
+ await writeFileAtomically(destination, bytes)
196
209
  }
package/src/icons.test.ts CHANGED
@@ -29,6 +29,8 @@ const CLEAN_SVG =
29
29
  describe('icon file selection', () => {
30
30
  test('prefers the conventional mark in a conventional location', () => {
31
31
  expect(selectIconPath(['assets/logo.png', 'assets/icon.svg'])).toBe('assets/icon.svg')
32
+ expect(selectIconPath(['assets/brand.svg', 'assets/icon.png'])).toBe('assets/icon.png')
33
+ expect(selectIconPath(['assets/logo.svg', 'assets/avatar.svg'])).toBe('assets/avatar.svg')
32
34
  expect(selectIconPath(['assets/logo.png', 'docs/media/logo.png'])).toBe('assets/logo.png')
33
35
  expect(selectIconPath(['images/logo.png', 'assets/logo.png'])).toBe('assets/logo.png')
34
36
  })
@@ -58,6 +60,9 @@ describe('icon file selection', () => {
58
60
  expect(selectIconPath(['assets/logo.txt', 'assets/logo.svg'])).toBe('assets/logo.svg')
59
61
  expect(selectIconPath(['../assets/logo.svg'])).toBeNull()
60
62
  expect(selectIconPath([])).toBeNull()
63
+ const tied = ['assets/aaa-logo.svg', 'assets/zzz-logo.svg']
64
+ expect(selectIconPath(tied)).toBe('assets/aaa-logo.svg')
65
+ expect(selectIconPath(tied.toReversed())).toBe('assets/aaa-logo.svg')
61
66
  // An unnamed image at the plugin root is as likely a screenshot as a mark.
62
67
  expect(selectIconPath(['screenshot.png', 'readme.md'])).toBeNull()
63
68
  expect(selectIconPath(['assets/logo.png', 'assets/logo.svg'])).toBe(