@adea-ai/plugins 4.0.0 → 4.0.1

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 (42) hide show
  1. package/dist/catalog-store.d.ts +81 -0
  2. package/dist/catalog-store.js +487 -0
  3. package/dist/catalog-store.js.map +1 -0
  4. package/dist/consumer-index.d.ts +1 -1
  5. package/dist/consumer-index.js +17 -7
  6. package/dist/consumer-index.js.map +1 -1
  7. package/dist/icon-mirror.d.ts +2 -2
  8. package/dist/icon-mirror.js +38 -25
  9. package/dist/icon-mirror.js.map +1 -1
  10. package/dist/index.d.ts +7 -1
  11. package/dist/index.js +56 -51
  12. package/dist/index.js.map +1 -1
  13. package/dist/publication.d.ts +8 -11
  14. package/dist/publication.js +8 -11
  15. package/dist/publication.js.map +1 -1
  16. package/dist/site-icons.d.ts +2 -0
  17. package/dist/site-icons.js +34 -8
  18. package/dist/site-icons.js.map +1 -1
  19. package/dist/sources.js +1 -1
  20. package/dist/sources.js.map +1 -1
  21. package/native/build-provenance.txt +5 -5
  22. package/native/staged-reader-win32-arm64.node +0 -0
  23. package/native/staged-reader-win32-x64.node +0 -0
  24. package/package.json +6 -4
  25. package/src/artifacts.test.ts +80 -0
  26. package/src/catalog-store.ts +546 -0
  27. package/src/consumer-index.test.ts +125 -0
  28. package/src/consumer-index.ts +23 -8
  29. package/src/default-head.test.ts +143 -0
  30. package/src/icon-mirror.ts +36 -23
  31. package/src/index.test.ts +122 -0
  32. package/src/index.ts +82 -53
  33. package/src/manifest-retry.test.ts +49 -0
  34. package/src/publication.ts +8 -11
  35. package/src/site-icons.test.ts +73 -0
  36. package/src/site-icons.ts +27 -10
  37. package/src/source-config-sync.test.ts +84 -0
  38. package/src/source-digest.test.ts +58 -1
  39. package/src/sources-json.test.ts +18 -1
  40. package/src/sources.ts +1 -1
  41. package/src/write-artifacts.test.ts +375 -0
  42. package/native/README.md +0 -24
@@ -194,7 +194,11 @@ export interface ConsumerCategory {
194
194
  export interface LeadingDiagnostic {
195
195
  readonly category: string
196
196
  readonly name: string
197
- readonly reason: 'UNKNOWN_CATEGORY' | 'PRODUCT_NOT_FOUND' | 'PRODUCT_NOT_IN_CATEGORY'
197
+ readonly reason:
198
+ | 'UNKNOWN_CATEGORY'
199
+ | 'PRODUCT_NOT_FOUND'
200
+ | 'PRODUCT_NOT_IN_CATEGORY'
201
+ | 'PRODUCT_AMBIGUOUS'
198
202
  }
199
203
 
200
204
  /** Brand-mark coverage, so an incomplete set is visible rather than implied. */
@@ -257,10 +261,10 @@ function selectCanonical(
257
261
  const index = preference.sourcePreference.indexOf(plugin.sourceId)
258
262
  return index === -1 ? preference.sourcePreference.length : index
259
263
  }
260
- // A variant with no resolved release repository proves nothing about its
264
+ // A variant with no resolved current-release repository proves nothing about its
261
265
  // origin, so it is treated as marketplace-shipped rather than vendor-published.
262
266
  const vendorPublished = (plugin: Plugin): boolean => {
263
- const repository = plugin.availableReleases[0]?.resolvedRepositoryUrl
267
+ const repository = currentRelease(plugin)?.resolvedRepositoryUrl
264
268
  return repository !== undefined && repository !== sourceRepository.get(plugin.sourceId)
265
269
  }
266
270
  return [...variants].toSorted(
@@ -417,7 +421,8 @@ export function buildConsumerIndex(input: {
417
421
  sourcePreference: [...DEFAULT_SOURCE_PREFERENCE],
418
422
  }
419
423
  const leading = input.leading ?? {}
420
- const topCount = input.topCount ?? DEFAULT_TOP_COUNT
424
+ const topCount = input.topCount === undefined ? DEFAULT_TOP_COUNT : input.topCount
425
+ if (!Number.isSafeInteger(topCount) || topCount < 0) throw new Error('TOP_COUNT_INVALID')
421
426
  const sourceRepository = new Map(
422
427
  input.catalog.sources.map((source) => [source.sourceId, source.repositoryUrl])
423
428
  )
@@ -503,18 +508,28 @@ export function buildConsumerIndex(input: {
503
508
  const curatedByCategory = new Map<string, string[]>()
504
509
  for (const category of categoryNames) {
505
510
  const members = input.catalog.plugins.filter((plugin) => plugin.categories.includes(category))
511
+ const categoryProducts = new Set(members.map((plugin) => plugin.productGroupingKey))
506
512
  const curated: string[] = []
507
513
  for (const name of leading[category] ?? []) {
508
- const matches = members.filter((plugin) => plugin.upstreamPluginName === name)
509
- if (matches.length === 0) {
514
+ const matches = input.catalog.plugins.filter((plugin) => plugin.upstreamPluginName === name)
515
+ const keys = new Set(
516
+ matches
517
+ .map((plugin) => plugin.productGroupingKey)
518
+ .filter((key) => categoryProducts.has(key))
519
+ )
520
+ if (keys.size === 0) {
510
521
  diagnostics.push({
511
522
  category,
512
523
  name,
513
- reason: variantsByProduct.has(name) ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
524
+ reason: matches.length > 0 ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
514
525
  })
515
526
  continue
516
527
  }
517
- const key = matches[0]!.productGroupingKey
528
+ if (keys.size > 1) {
529
+ diagnostics.push({ category, name, reason: 'PRODUCT_AMBIGUOUS' })
530
+ continue
531
+ }
532
+ const key = keys.values().next().value!
518
533
  if (!curated.includes(key)) curated.push(key)
519
534
  }
520
535
  curatedByCategory.set(category, curated)
@@ -0,0 +1,143 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { chmod, mkdir, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { expect, test } from 'bun:test'
5
+ import { buildCatalog, digest, synchronize } from './index.js'
6
+ import { parseMarketplaceManifest } from './sources.js'
7
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
8
+
9
+ test.skipIf(process.platform === 'win32')(
10
+ 'resolves unspecified external refs from the remote default branch',
11
+ () =>
12
+ withPortableFixture(async (input, root) => {
13
+ const repo = join(root, 'repository')
14
+ const bin = join(root, 'bin')
15
+ const originalPath = process.env.PATH
16
+ const originalGit = process.env.ADEA_TEST_REAL_GIT
17
+ const originalRepo = process.env.ADEA_TEST_HEAD_REPO
18
+ const originalFetch = globalThis.fetch
19
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
20
+ const git = (...args: string[]) =>
21
+ execFileSync(realGit, ['-C', repo, ...args], { encoding: 'utf8' }).trim()
22
+ try {
23
+ await mkdir(repo)
24
+ await mkdir(bin)
25
+ git('init', '-q', '-b', 'legacy')
26
+ git(
27
+ '-c',
28
+ 'user.name=Fixture',
29
+ '-c',
30
+ 'user.email=fixture@example.invalid',
31
+ 'commit',
32
+ '--allow-empty',
33
+ '-qm',
34
+ 'main commit'
35
+ )
36
+ git('branch', 'main')
37
+ const mainCommit = git('rev-parse', 'refs/heads/main')
38
+ git(
39
+ '-c',
40
+ 'user.name=Fixture',
41
+ '-c',
42
+ 'user.email=fixture@example.invalid',
43
+ 'commit',
44
+ '--allow-empty',
45
+ '-qm',
46
+ 'default commit'
47
+ )
48
+ const defaultCommit = git('rev-parse', 'HEAD')
49
+ expect(defaultCommit).not.toBe(mainCommit)
50
+ const sourceSha = '5'.repeat(40)
51
+ const source = {
52
+ ...input.sources[0]!,
53
+ repositoryUrl: 'https://github.com/acme/marketplace',
54
+ }
55
+ const script = join(bin, 'git')
56
+ await writeFile(
57
+ script,
58
+ `#!/bin/sh\nif [ "$2" = "${source.repositoryUrl}" ]; then\n printf '%s\\t%s\\n' '${sourceSha}' "$3"\nelse\n shift 2\n exec "$ADEA_TEST_REAL_GIT" ls-remote "$ADEA_TEST_HEAD_REPO" "$@"\nfi\n`
59
+ )
60
+ await chmod(script, 0o700)
61
+ process.env.ADEA_TEST_REAL_GIT = realGit
62
+ process.env.ADEA_TEST_HEAD_REPO = repo
63
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
64
+ const url = 'https://github.com/acme/remote-plugin'
65
+ const manifest = JSON.stringify({
66
+ plugins: [
67
+ { name: 'object-source', source: { source: 'git', url } },
68
+ { name: 'url-source', source: url },
69
+ { name: 'subdir-source', source: { source: 'git-subdir', url, path: 'plugins/demo' } },
70
+ { name: 'explicit-main', source: { source: 'git', url, ref: 'main' } },
71
+ ],
72
+ })
73
+ globalThis.fetch = (async (_url: unknown) => new Response(manifest)) as typeof fetch
74
+ const options = {
75
+ ...input,
76
+ sources: [source],
77
+ mode: 'live' as const,
78
+ snapshotLoader: {
79
+ load: async () => ({
80
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
81
+ symlinks: [],
82
+ }),
83
+ },
84
+ }
85
+ const result = await synchronize(options)
86
+ const pins = Object.fromEntries(
87
+ result.lock!.sources[0]!.pluginPins.map((pin) => [pin.pluginName, pin.resolvedCommitSha])
88
+ )
89
+ expect(pins).toEqual({
90
+ 'object-source': defaultCommit,
91
+ 'url-source': defaultCommit,
92
+ 'subdir-source': defaultCommit,
93
+ 'explicit-main': mainCommit,
94
+ })
95
+ const direct = await buildCatalog({
96
+ resolvedSources: [
97
+ {
98
+ config: source,
99
+ commitSha: sourceSha,
100
+ manifestText: manifest,
101
+ parsed: parseMarketplaceManifest(JSON.parse(manifest), source),
102
+ pluginPins: [],
103
+ retrievedAt: '2026-01-01T00:00:00.000Z',
104
+ manifestDigest: digest(manifest),
105
+ },
106
+ ],
107
+ categoryMap: input.categoryMap,
108
+ productAliases: input.productAliases,
109
+ policy: input.policy,
110
+ snapshotLoader: options.snapshotLoader,
111
+ resolveExternalRefs: true,
112
+ })
113
+ expect(
114
+ direct.plugins.find((plugin) => plugin.upstreamPluginName === 'url-source')
115
+ ?.availableReleases[0]?.resolvedCommitSha
116
+ ).toBe(defaultCommit)
117
+ git('symbolic-ref', 'HEAD', 'refs/heads/main')
118
+ const replay = await synchronize({ ...options, fromLock: result.lock! })
119
+ expect(
120
+ replay.lock!.sources[0]!.pluginPins.find((pin) => pin.pluginName === 'url-source')
121
+ ?.resolvedCommitSha
122
+ ).toBe(defaultCommit)
123
+ const refreshed = await synchronize({ ...options, existingLock: result.lock! })
124
+ expect(refreshed.changed).toBe(true)
125
+ expect(
126
+ refreshed.lock!.sources[0]!.pluginPins.find((pin) => pin.pluginName === 'url-source')
127
+ ?.resolvedCommitSha
128
+ ).toBe(mainCommit)
129
+ git('branch', 'x/HEAD', 'main')
130
+ git('symbolic-ref', 'HEAD', 'refs/heads/missing')
131
+ expect(git('ls-remote', repo, 'HEAD')).toContain('refs/heads/x/HEAD')
132
+ await expect(synchronize(options)).rejects.toThrow('GIT_REF_NOT_FOUND')
133
+ } finally {
134
+ globalThis.fetch = originalFetch
135
+ if (originalPath === undefined) delete process.env.PATH
136
+ else process.env.PATH = originalPath
137
+ if (originalGit === undefined) delete process.env.ADEA_TEST_REAL_GIT
138
+ else process.env.ADEA_TEST_REAL_GIT = originalGit
139
+ if (originalRepo === undefined) delete process.env.ADEA_TEST_HEAD_REPO
140
+ else process.env.ADEA_TEST_HEAD_REPO = originalRepo
141
+ }
142
+ })
143
+ )
@@ -4,6 +4,7 @@ import type { Catalog } from './schema.js'
4
4
  import { byteDigest, type PublishedAsset } from './index.js'
5
5
  import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
6
6
  import type { ConsumerIndex } from './consumer-index.js'
7
+ import { writeFileAtomically } from './catalog-store.js'
7
8
 
8
9
  /**
9
10
  * Where an icon's bytes come from, so a mirror needs no tree walk and no
@@ -44,22 +45,27 @@ export interface IconMirrorResult {
44
45
 
45
46
  /**
46
47
  * 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.
48
+ * matching the digest the product advertises. Retain each distinct pinned
49
+ * origin so the mirror can try another source if one is unavailable.
49
50
  */
50
51
  export function resolveIconSources(
51
52
  catalog: Pick<Catalog, 'plugins'>,
52
53
  index: ConsumerIndex
53
54
  ): readonly IconSource[] {
54
- const byAsset = new Map<string, IconSource>()
55
+ const byAsset = new Map<string, IconSource[]>()
56
+ const add = (source: IconSource) => {
57
+ const candidates = byAsset.get(source.asset) ?? []
58
+ if (!candidates.some((candidate) => JSON.stringify(candidate) === JSON.stringify(source)))
59
+ candidates.push(source)
60
+ byAsset.set(source.asset, candidates)
61
+ }
55
62
  for (const plugin of catalog.plugins) {
56
63
  for (const release of plugin.availableReleases) {
57
64
  const icon = release.icon
58
65
  if (!icon) continue
59
66
  const asset = iconAssetName(icon.digest, icon.contentType)
60
- if (byAsset.has(asset)) continue
61
67
  if (icon.kind === 'favicon') {
62
- byAsset.set(asset, {
68
+ add({
63
69
  kind: 'favicon',
64
70
  asset,
65
71
  digest: icon.digest,
@@ -74,7 +80,7 @@ export function resolveIconSources(
74
80
  release.pluginSubdirectory && release.pluginSubdirectory !== '.'
75
81
  ? release.pluginSubdirectory
76
82
  : ''
77
- byAsset.set(asset, {
83
+ add({
78
84
  kind: 'content',
79
85
  asset,
80
86
  digest: icon.digest,
@@ -88,14 +94,12 @@ export function resolveIconSources(
88
94
  })
89
95
  }
90
96
  }
91
- const sources: IconSource[] = []
97
+ const selected = new Set<string>()
92
98
  for (const product of Object.values(index.products)) {
93
99
  const icon = product.icon
94
- if (!icon) continue
95
- const source = byAsset.get(icon.asset)
96
- if (source) sources.push(source)
100
+ if (icon) selected.add(icon.asset)
97
101
  }
98
- return sources.toSorted((left, right) => left.asset.localeCompare(right.asset))
102
+ return [...selected].toSorted().flatMap((asset) => byAsset.get(asset) ?? [])
99
103
  }
100
104
 
101
105
  /**
@@ -113,49 +117,58 @@ export async function stageIconAssets(input: {
113
117
  readonly readBytes: (source: IconSource) => Promise<Uint8Array>
114
118
  }): Promise<IconMirrorResult> {
115
119
  const staged: PublishedAsset[] = []
116
- const skipped: { asset: string; reason: string }[] = []
120
+ const skipped = new Map<string, string>()
121
+ const complete = new Set<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) {
127
+ if (complete.has(source.asset)) continue
119
128
  const destination = join(input.assetsDirectory, source.asset)
120
129
  const existing = await fs.readFile(destination).catch(() => undefined)
121
130
  if (existing && byteDigest(existing) === source.digest) {
122
131
  staged.push(publishedAssetOf(source))
132
+ complete.add(source.asset)
133
+ skipped.delete(source.asset)
123
134
  continue
124
135
  }
125
136
  if (existing) {
126
- skipped.push({ asset: source.asset, reason: 'ASSET_CONFLICT' })
137
+ skip(source.asset, 'ASSET_CONFLICT')
127
138
  continue
128
139
  }
129
140
  let bytes: Uint8Array
130
141
  try {
131
142
  bytes = await input.readBytes(source)
132
143
  } catch (error) {
133
- skipped.push({
134
- asset: source.asset,
135
- reason: `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`,
136
- })
144
+ skip(source.asset, `FETCH_FAILED: ${error instanceof Error ? error.message : String(error)}`)
137
145
  continue
138
146
  }
139
147
  const inspected = inspectIconBytes(bytes)
140
148
  if (!('contentType' in inspected)) {
141
- skipped.push({ asset: source.asset, reason: inspected.rejected })
149
+ skip(source.asset, inspected.rejected)
142
150
  continue
143
151
  }
144
152
  if (inspected.contentType !== source.contentType) {
145
- skipped.push({ asset: source.asset, reason: 'CONTENT_TYPE_CHANGED' })
153
+ skip(source.asset, 'CONTENT_TYPE_CHANGED')
146
154
  continue
147
155
  }
148
156
  if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
149
- skipped.push({ asset: source.asset, reason: 'DIGEST_MISMATCH' })
157
+ skip(source.asset, 'DIGEST_MISMATCH')
150
158
  continue
151
159
  }
152
160
  await fs.mkdir(dirname(destination), { recursive: true })
153
- await fs.writeFile(destination, bytes)
161
+ await writeFileAtomically(destination, bytes)
154
162
  staged.push(publishedAssetOf(source))
163
+ complete.add(source.asset)
164
+ skipped.delete(source.asset)
155
165
  }
156
166
  // Distinct assets: two products can share one mark, and the catalog declares
157
167
  // each asset once.
158
- return { staged: dedupeAssets(staged), skipped }
168
+ return {
169
+ staged: dedupeAssets(staged),
170
+ skipped: [...skipped].map(([asset, reason]) => ({ asset, reason })),
171
+ }
159
172
  }
160
173
 
161
174
  function dedupeAssets(assets: readonly PublishedAsset[]): readonly PublishedAsset[] {
@@ -192,5 +205,5 @@ export async function stageIconBytes(
192
205
  const existing = await fs.readFile(destination).catch(() => undefined)
193
206
  if (existing && byteDigest(existing) === byteDigest(bytes)) return
194
207
  await fs.mkdir(dirname(destination), { recursive: true })
195
- await fs.writeFile(destination, bytes)
208
+ await writeFileAtomically(destination, bytes)
196
209
  }
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,
@@ -466,6 +468,106 @@ describe('deterministic catalog synchronization', () => {
466
468
  }
467
469
  })
468
470
 
471
+ test.skipIf(process.platform === 'win32')(
472
+ 'pins annotated and lightweight tags to the target commit',
473
+ async () => {
474
+ const root = await mkdtemp(join(tmpdir(), 'plugin-tag-pin-'))
475
+ const repo = join(root, 'repository')
476
+ const bin = join(root, 'bin')
477
+ const originalPath = process.env.PATH
478
+ const originalGit = process.env.ADEA_TEST_REAL_GIT
479
+ const originalRepo = process.env.ADEA_TEST_TAG_REPO
480
+ const originalFetch = globalThis.fetch
481
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
482
+ const git = (...args: string[]) =>
483
+ execFileSync(realGit, ['-C', repo, ...args], { encoding: 'utf8' }).trim()
484
+ try {
485
+ await mkdir(repo)
486
+ await mkdir(bin)
487
+ git('init', '-q', '-b', 'main')
488
+ git(
489
+ '-c',
490
+ 'user.name=Fixture',
491
+ '-c',
492
+ 'user.email=fixture@example.invalid',
493
+ 'commit',
494
+ '--allow-empty',
495
+ '-qm',
496
+ 'fixture'
497
+ )
498
+ git(
499
+ '-c',
500
+ 'user.name=Fixture',
501
+ '-c',
502
+ 'user.email=fixture@example.invalid',
503
+ 'tag',
504
+ '-a',
505
+ 'annotated',
506
+ '-m',
507
+ 'fixture'
508
+ )
509
+ git('tag', 'lightweight')
510
+ const commit = git('rev-parse', 'HEAD')
511
+ expect(git('rev-parse', 'refs/tags/annotated')).not.toBe(commit)
512
+ const sourceSha = '5'.repeat(40)
513
+ const source: SourceConfig = {
514
+ ...sources[0]!,
515
+ sourceId: 'tag-fixture',
516
+ repositoryUrl: 'https://github.com/acme/marketplace',
517
+ manifestPath: 'marketplace.json',
518
+ }
519
+ const script = join(bin, 'git')
520
+ await writeFile(
521
+ script,
522
+ `#!/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`
523
+ )
524
+ await chmod(script, 0o700)
525
+ process.env.ADEA_TEST_REAL_GIT = realGit
526
+ process.env.ADEA_TEST_TAG_REPO = repo
527
+ process.env.PATH = `${bin}:${originalPath ?? ''}`
528
+ for (const ref of ['refs/tags/annotated', 'refs/tags/lightweight', 'refs/heads/main']) {
529
+ const manifest = JSON.stringify({
530
+ plugins: [
531
+ {
532
+ name: 'remote',
533
+ source: { source: 'git', url: 'https://github.com/acme/remote-plugin', ref },
534
+ },
535
+ ],
536
+ })
537
+ globalThis.fetch = (async (_url: unknown) => new Response(manifest)) as typeof fetch
538
+ const loaded: string[] = []
539
+ const result = await synchronize({
540
+ sources: [source],
541
+ categoryMap,
542
+ productAliases,
543
+ policy,
544
+ mode: 'live',
545
+ snapshotLoader: {
546
+ load: async (_url, sha) => {
547
+ loaded.push(sha)
548
+ return {
549
+ files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
550
+ symlinks: [],
551
+ }
552
+ },
553
+ },
554
+ })
555
+ expect(result.lock?.sources[0]?.pluginPins[0]?.resolvedCommitSha).toBe(commit)
556
+ expect(loaded).toEqual([commit])
557
+ }
558
+ } finally {
559
+ globalThis.fetch = originalFetch
560
+ if (originalPath === undefined) delete process.env.PATH
561
+ else process.env.PATH = originalPath
562
+ if (originalGit === undefined) delete process.env.ADEA_TEST_REAL_GIT
563
+ else process.env.ADEA_TEST_REAL_GIT = originalGit
564
+ if (originalRepo === undefined) delete process.env.ADEA_TEST_TAG_REPO
565
+ else process.env.ADEA_TEST_TAG_REPO = originalRepo
566
+ await rm(root, { recursive: true, force: true })
567
+ }
568
+ }
569
+ )
570
+
469
571
  test.skipIf(process.platform === 'win32')(
470
572
  'refreshes floating external refs even when the marketplace head is unchanged',
471
573
  async () => {
@@ -602,6 +704,26 @@ describe('deterministic catalog synchronization', () => {
602
704
  verifyArtifacts(result.artifacts!)
603
705
  })
604
706
 
707
+ test('preserves an existing recovery directory when writing artifacts', async () => {
708
+ const parent = await mkdtemp(join(tmpdir(), 'plugin-recovery-'))
709
+ const output = join(parent, 'generated')
710
+ const backup = `${output}.previous`
711
+ try {
712
+ await mkdir(backup)
713
+ await writeFile(join(backup, 'keep.txt'), 'recover me')
714
+ const artifacts = { 'catalog.v1.json': '{}' } as unknown as GeneratedArtifacts
715
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
716
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
717
+ await mkdir(output)
718
+ await writeFile(join(output, 'current.txt'), 'current')
719
+ await expect(writeArtifacts(output, artifacts)).rejects.toThrow('CATALOG_RECOVERY_REQUIRED')
720
+ expect(await readFile(join(backup, 'keep.txt'), 'utf8')).toBe('recover me')
721
+ expect(await readFile(join(output, 'current.txt'), 'utf8')).toBe('current')
722
+ } finally {
723
+ await rm(parent, { recursive: true, force: true })
724
+ }
725
+ })
726
+
605
727
  test('keeps a failed build from producing a new artifact set', async () => {
606
728
  const first = await synchronize({
607
729
  sources,