@adea-ai/plugins 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,545 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import { mkdtemp } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { describe, expect, test } from 'bun:test'
6
+ import type { Catalog, Plugin } from './schema.js'
7
+ import {
8
+ byteDigest,
9
+ createArtifacts,
10
+ digest,
11
+ verifyArtifacts,
12
+ verifyConsumerIndex,
13
+ } from './index.js'
14
+ import { buildConsumerIndex, type ConsumerIndex } from './consumer-index.js'
15
+ import { resolveIconSources, stageIconAssets, type IconSource } from './icon-mirror.js'
16
+ import { iconAssetName } from './icons.js'
17
+
18
+ const CLEAN_SVG = new TextEncoder().encode(
19
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>'
20
+ )
21
+ const OTHER_SVG = new TextEncoder().encode(
22
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4"><circle r="2"/></svg>'
23
+ )
24
+ const svgDigest = byteDigest(CLEAN_SVG)
25
+ const svgAsset = iconAssetName(svgDigest, 'image/svg+xml')
26
+
27
+ const SHA = 'a'.repeat(40)
28
+ const DIGEST = `sha256:${'d'.repeat(64)}`
29
+
30
+ /** A schema-valid plugin, so the artifact verifier is exercised for real. */
31
+ function plugin(input: {
32
+ sourceId: string
33
+ name: string
34
+ categories: string[]
35
+ icon?: { path: string; bytes: Uint8Array }
36
+ repositoryUrl?: string
37
+ commitSha?: string
38
+ subdirectory?: string
39
+ }): Plugin {
40
+ const repositoryUrl = input.repositoryUrl ?? `https://github.com/${input.sourceId}/marketplace`
41
+ const icon = input.icon
42
+ const releaseId = `release:${'e'.repeat(64)}`
43
+ return {
44
+ pluginId: `plugin:${input.sourceId}:${input.name}`,
45
+ displayName: input.name,
46
+ description: `${input.name} description`,
47
+ productGroupingKey: input.name,
48
+ categories: input.categories,
49
+ keywords: [input.name],
50
+ authors: ['Test'],
51
+ icons: [],
52
+ sourceId: input.sourceId,
53
+ upstreamPluginName: input.name,
54
+ currentReleaseId: releaseId,
55
+ availableReleases: [
56
+ {
57
+ releaseId,
58
+ resolvedRepositoryUrl: repositoryUrl,
59
+ resolvedCommitSha: input.commitSha ?? SHA,
60
+ pluginSubdirectory: input.subdirectory ?? '.',
61
+ canonicalContentDigest: DIGEST,
62
+ manifestDigest: DIGEST,
63
+ contentResolution: 'complete',
64
+ releaseMetadata: {},
65
+ capabilities: [],
66
+ requiredConnectors: [],
67
+ requiredCredentials: [],
68
+ permissionSensitiveChanges: [],
69
+ fileIndex: icon ? [icon.path] : [],
70
+ publicationTimestamp: '2026-01-01T00:00:00.000Z',
71
+ ...(icon
72
+ ? {
73
+ icon: {
74
+ kind: 'content',
75
+ path: icon.path,
76
+ contentType: 'image/svg+xml',
77
+ digest: byteDigest(icon.bytes),
78
+ bytes: icon.bytes.byteLength,
79
+ },
80
+ }
81
+ : {}),
82
+ },
83
+ ],
84
+ capabilitySummary: {},
85
+ harnessCompatibility: Object.fromEntries(
86
+ ['codex', 'claude-code', 'cursor', 'pi', 'hermes', 'opencode', 'generic-skill-mcp'].map(
87
+ (harness) => [harness, { status: 'unknown', reasons: [], responsibleCapabilities: [] }]
88
+ )
89
+ ),
90
+ license: { name: 'MIT', source: 'plugin-manifest' },
91
+ provenance: {
92
+ sourceId: input.sourceId,
93
+ repositoryUrl,
94
+ manifestPath: 'marketplace.json',
95
+ pluginSubdirectory: input.subdirectory ?? '.',
96
+ resolvedCommitSha: input.commitSha ?? SHA,
97
+ sourceManifestDigest: DIGEST,
98
+ upstreamEntryDigest: DIGEST,
99
+ },
100
+ securityClassification: {
101
+ level: 'low',
102
+ reasons: [],
103
+ permissionSensitiveChanges: [],
104
+ contentResolution: 'complete',
105
+ },
106
+ } as unknown as Plugin
107
+ }
108
+
109
+ function catalog(plugins: Plugin[]): Catalog {
110
+ return {
111
+ schemaVersion: 1,
112
+ catalogId: `catalog:${'a'.repeat(64)}`,
113
+ generatedAt: '2026-01-01T00:00:00.000Z',
114
+ sources: [...new Set(plugins.map((item) => item.sourceId))].toSorted().map((sourceId) => ({
115
+ sourceId,
116
+ displayName: `${sourceId} source`,
117
+ repositoryUrl: `https://github.com/${sourceId}/marketplace`,
118
+ marketplaceDialect: 'claude' as const,
119
+ manifestPath: 'marketplace.json',
120
+ defaultBranch: 'main',
121
+ resolvedCommitSha: SHA,
122
+ retrievalTimestamp: '2026-01-01T00:00:00.000Z',
123
+ trustClassification: 'official' as const,
124
+ sourceManifestDigest: DIGEST,
125
+ synchronizationStatus: 'synchronized' as const,
126
+ })),
127
+ plugins,
128
+ } as unknown as Catalog
129
+ }
130
+
131
+ const SHA_FOR_LOCK = 'a'.repeat(40)
132
+
133
+ /** A lock covering the catalog's sources, as `synchronize` would emit. */
134
+ function lockFor(plugins: Plugin[]) {
135
+ return {
136
+ schemaVersion: 1 as const,
137
+ lockId: `lock:${'b'.repeat(64)}`,
138
+ sources: [...new Set(plugins.map((item) => item.sourceId))].toSorted().map((sourceId) => ({
139
+ sourceId,
140
+ repositoryUrl: `https://github.com/${sourceId}/marketplace`,
141
+ defaultBranch: 'main',
142
+ resolvedCommitSha: SHA_FOR_LOCK,
143
+ manifestPath: 'marketplace.json',
144
+ marketplaceDialect: 'claude' as const,
145
+ sourceManifestDigest: `sha256:${'d'.repeat(64)}`,
146
+ retrievedAt: '2026-01-01T00:00:00.000Z',
147
+ synchronizationStatus: 'synchronized' as const,
148
+ pluginPins: [],
149
+ })),
150
+ }
151
+ }
152
+
153
+ function artifactsOf(plugins: Plugin[]) {
154
+ const base = catalog(plugins)
155
+ const artifacts = createArtifacts(base, lockFor(plugins) as never, {
156
+ curationDiagnostics: false,
157
+ })
158
+ const index = JSON.parse(artifacts['catalog-index.v1.json']!) as ConsumerIndex
159
+ return { base, artifacts, index }
160
+ }
161
+
162
+ /** A canned reader so mirror tests never touch the network. */
163
+ function reader(bytes: Record<string, Uint8Array>) {
164
+ const calls: string[] = []
165
+ return {
166
+ calls,
167
+ read: async (source: IconSource) => {
168
+ const key = source.kind === 'content' ? source.sourcePath : source.url
169
+ calls.push(key)
170
+ const value = bytes[key]
171
+ if (!value) throw new Error(`MISSING: ${key}`)
172
+ return value
173
+ },
174
+ }
175
+ }
176
+
177
+ describe('artifact shards', () => {
178
+ const plugins = [
179
+ plugin({
180
+ sourceId: 'cursor-official',
181
+ name: 'calendar',
182
+ categories: ['productivity'],
183
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
184
+ }),
185
+ plugin({ sourceId: 'claude-official', name: 'sales', categories: ['business-operations'] }),
186
+ ]
187
+
188
+ test('publishes navigation, one index, and a shelf and list per category', () => {
189
+ const { artifacts } = artifactsOf(plugins)
190
+ const names = Object.keys(artifacts).toSorted()
191
+ expect(names).toEqual([
192
+ 'catalog-index.v1.json',
193
+ 'catalog-summary.v1.json',
194
+ 'catalog.v1.json',
195
+ 'categories.v1.json',
196
+ 'category-business-operations.v1.json',
197
+ 'category-productivity.v1.json',
198
+ 'compatibility.v1.json',
199
+ 'integrity.json',
200
+ 'shelf-business-operations.v1.json',
201
+ 'shelf-productivity.v1.json',
202
+ 'sources.lock.json',
203
+ ])
204
+ const navigation = JSON.parse(artifacts['categories.v1.json']!)
205
+ expect(navigation.catalogIndexArtifact).toBe('catalog-index.v1.json')
206
+ expect(navigation.categories[0].shelfArtifact).toBe('shelf-business-operations.v1.json')
207
+ expect(navigation.categories[0].listArtifact).toBe('category-business-operations.v1.json')
208
+ // Navigation stays a router: full records live in the shards.
209
+ expect(navigation.products).toBeUndefined()
210
+ expect(navigation.iconCoverage).toMatchObject({ total: 2, content: 1, monogramOnly: 1 })
211
+ expect(navigation.iconCoverage.monogramProductKeys).toEqual(['sales'])
212
+ const shelf = JSON.parse(artifacts['shelf-productivity.v1.json']!)
213
+ expect(shelf.products.map((product: { productKey: string }) => product.productKey)).toEqual([
214
+ 'calendar',
215
+ ])
216
+ expect(shelf.products[0].icon).toEqual({
217
+ kind: 'content',
218
+ asset: svgAsset,
219
+ digest: svgDigest,
220
+ contentType: 'image/svg+xml',
221
+ bytes: CLEAN_SVG.byteLength,
222
+ path: 'assets/logo.svg',
223
+ url: null,
224
+ homepage: null,
225
+ })
226
+ // A product without a mark always carries a renderable monogram instead.
227
+ expect(shelf.products[0].monogram.text).toBe('CA')
228
+ const other = JSON.parse(artifacts['catalog-index.v1.json']!).products.sales
229
+ expect(other.icon).toBeNull()
230
+ expect(other.monogram.text).toBe('SA')
231
+ })
232
+
233
+ test('advertises an absolute immutable URL when the publication is known', () => {
234
+ const base = catalog([
235
+ plugin({
236
+ sourceId: 'cursor-official',
237
+ name: 'calendar',
238
+ categories: ['productivity'],
239
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
240
+ }),
241
+ ])
242
+ const index = buildConsumerIndex({
243
+ catalog: base,
244
+ publicationRepositoryUrl: 'https://github.com/adea-ai/plugins',
245
+ })
246
+ expect(index.products.calendar!.icon!.assetUrl).toBe(
247
+ `https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets/catalogs/${base.catalogId.slice('catalog:'.length)}/${svgAsset}`
248
+ )
249
+ // An unpublished build advertises no URL rather than a wrong one.
250
+ const unpublished = buildConsumerIndex({ catalog: base })
251
+ expect(unpublished.products.calendar!.icon!.assetUrl).toBeUndefined()
252
+ // A URL that does not point at its own asset is rejected.
253
+ const tampered = {
254
+ ...index,
255
+ products: {
256
+ calendar: {
257
+ ...index.products.calendar!,
258
+ icon: { ...index.products.calendar!.icon!, assetUrl: 'https://evil.example/icon.png' },
259
+ },
260
+ },
261
+ }
262
+ expect(() => verifyConsumerIndex(JSON.stringify(tampered), base)).toThrow(
263
+ 'CONSUMER_INDEX_ASSET_URL_INVALID'
264
+ )
265
+ })
266
+
267
+ test('carries the install facts of the release a product pins', () => {
268
+ const base = catalog([
269
+ plugin({
270
+ sourceId: 'cursor-official',
271
+ name: 'calendar',
272
+ categories: ['productivity'],
273
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
274
+ }),
275
+ ])
276
+ const index = buildConsumerIndex({ catalog: base })
277
+ const product = index.products.calendar!
278
+ expect(product.release).toEqual({
279
+ releaseId: base.plugins[0]!.currentReleaseId,
280
+ canonicalContentDigest: DIGEST,
281
+ contentResolution: 'complete',
282
+ capabilities: [],
283
+ requiredConnectors: [],
284
+ requiredCredentials: [],
285
+ sourceRevision: SHA,
286
+ })
287
+ expect(product.authors).toEqual(['Test'])
288
+ expect(product.provenance).toEqual({
289
+ repositoryUrl: 'https://github.com/cursor-official/marketplace',
290
+ resolvedCommitSha: SHA,
291
+ manifestPath: 'marketplace.json',
292
+ pluginSubdirectory: '.',
293
+ })
294
+ // A browsing client can plan an install from the index alone.
295
+ expect(product.release.canonicalContentDigest).toBe(
296
+ base.plugins[0]!.availableReleases[0]!.canonicalContentDigest
297
+ )
298
+ // A record whose release facts disagree with the catalog is rejected.
299
+ const tampered = {
300
+ ...index,
301
+ products: {
302
+ calendar: {
303
+ ...product,
304
+ release: { ...product.release, canonicalContentDigest: `sha256:${'f'.repeat(64)}` },
305
+ },
306
+ },
307
+ }
308
+ expect(() => verifyConsumerIndex(JSON.stringify(tampered), base)).toThrow(
309
+ 'CONSUMER_INDEX_RELEASE_MISMATCH'
310
+ )
311
+ })
312
+
313
+ test('serves marks and the index from the committed asset base when configured', () => {
314
+ const base = catalog([
315
+ plugin({
316
+ sourceId: 'cursor-official',
317
+ name: 'calendar',
318
+ categories: ['productivity'],
319
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
320
+ }),
321
+ ])
322
+ const assetsBase = 'https://raw.githubusercontent.com/adea-ai/plugins/main/catalog-assets'
323
+ const artifacts = createArtifacts(base, lockFor([base.plugins[0]!]) as never, {
324
+ curationDiagnostics: false,
325
+ publicationRepositoryUrl: 'https://github.com/adea-ai/plugins',
326
+ publicationAssetsBaseUrl: assetsBase,
327
+ })
328
+ const navigation = JSON.parse(artifacts['categories.v1.json']!)
329
+ // A release asset answers a browser fetch with no CORS header and an
330
+ // uncacheable redirect; the committed path is what a browser can use.
331
+ expect(navigation.catalogIndexUrl).toBe(`${assetsBase}/catalog-index.v1.json`)
332
+ expect(navigation.brandMarks.calendar).toBe(`${assetsBase}/${svgAsset}`)
333
+ const index = JSON.parse(artifacts['catalog-index.v1.json']!)
334
+ expect(index.products.calendar.icon.assetUrl).toBe(`${assetsBase}/${svgAsset}`)
335
+ expect(index.products.calendar.icon.assetUrl).not.toContain('/releases/')
336
+ })
337
+
338
+ test('carries compiled marks in the navigation artifact', () => {
339
+ const base = catalog([
340
+ plugin({
341
+ sourceId: 'cursor-official',
342
+ name: 'calendar',
343
+ categories: ['productivity'],
344
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
345
+ }),
346
+ plugin({ sourceId: 'claude-official', name: 'sales', categories: ['business-operations'] }),
347
+ ])
348
+ const artifacts = createArtifacts(
349
+ base,
350
+ lockFor([base.plugins[0]!, base.plugins[1]!]) as never,
351
+ {
352
+ curationDiagnostics: false,
353
+ publicationRepositoryUrl: 'https://github.com/adea-ai/plugins',
354
+ }
355
+ )
356
+ const navigation = JSON.parse(artifacts['categories.v1.json']!)
357
+ expect(navigation.catalogIndexUrl).toBe(
358
+ `https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets/catalogs/${base.catalogId.slice('catalog:'.length)}/catalog-index.v1.json`
359
+ )
360
+ expect(Object.keys(navigation.brandMarks)).toEqual(['calendar'])
361
+ expect(navigation.brandMarks.calendar).toContain(
362
+ `/catalogs/${base.catalogId.slice(8)}/${svgAsset}`
363
+ )
364
+
365
+ // An unpublished build carries no marks rather than URLs that would not resolve.
366
+ const offline = JSON.parse(
367
+ createArtifacts(base, lockFor([base.plugins[0]!, base.plugins[1]!]) as never, {
368
+ curationDiagnostics: false,
369
+ })['categories.v1.json']!
370
+ )
371
+ expect(offline.brandMarks).toEqual({})
372
+ expect(offline.catalogIndexUrl).toBeUndefined()
373
+ })
374
+
375
+ test('declares exactly the mirrored assets the index references', () => {
376
+ const { artifacts, index } = artifactsOf(plugins)
377
+ const integrity = JSON.parse(artifacts['integrity.json']!)
378
+ expect(integrity.assets).toEqual([
379
+ {
380
+ name: svgAsset,
381
+ digest: svgDigest,
382
+ bytes: CLEAN_SVG.byteLength,
383
+ contentType: 'image/svg+xml',
384
+ kind: 'content',
385
+ origin: 'assets/logo.svg',
386
+ },
387
+ ])
388
+ expect(index.products.calendar!.icon!.asset).toBe(svgAsset)
389
+ expect(index.products.sales!.icon).toBeNull()
390
+ })
391
+
392
+ // The published convention is the canonical digest of the artifact text,
393
+ // which consumers reproduce after reading the artifact as a string. It is
394
+ // deliberately not a hash of the file bytes, so switching this to a byte
395
+ // digest would make every shipped consumer reject the catalog.
396
+ test('digests artifacts the way consumers verify them, not as raw file bytes', () => {
397
+ const { artifacts } = artifactsOf(plugins)
398
+ const integrity = JSON.parse(artifacts['integrity.json']!)
399
+ for (const [name, declared] of Object.entries(integrity.files as Record<string, string>)) {
400
+ expect(declared).toBe(digest(artifacts[name as keyof typeof artifacts]!))
401
+ expect(declared).not.toBe(
402
+ byteDigest(new TextEncoder().encode(artifacts[name as keyof typeof artifacts]!))
403
+ )
404
+ }
405
+ })
406
+
407
+ test('verifies a consistent release and rejects a tampered shard', () => {
408
+ const { artifacts } = artifactsOf(plugins)
409
+ expect(() => verifyArtifacts(artifacts)).not.toThrow()
410
+ const tampered = {
411
+ ...artifacts,
412
+ 'shelf-productivity.v1.json': artifacts['shelf-productivity.v1.json']!.replace(
413
+ 'calendar',
414
+ 'notion'
415
+ ),
416
+ }
417
+ expect(() => verifyArtifacts(tampered)).toThrow('INTEGRITY_DIGEST_MISMATCH')
418
+ })
419
+
420
+ test('rejects a release that drops a shard or an asset declaration', () => {
421
+ const { artifacts } = artifactsOf(plugins)
422
+ const withoutShard = { ...artifacts }
423
+ delete withoutShard['shelf-productivity.v1.json']
424
+ expect(() => verifyArtifacts(withoutShard)).toThrow('INTEGRITY_FILE_SET_MISMATCH')
425
+
426
+ const integrity = JSON.parse(artifacts['integrity.json']!)
427
+ const withoutAsset = {
428
+ ...artifacts,
429
+ 'integrity.json': JSON.stringify({ ...integrity, assets: [] }),
430
+ }
431
+ expect(() => verifyArtifacts(withoutAsset)).toThrow('INTEGRITY_ASSET_SET_MISMATCH')
432
+ })
433
+ })
434
+
435
+ describe('icon mirroring', () => {
436
+ test('resolves each asset to the commit it was published from', () => {
437
+ const plugins = [
438
+ plugin({
439
+ sourceId: 'cursor-official',
440
+ name: 'calendar',
441
+ categories: ['productivity'],
442
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
443
+ repositoryUrl: 'https://github.com/cursor/plugins',
444
+ commitSha: 'c'.repeat(40),
445
+ subdirectory: 'third_party/calendar',
446
+ }),
447
+ ]
448
+ const { base, index } = artifactsOf(plugins)
449
+ expect(resolveIconSources(base, index)).toEqual([
450
+ {
451
+ kind: 'content',
452
+ asset: svgAsset,
453
+ digest: svgDigest,
454
+ bytes: CLEAN_SVG.byteLength,
455
+ contentType: 'image/svg+xml',
456
+ repositoryUrl: 'https://github.com/cursor/plugins',
457
+ commitSha: 'c'.repeat(40),
458
+ pluginSubdirectory: 'third_party/calendar',
459
+ path: 'assets/logo.svg',
460
+ sourcePath: 'third_party/calendar/assets/logo.svg',
461
+ },
462
+ ])
463
+ })
464
+
465
+ test('stages verified bytes and is idempotent', async () => {
466
+ const { base, index } = artifactsOf([
467
+ plugin({
468
+ sourceId: 'cursor-official',
469
+ name: 'calendar',
470
+ categories: ['productivity'],
471
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
472
+ }),
473
+ ])
474
+ const sources = resolveIconSources(base, index)
475
+ const directory = await mkdtemp(join(tmpdir(), 'adea-icons-'))
476
+ const first = reader({ 'assets/logo.svg': CLEAN_SVG })
477
+ const staged = await stageIconAssets({
478
+ sources,
479
+ assetsDirectory: directory,
480
+ readBytes: first.read,
481
+ })
482
+ expect(staged.skipped).toEqual([])
483
+ expect(staged.staged.map((asset) => asset.name)).toEqual([svgAsset])
484
+ expect(await fs.readFile(join(directory, svgAsset))).toEqual(Buffer.from(CLEAN_SVG))
485
+
486
+ // A second pass reads nothing: content-addressed names are already there.
487
+ const second = reader({ 'assets/logo.svg': CLEAN_SVG })
488
+ const again = await stageIconAssets({
489
+ sources,
490
+ assetsDirectory: directory,
491
+ readBytes: second.read,
492
+ })
493
+ expect(second.calls).toEqual([])
494
+ expect(again.staged).toHaveLength(1)
495
+ })
496
+
497
+ test('refuses bytes that do not match the advertised digest', async () => {
498
+ const { base, index } = artifactsOf([
499
+ plugin({
500
+ sourceId: 'cursor-official',
501
+ name: 'calendar',
502
+ categories: ['productivity'],
503
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
504
+ }),
505
+ ])
506
+ const directory = await mkdtemp(join(tmpdir(), 'adea-icons-'))
507
+ const result = await stageIconAssets({
508
+ sources: resolveIconSources(base, index),
509
+ assetsDirectory: directory,
510
+ readBytes: async () => OTHER_SVG,
511
+ })
512
+ expect(result.staged).toEqual([])
513
+ expect(result.skipped).toEqual([{ asset: svgAsset, reason: 'DIGEST_MISMATCH' }])
514
+ expect(await fs.readdir(directory)).toEqual([])
515
+ })
516
+
517
+ test('reports a fetch failure and an existing conflicting file', async () => {
518
+ const { base, index } = artifactsOf([
519
+ plugin({
520
+ sourceId: 'cursor-official',
521
+ name: 'calendar',
522
+ categories: ['productivity'],
523
+ icon: { path: 'assets/logo.svg', bytes: CLEAN_SVG },
524
+ }),
525
+ ])
526
+ const sources = resolveIconSources(base, index)
527
+ const directory = await mkdtemp(join(tmpdir(), 'adea-icons-'))
528
+ const failed = await stageIconAssets({
529
+ sources,
530
+ assetsDirectory: directory,
531
+ readBytes: async () => {
532
+ throw new Error('network down')
533
+ },
534
+ })
535
+ expect(failed.skipped[0]!.reason).toContain('FETCH_FAILED')
536
+
537
+ await fs.writeFile(join(directory, svgAsset), OTHER_SVG)
538
+ const conflicted = await stageIconAssets({
539
+ sources,
540
+ assetsDirectory: directory,
541
+ readBytes: async () => CLEAN_SVG,
542
+ })
543
+ expect(conflicted.skipped).toEqual([{ asset: svgAsset, reason: 'ASSET_CONFLICT' }])
544
+ })
545
+ })
@@ -0,0 +1,42 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { synchronizePortable } from './portable-catalog.js'
4
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
5
+ import { createCatalogInstallationPlan } from './agent-plugins-harness.js'
6
+
7
+ it('selects exact immutable catalog releases and requires canonical metadata', () =>
8
+ withPortableFixture(async (input) => {
9
+ const result = await synchronizePortable(input)
10
+ const args = {
11
+ catalog: result.catalog!,
12
+ pluginId: 'plugin:test-source:review-kit',
13
+ instanceId: 'user-a/workspace-a',
14
+ profile: {
15
+ profileVersion: 1 as const,
16
+ harness: 'test-harness',
17
+ runtimeVersion: 'test-1',
18
+ adapterVersion: 'test-1',
19
+ agentPlugins: { versions: [], skills: false, mcpTransports: [] },
20
+ components: { skillDirectories: true, mcpTransports: ['stdio' as const] },
21
+ },
22
+ }
23
+ const plan = createCatalogInstallationPlan(args)
24
+ assert.equal(plan.releaseId, result.catalog?.plugins[0]?.currentReleaseId)
25
+ assert.equal(plan.strategy, 'component-adapter')
26
+ assert.equal(plan.compatibility, 'full')
27
+ assert.equal(plan.allowedToActivate, false)
28
+ assert.throws(
29
+ () => createCatalogInstallationPlan({ ...args, releaseId: 'missing' }),
30
+ /RELEASE_NOT_FOUND/
31
+ )
32
+ assert.throws(
33
+ () => createCatalogInstallationPlan({ ...args, pluginId: 'missing' }),
34
+ /PLUGIN_NOT_FOUND/
35
+ )
36
+ const tampered = structuredClone(args.catalog)
37
+ tampered.plugins[0]!.description = 'Modified without rehashing'
38
+ assert.throws(
39
+ () => createCatalogInstallationPlan({ ...args, catalog: tampered }),
40
+ /CATALOG_ID_MISMATCH/
41
+ )
42
+ }))