@adea-ai/plugins 4.0.0 → 4.0.6

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 (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -101,7 +101,7 @@ describe('product placement', () => {
101
101
  categories: ['productivity'],
102
102
  }),
103
103
  ]),
104
- preference,
104
+ productPreference: preference,
105
105
  })
106
106
  expect(index.counts).toEqual({ plugins: 2, products: 1, redundantPlugins: 1 })
107
107
  expect(index.products['google-calendar']).toMatchObject({
@@ -121,7 +121,7 @@ describe('product placement', () => {
121
121
  plugin({ sourceId: 'claude-official', name: 'slack', categories: ['productivity'] }),
122
122
  plugin({ sourceId: 'openai-official', name: 'slack', categories: ['communication'] }),
123
123
  ])
124
- const index = buildConsumerIndex({ catalog: base, preference })
124
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
125
125
  // Sources disagree about where Slack belongs; the product belongs in both,
126
126
  // while the canonical variant still decides identity.
127
127
  expect(index.products.slack!.categories).toEqual(['communication', 'productivity'])
@@ -156,7 +156,7 @@ describe('product placement', () => {
156
156
  { ...first, currentReleaseId: 'release:same' },
157
157
  { ...second, currentReleaseId: 'release:same' },
158
158
  ]),
159
- preference,
159
+ productPreference: preference,
160
160
  })
161
161
  expect(identical.products.atlan!.variantsIdentical).toBe(true)
162
162
  })
@@ -172,18 +172,57 @@ describe('product placement', () => {
172
172
  releaseRepositoryUrl: 'https://github.com/slackapi/slack-mcp-plugin',
173
173
  }),
174
174
  ]),
175
- preference,
175
+ productPreference: preference,
176
176
  })
177
177
  expect(index.products.slack!.pluginId).toBe('plugin:openai-official:slack')
178
178
  })
179
179
 
180
+ test('selects the canonical variant using the current release rather than an earlier release', () => {
181
+ const wrapper = plugin({
182
+ sourceId: 'claude-official',
183
+ name: 'slack',
184
+ categories: ['communication'],
185
+ })
186
+ const candidate = plugin({
187
+ sourceId: 'openai-official',
188
+ name: 'slack',
189
+ categories: ['communication'],
190
+ releaseRepositoryUrl: 'https://github.com/slackapi/slack-mcp-plugin',
191
+ })
192
+ const current = candidate.availableReleases[0]!
193
+ const oldWrapper = {
194
+ ...current,
195
+ releaseId: 'release:old-wrapper',
196
+ resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
197
+ }
198
+ const vendorNow = { ...candidate, availableReleases: [oldWrapper, current] }
199
+ const vendorIndex = buildConsumerIndex({
200
+ catalog: catalog([wrapper, vendorNow]),
201
+ productPreference: preference,
202
+ })
203
+ expect(vendorIndex.products.slack!.pluginId).toBe(candidate.pluginId)
204
+ expect(vendorIndex.products.slack!.release.releaseId).toBe(current.releaseId)
205
+
206
+ const currentWrapper = {
207
+ ...current,
208
+ resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
209
+ }
210
+ const oldVendor = { ...current, releaseId: 'release:old-vendor' }
211
+ const wrapperNow = { ...candidate, availableReleases: [oldVendor, currentWrapper] }
212
+ const wrapperIndex = buildConsumerIndex({
213
+ catalog: catalog([wrapper, wrapperNow]),
214
+ productPreference: preference,
215
+ })
216
+ expect(wrapperIndex.products.slack!.pluginId).toBe(wrapper.pluginId)
217
+ })
218
+
180
219
  test('falls back to the configured source order when every variant is a wrapper', () => {
181
220
  const index = buildConsumerIndex({
182
221
  catalog: catalog([
183
222
  plugin({ sourceId: 'openai-official', name: 'github', categories: ['developer-tools'] }),
184
223
  plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
185
224
  ]),
186
- preference,
225
+ productPreference: preference,
187
226
  })
188
227
  expect(index.products.github!.pluginId).toBe('plugin:claude-official:github')
189
228
  })
@@ -194,7 +233,7 @@ describe('product placement', () => {
194
233
  plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
195
234
  plugin({ sourceId: 'cursor-official', name: 'github', categories: ['developer-tools'] }),
196
235
  ]),
197
- preference: {
236
+ productPreference: {
198
237
  ...preference,
199
238
  canonicalOverrides: { github: 'plugin:cursor-official:github' },
200
239
  },
@@ -213,7 +252,7 @@ describe('product placement', () => {
213
252
  displayName: 'Figma',
214
253
  }),
215
254
  ]),
216
- preference,
255
+ productPreference: preference,
217
256
  })
218
257
  expect(index.products.figma!.displayName).toBe('Figma')
219
258
  expect(index.products.figma!.pluginId).toBe('plugin:claude-official:figma')
@@ -224,7 +263,7 @@ describe('product placement', () => {
224
263
  catalog: catalog([
225
264
  plugin({ sourceId: 'unlisted-source', name: 'tool', categories: ['developer-tools'] }),
226
265
  ]),
227
- preference,
266
+ productPreference: preference,
228
267
  })
229
268
  expect(index.products.tool!.pluginId).toBe('plugin:unlisted-source:tool')
230
269
  })
@@ -233,6 +272,31 @@ describe('product placement', () => {
233
272
  describe('category shelves', () => {
234
273
  const names = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta']
235
274
 
275
+ test('rejects invalid shelf limits while allowing an empty shelf', () => {
276
+ const input = {
277
+ catalog: catalog(
278
+ names.map((name) =>
279
+ plugin({ sourceId: 'claude-official', name, categories: ['productivity'] })
280
+ )
281
+ ),
282
+ productPreference: preference,
283
+ }
284
+ expect(buildConsumerIndex({ ...input, topCount: 0 }).categories[0]!.topProductKeys).toEqual([])
285
+ for (const topCount of [
286
+ -1,
287
+ 1.5,
288
+ Number.NaN,
289
+ Infinity,
290
+ Number.MAX_SAFE_INTEGER + 1,
291
+ null,
292
+ '3',
293
+ ]) {
294
+ expect(() => buildConsumerIndex({ ...input, topCount: topCount as number })).toThrow(
295
+ 'TOP_COUNT_INVALID'
296
+ )
297
+ }
298
+ })
299
+
236
300
  test('orders curated products first and fills the rest deterministically', () => {
237
301
  const index = buildConsumerIndex({
238
302
  catalog: catalog(
@@ -242,7 +306,7 @@ describe('category shelves', () => {
242
306
  ),
243
307
  leading: { productivity: ['zeta', 'gamma'] },
244
308
  topCount: 4,
245
- preference,
309
+ productPreference: preference,
246
310
  })
247
311
  const category = index.categories[0]!
248
312
  expect(category.productKeys).toEqual([
@@ -270,7 +334,7 @@ describe('category shelves', () => {
270
334
  }),
271
335
  ]),
272
336
  leading: { 'education-research': ['teaching'] },
273
- preference,
337
+ productPreference: preference,
274
338
  })
275
339
  expect(
276
340
  index.categories.find((item) => item.category === 'developer-tools')!.topProductKeys
@@ -296,7 +360,7 @@ describe('category shelves', () => {
296
360
  security: ['nightvision', 'no-such-plugin'],
297
361
  'unknown-category': ['anything'],
298
362
  },
299
- preference,
363
+ productPreference: preference,
300
364
  })
301
365
  expect(index.diagnostics).toEqual([
302
366
  { category: 'developer-tools', name: 'getsentry', reason: 'PRODUCT_NOT_FOUND' },
@@ -309,6 +373,73 @@ describe('category shelves', () => {
309
373
  expect(developer.topProductKeys).toEqual(['sentry', 'nightvision'])
310
374
  })
311
375
 
376
+ test('reports aliases outside the category and ambiguous curated names', () => {
377
+ const plugins = [
378
+ plugin({
379
+ sourceId: 'source-one',
380
+ name: 'shared',
381
+ categories: ['tools'],
382
+ productGroupingKey: 'first-product',
383
+ }),
384
+ plugin({
385
+ sourceId: 'source-two',
386
+ name: 'shared',
387
+ categories: ['tools'],
388
+ productGroupingKey: 'second-product',
389
+ }),
390
+ plugin({
391
+ sourceId: 'source-one',
392
+ name: 'renamed',
393
+ categories: ['other'],
394
+ productGroupingKey: 'actual-product',
395
+ }),
396
+ plugin({ sourceId: 'source-one', name: 'present', categories: ['tools'] }),
397
+ ]
398
+ for (const entries of [plugins, plugins.toReversed()]) {
399
+ const index = buildConsumerIndex({
400
+ catalog: catalog(entries),
401
+ leading: { tools: ['shared', 'renamed', 'present'] },
402
+ productPreference: preference,
403
+ })
404
+ expect(index.diagnostics).toEqual([
405
+ { category: 'tools', name: 'renamed', reason: 'PRODUCT_NOT_IN_CATEGORY' },
406
+ { category: 'tools', name: 'shared', reason: 'PRODUCT_AMBIGUOUS' },
407
+ ])
408
+ expect(index.categories.find((item) => item.category === 'tools')!.productKeys[0]).toBe(
409
+ 'present'
410
+ )
411
+ }
412
+ })
413
+
414
+ test('curates a product when a differently named variant belongs to the category', () => {
415
+ const plugins = [
416
+ plugin({
417
+ sourceId: 'source-one',
418
+ name: 'named',
419
+ categories: ['other'],
420
+ productGroupingKey: 'shared-product',
421
+ }),
422
+ plugin({
423
+ sourceId: 'source-two',
424
+ name: 'alternate',
425
+ categories: ['tools'],
426
+ productGroupingKey: 'shared-product',
427
+ }),
428
+ plugin({ sourceId: 'source-one', name: 'alpha', categories: ['tools'] }),
429
+ ]
430
+ for (const entries of [plugins, plugins.toReversed()]) {
431
+ const index = buildConsumerIndex({
432
+ catalog: catalog(entries),
433
+ leading: { tools: ['named'] },
434
+ productPreference: preference,
435
+ })
436
+ expect(index.diagnostics).toEqual([])
437
+ expect(index.categories.find((item) => item.category === 'tools')!.topProductKeys[0]).toBe(
438
+ 'shared-product'
439
+ )
440
+ }
441
+ })
442
+
312
443
  test('is deterministic for the same catalog in any input order', () => {
313
444
  const forward = names
314
445
  .slice(0, 3)
@@ -316,12 +447,12 @@ describe('category shelves', () => {
316
447
  const first = buildConsumerIndex({
317
448
  catalog: catalog(forward),
318
449
  leading: { productivity: ['gamma'] },
319
- preference,
450
+ productPreference: preference,
320
451
  })
321
452
  const second = buildConsumerIndex({
322
453
  catalog: catalog(forward.toReversed()),
323
454
  leading: { productivity: ['gamma'] },
324
- preference,
455
+ productPreference: preference,
325
456
  })
326
457
  expect(JSON.stringify(first)).toBe(JSON.stringify(second))
327
458
  })
@@ -402,7 +533,7 @@ describe('consumer index verification', () => {
402
533
  const index = buildConsumerIndex({
403
534
  catalog: base,
404
535
  leading: { productivity: ['b'] },
405
- preference,
536
+ productPreference: preference,
406
537
  })
407
538
  const result = verifyConsumerIndex(JSON.stringify(index), base)
408
539
  expect(result.hasConsumerIndex).toBe(true)
@@ -429,7 +560,7 @@ describe('consumer index verification', () => {
429
560
 
430
561
  test('rejects a category list that names a plugin outside the catalog', () => {
431
562
  const base = catalog(plugins)
432
- const index = buildConsumerIndex({ catalog: base, preference })
563
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
433
564
  const tampered = {
434
565
  ...index,
435
566
  categories: index.categories.map((category) => ({
@@ -450,7 +581,7 @@ describe('consumer index verification', () => {
450
581
  categories: ['productivity', 'communication'],
451
582
  }),
452
583
  ])
453
- const index = buildConsumerIndex({ catalog: base, preference })
584
+ const index = buildConsumerIndex({ catalog: base, productPreference: preference })
454
585
  const tampered = {
455
586
  ...index,
456
587
  categories: index.categories.map((category) => ({
@@ -1,4 +1,5 @@
1
1
  import type { Catalog, Plugin, PluginRelease } from './schema.js'
2
+ import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
2
3
  import { iconAssetName, type IconContentType } from './icons.js'
3
4
  import { immutableAssetUrl } from './publication.js'
4
5
 
@@ -60,7 +61,7 @@ export function parseProductPreference(input: unknown): ProductPreference {
60
61
  export interface ProductIcon {
61
62
  /** `content` is the vendor's own plugin file; `favicon` is the product site's. */
62
63
  readonly kind: 'content' | 'favicon'
63
- /** Release asset name; fetch it from the release for this `catalogId`. */
64
+ /** Content-addressed asset name, published beside the catalog's artifacts. */
64
65
  readonly asset: string
65
66
  /**
66
67
  * Absolute, immutable URL for the asset, present when the build knows where
@@ -111,29 +112,6 @@ export interface ConsumerRelease {
111
112
  readonly packageDigest?: string
112
113
  }
113
114
 
114
- /** One installable product, resolved from every source variant that publishes it. */ /**
115
- * The install-relevant facts of the release a product installs.
116
- *
117
- * A browsing client shows a plugin, then plans an install from it: the digest it
118
- * must pin, the connectors and credentials setup will ask for, and the
119
- * capabilities the harness has to negotiate. Carrying them here means the grid
120
- * needs no second artifact.
121
- */
122
- export interface ConsumerRelease {
123
- readonly releaseId: string
124
- readonly canonicalContentDigest: string
125
- readonly contentResolution: string
126
- readonly capabilities: readonly { readonly type: string; readonly name: string }[]
127
- readonly requiredConnectors: readonly string[]
128
- readonly requiredCredentials: readonly string[]
129
- /** Commit the release was resolved from, for audit and update state. */
130
- readonly sourceRevision: string
131
- readonly upstreamVersion?: string
132
- /** Canonical package status, when the release carries one. */
133
- readonly packageStatus?: string
134
- readonly packageDigest?: string
135
- }
136
-
137
115
  /** One installable product, resolved from every source variant that publishes it. */
138
116
  export interface ConsumerProduct {
139
117
  readonly productKey: string
@@ -194,7 +172,11 @@ export interface ConsumerCategory {
194
172
  export interface LeadingDiagnostic {
195
173
  readonly category: string
196
174
  readonly name: string
197
- readonly reason: 'UNKNOWN_CATEGORY' | 'PRODUCT_NOT_FOUND' | 'PRODUCT_NOT_IN_CATEGORY'
175
+ readonly reason:
176
+ | 'UNKNOWN_CATEGORY'
177
+ | 'PRODUCT_NOT_FOUND'
178
+ | 'PRODUCT_NOT_IN_CATEGORY'
179
+ | 'PRODUCT_AMBIGUOUS'
198
180
  }
199
181
 
200
182
  /** Brand-mark coverage, so an incomplete set is visible rather than implied. */
@@ -257,10 +239,10 @@ function selectCanonical(
257
239
  const index = preference.sourcePreference.indexOf(plugin.sourceId)
258
240
  return index === -1 ? preference.sourcePreference.length : index
259
241
  }
260
- // A variant with no resolved release repository proves nothing about its
242
+ // A variant with no resolved current-release repository proves nothing about its
261
243
  // origin, so it is treated as marketplace-shipped rather than vendor-published.
262
244
  const vendorPublished = (plugin: Plugin): boolean => {
263
- const repository = plugin.availableReleases[0]?.resolvedRepositoryUrl
245
+ const repository = currentRelease(plugin)?.resolvedRepositoryUrl
264
246
  return repository !== undefined && repository !== sourceRepository.get(plugin.sourceId)
265
247
  }
266
248
  return [...variants].toSorted(
@@ -315,7 +297,9 @@ function nestedString(value: unknown, key: string): string | undefined {
315
297
 
316
298
  function describeRelease(plugin: Plugin): ConsumerRelease {
317
299
  const release = currentRelease(plugin)!
318
- const packages = ((release.releaseMetadata ?? {}) as Record<string, unknown>).agentPlugins
300
+ const packages = release.releaseMetadata[PACKAGE_METADATA_KEY]
301
+ const packageStatus = nestedString(packages, 'status')
302
+ const packageDigest = nestedString(packages, 'packageDigest')
319
303
  return {
320
304
  releaseId: release.releaseId,
321
305
  canonicalContentDigest: release.canonicalContentDigest,
@@ -328,12 +312,8 @@ function describeRelease(plugin: Plugin): ConsumerRelease {
328
312
  requiredCredentials: [...release.requiredCredentials],
329
313
  sourceRevision: release.resolvedCommitSha,
330
314
  ...(release.upstreamVersion ? { upstreamVersion: release.upstreamVersion } : {}),
331
- ...(nestedString(packages, 'status')
332
- ? { packageStatus: nestedString(packages, 'status')! }
333
- : {}),
334
- ...(nestedString(packages, 'packageDigest')
335
- ? { packageDigest: nestedString(packages, 'packageDigest')! }
336
- : {}),
315
+ ...(packageStatus ? { packageStatus } : {}),
316
+ ...(packageDigest ? { packageDigest } : {}),
337
317
  }
338
318
  }
339
319
 
@@ -403,21 +383,23 @@ function compactCompatibility(plugin: Plugin): Record<string, string> {
403
383
  * stable identifiers.
404
384
  */
405
385
  export function buildConsumerIndex(input: {
406
- readonly catalog: Pick<Catalog, 'plugins' | 'sources'>
386
+ /** A catalog without its ID yields records with no published asset URLs. */
387
+ readonly catalog: Pick<Catalog, 'plugins' | 'sources'> & { readonly catalogId?: string }
407
388
  readonly leading?: Readonly<Record<string, readonly string[]>>
408
389
  readonly topCount?: number
409
- readonly preference?: ProductPreference
390
+ readonly productPreference?: ProductPreference
410
391
  /** Where the catalog will be published, so records can carry absolute URLs. */
411
392
  readonly publicationRepositoryUrl?: string
412
393
  /** Committed asset base a browser can fetch directly; preferred when set. */
413
394
  readonly publicationAssetsBaseUrl?: string
414
395
  }): ConsumerIndex {
415
- const preference = input.preference ?? {
396
+ const preference = input.productPreference ?? {
416
397
  schemaVersion: 1 as const,
417
398
  sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
418
399
  }
419
400
  const leading = input.leading ?? {}
420
- const topCount = input.topCount ?? DEFAULT_TOP_COUNT
401
+ const topCount = input.topCount === undefined ? DEFAULT_TOP_COUNT : input.topCount
402
+ if (!Number.isSafeInteger(topCount) || topCount < 0) throw new Error('TOP_COUNT_INVALID')
421
403
  const sourceRepository = new Map(
422
404
  input.catalog.sources.map((source) => [source.sourceId, source.repositoryUrl])
423
405
  )
@@ -427,7 +409,7 @@ export function buildConsumerIndex(input: {
427
409
  if (list) list.push(plugin)
428
410
  else variantsByProduct.set(plugin.productGroupingKey, [plugin])
429
411
  }
430
- const catalogId = (input.catalog as { catalogId?: string }).catalogId ?? ''
412
+ const catalogId = input.catalog.catalogId ?? ''
431
413
  const products: Record<string, ConsumerProduct> = {}
432
414
  for (const [productKey, variants] of [...variantsByProduct].toSorted((left, right) =>
433
415
  left[0].localeCompare(right[0])
@@ -503,18 +485,28 @@ export function buildConsumerIndex(input: {
503
485
  const curatedByCategory = new Map<string, string[]>()
504
486
  for (const category of categoryNames) {
505
487
  const members = input.catalog.plugins.filter((plugin) => plugin.categories.includes(category))
488
+ const categoryProducts = new Set(members.map((plugin) => plugin.productGroupingKey))
506
489
  const curated: string[] = []
507
490
  for (const name of leading[category] ?? []) {
508
- const matches = members.filter((plugin) => plugin.upstreamPluginName === name)
509
- if (matches.length === 0) {
491
+ const matches = input.catalog.plugins.filter((plugin) => plugin.upstreamPluginName === name)
492
+ const keys = new Set(
493
+ matches
494
+ .map((plugin) => plugin.productGroupingKey)
495
+ .filter((key) => categoryProducts.has(key))
496
+ )
497
+ if (keys.size === 0) {
510
498
  diagnostics.push({
511
499
  category,
512
500
  name,
513
- reason: variantsByProduct.has(name) ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
501
+ reason: matches.length > 0 ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
514
502
  })
515
503
  continue
516
504
  }
517
- const key = matches[0]!.productGroupingKey
505
+ if (keys.size > 1) {
506
+ diagnostics.push({ category, name, reason: 'PRODUCT_AMBIGUOUS' })
507
+ continue
508
+ }
509
+ const key = keys.values().next().value!
518
510
  if (!curated.includes(key)) curated.push(key)
519
511
  }
520
512
  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
+ )
package/src/digest.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const DIGEST_PREFIX = 'sha256:'
4
+
5
+ function sha256Hex(data: string | Uint8Array): string {
6
+ return createHash('sha256').update(data).digest('hex')
7
+ }
8
+
9
+ /** Ordinal (UTF-16 code unit) order: the same on every machine and locale. */
10
+ export function ordinalCompare(left: string, right: string): number {
11
+ return left < right ? -1 : left > right ? 1 : 0
12
+ }
13
+
14
+ export function canonicalJson(value: unknown): string {
15
+ if (value === null || typeof value !== 'object') return JSON.stringify(value)
16
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
17
+ const object = value as Record<string, unknown>
18
+ return `{${Object.keys(object)
19
+ .toSorted()
20
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
21
+ .join(',')}}`
22
+ }
23
+
24
+ /** Digest of a JSON value's canonical form, in the catalog's digest format. */
25
+ export function digest(value: unknown): string {
26
+ return `${DIGEST_PREFIX}${sha256Hex(canonicalJson(value))}`
27
+ }
28
+
29
+ /** Digest of one byte buffer (a string is hashed as UTF-8), in the catalog's digest format. */
30
+ export function byteDigest(bytes: string | Uint8Array): string {
31
+ return `${DIGEST_PREFIX}${sha256Hex(bytes)}`
32
+ }
33
+
34
+ /** Shared source-byte identity for catalog provenance and canonical package recipes. */
35
+ export function sourceDigest(files: ReadonlyMap<string, Uint8Array>): string {
36
+ const hash = createHash('sha256')
37
+ // English matches the compiler runtime; ordinal ties distinguish equivalent Unicode names.
38
+ for (const [path, bytes] of [...files].toSorted(
39
+ ([a], [b]) => a.localeCompare(b, 'en') || ordinalCompare(a, b)
40
+ )) {
41
+ hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
42
+ hash.update(bytes)
43
+ }
44
+ return `${DIGEST_PREFIX}${hash.digest('hex')}`
45
+ }
46
+
47
+ /** A content-addressed identifier such as `catalog:<hex>` for a JSON body. */
48
+ export function contentId(prefix: string, body: unknown): string {
49
+ return `${prefix}:${sha256Hex(canonicalJson(body))}`
50
+ }
51
+
52
+ /** The hex part of a `sha256:<hex>` digest. */
53
+ export function digestHex(value: string): string {
54
+ return value.startsWith(DIGEST_PREFIX) ? value.slice(DIGEST_PREFIX.length) : value
55
+ }
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { promises as fs } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+
5
+ /** Creates `path`, failing if it exists, and syncs the bytes before returning. */
6
+ export async function writeDurably(path: string, content: string | Uint8Array): Promise<void> {
7
+ const handle = await fs.open(path, 'wx')
8
+ try {
9
+ await handle.writeFile(content)
10
+ await handle.sync()
11
+ } finally {
12
+ await handle.close()
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Writes one file so a reader sees either the old or the new bytes. For
18
+ * content-addressed assets and the browsing index beside the catalog.
19
+ */
20
+ export async function writeFileAtomically(
21
+ path: string,
22
+ content: string | Uint8Array
23
+ ): Promise<void> {
24
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`)
25
+ try {
26
+ await writeDurably(temporary, content)
27
+ await fs.rename(temporary, path)
28
+ } catch (error) {
29
+ await fs.rm(temporary, { force: true })
30
+ throw error
31
+ }
32
+ }