@adea-ai/plugins 4.0.1 → 4.0.7
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 +0 -5
- package/dist/catalog-store.js +1 -25
- package/dist/catalog-store.js.map +1 -1
- 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 +6 -28
- package/dist/consumer-index.js +7 -8
- 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 +3 -2
- package/dist/icon-mirror.js +51 -63
- 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 -315
- package/dist/index.js +12 -1905
- 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 +6 -3
- package/dist/publication.js +25 -12
- 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 +2 -3
- package/dist/site-icons.js +4 -3
- 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 +2 -4
- 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.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +1 -28
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +27 -21
- package/src/consumer-index.ts +12 -35
- 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 +57 -64
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +52 -48
- package/src/index.ts +50 -2583
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -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 +32 -13
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +0 -1
- package/src/site-icons.ts +4 -4
- package/src/snapshots.ts +287 -0
- package/src/source-digest.test.ts +3 -7
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +0 -1
- 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 +0 -1
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- 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,7 +172,7 @@ 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
|
})
|
|
@@ -196,7 +196,10 @@ describe('product placement', () => {
|
|
|
196
196
|
resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
|
|
197
197
|
}
|
|
198
198
|
const vendorNow = { ...candidate, availableReleases: [oldWrapper, current] }
|
|
199
|
-
const vendorIndex = buildConsumerIndex({
|
|
199
|
+
const vendorIndex = buildConsumerIndex({
|
|
200
|
+
catalog: catalog([wrapper, vendorNow]),
|
|
201
|
+
productPreference: preference,
|
|
202
|
+
})
|
|
200
203
|
expect(vendorIndex.products.slack!.pluginId).toBe(candidate.pluginId)
|
|
201
204
|
expect(vendorIndex.products.slack!.release.releaseId).toBe(current.releaseId)
|
|
202
205
|
|
|
@@ -206,7 +209,10 @@ describe('product placement', () => {
|
|
|
206
209
|
}
|
|
207
210
|
const oldVendor = { ...current, releaseId: 'release:old-vendor' }
|
|
208
211
|
const wrapperNow = { ...candidate, availableReleases: [oldVendor, currentWrapper] }
|
|
209
|
-
const wrapperIndex = buildConsumerIndex({
|
|
212
|
+
const wrapperIndex = buildConsumerIndex({
|
|
213
|
+
catalog: catalog([wrapper, wrapperNow]),
|
|
214
|
+
productPreference: preference,
|
|
215
|
+
})
|
|
210
216
|
expect(wrapperIndex.products.slack!.pluginId).toBe(wrapper.pluginId)
|
|
211
217
|
})
|
|
212
218
|
|
|
@@ -216,7 +222,7 @@ describe('product placement', () => {
|
|
|
216
222
|
plugin({ sourceId: 'openai-official', name: 'github', categories: ['developer-tools'] }),
|
|
217
223
|
plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
|
|
218
224
|
]),
|
|
219
|
-
preference,
|
|
225
|
+
productPreference: preference,
|
|
220
226
|
})
|
|
221
227
|
expect(index.products.github!.pluginId).toBe('plugin:claude-official:github')
|
|
222
228
|
})
|
|
@@ -227,7 +233,7 @@ describe('product placement', () => {
|
|
|
227
233
|
plugin({ sourceId: 'claude-official', name: 'github', categories: ['developer-tools'] }),
|
|
228
234
|
plugin({ sourceId: 'cursor-official', name: 'github', categories: ['developer-tools'] }),
|
|
229
235
|
]),
|
|
230
|
-
|
|
236
|
+
productPreference: {
|
|
231
237
|
...preference,
|
|
232
238
|
canonicalOverrides: { github: 'plugin:cursor-official:github' },
|
|
233
239
|
},
|
|
@@ -246,7 +252,7 @@ describe('product placement', () => {
|
|
|
246
252
|
displayName: 'Figma',
|
|
247
253
|
}),
|
|
248
254
|
]),
|
|
249
|
-
preference,
|
|
255
|
+
productPreference: preference,
|
|
250
256
|
})
|
|
251
257
|
expect(index.products.figma!.displayName).toBe('Figma')
|
|
252
258
|
expect(index.products.figma!.pluginId).toBe('plugin:claude-official:figma')
|
|
@@ -257,7 +263,7 @@ describe('product placement', () => {
|
|
|
257
263
|
catalog: catalog([
|
|
258
264
|
plugin({ sourceId: 'unlisted-source', name: 'tool', categories: ['developer-tools'] }),
|
|
259
265
|
]),
|
|
260
|
-
preference,
|
|
266
|
+
productPreference: preference,
|
|
261
267
|
})
|
|
262
268
|
expect(index.products.tool!.pluginId).toBe('plugin:unlisted-source:tool')
|
|
263
269
|
})
|
|
@@ -273,7 +279,7 @@ describe('category shelves', () => {
|
|
|
273
279
|
plugin({ sourceId: 'claude-official', name, categories: ['productivity'] })
|
|
274
280
|
)
|
|
275
281
|
),
|
|
276
|
-
preference,
|
|
282
|
+
productPreference: preference,
|
|
277
283
|
}
|
|
278
284
|
expect(buildConsumerIndex({ ...input, topCount: 0 }).categories[0]!.topProductKeys).toEqual([])
|
|
279
285
|
for (const topCount of [
|
|
@@ -300,7 +306,7 @@ describe('category shelves', () => {
|
|
|
300
306
|
),
|
|
301
307
|
leading: { productivity: ['zeta', 'gamma'] },
|
|
302
308
|
topCount: 4,
|
|
303
|
-
preference,
|
|
309
|
+
productPreference: preference,
|
|
304
310
|
})
|
|
305
311
|
const category = index.categories[0]!
|
|
306
312
|
expect(category.productKeys).toEqual([
|
|
@@ -328,7 +334,7 @@ describe('category shelves', () => {
|
|
|
328
334
|
}),
|
|
329
335
|
]),
|
|
330
336
|
leading: { 'education-research': ['teaching'] },
|
|
331
|
-
preference,
|
|
337
|
+
productPreference: preference,
|
|
332
338
|
})
|
|
333
339
|
expect(
|
|
334
340
|
index.categories.find((item) => item.category === 'developer-tools')!.topProductKeys
|
|
@@ -354,7 +360,7 @@ describe('category shelves', () => {
|
|
|
354
360
|
security: ['nightvision', 'no-such-plugin'],
|
|
355
361
|
'unknown-category': ['anything'],
|
|
356
362
|
},
|
|
357
|
-
preference,
|
|
363
|
+
productPreference: preference,
|
|
358
364
|
})
|
|
359
365
|
expect(index.diagnostics).toEqual([
|
|
360
366
|
{ category: 'developer-tools', name: 'getsentry', reason: 'PRODUCT_NOT_FOUND' },
|
|
@@ -393,7 +399,7 @@ describe('category shelves', () => {
|
|
|
393
399
|
const index = buildConsumerIndex({
|
|
394
400
|
catalog: catalog(entries),
|
|
395
401
|
leading: { tools: ['shared', 'renamed', 'present'] },
|
|
396
|
-
preference,
|
|
402
|
+
productPreference: preference,
|
|
397
403
|
})
|
|
398
404
|
expect(index.diagnostics).toEqual([
|
|
399
405
|
{ category: 'tools', name: 'renamed', reason: 'PRODUCT_NOT_IN_CATEGORY' },
|
|
@@ -425,7 +431,7 @@ describe('category shelves', () => {
|
|
|
425
431
|
const index = buildConsumerIndex({
|
|
426
432
|
catalog: catalog(entries),
|
|
427
433
|
leading: { tools: ['named'] },
|
|
428
|
-
preference,
|
|
434
|
+
productPreference: preference,
|
|
429
435
|
})
|
|
430
436
|
expect(index.diagnostics).toEqual([])
|
|
431
437
|
expect(index.categories.find((item) => item.category === 'tools')!.topProductKeys[0]).toBe(
|
|
@@ -441,12 +447,12 @@ describe('category shelves', () => {
|
|
|
441
447
|
const first = buildConsumerIndex({
|
|
442
448
|
catalog: catalog(forward),
|
|
443
449
|
leading: { productivity: ['gamma'] },
|
|
444
|
-
preference,
|
|
450
|
+
productPreference: preference,
|
|
445
451
|
})
|
|
446
452
|
const second = buildConsumerIndex({
|
|
447
453
|
catalog: catalog(forward.toReversed()),
|
|
448
454
|
leading: { productivity: ['gamma'] },
|
|
449
|
-
preference,
|
|
455
|
+
productPreference: preference,
|
|
450
456
|
})
|
|
451
457
|
expect(JSON.stringify(first)).toBe(JSON.stringify(second))
|
|
452
458
|
})
|
|
@@ -527,7 +533,7 @@ describe('consumer index verification', () => {
|
|
|
527
533
|
const index = buildConsumerIndex({
|
|
528
534
|
catalog: base,
|
|
529
535
|
leading: { productivity: ['b'] },
|
|
530
|
-
preference,
|
|
536
|
+
productPreference: preference,
|
|
531
537
|
})
|
|
532
538
|
const result = verifyConsumerIndex(JSON.stringify(index), base)
|
|
533
539
|
expect(result.hasConsumerIndex).toBe(true)
|
|
@@ -554,7 +560,7 @@ describe('consumer index verification', () => {
|
|
|
554
560
|
|
|
555
561
|
test('rejects a category list that names a plugin outside the catalog', () => {
|
|
556
562
|
const base = catalog(plugins)
|
|
557
|
-
const index = buildConsumerIndex({ catalog: base, preference })
|
|
563
|
+
const index = buildConsumerIndex({ catalog: base, productPreference: preference })
|
|
558
564
|
const tampered = {
|
|
559
565
|
...index,
|
|
560
566
|
categories: index.categories.map((category) => ({
|
|
@@ -575,7 +581,7 @@ describe('consumer index verification', () => {
|
|
|
575
581
|
categories: ['productivity', 'communication'],
|
|
576
582
|
}),
|
|
577
583
|
])
|
|
578
|
-
const index = buildConsumerIndex({ catalog: base, preference })
|
|
584
|
+
const index = buildConsumerIndex({ catalog: base, productPreference: preference })
|
|
579
585
|
const tampered = {
|
|
580
586
|
...index,
|
|
581
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
|
|
@@ -319,7 +297,9 @@ function nestedString(value: unknown, key: string): string | undefined {
|
|
|
319
297
|
|
|
320
298
|
function describeRelease(plugin: Plugin): ConsumerRelease {
|
|
321
299
|
const release = currentRelease(plugin)!
|
|
322
|
-
const packages =
|
|
300
|
+
const packages = release.releaseMetadata[PACKAGE_METADATA_KEY]
|
|
301
|
+
const packageStatus = nestedString(packages, 'status')
|
|
302
|
+
const packageDigest = nestedString(packages, 'packageDigest')
|
|
323
303
|
return {
|
|
324
304
|
releaseId: release.releaseId,
|
|
325
305
|
canonicalContentDigest: release.canonicalContentDigest,
|
|
@@ -332,12 +312,8 @@ function describeRelease(plugin: Plugin): ConsumerRelease {
|
|
|
332
312
|
requiredCredentials: [...release.requiredCredentials],
|
|
333
313
|
sourceRevision: release.resolvedCommitSha,
|
|
334
314
|
...(release.upstreamVersion ? { upstreamVersion: release.upstreamVersion } : {}),
|
|
335
|
-
...(
|
|
336
|
-
|
|
337
|
-
: {}),
|
|
338
|
-
...(nestedString(packages, 'packageDigest')
|
|
339
|
-
? { packageDigest: nestedString(packages, 'packageDigest')! }
|
|
340
|
-
: {}),
|
|
315
|
+
...(packageStatus ? { packageStatus } : {}),
|
|
316
|
+
...(packageDigest ? { packageDigest } : {}),
|
|
341
317
|
}
|
|
342
318
|
}
|
|
343
319
|
|
|
@@ -407,16 +383,17 @@ function compactCompatibility(plugin: Plugin): Record<string, string> {
|
|
|
407
383
|
* stable identifiers.
|
|
408
384
|
*/
|
|
409
385
|
export function buildConsumerIndex(input: {
|
|
410
|
-
|
|
386
|
+
/** A catalog without its ID yields records with no published asset URLs. */
|
|
387
|
+
readonly catalog: Pick<Catalog, 'plugins' | 'sources'> & { readonly catalogId?: string }
|
|
411
388
|
readonly leading?: Readonly<Record<string, readonly string[]>>
|
|
412
389
|
readonly topCount?: number
|
|
413
|
-
readonly
|
|
390
|
+
readonly productPreference?: ProductPreference
|
|
414
391
|
/** Where the catalog will be published, so records can carry absolute URLs. */
|
|
415
392
|
readonly publicationRepositoryUrl?: string
|
|
416
393
|
/** Committed asset base a browser can fetch directly; preferred when set. */
|
|
417
394
|
readonly publicationAssetsBaseUrl?: string
|
|
418
395
|
}): ConsumerIndex {
|
|
419
|
-
const preference = input.
|
|
396
|
+
const preference = input.productPreference ?? {
|
|
420
397
|
schemaVersion: 1 as const,
|
|
421
398
|
sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
|
|
422
399
|
}
|
|
@@ -432,7 +409,7 @@ export function buildConsumerIndex(input: {
|
|
|
432
409
|
if (list) list.push(plugin)
|
|
433
410
|
else variantsByProduct.set(plugin.productGroupingKey, [plugin])
|
|
434
411
|
}
|
|
435
|
-
const catalogId =
|
|
412
|
+
const catalogId = input.catalog.catalogId ?? ''
|
|
436
413
|
const products: Record<string, ConsumerProduct> = {}
|
|
437
414
|
for (const [productKey, variants] of [...variantsByProduct].toSorted((left, right) =>
|
|
438
415
|
left[0].localeCompare(right[0])
|
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
|
+
}
|
package/src/harness.test.ts
CHANGED
|
@@ -129,34 +129,6 @@ describe('Control Plane installation plan v2', () => {
|
|
|
129
129
|
assert.equal(plan.compatibility, 'partial')
|
|
130
130
|
assert.ok(plan.disabled.some((item) => item.component === 'hooks'))
|
|
131
131
|
})
|
|
132
|
-
it('preserves instance data across releases and harnesses but isolates other instances', () => {
|
|
133
|
-
const first = createInstallationPlan(input())
|
|
134
|
-
const updatedPkg = packageFixture({ 'CHANGELOG.md': 'A new release.' })
|
|
135
|
-
const updated = createInstallationPlan({
|
|
136
|
-
...input(updatedPkg),
|
|
137
|
-
releaseId: `release:${'c'.repeat(64)}`,
|
|
138
|
-
profile: profile({ harness: 'another-harness' }),
|
|
139
|
-
})
|
|
140
|
-
assert.equal(first.dataKey, updated.dataKey)
|
|
141
|
-
assert.notEqual(first.packageKey, updated.packageKey)
|
|
142
|
-
assert.notEqual(
|
|
143
|
-
first.dataKey,
|
|
144
|
-
createInstallationPlan({ ...input(), instanceId: 'workspace-b/user-a' }).dataKey
|
|
145
|
-
)
|
|
146
|
-
assert.notEqual(
|
|
147
|
-
first.dataKey,
|
|
148
|
-
createInstallationPlan({ ...input(), pluginId: 'plugin:other:issues' }).dataKey
|
|
149
|
-
)
|
|
150
|
-
})
|
|
151
|
-
it('does not reuse a package cache across source commits with potentially different file modes', () => {
|
|
152
|
-
const original = input()
|
|
153
|
-
const other = { ...original, source: { ...original.source, commitSha: 'c'.repeat(40) } }
|
|
154
|
-
assert.notEqual(
|
|
155
|
-
createInstallationPlan(original).packageKey,
|
|
156
|
-
createInstallationPlan(other).packageKey
|
|
157
|
-
)
|
|
158
|
-
assert.equal(createInstallationPlan(original).dataKey, createInstallationPlan(other).dataKey)
|
|
159
|
-
})
|
|
160
132
|
it('rejects mismatched source provenance and unversioned capability claims', () => {
|
|
161
133
|
const args = input()
|
|
162
134
|
assert.throws(() =>
|
|
@@ -442,7 +414,7 @@ const MATRIX: {
|
|
|
442
414
|
disabled: ['mcp:api'],
|
|
443
415
|
},
|
|
444
416
|
{
|
|
445
|
-
name: '
|
|
417
|
+
name: 'a package missing one native transport is adopted partially on opt-in',
|
|
446
418
|
pkg: 'full',
|
|
447
419
|
profile: 'nativeWithoutHttp',
|
|
448
420
|
allowPartial: true,
|
|
@@ -483,7 +455,7 @@ const MATRIX: {
|
|
|
483
455
|
disabled: ['hooks'],
|
|
484
456
|
},
|
|
485
457
|
{
|
|
486
|
-
name: '
|
|
458
|
+
name: 'a package with a non-portable extra is adopted partially on opt-in',
|
|
487
459
|
pkg: 'withHooks',
|
|
488
460
|
profile: 'native',
|
|
489
461
|
allowPartial: true,
|
|
@@ -560,10 +532,11 @@ describe('upgrade identity isolation', () => {
|
|
|
560
532
|
)
|
|
561
533
|
|
|
562
534
|
// An upgraded package (new bytes) is a different PACKAGE, under the same
|
|
563
|
-
// instance data: the data is preserved across the upgrade.
|
|
535
|
+
// instance data: the data is preserved across the upgrade and the harness.
|
|
564
536
|
const upgraded = createInstallationPlan({
|
|
565
537
|
...input(packageFixture({ 'CHANGELOG.md': 'A new release.' })),
|
|
566
538
|
releaseId: `release:${'c'.repeat(64)}`,
|
|
539
|
+
profile: profile({ harness: 'another-harness' }),
|
|
567
540
|
})
|
|
568
541
|
assert.notEqual(first.packageKey, upgraded.packageKey)
|
|
569
542
|
assert.equal(first.dataKey, upgraded.dataKey)
|
|
@@ -575,7 +548,6 @@ describe('upgrade identity isolation', () => {
|
|
|
575
548
|
const rereleased = createInstallationPlan({ ...base, releaseId: `release:${'d'.repeat(64)}` })
|
|
576
549
|
assert.equal(first.packageKey, rereleased.packageKey)
|
|
577
550
|
assert.equal(first.dataKey, rereleased.dataKey)
|
|
578
|
-
assert.notEqual(first.releaseId, rereleased.releaseId)
|
|
579
551
|
|
|
580
552
|
// A different source commit is a different package identity under the same
|
|
581
553
|
// instance data.
|
package/src/harness.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
type InstallationPlan,
|
|
10
10
|
} from './agent-plugins-schema.js'
|
|
11
11
|
import type { Catalog } from './schema.js'
|
|
12
|
-
import {
|
|
12
|
+
import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
|
|
13
|
+
import { verifyPortableCatalog } from './portable-verify.js'
|
|
13
14
|
import { verifyAgentPackage, validateServer } from './agent-plugins-catalog.js'
|
|
14
15
|
|
|
15
16
|
export interface InstallationInput {
|
package/src/icon-mirror.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
import type { Catalog } from './schema.js'
|
|
4
|
-
import { byteDigest
|
|
4
|
+
import { byteDigest } from './digest.js'
|
|
5
5
|
import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
|
|
6
6
|
import type { ConsumerIndex } from './consumer-index.js'
|
|
7
|
-
import { writeFileAtomically } from './
|
|
7
|
+
import { writeFileAtomically } from './fs-atomic.js'
|
|
8
|
+
import { publishedAssetOf, type PublishedAsset } from './integrity-manifest.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Where an icon's bytes come from, so a mirror needs no tree walk and no
|
|
@@ -36,7 +37,7 @@ export type IconSource =
|
|
|
36
37
|
readonly homepage: string
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
interface IconMirrorResult {
|
|
40
41
|
/** Icons whose verified bytes are present in the assets directory. */
|
|
41
42
|
readonly staged: readonly PublishedAsset[]
|
|
42
43
|
/** Icons that could not be staged, with the reason, never silently dropped. */
|
|
@@ -116,78 +117,54 @@ export async function stageIconAssets(input: {
|
|
|
116
117
|
/** Reads the icon bytes for a source; live fetches raw content, offline reads fixtures. */
|
|
117
118
|
readonly readBytes: (source: IconSource) => Promise<Uint8Array>
|
|
118
119
|
}): Promise<IconMirrorResult> {
|
|
119
|
-
const staged
|
|
120
|
+
const staged = new Map<string, PublishedAsset>()
|
|
120
121
|
const skipped = new Map<string, string>()
|
|
121
|
-
const complete = new Set<string>()
|
|
122
122
|
const skip = (asset: string, reason: string) => {
|
|
123
123
|
if (!skipped.has(asset)) skipped.set(asset, reason)
|
|
124
124
|
}
|
|
125
125
|
await fs.mkdir(input.assetsDirectory, { recursive: true })
|
|
126
126
|
for (const source of input.sources) {
|
|
127
|
-
if (
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
if (existing && byteDigest(existing) === source.digest) {
|
|
131
|
-
staged.push(publishedAssetOf(source))
|
|
132
|
-
complete.add(source.asset)
|
|
133
|
-
skipped.delete(source.asset)
|
|
134
|
-
continue
|
|
135
|
-
}
|
|
136
|
-
if (existing) {
|
|
127
|
+
if (staged.has(source.asset)) continue
|
|
128
|
+
const existing = await stagedState(input.assetsDirectory, source.asset, source.digest)
|
|
129
|
+
if (existing === 'conflict') {
|
|
137
130
|
skip(source.asset, 'ASSET_CONFLICT')
|
|
138
131
|
continue
|
|
139
132
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
133
|
+
if (existing === 'absent') {
|
|
134
|
+
let bytes: Uint8Array
|
|
135
|
+
try {
|
|
136
|
+
bytes = await input.readBytes(source)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
skip(
|
|
139
|
+
source.asset,
|
|
140
|
+
`FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`
|
|
141
|
+
)
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
const inspected = inspectIconBytes(bytes)
|
|
145
|
+
if (!('contentType' in inspected)) {
|
|
146
|
+
skip(source.asset, inspected.rejected)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
if (inspected.contentType !== source.contentType) {
|
|
150
|
+
skip(source.asset, 'CONTENT_TYPE_CHANGED')
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
|
|
154
|
+
skip(source.asset, 'DIGEST_MISMATCH')
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
await writeFileAtomically(join(input.assetsDirectory, source.asset), bytes)
|
|
159
158
|
}
|
|
160
|
-
|
|
161
|
-
await writeFileAtomically(destination, bytes)
|
|
162
|
-
staged.push(publishedAssetOf(source))
|
|
163
|
-
complete.add(source.asset)
|
|
159
|
+
staged.set(source.asset, publishedAssetOf(source))
|
|
164
160
|
skipped.delete(source.asset)
|
|
165
161
|
}
|
|
166
|
-
// Distinct assets: two products can share one mark, and the catalog declares
|
|
167
|
-
// each asset once.
|
|
168
162
|
return {
|
|
169
|
-
staged:
|
|
163
|
+
staged: [...staged.values()],
|
|
170
164
|
skipped: [...skipped].map(([asset, reason]) => ({ asset, reason })),
|
|
171
165
|
}
|
|
172
166
|
}
|
|
173
167
|
|
|
174
|
-
function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
|
|
175
|
-
const unique = new Map<string, PublishedAsset>()
|
|
176
|
-
for (const asset of assets) unique.set(asset.name, asset)
|
|
177
|
-
return [...unique.values()]
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function publishedAssetOf(source: IconSource): PublishedAsset {
|
|
181
|
-
return {
|
|
182
|
-
name: source.asset,
|
|
183
|
-
digest: source.digest,
|
|
184
|
-
bytes: source.bytes,
|
|
185
|
-
contentType: source.contentType,
|
|
186
|
-
kind: source.kind,
|
|
187
|
-
origin: source.kind === 'content' ? source.sourcePath : source.url,
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
168
|
/**
|
|
192
169
|
* Writes one verified icon into the assets directory.
|
|
193
170
|
*
|
|
@@ -201,9 +178,25 @@ export async function stageIconBytes(
|
|
|
201
178
|
asset: string,
|
|
202
179
|
bytes: Uint8Array
|
|
203
180
|
): Promise<void> {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
await fs.mkdir(
|
|
208
|
-
await writeFileAtomically(
|
|
181
|
+
// These bytes are the ones the digest was computed from, so they replace a
|
|
182
|
+
// differing file rather than fail the build over it.
|
|
183
|
+
if ((await stagedState(assetsDirectory, asset, byteDigest(bytes))) === 'present') return
|
|
184
|
+
await fs.mkdir(assetsDirectory, { recursive: true })
|
|
185
|
+
await writeFileAtomically(join(assetsDirectory, asset), bytes)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether a content-addressed asset is already staged with the expected bytes. */
|
|
189
|
+
async function stagedState(
|
|
190
|
+
directory: string,
|
|
191
|
+
asset: string,
|
|
192
|
+
digest: string
|
|
193
|
+
): Promise<'absent' | 'present' | 'conflict'> {
|
|
194
|
+
let existing: Uint8Array
|
|
195
|
+
try {
|
|
196
|
+
existing = await fs.readFile(join(directory, asset))
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'absent'
|
|
199
|
+
throw error
|
|
200
|
+
}
|
|
201
|
+
return byteDigest(existing) === digest ? 'present' : 'conflict'
|
|
209
202
|
}
|
package/src/icons.test.ts
CHANGED
|
@@ -138,10 +138,6 @@ describe('icon asset names', () => {
|
|
|
138
138
|
expect(iconAssetName(`sha256:${'b'.repeat(64)}`, 'image/svg+xml')).toBe(
|
|
139
139
|
`icon-${'b'.repeat(32)}.svg`
|
|
140
140
|
)
|
|
141
|
-
// Identical bytes always resolve to the same asset, so caches stay warm.
|
|
142
|
-
expect(iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')).toBe(
|
|
143
|
-
iconAssetName(`sha256:${'c'.repeat(64)}`, 'image/webp')
|
|
144
|
-
)
|
|
145
141
|
})
|
|
146
142
|
|
|
147
143
|
test('refuses a content type it cannot name', () => {
|
package/src/icons.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { digestHex } from './digest.js'
|
|
1
2
|
/**
|
|
2
3
|
* Compile-time icon resolution.
|
|
3
4
|
*
|
|
@@ -209,14 +210,13 @@ const EXTENSION_BY_CONTENT_TYPE: Readonly<Record<IconContentType, string>> = {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/**
|
|
212
|
-
* Content-addressed asset name.
|
|
213
|
-
* digest
|
|
213
|
+
* Content-addressed asset name. Published names are flat (no `/`), so the
|
|
214
|
+
* digest and extension form one name that is safe as a file and a URL segment.
|
|
214
215
|
*/
|
|
215
216
|
export function iconAssetName(digest: string, contentType: IconContentType): string {
|
|
216
217
|
const extension = EXTENSION_BY_CONTENT_TYPE[contentType]
|
|
217
218
|
if (!extension) throw new Error(`ICON_CONTENT_TYPE_UNSUPPORTED: ${contentType}`)
|
|
218
|
-
|
|
219
|
-
return `icon-${hex.slice(0, 32)}.${extension}`
|
|
219
|
+
return `icon-${digestHex(digest).slice(0, 32)}.${extension}`
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|