@adea-ai/plugins 3.0.9 → 4.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 (53) 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/display-name.js +3 -2
  6. package/dist/display-name.js.map +1 -1
  7. package/dist/harness.d.ts +50 -9
  8. package/dist/harness.js +190 -147
  9. package/dist/harness.js.map +1 -1
  10. package/dist/icons.js +5 -27
  11. package/dist/icons.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +47 -37
  14. package/dist/index.js.map +1 -1
  15. package/dist/schema.d.ts +0 -40
  16. package/dist/schema.js +0 -36
  17. package/dist/schema.js.map +1 -1
  18. package/dist/site-icons.js +18 -7
  19. package/dist/site-icons.js.map +1 -1
  20. package/dist/source-digest.d.ts +2 -0
  21. package/dist/source-digest.js +12 -0
  22. package/dist/source-digest.js.map +1 -0
  23. package/dist/sources.js +2 -2
  24. package/dist/sources.js.map +1 -1
  25. package/native/README.md +24 -0
  26. package/native/build-provenance.txt +5 -5
  27. package/native/staged-reader-win32-arm64.node +0 -0
  28. package/native/staged-reader-win32-x64.node +0 -0
  29. package/package.json +3 -10
  30. package/src/agent-plugins-catalog.ts +3 -10
  31. package/src/catalog-installation.test.ts +1 -1
  32. package/src/display-name.test.ts +8 -0
  33. package/src/display-name.ts +3 -2
  34. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  35. package/src/harness.ts +249 -170
  36. package/src/icons.test.ts +5 -0
  37. package/src/icons.ts +6 -29
  38. package/src/index.test.ts +150 -40
  39. package/src/index.ts +49 -41
  40. package/src/portable-catalog.test.ts +8 -5
  41. package/src/schema.ts +0 -40
  42. package/src/site-icon-fallback.test.ts +36 -19
  43. package/src/site-icons.test.ts +31 -0
  44. package/src/site-icons.ts +28 -7
  45. package/src/source-digest.test.ts +44 -0
  46. package/src/source-digest.ts +14 -0
  47. package/src/sources-json.test.ts +36 -0
  48. package/src/sources.ts +2 -2
  49. package/src/upstream-headers.test.ts +31 -7
  50. package/dist/agent-plugins-harness.d.ts +0 -54
  51. package/dist/agent-plugins-harness.js +0 -212
  52. package/dist/agent-plugins-harness.js.map +0 -1
  53. 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
  }
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(
package/src/icons.ts CHANGED
@@ -73,42 +73,20 @@ const MARK_WORDS = new Set([
73
73
  'logomark',
74
74
  'wordmark',
75
75
  ])
76
- const VARIANT_WORDS = new Set([
77
- 'large',
78
- 'small',
79
- 'light',
80
- 'dark',
81
- 'mono',
82
- 'white',
83
- 'black',
84
- 'positive',
85
- 'negative',
86
- 'color',
87
- 'colour',
88
- 'primary',
89
- 'secondary',
90
- 'horizontal',
91
- 'vertical',
92
- 'square',
93
- 'flat',
94
- 'glyph',
95
- ])
96
-
97
76
  /**
98
77
  * Splits a file name into mark tokens. Separators are normalised because
99
78
  * vendors write marks as `logo.svg`, `atlan-logo.png`, `Dynatrace_Logo_color_positive.png`
100
79
  * or `cs logo big.png` alike.
101
80
  */
102
- function splitName(basename: string): { stems: string[]; marks: boolean; size: number } {
81
+ function splitName(basename: string): { stemRank: number | undefined; size: number } {
103
82
  const withoutExtension = basename.slice(0, basename.lastIndexOf('.'))
104
83
  const words = withoutExtension
105
84
  .split(/[\s._-]+/)
106
85
  .filter(Boolean)
107
86
  .map((word) => word.toLowerCase())
108
87
  const size = words.some((word) => word === 'small') ? 1 : 0
109
- const marks = words.some((word) => MARK_WORDS.has(word))
110
- const stems = words.filter((word) => !MARK_WORDS.has(word) && !VARIANT_WORDS.has(word))
111
- return { stems, marks, size }
88
+ const ranks = words.filter((word) => MARK_WORDS.has(word)).map((word) => STEM_RANK[word] ?? 0)
89
+ return { stemRank: ranks.length > 0 ? Math.min(...ranks) : undefined, size }
112
90
  }
113
91
 
114
92
  /**
@@ -133,9 +111,7 @@ export function selectIconPath(fileIndex: readonly string[]): string | null {
133
111
  const directories = segments.map((segment) => segment.toLowerCase())
134
112
  if (directories.some((segment) => EXCLUDED.test(segment))) continue
135
113
  if (directories.some((segment) => EXCLUDED_DIRECTORIES.includes(segment))) continue
136
- const { stems, marks, size } = splitName(basename)
137
- const single = stems.length === 1 ? stems[0]! : undefined
138
- const stemRank = marks ? (single ? (STEM_RANK[single] ?? 0) : 0) : undefined
114
+ const { stemRank, size } = splitName(basename)
139
115
  const nested = directories.filter((segment) => DIRECTORY_RANK[segment] !== undefined)
140
116
  // A mark named as one — `logo.svg`, `atlan-logo.png`, `cs_logo_large.png` —
141
117
  // is accepted anywhere, including the plugin root. An unnamed image must sit
@@ -149,7 +125,8 @@ export function selectIconPath(fileIndex: readonly string[]): string | null {
149
125
  : 0
150
126
  if (!Number.isFinite(directoryRank)) continue
151
127
  const score = [stemRank ?? 5, directoryRank, size, EXTENSION_RANK[extension] ?? 9, path.length]
152
- if (!best || compareScore(score, best.score) < 0) best = { path, score }
128
+ const comparison = best ? compareScore(score, best.score) : -1
129
+ if (!best || comparison < 0 || (comparison === 0 && path < best.path)) best = { path, score }
153
130
  }
154
131
  return best ? best.path : null
155
132
  }