@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.
- package/dist/agent-plugins-catalog.d.ts +2 -4
- package/dist/agent-plugins-catalog.js +42 -50
- package/dist/agent-plugins-catalog.js.map +1 -1
- package/dist/agent-plugins-schema.d.ts +8 -0
- package/dist/agent-plugins-schema.js +10 -1
- package/dist/agent-plugins-schema.js.map +1 -1
- package/dist/artifacts.d.ts +57 -0
- package/dist/artifacts.js +159 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/build.d.ts +20 -0
- package/dist/build.js +103 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.d.ts +7 -0
- package/dist/capabilities.js +116 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog-store.d.ts +76 -0
- package/dist/catalog-store.js +463 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/consumer-index.d.ts +7 -29
- package/dist/consumer-index.js +24 -15
- package/dist/consumer-index.js.map +1 -1
- package/dist/digest.d.ts +13 -0
- package/dist/digest.js +47 -0
- package/dist/digest.js.map +1 -0
- package/dist/fs-atomic.d.ts +7 -0
- package/dist/fs-atomic.js +30 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/harness.js +2 -1
- package/dist/harness.js.map +1 -1
- package/dist/icon-mirror.d.ts +5 -4
- package/dist/icon-mirror.js +73 -72
- package/dist/icon-mirror.js.map +1 -1
- package/dist/icons.d.ts +2 -2
- package/dist/icons.js +4 -12
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +16 -309
- package/dist/index.js +12 -1900
- package/dist/index.js.map +1 -1
- package/dist/integrity-manifest.d.ts +43 -13
- package/dist/integrity-manifest.js +28 -17
- package/dist/integrity-manifest.js.map +1 -1
- package/dist/json-values.d.ts +3 -0
- package/dist/json-values.js +10 -0
- package/dist/json-values.js.map +1 -0
- package/dist/plugin-release.d.ts +38 -0
- package/dist/plugin-release.js +321 -0
- package/dist/plugin-release.js.map +1 -0
- package/dist/plugin-skips.d.ts +19 -0
- package/dist/plugin-skips.js +65 -0
- package/dist/plugin-skips.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +26 -0
- package/dist/policy.js.map +1 -0
- package/dist/portable-catalog.d.ts +4 -4
- package/dist/portable-catalog.js +14 -126
- package/dist/portable-catalog.js.map +1 -1
- package/dist/portable-verify.d.ts +2 -0
- package/dist/portable-verify.js +68 -0
- package/dist/portable-verify.js.map +1 -0
- package/dist/publication.d.ts +12 -12
- package/dist/publication.js +31 -21
- package/dist/publication.js.map +1 -1
- package/dist/published-output.d.ts +17 -0
- package/dist/published-output.js +49 -0
- package/dist/published-output.js.map +1 -0
- package/dist/site-icons.d.ts +4 -3
- package/dist/site-icons.js +38 -11
- package/dist/site-icons.js.map +1 -1
- package/dist/snapshots.d.ts +39 -0
- package/dist/snapshots.js +238 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/source-resolution.d.ts +28 -0
- package/dist/source-resolution.js +158 -0
- package/dist/source-resolution.js.map +1 -0
- package/dist/sources.d.ts +5 -1
- package/dist/sources.js +21 -27
- package/dist/sources.js.map +1 -1
- package/dist/synchronize.d.ts +44 -0
- package/dist/synchronize.js +118 -0
- package/dist/synchronize.js.map +1 -0
- package/dist/taxonomy.d.ts +23 -0
- package/dist/taxonomy.js +62 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/upstream.d.ts +42 -0
- package/dist/upstream.js +179 -0
- package/dist/upstream.js.map +1 -0
- package/dist/verify-artifacts.d.ts +36 -0
- package/dist/verify-artifacts.js +286 -0
- package/dist/verify-artifacts.js.map +1 -0
- package/native/build-provenance.txt +5 -5
- package/native/staged-reader-win32-arm64.node +0 -0
- package/native/staged-reader-win32-x64.node +0 -0
- package/package.json +3 -3
- package/src/agent-plugins-catalog.test.ts +2 -2
- package/src/agent-plugins-catalog.ts +46 -49
- package/src/agent-plugins-schema.ts +11 -1
- package/src/artifacts.test.ts +80 -0
- package/src/artifacts.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +519 -0
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +147 -16
- package/src/consumer-index.ts +35 -43
- package/src/default-head.test.ts +143 -0
- package/src/digest.ts +55 -0
- package/src/fs-atomic.ts +32 -0
- package/src/harness.test.ts +4 -32
- package/src/harness.ts +2 -1
- package/src/icon-mirror.ts +78 -72
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +172 -46
- package/src/index.ts +58 -2562
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -0
- package/src/manifest-retry.test.ts +49 -0
- package/src/plugin-release.ts +457 -0
- package/src/plugin-skips.ts +110 -0
- package/src/policy.ts +39 -0
- package/src/portable-catalog.test.ts +0 -5
- package/src/portable-catalog.ts +16 -143
- package/src/portable-verify.ts +71 -0
- package/src/publication.test.ts +29 -1
- package/src/publication.ts +38 -22
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +73 -1
- package/src/site-icons.ts +31 -14
- package/src/snapshots.ts +287 -0
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +61 -8
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +18 -2
- package/src/sources.ts +27 -33
- package/src/synchronize.ts +213 -0
- package/src/taxonomy.ts +87 -0
- package/src/upstream.ts +194 -0
- package/src/verify-artifacts.ts +365 -0
- package/src/write-artifacts.test.ts +374 -0
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/native/README.md +0 -24
- package/src/source-digest.ts +0 -14
package/src/harness.test.ts
CHANGED
|
@@ -129,34 +129,6 @@ describe('Control Plane installation plan v2', () => {
|
|
|
129
129
|
assert.equal(plan.compatibility, 'partial')
|
|
130
130
|
assert.ok(plan.disabled.some((item) => item.component === 'hooks'))
|
|
131
131
|
})
|
|
132
|
-
it('preserves instance data across releases and harnesses but isolates other instances', () => {
|
|
133
|
-
const first = createInstallationPlan(input())
|
|
134
|
-
const updatedPkg = packageFixture({ 'CHANGELOG.md': 'A new release.' })
|
|
135
|
-
const updated = createInstallationPlan({
|
|
136
|
-
...input(updatedPkg),
|
|
137
|
-
releaseId: `release:${'c'.repeat(64)}`,
|
|
138
|
-
profile: profile({ harness: 'another-harness' }),
|
|
139
|
-
})
|
|
140
|
-
assert.equal(first.dataKey, updated.dataKey)
|
|
141
|
-
assert.notEqual(first.packageKey, updated.packageKey)
|
|
142
|
-
assert.notEqual(
|
|
143
|
-
first.dataKey,
|
|
144
|
-
createInstallationPlan({ ...input(), instanceId: 'workspace-b/user-a' }).dataKey
|
|
145
|
-
)
|
|
146
|
-
assert.notEqual(
|
|
147
|
-
first.dataKey,
|
|
148
|
-
createInstallationPlan({ ...input(), pluginId: 'plugin:other:issues' }).dataKey
|
|
149
|
-
)
|
|
150
|
-
})
|
|
151
|
-
it('does not reuse a package cache across source commits with potentially different file modes', () => {
|
|
152
|
-
const original = input()
|
|
153
|
-
const other = { ...original, source: { ...original.source, commitSha: 'c'.repeat(40) } }
|
|
154
|
-
assert.notEqual(
|
|
155
|
-
createInstallationPlan(original).packageKey,
|
|
156
|
-
createInstallationPlan(other).packageKey
|
|
157
|
-
)
|
|
158
|
-
assert.equal(createInstallationPlan(original).dataKey, createInstallationPlan(other).dataKey)
|
|
159
|
-
})
|
|
160
132
|
it('rejects mismatched source provenance and unversioned capability claims', () => {
|
|
161
133
|
const args = input()
|
|
162
134
|
assert.throws(() =>
|
|
@@ -442,7 +414,7 @@ const MATRIX: {
|
|
|
442
414
|
disabled: ['mcp:api'],
|
|
443
415
|
},
|
|
444
416
|
{
|
|
445
|
-
name: '
|
|
417
|
+
name: 'a package missing one native transport is adopted partially on opt-in',
|
|
446
418
|
pkg: 'full',
|
|
447
419
|
profile: 'nativeWithoutHttp',
|
|
448
420
|
allowPartial: true,
|
|
@@ -483,7 +455,7 @@ const MATRIX: {
|
|
|
483
455
|
disabled: ['hooks'],
|
|
484
456
|
},
|
|
485
457
|
{
|
|
486
|
-
name: '
|
|
458
|
+
name: 'a package with a non-portable extra is adopted partially on opt-in',
|
|
487
459
|
pkg: 'withHooks',
|
|
488
460
|
profile: 'native',
|
|
489
461
|
allowPartial: true,
|
|
@@ -560,10 +532,11 @@ describe('upgrade identity isolation', () => {
|
|
|
560
532
|
)
|
|
561
533
|
|
|
562
534
|
// An upgraded package (new bytes) is a different PACKAGE, under the same
|
|
563
|
-
// instance data: the data is preserved across the upgrade.
|
|
535
|
+
// instance data: the data is preserved across the upgrade and the harness.
|
|
564
536
|
const upgraded = createInstallationPlan({
|
|
565
537
|
...input(packageFixture({ 'CHANGELOG.md': 'A new release.' })),
|
|
566
538
|
releaseId: `release:${'c'.repeat(64)}`,
|
|
539
|
+
profile: profile({ harness: 'another-harness' }),
|
|
567
540
|
})
|
|
568
541
|
assert.notEqual(first.packageKey, upgraded.packageKey)
|
|
569
542
|
assert.equal(first.dataKey, upgraded.dataKey)
|
|
@@ -575,7 +548,6 @@ describe('upgrade identity isolation', () => {
|
|
|
575
548
|
const rereleased = createInstallationPlan({ ...base, releaseId: `release:${'d'.repeat(64)}` })
|
|
576
549
|
assert.equal(first.packageKey, rereleased.packageKey)
|
|
577
550
|
assert.equal(first.dataKey, rereleased.dataKey)
|
|
578
|
-
assert.notEqual(first.releaseId, rereleased.releaseId)
|
|
579
551
|
|
|
580
552
|
// A different source commit is a different package identity under the same
|
|
581
553
|
// instance data.
|
package/src/harness.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
type InstallationPlan,
|
|
10
10
|
} from './agent-plugins-schema.js'
|
|
11
11
|
import type { Catalog } from './schema.js'
|
|
12
|
-
import {
|
|
12
|
+
import { PACKAGE_METADATA_KEY } from './agent-plugins-schema.js'
|
|
13
|
+
import { verifyPortableCatalog } from './portable-verify.js'
|
|
13
14
|
import { verifyAgentPackage, validateServer } from './agent-plugins-catalog.js'
|
|
14
15
|
|
|
15
16
|
export interface InstallationInput {
|
package/src/icon-mirror.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
import type { Catalog } from './schema.js'
|
|
4
|
-
import { byteDigest
|
|
4
|
+
import { byteDigest } from './digest.js'
|
|
5
5
|
import { iconAssetName, inspectIconBytes, type IconContentType } from './icons.js'
|
|
6
6
|
import type { ConsumerIndex } from './consumer-index.js'
|
|
7
|
+
import { writeFileAtomically } from './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
|
-
|
|
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.
|
|
48
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
98
|
+
const selected = new Set<string>()
|
|
92
99
|
for (const product of Object.values(index.products)) {
|
|
93
100
|
const icon = product.icon
|
|
94
|
-
if (
|
|
95
|
-
const source = byAsset.get(icon.asset)
|
|
96
|
-
if (source) sources.push(source)
|
|
101
|
+
if (icon) selected.add(icon.asset)
|
|
97
102
|
}
|
|
98
|
-
return
|
|
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
|
|
116
|
-
const skipped
|
|
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
|
-
|
|
120
|
-
const existing = await
|
|
121
|
-
if (existing
|
|
122
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
await fs.mkdir(
|
|
195
|
-
await
|
|
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.
|
|
213
|
-
* digest
|
|
213
|
+
* Content-addressed asset name. Published names are flat (no `/`), so the
|
|
214
|
+
* digest and extension form one name that is safe as a file and a URL segment.
|
|
214
215
|
*/
|
|
215
216
|
export function iconAssetName(digest: string, contentType: IconContentType): string {
|
|
216
217
|
const extension = EXTENSION_BY_CONTENT_TYPE[contentType]
|
|
217
218
|
if (!extension) throw new Error(`ICON_CONTENT_TYPE_UNSUPPORTED: ${contentType}`)
|
|
218
|
-
|
|
219
|
-
return `icon-${hex.slice(0, 32)}.${extension}`
|
|
219
|
+
return `icon-${digestHex(digest).slice(0, 32)}.${extension}`
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
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('
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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('
|
|
678
|
-
const
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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({
|