@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
@@ -194,7 +194,11 @@ export interface ConsumerCategory {
194
194
  export interface LeadingDiagnostic {
195
195
  readonly category: string
196
196
  readonly name: string
197
- readonly reason: 'UNKNOWN_CATEGORY' | 'PRODUCT_NOT_FOUND' | 'PRODUCT_NOT_IN_CATEGORY'
197
+ readonly reason:
198
+ | 'UNKNOWN_CATEGORY'
199
+ | 'PRODUCT_NOT_FOUND'
200
+ | 'PRODUCT_NOT_IN_CATEGORY'
201
+ | 'PRODUCT_AMBIGUOUS'
198
202
  }
199
203
 
200
204
  /** Brand-mark coverage, so an incomplete set is visible rather than implied. */
@@ -257,10 +261,10 @@ function selectCanonical(
257
261
  const index = preference.sourcePreference.indexOf(plugin.sourceId)
258
262
  return index === -1 ? preference.sourcePreference.length : index
259
263
  }
260
- // A variant with no resolved release repository proves nothing about its
264
+ // A variant with no resolved current-release repository proves nothing about its
261
265
  // origin, so it is treated as marketplace-shipped rather than vendor-published.
262
266
  const vendorPublished = (plugin: Plugin): boolean => {
263
- const repository = plugin.availableReleases[0]?.resolvedRepositoryUrl
267
+ const repository = currentRelease(plugin)?.resolvedRepositoryUrl
264
268
  return repository !== undefined && repository !== sourceRepository.get(plugin.sourceId)
265
269
  }
266
270
  return [...variants].toSorted(
@@ -417,7 +421,8 @@ export function buildConsumerIndex(input: {
417
421
  sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
418
422
  }
419
423
  const leading = input.leading ?? {}
420
- const topCount = input.topCount ?? DEFAULT_TOP_COUNT
424
+ const topCount = input.topCount === undefined ? DEFAULT_TOP_COUNT : input.topCount
425
+ if (!Number.isSafeInteger(topCount) || topCount < 0) throw new Error('TOP_COUNT_INVALID')
421
426
  const sourceRepository = new Map(
422
427
  input.catalog.sources.map((source) => [source.sourceId, source.repositoryUrl])
423
428
  )
@@ -503,18 +508,28 @@ export function buildConsumerIndex(input: {
503
508
  const curatedByCategory = new Map<string, string[]>()
504
509
  for (const category of categoryNames) {
505
510
  const members = input.catalog.plugins.filter((plugin) => plugin.categories.includes(category))
511
+ const categoryProducts = new Set(members.map((plugin) => plugin.productGroupingKey))
506
512
  const curated: string[] = []
507
513
  for (const name of leading[category] ?? []) {
508
- const matches = members.filter((plugin) => plugin.upstreamPluginName === name)
509
- if (matches.length === 0) {
514
+ const matches = input.catalog.plugins.filter((plugin) => plugin.upstreamPluginName === name)
515
+ const keys = new Set(
516
+ matches
517
+ .map((plugin) => plugin.productGroupingKey)
518
+ .filter((key) => categoryProducts.has(key))
519
+ )
520
+ if (keys.size === 0) {
510
521
  diagnostics.push({
511
522
  category,
512
523
  name,
513
- reason: variantsByProduct.has(name) ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
524
+ reason: matches.length > 0 ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
514
525
  })
515
526
  continue
516
527
  }
517
- const key = matches[0]!.productGroupingKey
528
+ if (keys.size > 1) {
529
+ diagnostics.push({ category, name, reason: 'PRODUCT_AMBIGUOUS' })
530
+ continue
531
+ }
532
+ const key = keys.values().next().value!
518
533
  if (!curated.includes(key)) curated.push(key)
519
534
  }
520
535
  curatedByCategory.set(category, curated)
@@ -0,0 +1,143 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { chmod, mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'bun:test'
5
+ import { buildCatalog, digest, synchronize } from './index.js'
6
+ import { parseMarketplaceManifest } from './sources.js'
7
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
8
+
9
+ test.skipIf(process.platform === 'win32')(
10
+ 'resolves unspecified external refs from the remote default branch',
11
+ () =>
12
+ withPortableFixture(async (input, root) => {
13
+ const repo = join(root, 'repository')
14
+ const bin = join(root, 'bin')
15
+ const originalPath = process.env.PATH
16
+ const originalGit = process.env.ADEA_TEST_REAL_GIT
17
+ const originalRepo = process.env.ADEA_TEST_HEAD_REPO
18
+ const originalFetch = globalThis.fetch
19
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
20
+ const git = (...args: string[]) =>
21
+ execFileSync(realGit, ['-C', repo, ...args], { encoding: 'utf8' }).trim()
22
+ try {
23
+ await mkdir(repo)
24
+ await mkdir(bin)
25
+ git('init', '-q', '-b', 'legacy')
26
+ git(
27
+ '-c',
28
+ 'user.name=Fixture',
29
+ '-c',
30
+ 'user.email=fixture@example.invalid',
31
+ 'commit',
32
+ '--allow-empty',
33
+ '-qm',
34
+ 'main commit'
35
+ )
36
+ git('branch', 'main')
37
+ const mainCommit = git('rev-parse', 'refs/heads/main')
38
+ git(
39
+ '-c',
40
+ 'user.name=Fixture',
41
+ '-c',
42
+ 'user.email=fixture@example.invalid',
43
+ 'commit',
44
+ '--allow-empty',
45
+ '-qm',
46
+ 'default commit'
47
+ )
48
+ const defaultCommit = git('rev-parse', 'HEAD')
49
+ expect(defaultCommit).not.toBe(mainCommit)
50
+ const sourceSha = '5'.repeat(40)
51
+ const source = {
52
+ ...input.sources[0]!,
53
+ repositoryUrl: 'https://github.com/acme/marketplace',
54
+ }
55
+ const script = join(bin, 'git')
56
+ await writeFile(
57
+ script,
58
+ `#!/bin/sh\nif [ "$2" = "${source.repositoryUrl}" ]; then\n printf '%s\\t%s\\n' '${sourceSha}' "$3"\nelse\n shift 2\n exec "$ADEA_TEST_REAL_GIT" ls-remote "$ADEA_TEST_HEAD_REPO" "$@"\nfi\n`
59
+ )
60
+ await chmod(script, 0o700)
61
+ process.env.ADEA_TEST_REAL_GIT = realGit
62
+ process.env.ADEA_TEST_HEAD_REPO = repo
63
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
64
+ const url = 'https://github.com/acme/remote-plugin'
65
+ const manifest = JSON.stringify({
66
+ plugins: [
67
+ { name: 'object-source', source: { source: 'git', url } },
68
+ { name: 'url-source', source: url },
69
+ { name: 'subdir-source', source: { source: 'git-subdir', url, path: 'plugins/demo' } },
70
+ { name: 'explicit-main', source: { source: 'git', url, ref: 'main' } },
71
+ ],
72
+ })
73
+ globalThis.fetch = (async (_url: unknown) => new Response(manifest)) as typeof fetch
74
+ const options = {
75
+ ...input,
76
+ sources: [source],
77
+ mode: 'live' as const,
78
+ snapshotLoader: {
79
+ load: async () => ({
80
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
81
+ symlinks: [],
82
+ }),
83
+ },
84
+ }
85
+ const result = await synchronize(options)
86
+ const pins = Object.fromEntries(
87
+ result.lock!.sources[0]!.pluginPins.map((pin) => [pin.pluginName, pin.resolvedCommitSha])
88
+ )
89
+ expect(pins).toEqual({
90
+ 'object-source': defaultCommit,
91
+ 'url-source': defaultCommit,
92
+ 'subdir-source': defaultCommit,
93
+ 'explicit-main': mainCommit,
94
+ })
95
+ const direct = await buildCatalog({
96
+ resolvedSources: [
97
+ {
98
+ config: source,
99
+ commitSha: sourceSha,
100
+ manifestText: manifest,
101
+ parsed: parseMarketplaceManifest(JSON.parse(manifest), source),
102
+ pluginPins: [],
103
+ retrievedAt: '2026-01-01T00:00:00.000Z',
104
+ manifestDigest: digest(manifest),
105
+ },
106
+ ],
107
+ categoryMap: input.categoryMap,
108
+ productAliases: input.productAliases,
109
+ policy: input.policy,
110
+ snapshotLoader: options.snapshotLoader,
111
+ resolveExternalRefs: true,
112
+ })
113
+ expect(
114
+ direct.plugins.find((plugin) => plugin.upstreamPluginName === 'url-source')
115
+ ?.availableReleases[0]?.resolvedCommitSha
116
+ ).toBe(defaultCommit)
117
+ git('symbolic-ref', 'HEAD', 'refs/heads/main')
118
+ const replay = await synchronize({ ...options, fromLock: result.lock! })
119
+ expect(
120
+ replay.lock!.sources[0]!.pluginPins.find((pin) => pin.pluginName === 'url-source')
121
+ ?.resolvedCommitSha
122
+ ).toBe(defaultCommit)
123
+ const refreshed = await synchronize({ ...options, existingLock: result.lock! })
124
+ expect(refreshed.changed).toBe(true)
125
+ expect(
126
+ refreshed.lock!.sources[0]!.pluginPins.find((pin) => pin.pluginName === 'url-source')
127
+ ?.resolvedCommitSha
128
+ ).toBe(mainCommit)
129
+ git('branch', 'x/HEAD', 'main')
130
+ git('symbolic-ref', 'HEAD', 'refs/heads/missing')
131
+ expect(git('ls-remote', repo, 'HEAD')).toContain('refs/heads/x/HEAD')
132
+ await expect(synchronize(options)).rejects.toThrow('GIT_REF_NOT_FOUND')
133
+ } finally {
134
+ globalThis.fetch = originalFetch
135
+ if (originalPath === undefined) delete process.env.PATH
136
+ else process.env.PATH = originalPath
137
+ if (originalGit === undefined) delete process.env.ADEA_TEST_REAL_GIT
138
+ else process.env.ADEA_TEST_REAL_GIT = originalGit
139
+ if (originalRepo === undefined) delete process.env.ADEA_TEST_HEAD_REPO
140
+ else process.env.ADEA_TEST_HEAD_REPO = originalRepo
141
+ }
142
+ })
143
+ )
@@ -49,6 +49,14 @@ describe('plugin display names', () => {
49
49
  )
50
50
  })
51
51
 
52
+ test('treats object property names as ordinary display-name text', () => {
53
+ expect(normalizeDisplayName('constructor')).toBe('Constructor')
54
+ expect(normalizeDisplayName('constructor-tools')).toBe('Constructor Tools')
55
+ expect(normalizeDisplayName('toString')).toBe('toString')
56
+ expect(normalizeDisplayName('valueOf')).toBe('valueOf')
57
+ expect(normalizeDisplayName('__proto__')).toBe('Proto')
58
+ })
59
+
52
60
  test('leaves a name it cannot improve alone', () => {
53
61
  expect(normalizeDisplayName('')).toBe('')
54
62
  expect(normalizeDisplayName(' ')).toBe(' ')
@@ -139,14 +139,15 @@ const FULL_NAMES: Readonly<Record<string, string>> = {
139
139
  */
140
140
  export function normalizeDisplayName(value: string): string {
141
141
  const trimmed = value.trim()
142
- const known = FULL_NAMES[trimmed.toLowerCase()]
142
+ const fullNameKey = trimmed.toLowerCase()
143
+ const known = Object.hasOwn(FULL_NAMES, fullNameKey) ? FULL_NAMES[fullNameKey] : undefined
143
144
  if (known !== undefined) return known
144
145
  const tokens = trimmed.split(/[-_\s]+/u).filter(Boolean)
145
146
  if (tokens.length === 0) return value
146
147
  return tokens
147
148
  .map((token, index) => {
148
149
  const key = token.toLowerCase()
149
- const brand = BRAND_TOKENS[key]
150
+ const brand = Object.hasOwn(BRAND_TOKENS, key) ? BRAND_TOKENS[key] : undefined
150
151
  if (brand !== undefined) return brand
151
152
  // Any uppercase means the vendor already chose a casing.
152
153
  if (/\p{Lu}/u.test(token)) return token
@@ -15,7 +15,7 @@ import {
15
15
  resolveMcpBinding,
16
16
  expandPluginPaths,
17
17
  verifyBindingPaths,
18
- } from './agent-plugins-harness.js'
18
+ } from './harness.js'
19
19
 
20
20
  function profile(overrides: Partial<HarnessProfile> = {}): HarnessProfile {
21
21
  return {