@adea-ai/plugins 4.0.0 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -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: 'the same package is adopted partially when the caller opts in',
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: 'the same package is adopted partially when the caller opts in',
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 { verifyPortableCatalog, PACKAGE_METADATA_KEY } from './portable-catalog.js'
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 {
@@ -1,9 +1,11 @@
1
1
  import { promises as fs } from 'node:fs'
2
- import { dirname, join } from 'node:path'
2
+ import { join } from 'node:path'
3
3
  import type { Catalog } from './schema.js'
4
- import { byteDigest, type PublishedAsset } from './index.js'
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 './fs-atomic.js'
8
+ import { publishedAssetOf, type PublishedAsset } from './integrity-manifest.js'
7
9
 
8
10
  /**
9
11
  * Where an icon's bytes come from, so a mirror needs no tree walk and no
@@ -35,7 +37,7 @@ export type IconSource =
35
37
  readonly homepage: string
36
38
  }
37
39
 
38
- export interface IconMirrorResult {
40
+ interface IconMirrorResult {
39
41
  /** Icons whose verified bytes are present in the assets directory. */
40
42
  readonly staged: readonly PublishedAsset[]
41
43
  /** Icons that could not be staged, with the reason, never silently dropped. */
@@ -44,22 +46,27 @@ export interface IconMirrorResult {
44
46
 
45
47
  /**
46
48
  * Maps every mirrored icon back to the exact commit it was resolved from, by
47
- * matching the digest the product advertises. Canonical variants are inspected
48
- * first, mirroring the order product records were built in.
49
+ * matching the digest the product advertises. Retain each distinct pinned
50
+ * origin so the mirror can try another source if one is unavailable.
49
51
  */
50
52
  export function resolveIconSources(
51
53
  catalog: Pick<Catalog, 'plugins'>,
52
54
  index: ConsumerIndex
53
55
  ): readonly IconSource[] {
54
- const byAsset = new Map<string, IconSource>()
56
+ const byAsset = new Map<string, IconSource[]>()
57
+ const add = (source: IconSource) => {
58
+ const candidates = byAsset.get(source.asset) ?? []
59
+ if (!candidates.some((candidate) => JSON.stringify(candidate) === JSON.stringify(source)))
60
+ candidates.push(source)
61
+ byAsset.set(source.asset, candidates)
62
+ }
55
63
  for (const plugin of catalog.plugins) {
56
64
  for (const release of plugin.availableReleases) {
57
65
  const icon = release.icon
58
66
  if (!icon) continue
59
67
  const asset = iconAssetName(icon.digest, icon.contentType)
60
- if (byAsset.has(asset)) continue
61
68
  if (icon.kind === 'favicon') {
62
- byAsset.set(asset, {
69
+ add({
63
70
  kind: 'favicon',
64
71
  asset,
65
72
  digest: icon.digest,
@@ -74,7 +81,7 @@ export function resolveIconSources(
74
81
  release.pluginSubdirectory && release.pluginSubdirectory !== '.'
75
82
  ? release.pluginSubdirectory
76
83
  : ''
77
- byAsset.set(asset, {
84
+ add({
78
85
  kind: 'content',
79
86
  asset,
80
87
  digest: icon.digest,
@@ -88,14 +95,12 @@ export function resolveIconSources(
88
95
  })
89
96
  }
90
97
  }
91
- const sources: IconSource[] = []
98
+ const selected = new Set<string>()
92
99
  for (const product of Object.values(index.products)) {
93
100
  const icon = product.icon
94
- if (!icon) continue
95
- const source = byAsset.get(icon.asset)
96
- if (source) sources.push(source)
101
+ if (icon) selected.add(icon.asset)
97
102
  }
98
- return sources.toSorted((left, right) => left.asset.localeCompare(right.asset))
103
+ return [...selected].toSorted().flatMap((asset) => byAsset.get(asset) ?? [])
99
104
  }
100
105
 
101
106
  /**
@@ -112,66 +117,51 @@ export async function stageIconAssets(input: {
112
117
  /** Reads the icon bytes for a source; live fetches raw content, offline reads fixtures. */
113
118
  readonly readBytes: (source: IconSource) => Promise<Uint8Array>
114
119
  }): Promise<IconMirrorResult> {
115
- const staged: PublishedAsset[] = []
116
- const skipped: { asset: string; reason: string }[] = []
120
+ const staged = new Map<string, PublishedAsset>()
121
+ const skipped = new Map<string, string>()
122
+ const skip = (asset: string, reason: string) => {
123
+ if (!skipped.has(asset)) skipped.set(asset, reason)
124
+ }
117
125
  await fs.mkdir(input.assetsDirectory, { recursive: true })
118
126
  for (const source of input.sources) {
119
- const destination = join(input.assetsDirectory, source.asset)
120
- const existing = await fs.readFile(destination).catch(() => undefined)
121
- if (existing && byteDigest(existing) === source.digest) {
122
- staged.push(publishedAssetOf(source))
123
- continue
124
- }
125
- if (existing) {
126
- skipped.push({ asset: source.asset, reason: 'ASSET_CONFLICT' })
127
- continue
128
- }
129
- let bytes: Uint8Array
130
- try {
131
- bytes = await input.readBytes(source)
132
- } catch (error) {
133
- skipped.push({
134
- asset: source.asset,
135
- reason: `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
136
- })
127
+ if (staged.has(source.asset)) continue
128
+ const existing = await stagedState(input.assetsDirectory, source.asset, source.digest)
129
+ if (existing === 'conflict') {
130
+ skip(source.asset, 'ASSET_CONFLICT')
137
131
  continue
138
132
  }
139
- const inspected = inspectIconBytes(bytes)
140
- if (!('contentType' in inspected)) {
141
- skipped.push({ asset: source.asset, reason: inspected.rejected })
142
- continue
143
- }
144
- if (inspected.contentType !== source.contentType) {
145
- skipped.push({ asset: source.asset, reason: 'CONTENT_TYPE_CHANGED' })
146
- continue
147
- }
148
- if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
149
- skipped.push({ asset: source.asset, reason: 'DIGEST_MISMATCH' })
150
- continue
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)
151
158
  }
152
- await fs.mkdir(dirname(destination), { recursive: true })
153
- await fs.writeFile(destination, bytes)
154
- staged.push(publishedAssetOf(source))
159
+ staged.set(source.asset, publishedAssetOf(source))
160
+ skipped.delete(source.asset)
155
161
  }
156
- // Distinct assets: two products can share one mark, and the catalog declares
157
- // each asset once.
158
- return { staged: dedupeAssets(staged), skipped }
159
- }
160
-
161
- function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
162
- const unique = new Map<string, PublishedAsset>()
163
- for (const asset of assets) unique.set(asset.name, asset)
164
- return [...unique.values()]
165
- }
166
-
167
- function publishedAssetOf(source: IconSource): PublishedAsset {
168
162
  return {
169
- name: source.asset,
170
- digest: source.digest,
171
- bytes: source.bytes,
172
- contentType: source.contentType,
173
- kind: source.kind,
174
- origin: source.kind === 'content' ? source.sourcePath : source.url,
163
+ staged: [...staged.values()],
164
+ skipped: [...skipped].map(([asset, reason]) => ({ asset, reason })),
175
165
  }
176
166
  }
177
167
 
@@ -188,9 +178,25 @@ export async function stageIconBytes(
188
178
  asset: string,
189
179
  bytes: Uint8Array
190
180
  ): Promise<void> {
191
- const destination = join(assetsDirectory, asset)
192
- const existing = await fs.readFile(destination).catch(() => undefined)
193
- if (existing && byteDigest(existing) === byteDigest(bytes)) return
194
- await fs.mkdir(dirname(destination), { recursive: true })
195
- await fs.writeFile(destination, bytes)
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'
196
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. Release assets cannot contain `/`, so the
213
- * digest prefix and extension form one flat name that any release can host.
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
- const hex = digest.startsWith('sha256:') ? digest.slice('sha256:'.length) : digest
219
- return `icon-${hex.slice(0, 32)}.${extension}`
219
+ return `icon-${digestHex(digest).slice(0, 32)}.${extension}`
220
220
  }
221
221
 
222
222
  /**
package/src/index.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chmod, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
2
+ import { execFileSync } from 'node:child_process'
2
3
  import { readdirSync, readFileSync } from 'node:fs'
3
4
  import { gzipSync } from 'node:zlib'
4
5
  import { pack } from 'tar-stream'
@@ -21,6 +22,7 @@ import {
21
22
  type CategoryMap,
22
23
  type ProductAliases,
23
24
  type ResolvedSource,
25
+ type GeneratedArtifacts,
24
26
  } from './index.js'
25
27
  import {
26
28
  parseJsonDocument,
@@ -106,6 +108,9 @@ const policy: CatalogPolicy = {
106
108
  ],
107
109
  }
108
110
 
111
+ /** Upstream failures retry immediately, so retry tests do not wait out real backoff. */
112
+ const RETRY_NOW = { 'retry-after': '0' }
113
+
109
114
  function stubFetch(handler: (url: string) => Response | Promise<Response>) {
110
115
  const original = globalThis.fetch
111
116
  const calls: string[] = []
@@ -231,6 +236,23 @@ describe('source adapters', () => {
231
236
  expect(gitSource.kind === 'git' ? gitSource.subdirectory : undefined).toBe('plugins/demo')
232
237
  })
233
238
 
239
+ test('leaves out entries with an unsupported source kind and keeps the rest', () => {
240
+ const parsed = parseMarketplaceManifest(
241
+ {
242
+ plugins: [
243
+ { name: 'xcode', source: { source: 'command', command: 'install-xcode-plugin' } },
244
+ { name: 'demo', source: './plugins/demo' },
245
+ ],
246
+ },
247
+ sources[0]!
248
+ )
249
+ expect(parsed.entries.map((entry) => entry.name)).toEqual(['demo'])
250
+ expect(parsed.unsupportedEntries).toEqual(['xcode: command'])
251
+ expect(() => parseSourceSpec({ source: 'command' }, sources[0]!, 'xcode')).toThrow(
252
+ 'PLUGIN_SOURCE_KIND_UNSUPPORTED'
253
+ )
254
+ })
255
+
234
256
  test('rejects duplicate keys, traversal, and unsupported URL schemes', () => {
235
257
  expect(() => parseJsonDocument('{"plugins":[],"plugins":[]}')).toThrow('JSON_DUPLICATE_KEY')
236
258
  expect(() => parseSourceSpec({ source: 'local', path: '../outside' }, sources[0]!)).toThrow(
@@ -466,6 +488,106 @@ describe('deterministic catalog synchronization', () => {
466
488
  }
467
489
  })
468
490
 
491
+ test.skipIf(process.platform === 'win32')(
492
+ 'pins annotated and lightweight tags to the target commit',
493
+ async () => {
494
+ const root = await mkdtemp(join(tmpdir(), 'plugin-tag-pin-'))
495
+ const repo = join(root, 'repository')
496
+ const bin = join(root, 'bin')
497
+ const originalPath = process.env.PATH
498
+ const originalGit = process.env.ADEA_TEST_REAL_GIT
499
+ const originalRepo = process.env.ADEA_TEST_TAG_REPO
500
+ const originalFetch = globalThis.fetch
501
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
502
+ const git = (...args: string[]) =>
503
+ execFileSync(realGit, ['-C', repo, ...args], { encoding: 'utf8' }).trim()
504
+ try {
505
+ await mkdir(repo)
506
+ await mkdir(bin)
507
+ git('init', '-q', '-b', 'main')
508
+ git(
509
+ '-c',
510
+ 'user.name=Fixture',
511
+ '-c',
512
+ 'user.email=fixture@example.invalid',
513
+ 'commit',
514
+ '--allow-empty',
515
+ '-qm',
516
+ 'fixture'
517
+ )
518
+ git(
519
+ '-c',
520
+ 'user.name=Fixture',
521
+ '-c',
522
+ 'user.email=fixture@example.invalid',
523
+ 'tag',
524
+ '-a',
525
+ 'annotated',
526
+ '-m',
527
+ 'fixture'
528
+ )
529
+ git('tag', 'lightweight')
530
+ const commit = git('rev-parse', 'HEAD')
531
+ expect(git('rev-parse', 'refs/tags/annotated')).not.toBe(commit)
532
+ const sourceSha = '5'.repeat(40)
533
+ const source: SourceConfig = {
534
+ ...sources[0]!,
535
+ sourceId: 'tag-fixture',
536
+ repositoryUrl: 'https://github.com/acme/marketplace',
537
+ manifestPath: 'marketplace.json',
538
+ }
539
+ const script = join(bin, 'git')
540
+ await writeFile(
541
+ script,
542
+ `#!/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_TAG_REPO" "$@"\nfi\n`
543
+ )
544
+ await chmod(script, 0o700)
545
+ process.env.ADEA_TEST_REAL_GIT = realGit
546
+ process.env.ADEA_TEST_TAG_REPO = repo
547
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
548
+ for (const ref of ['refs/tags/annotated', 'refs/tags/lightweight', 'refs/heads/main']) {
549
+ const manifest = JSON.stringify({
550
+ plugins: [
551
+ {
552
+ name: 'remote',
553
+ source: { source: 'git', url: 'https://github.com/acme/remote-plugin', ref },
554
+ },
555
+ ],
556
+ })
557
+ globalThis.fetch = (async (_url: unknown) => new Response(manifest)) as typeof fetch
558
+ const loaded: string[] = []
559
+ const result = await synchronize({
560
+ sources: [source],
561
+ categoryMap,
562
+ productAliases,
563
+ policy,
564
+ mode: 'live',
565
+ snapshotLoader: {
566
+ load: async (_url, sha) => {
567
+ loaded.push(sha)
568
+ return {
569
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
570
+ symlinks: [],
571
+ }
572
+ },
573
+ },
574
+ })
575
+ expect(result.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe(commit)
576
+ expect(loaded).toEqual([commit])
577
+ }
578
+ } finally {
579
+ globalThis.fetch = originalFetch
580
+ if (originalPath === undefined) delete process.env.PATH
581
+ else process.env.PATH = originalPath
582
+ if (originalGit === undefined) delete process.env.ADEA_TEST_REAL_GIT
583
+ else process.env.ADEA_TEST_REAL_GIT = originalGit
584
+ if (originalRepo === undefined) delete process.env.ADEA_TEST_TAG_REPO
585
+ else process.env.ADEA_TEST_TAG_REPO = originalRepo
586
+ await rm(root, { recursive: true, force: true })
587
+ }
588
+ }
589
+ )
590
+
469
591
  test.skipIf(process.platform === 'win32')(
470
592
  'refreshes floating external refs even when the marketplace head is unchanged',
471
593
  async () => {
@@ -602,35 +724,24 @@ describe('deterministic catalog synchronization', () => {
602
724
  verifyArtifacts(result.artifacts!)
603
725
  })
604
726
 
605
- test('keeps a failed build from producing a new artifact set', async () => {
606
- const first = await synchronize({
607
- sources,
608
- categoryMap,
609
- productAliases,
610
- policy,
611
- mode: 'offline',
612
- fixtureRoot: join(process.cwd(), 'fixtures'),
613
- })
614
- const outputDirectory = join(await mkdtemp(join(tmpdir(), 'plugin-lkg-')), 'generated')
615
- await writeArtifacts(outputDirectory, first.artifacts!)
616
- const previousCatalog = await readFile(join(outputDirectory, 'catalog.v1.json'), 'utf8')
617
- await expect(
618
- synchronize({
619
- sources,
620
- categoryMap,
621
- productAliases,
622
- policy,
623
- mode: 'offline',
624
- fixtureRoot: join(process.cwd(), 'fixtures'),
625
- snapshotLoader: {
626
- load: async () => {
627
- throw new Error('UPSTREAM_FETCH_FAILED')
628
- },
629
- },
630
- })
631
- ).rejects.toThrow('UPSTREAM_FETCH_FAILED')
632
- expect(first.artifacts?.['catalog.v1.json']).toContain(first.catalog?.catalogId)
633
- expect(await readFile(join(outputDirectory, 'catalog.v1.json'), 'utf8')).toBe(previousCatalog)
727
+ test('preserves an existing recovery directory when writing artifacts', async () => {
728
+ const parent = await mkdtemp(join(tmpdir(), 'plugin-recovery-'))
729
+ const output = join(parent, 'generated')
730
+ const backup = `${output}.previous`
731
+ try {
732
+ await mkdir(backup)
733
+ await writeFile(join(backup, 'keep.txt'), 'recover me')
734
+ const artifacts = { 'catalog.v1.json': '{}' } as unknown as GeneratedArtifacts
735
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
736
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
737
+ await mkdir(output)
738
+ await writeFile(join(output, 'current.txt'), 'current')
739
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
740
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
741
+ expect(await readFile(join(output, 'current.txt'), 'utf8')).toBe('current')
742
+ } finally {
743
+ await rm(parent, { recursive: true, force: true })
744
+ }
634
745
  })
635
746
  })
636
747
 
@@ -640,11 +751,16 @@ describe('identity, classification, and harness contracts', () => {
640
751
  stablePluginId('cursor-official', 'gmail')
641
752
  )
642
753
  expect(stablePluginId('--OpenAI--', '---Gmail---')).toBe('plugin:openai:gmail')
643
- expect(
644
- stableReleaseId('https://github.com/acme/a', 'plugins/x', 'a'.repeat(40), digest('same'))
645
- ).toBe(
646
- stableReleaseId('https://github.com/acme/a', 'plugins/x', 'a'.repeat(40), digest('same'))
754
+ const releaseId = stableReleaseId(
755
+ 'https://github.com/acme/a',
756
+ 'plugins/x',
757
+ 'a'.repeat(40),
758
+ digest('same')
647
759
  )
760
+ expect(releaseId).toMatch(/^release:[a-f0-9]{64}$/)
761
+ expect(
762
+ stableReleaseId('https://github.com/acme/a', 'plugins/x', 'a'.repeat(40), digest('other'))
763
+ ).not.toBe(releaseId)
648
764
  expect(
649
765
  bytesDigest(
650
766
  new Map([
@@ -674,15 +790,25 @@ describe('filesystem safety', () => {
674
790
  expect(snapshot.symlinks).toEqual(['plugin'])
675
791
  })
676
792
 
677
- test('enforces file and archive-like size limits', async () => {
678
- const root = await mkdtemp(join(tmpdir(), 'plugins-size-'))
679
- await mkdir(join(root, 'plugin'), { recursive: true })
680
- await writeFile(join(root, 'plugin', 'large.txt'), '1234567890')
681
- const snapshot = await snapshotFromDirectory(root, 'plugin')
682
- expect(snapshot.files.size).toBe(1)
683
- expect(() => {
684
- if ([...snapshot.files.values()][0]!.byteLength > 5) throw new Error('PLUGIN_FILE_TOO_LARGE')
685
- }).toThrow('PLUGIN_FILE_TOO_LARGE')
793
+ test('skips a plugin with a file over the size limit', async () => {
794
+ const result = await synchronize({
795
+ sources,
796
+ categoryMap,
797
+ productAliases,
798
+ policy: { ...policy, maxFileBytes: 5 },
799
+ mode: 'offline',
800
+ fixtureRoot: join(process.cwd(), 'fixtures'),
801
+ snapshotLoader: {
802
+ load: async () => ({
803
+ files: new Map([['large.txt', new TextEncoder().encode('1234567890')]]),
804
+ symlinks: [],
805
+ }),
806
+ },
807
+ })
808
+ const reasons = result.changeReport.skippedPlugins.map((skipped) => skipped.reasonCode)
809
+ expect(reasons.length).toBeGreaterThan(0)
810
+ expect(new Set(reasons)).toEqual(new Set(['PLUGIN_FILE_TOO_LARGE']))
811
+ expect(result.catalog?.plugins).toEqual([])
686
812
  })
687
813
  })
688
814
 
@@ -736,7 +862,7 @@ describe('upstream fetch quarantine', () => {
736
862
  const stub = stubFetch(async (url) => {
737
863
  if (url === treeUrl) {
738
864
  treeCalls += 1
739
- if (treeCalls < 3) return new Response('busy', { status: 500 })
865
+ if (treeCalls < 3) return new Response('busy', { status: 500, headers: RETRY_NOW })
740
866
  return jsonResponse(treePayload(['plugins/demo/plugin.json']))
741
867
  }
742
868
  if (isArchive(url))
@@ -755,7 +881,7 @@ describe('upstream fetch quarantine', () => {
755
881
 
756
882
  test('quarantines a plugin when the repository tree stays unavailable', async () => {
757
883
  const stub = stubFetch((url) => {
758
- if (url === treeUrl) return new Response('down', { status: 500 })
884
+ if (url === treeUrl) return new Response('down', { status: 500, headers: RETRY_NOW })
759
885
  return new Response('unexpected', { status: 500 })
760
886
  })
761
887
  try {
@@ -799,7 +925,7 @@ describe('upstream fetch quarantine', () => {
799
925
  if (isArchive(url))
800
926
  return recovered
801
927
  ? tarballResponse([{ path: 'plugins/demo/plugin.json', body: manifestBytes }])
802
- : new Response('broken', { status: 500 })
928
+ : new Response('broken', { status: 500, headers: RETRY_NOW })
803
929
  return new Response('unexpected', { status: 200 })
804
930
  })
805
931
  try {
@@ -1031,7 +1157,7 @@ describe('upstream fetch quarantine', () => {
1031
1157
  })
1032
1158
 
1033
1159
  test('fails closed when every plugin in a source is unreachable', async () => {
1034
- const stub = stubFetch(() => new Response('down', { status: 500 }))
1160
+ const stub = stubFetch(() => new Response('down', { status: 500, headers: RETRY_NOW }))
1035
1161
  try {
1036
1162
  await expect(
1037
1163
  buildCatalog({