@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.
- package/dist/catalog-store.d.ts +81 -0
- package/dist/catalog-store.js +487 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/consumer-index.d.ts +1 -1
- package/dist/consumer-index.js +17 -7
- package/dist/consumer-index.js.map +1 -1
- package/dist/icon-mirror.d.ts +2 -2
- package/dist/icon-mirror.js +38 -25
- package/dist/icon-mirror.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +56 -51
- package/dist/index.js.map +1 -1
- package/dist/publication.d.ts +8 -11
- package/dist/publication.js +8 -11
- package/dist/publication.js.map +1 -1
- package/dist/site-icons.d.ts +2 -0
- package/dist/site-icons.js +34 -8
- package/dist/site-icons.js.map +1 -1
- package/dist/sources.js +1 -1
- package/dist/sources.js.map +1 -1
- 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 +6 -4
- package/src/artifacts.test.ts +80 -0
- package/src/catalog-store.ts +546 -0
- package/src/consumer-index.test.ts +125 -0
- package/src/consumer-index.ts +23 -8
- package/src/default-head.test.ts +143 -0
- package/src/icon-mirror.ts +36 -23
- package/src/index.test.ts +122 -0
- package/src/index.ts +82 -53
- package/src/manifest-retry.test.ts +49 -0
- package/src/publication.ts +8 -11
- package/src/site-icons.test.ts +73 -0
- package/src/site-icons.ts +27 -10
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +58 -1
- package/src/sources-json.test.ts +18 -1
- package/src/sources.ts +1 -1
- package/src/write-artifacts.test.ts +375 -0
- package/native/README.md +0 -24
package/src/consumer-index.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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 =
|
|
509
|
-
|
|
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:
|
|
524
|
+
reason: matches.length > 0 ? 'PRODUCT_NOT_IN_CATEGORY' : 'PRODUCT_NOT_FOUND',
|
|
514
525
|
})
|
|
515
526
|
continue
|
|
516
527
|
}
|
|
517
|
-
|
|
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
|
+
)
|
package/src/icon-mirror.ts
CHANGED
|
@@ -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.
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
97
|
+
const selected = new Set<string>()
|
|
92
98
|
for (const product of Object.values(index.products)) {
|
|
93
99
|
const icon = product.icon
|
|
94
|
-
if (
|
|
95
|
-
const source = byAsset.get(icon.asset)
|
|
96
|
-
if (source) sources.push(source)
|
|
100
|
+
if (icon) selected.add(icon.asset)
|
|
97
101
|
}
|
|
98
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
skip(source.asset, inspected.rejected)
|
|
142
150
|
continue
|
|
143
151
|
}
|
|
144
152
|
if (inspected.contentType !== source.contentType) {
|
|
145
|
-
|
|
153
|
+
skip(source.asset, 'CONTENT_TYPE_CHANGED')
|
|
146
154
|
continue
|
|
147
155
|
}
|
|
148
156
|
if (byteDigest(bytes) !== source.digest || bytes.byteLength !== source.bytes) {
|
|
149
|
-
|
|
157
|
+
skip(source.asset, 'DIGEST_MISMATCH')
|
|
150
158
|
continue
|
|
151
159
|
}
|
|
152
160
|
await fs.mkdir(dirname(destination), { recursive: true })
|
|
153
|
-
await
|
|
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 {
|
|
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
|
|
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,
|