@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.
- package/dist/agent-plugins-catalog.d.ts +2 -4
- package/dist/agent-plugins-catalog.js +42 -50
- package/dist/agent-plugins-catalog.js.map +1 -1
- package/dist/agent-plugins-schema.d.ts +8 -0
- package/dist/agent-plugins-schema.js +10 -1
- package/dist/agent-plugins-schema.js.map +1 -1
- package/dist/artifacts.d.ts +57 -0
- package/dist/artifacts.js +159 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/build.d.ts +20 -0
- package/dist/build.js +103 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.d.ts +7 -0
- package/dist/capabilities.js +116 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog-store.d.ts +76 -0
- package/dist/catalog-store.js +463 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/consumer-index.d.ts +7 -29
- package/dist/consumer-index.js +24 -15
- package/dist/consumer-index.js.map +1 -1
- package/dist/digest.d.ts +13 -0
- package/dist/digest.js +47 -0
- package/dist/digest.js.map +1 -0
- package/dist/fs-atomic.d.ts +7 -0
- package/dist/fs-atomic.js +30 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/harness.js +2 -1
- package/dist/harness.js.map +1 -1
- package/dist/icon-mirror.d.ts +5 -4
- package/dist/icon-mirror.js +73 -72
- package/dist/icon-mirror.js.map +1 -1
- package/dist/icons.d.ts +2 -2
- package/dist/icons.js +4 -12
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +16 -309
- package/dist/index.js +12 -1900
- package/dist/index.js.map +1 -1
- package/dist/integrity-manifest.d.ts +43 -13
- package/dist/integrity-manifest.js +28 -17
- package/dist/integrity-manifest.js.map +1 -1
- package/dist/json-values.d.ts +3 -0
- package/dist/json-values.js +10 -0
- package/dist/json-values.js.map +1 -0
- package/dist/plugin-release.d.ts +38 -0
- package/dist/plugin-release.js +321 -0
- package/dist/plugin-release.js.map +1 -0
- package/dist/plugin-skips.d.ts +19 -0
- package/dist/plugin-skips.js +65 -0
- package/dist/plugin-skips.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +26 -0
- package/dist/policy.js.map +1 -0
- package/dist/portable-catalog.d.ts +4 -4
- package/dist/portable-catalog.js +14 -126
- package/dist/portable-catalog.js.map +1 -1
- package/dist/portable-verify.d.ts +2 -0
- package/dist/portable-verify.js +68 -0
- package/dist/portable-verify.js.map +1 -0
- package/dist/publication.d.ts +12 -12
- package/dist/publication.js +31 -21
- package/dist/publication.js.map +1 -1
- package/dist/published-output.d.ts +17 -0
- package/dist/published-output.js +49 -0
- package/dist/published-output.js.map +1 -0
- package/dist/site-icons.d.ts +4 -3
- package/dist/site-icons.js +38 -11
- package/dist/site-icons.js.map +1 -1
- package/dist/snapshots.d.ts +39 -0
- package/dist/snapshots.js +238 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/source-resolution.d.ts +28 -0
- package/dist/source-resolution.js +158 -0
- package/dist/source-resolution.js.map +1 -0
- package/dist/sources.d.ts +5 -1
- package/dist/sources.js +21 -27
- package/dist/sources.js.map +1 -1
- package/dist/synchronize.d.ts +44 -0
- package/dist/synchronize.js +118 -0
- package/dist/synchronize.js.map +1 -0
- package/dist/taxonomy.d.ts +23 -0
- package/dist/taxonomy.js +62 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/upstream.d.ts +42 -0
- package/dist/upstream.js +179 -0
- package/dist/upstream.js.map +1 -0
- package/dist/verify-artifacts.d.ts +36 -0
- package/dist/verify-artifacts.js +286 -0
- package/dist/verify-artifacts.js.map +1 -0
- package/native/build-provenance.txt +5 -5
- package/native/staged-reader-win32-arm64.node +0 -0
- package/native/staged-reader-win32-x64.node +0 -0
- package/package.json +3 -3
- package/src/agent-plugins-catalog.test.ts +2 -2
- package/src/agent-plugins-catalog.ts +46 -49
- package/src/agent-plugins-schema.ts +11 -1
- package/src/artifacts.test.ts +80 -0
- package/src/artifacts.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +519 -0
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +147 -16
- package/src/consumer-index.ts +35 -43
- package/src/default-head.test.ts +143 -0
- package/src/digest.ts +55 -0
- package/src/fs-atomic.ts +32 -0
- package/src/harness.test.ts +4 -32
- package/src/harness.ts +2 -1
- package/src/icon-mirror.ts +78 -72
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +172 -46
- package/src/index.ts +58 -2562
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -0
- package/src/manifest-retry.test.ts +49 -0
- package/src/plugin-release.ts +457 -0
- package/src/plugin-skips.ts +110 -0
- package/src/policy.ts +39 -0
- package/src/portable-catalog.test.ts +0 -5
- package/src/portable-catalog.ts +16 -143
- package/src/portable-verify.ts +71 -0
- package/src/publication.test.ts +29 -1
- package/src/publication.ts +38 -22
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +73 -1
- package/src/site-icons.ts +31 -14
- package/src/snapshots.ts +287 -0
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +61 -8
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +18 -2
- package/src/sources.ts +27 -33
- package/src/synchronize.ts +213 -0
- package/src/taxonomy.ts +87 -0
- package/src/upstream.ts +194 -0
- package/src/verify-artifacts.ts +365 -0
- package/src/write-artifacts.test.ts +374 -0
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/native/README.md +0 -24
- 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
|
-
|
|
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) => ({
|
package/src/consumer-index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
...(
|
|
332
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 =
|
|
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 =
|
|
509
|
-
|
|
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:
|
|
501
|
+
reason: matches.length > 0 ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
|
|
514
502
|
})
|
|
515
503
|
continue
|
|
516
504
|
}
|
|
517
|
-
|
|
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
|
+
}
|
package/src/fs-atomic.ts
ADDED
|
@@ -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
|
+
}
|