@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
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { mapWithConcurrency } from './concurrency.js'
|
|
4
|
+
import { contentId, digest, ordinalCompare } from './digest.js'
|
|
5
|
+
import { SourcesLockSchema, type SourceLockEntry, type SourcesLock } from './schema.js'
|
|
6
|
+
import {
|
|
7
|
+
canonicalRepositoryUrl,
|
|
8
|
+
parseJsonDocument,
|
|
9
|
+
parseMarketplaceManifest,
|
|
10
|
+
safeRelativePath,
|
|
11
|
+
type ParsedMarketplace,
|
|
12
|
+
type PluginSourceSpec,
|
|
13
|
+
type SourceConfig,
|
|
14
|
+
} from './sources.js'
|
|
15
|
+
import { externalGitRef, fetchText, rawContentUrl, resolveGitRef } from './upstream.js'
|
|
16
|
+
|
|
17
|
+
export interface ResolvedSource {
|
|
18
|
+
readonly config: SourceConfig
|
|
19
|
+
readonly commitSha: string
|
|
20
|
+
readonly manifestText: string
|
|
21
|
+
readonly parsed: ParsedMarketplace
|
|
22
|
+
readonly pluginPins: readonly PluginSourcePin[]
|
|
23
|
+
readonly retrievedAt: string
|
|
24
|
+
readonly manifestDigest: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PluginSourcePin {
|
|
28
|
+
readonly pluginName: string
|
|
29
|
+
readonly repositoryUrl: string
|
|
30
|
+
readonly pluginSubdirectory: string
|
|
31
|
+
readonly resolvedCommitSha: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function resolvePluginSource(
|
|
35
|
+
pluginName: string,
|
|
36
|
+
spec: PluginSourceSpec,
|
|
37
|
+
source: ResolvedSource,
|
|
38
|
+
resolveExternalRefs: boolean
|
|
39
|
+
): Promise<ResolvedPluginSource> {
|
|
40
|
+
if (spec.kind === 'local')
|
|
41
|
+
return {
|
|
42
|
+
repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
|
|
43
|
+
subdirectory: spec.path,
|
|
44
|
+
commitSha: source.commitSha,
|
|
45
|
+
}
|
|
46
|
+
const canonicalUrl = canonicalRepositoryUrl(spec.repositoryUrl)
|
|
47
|
+
const pin = source.pluginPins.find(
|
|
48
|
+
(candidate) =>
|
|
49
|
+
candidate.pluginName === pluginName &&
|
|
50
|
+
candidate.repositoryUrl === canonicalUrl &&
|
|
51
|
+
candidate.pluginSubdirectory === spec.subdirectory
|
|
52
|
+
)
|
|
53
|
+
const commitSha =
|
|
54
|
+
pin?.resolvedCommitSha ??
|
|
55
|
+
spec.sha ??
|
|
56
|
+
(resolveExternalRefs
|
|
57
|
+
? await resolveGitRef(spec.repositoryUrl, externalGitRef(spec.ref))
|
|
58
|
+
: source.commitSha)
|
|
59
|
+
return {
|
|
60
|
+
repositoryUrl: canonicalUrl,
|
|
61
|
+
subdirectory: pin?.pluginSubdirectory ?? spec.subdirectory,
|
|
62
|
+
commitSha,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ResolvedPluginSource {
|
|
67
|
+
readonly repositoryUrl: string
|
|
68
|
+
readonly subdirectory: string
|
|
69
|
+
readonly commitSha: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The fields that differ between two lock entries for the same source. */
|
|
73
|
+
export function lockChangedFields(previous: SourceLockEntry, current: SourceLockEntry): string[] {
|
|
74
|
+
const fields = [
|
|
75
|
+
'repositoryUrl',
|
|
76
|
+
'defaultBranch',
|
|
77
|
+
'manifestPath',
|
|
78
|
+
'marketplaceDialect',
|
|
79
|
+
'resolvedCommitSha',
|
|
80
|
+
'sourceManifestDigest',
|
|
81
|
+
'synchronizationStatus',
|
|
82
|
+
] as const
|
|
83
|
+
const changed: string[] = fields.filter((field) => previous[field] !== current[field])
|
|
84
|
+
if (digest(sortedPins(previous)) !== digest(sortedPins(current))) changed.push('pluginPins')
|
|
85
|
+
return changed
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sourceLockChanged(previous: SourceLockEntry, current: SourceLockEntry): boolean {
|
|
89
|
+
return lockChangedFields(previous, current).length > 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sortedPins(source: SourceLockEntry): string[] {
|
|
93
|
+
return source.pluginPins
|
|
94
|
+
.map((pin) =>
|
|
95
|
+
JSON.stringify([
|
|
96
|
+
pin.pluginName,
|
|
97
|
+
pin.repositoryUrl,
|
|
98
|
+
pin.pluginSubdirectory,
|
|
99
|
+
pin.resolvedCommitSha,
|
|
100
|
+
])
|
|
101
|
+
)
|
|
102
|
+
.toSorted()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createSourcesLock(sources: readonly ResolvedSource[]): SourcesLock {
|
|
106
|
+
const entries: SourceLockEntry[] = sources
|
|
107
|
+
.map((source) => ({
|
|
108
|
+
sourceId: source.config.sourceId,
|
|
109
|
+
repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
|
|
110
|
+
defaultBranch: source.config.defaultBranch,
|
|
111
|
+
resolvedCommitSha: source.commitSha,
|
|
112
|
+
manifestPath: source.config.manifestPath,
|
|
113
|
+
marketplaceDialect: source.config.marketplaceDialect,
|
|
114
|
+
sourceManifestDigest: source.manifestDigest,
|
|
115
|
+
retrievedAt: source.retrievedAt,
|
|
116
|
+
synchronizationStatus: 'synchronized' as const,
|
|
117
|
+
pluginPins: source.pluginPins.map((pin) => ({ ...pin })),
|
|
118
|
+
}))
|
|
119
|
+
.toSorted((left, right) => left.sourceId.localeCompare(right.sourceId))
|
|
120
|
+
const body = { schemaVersion: 1 as const, sources: entries }
|
|
121
|
+
return SourcesLockSchema.parse({
|
|
122
|
+
...body,
|
|
123
|
+
lockId: contentId('lock', body),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function resolveSource(
|
|
128
|
+
config: SourceConfig,
|
|
129
|
+
mode: 'live' | 'offline',
|
|
130
|
+
fixtureRoot: string | undefined,
|
|
131
|
+
lock: SourcesLock | undefined,
|
|
132
|
+
fromLock: boolean
|
|
133
|
+
): Promise<ResolvedSource> {
|
|
134
|
+
const manifestPath = safeRelativePath(config.manifestPath, 'SOURCE_MANIFEST_PATH_INVALID')
|
|
135
|
+
const locked = lock?.sources.find((entry) => entry.sourceId === config.sourceId)
|
|
136
|
+
if (fromLock && !locked) throw new Error(`SOURCE_LOCK_ENTRY_MISSING: ${config.sourceId}`)
|
|
137
|
+
const commitSha =
|
|
138
|
+
fromLock && locked
|
|
139
|
+
? locked.resolvedCommitSha
|
|
140
|
+
: mode === 'offline'
|
|
141
|
+
? (config.fixtureCommitSha ?? locked?.resolvedCommitSha)
|
|
142
|
+
: await resolveGitRef(config.repositoryUrl, `refs/heads/${config.defaultBranch}`)
|
|
143
|
+
if (!commitSha || !/^[a-f0-9]{40}$/.test(commitSha))
|
|
144
|
+
throw new Error(`SOURCE_COMMIT_UNRESOLVED: ${config.sourceId}`)
|
|
145
|
+
const manifestText =
|
|
146
|
+
mode === 'offline'
|
|
147
|
+
? await readFixtureManifest({ ...config, manifestPath }, fixtureRoot)
|
|
148
|
+
: await fetchText(rawContentUrl(config.repositoryUrl, commitSha, manifestPath))
|
|
149
|
+
const parsed = parseMarketplaceManifest(parseJsonDocument(manifestText), config)
|
|
150
|
+
if (fromLock && locked && digest(manifestText) !== locked.sourceManifestDigest)
|
|
151
|
+
throw new Error(`SOURCE_LOCK_MANIFEST_MISMATCH: ${config.sourceId}`)
|
|
152
|
+
const pluginPins = await resolvePluginPins({
|
|
153
|
+
config,
|
|
154
|
+
parsed,
|
|
155
|
+
sourceCommitSha: commitSha,
|
|
156
|
+
lockedPins: locked?.pluginPins,
|
|
157
|
+
fromLock,
|
|
158
|
+
resolveExternalRefs: mode === 'live',
|
|
159
|
+
})
|
|
160
|
+
return {
|
|
161
|
+
config,
|
|
162
|
+
commitSha,
|
|
163
|
+
manifestText,
|
|
164
|
+
parsed,
|
|
165
|
+
pluginPins,
|
|
166
|
+
retrievedAt:
|
|
167
|
+
fromLock && locked
|
|
168
|
+
? locked.retrievedAt
|
|
169
|
+
: mode === 'offline'
|
|
170
|
+
? '2026-01-01T00:00:00.000Z'
|
|
171
|
+
: new Date().toISOString(),
|
|
172
|
+
manifestDigest: digest(manifestText),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function resolvePluginPins(input: {
|
|
177
|
+
readonly config: SourceConfig
|
|
178
|
+
readonly parsed: ParsedMarketplace
|
|
179
|
+
readonly sourceCommitSha: string
|
|
180
|
+
readonly lockedPins: readonly PluginSourcePin[] | undefined
|
|
181
|
+
readonly fromLock: boolean
|
|
182
|
+
readonly resolveExternalRefs: boolean
|
|
183
|
+
}): Promise<PluginSourcePin[]> {
|
|
184
|
+
const pins = await mapWithConcurrency(
|
|
185
|
+
input.parsed.entries,
|
|
186
|
+
8,
|
|
187
|
+
async (entry): Promise<PluginSourcePin> => {
|
|
188
|
+
if (entry.source.kind === 'local') {
|
|
189
|
+
return {
|
|
190
|
+
pluginName: entry.name,
|
|
191
|
+
repositoryUrl: canonicalRepositoryUrl(input.config.repositoryUrl),
|
|
192
|
+
pluginSubdirectory: entry.source.path,
|
|
193
|
+
resolvedCommitSha: input.sourceCommitSha,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const repositoryUrl = canonicalRepositoryUrl(entry.source.repositoryUrl)
|
|
198
|
+
const pluginSubdirectory = entry.source.subdirectory
|
|
199
|
+
// Existing locks describe the prior sync; only explicit replay freezes revisions.
|
|
200
|
+
const locked = input.fromLock
|
|
201
|
+
? input.lockedPins?.find((pin) => pin.pluginName === entry.name)
|
|
202
|
+
: undefined
|
|
203
|
+
if (input.fromLock && !locked)
|
|
204
|
+
throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISSING: ${input.config.sourceId}:${entry.name}`)
|
|
205
|
+
if (locked) {
|
|
206
|
+
if (
|
|
207
|
+
locked.repositoryUrl !== repositoryUrl ||
|
|
208
|
+
locked.pluginSubdirectory !== pluginSubdirectory
|
|
209
|
+
) {
|
|
210
|
+
throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISMATCH: ${input.config.sourceId}:${entry.name}`)
|
|
211
|
+
}
|
|
212
|
+
return locked
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const resolvedCommitSha =
|
|
216
|
+
entry.source.sha ??
|
|
217
|
+
(input.resolveExternalRefs
|
|
218
|
+
? await resolveGitRef(repositoryUrl, externalGitRef(entry.source.ref))
|
|
219
|
+
: input.sourceCommitSha)
|
|
220
|
+
return { pluginName: entry.name, repositoryUrl, pluginSubdirectory, resolvedCommitSha }
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
return pins.toSorted((left, right) => ordinalCompare(left.pluginName, right.pluginName))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readFixtureManifest(
|
|
227
|
+
config: SourceConfig,
|
|
228
|
+
fixtureRoot: string | undefined
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
if (!fixtureRoot || !config.fixturePath)
|
|
231
|
+
throw new Error(`FIXTURE_ROOT_REQUIRED: ${config.sourceId}`)
|
|
232
|
+
return fs.readFile(join(resolve(fixtureRoot), config.fixturePath, config.manifestPath), 'utf8')
|
|
233
|
+
}
|
package/src/sources-json.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { parseJsonDocument } from './sources.js'
|
|
2
|
+
import { parseJsonDocument, parseMarketplaceManifest, type SourceConfig } from './sources.js'
|
|
3
3
|
|
|
4
4
|
describe('JSON document whitespace', () => {
|
|
5
5
|
test('accepts the four JSON whitespace characters around nested values', () => {
|
|
@@ -19,7 +19,6 @@ describe('JSON document whitespace', () => {
|
|
|
19
19
|
`[1${space},2]`,
|
|
20
20
|
`[1,${space}2]`,
|
|
21
21
|
]) {
|
|
22
|
-
expect(() => JSON.parse(text)).toThrow()
|
|
23
22
|
expect(() => parseJsonDocument(text)).toThrow('JSON_MALFORMED')
|
|
24
23
|
}
|
|
25
24
|
}
|
|
@@ -34,3 +33,20 @@ describe('JSON document whitespace', () => {
|
|
|
34
33
|
expect(() => parseJsonDocument('{"name":1,"na\\u006de":2}')).toThrow('JSON_DUPLICATE_KEY')
|
|
35
34
|
})
|
|
36
35
|
})
|
|
36
|
+
|
|
37
|
+
test('sorts marketplace metadata independently of the host locale', () => {
|
|
38
|
+
const source: SourceConfig = {
|
|
39
|
+
sourceId: 'test',
|
|
40
|
+
displayName: 'Test',
|
|
41
|
+
repositoryUrl: 'https://github.com/example/plugins',
|
|
42
|
+
marketplaceDialect: 'openai',
|
|
43
|
+
manifestPath: 'marketplace.json',
|
|
44
|
+
defaultBranch: 'main',
|
|
45
|
+
trustClassification: 'official',
|
|
46
|
+
}
|
|
47
|
+
const parsed = parseMarketplaceManifest(
|
|
48
|
+
{ plugins: [{ name: 'test', source: './test', keywords: ['ä', 'z', 'a', 'ä'] }] },
|
|
49
|
+
source
|
|
50
|
+
)
|
|
51
|
+
expect(parsed.entries[0]?.keywords).toEqual(['a', 'z', 'ä'])
|
|
52
|
+
})
|
package/src/sources.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { digest, ordinalCompare } from './digest.js'
|
|
2
|
+
import { isString, stringValue } from './json-values.js'
|
|
3
|
+
import type { MarketplaceDialectSchema } from './schema.js'
|
|
4
|
+
import type { z } from 'zod'
|
|
2
5
|
|
|
3
|
-
export type MarketplaceDialect =
|
|
6
|
+
export type MarketplaceDialect = z.output<typeof MarketplaceDialectSchema>
|
|
4
7
|
|
|
5
8
|
export interface SourceConfig {
|
|
6
9
|
readonly sourceId: string
|
|
@@ -44,6 +47,8 @@ export interface ParsedMarketplace {
|
|
|
44
47
|
readonly marketplaceDescription: string
|
|
45
48
|
readonly owner: readonly string[]
|
|
46
49
|
readonly entries: readonly MarketplacePluginEntry[]
|
|
50
|
+
/** Entries left out because their source kind cannot be installed from a repository. */
|
|
51
|
+
readonly unsupportedEntries?: readonly string[]
|
|
47
52
|
readonly raw: Record<string, unknown>
|
|
48
53
|
}
|
|
49
54
|
|
|
@@ -62,7 +67,23 @@ export function parseMarketplaceManifest(input: unknown, source: SourceConfig):
|
|
|
62
67
|
const plugins = root.plugins
|
|
63
68
|
if (!Array.isArray(plugins)) throw new SourceAdapterError('MARKETPLACE_PLUGINS_REQUIRED')
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
// Upstream marketplaces can list plugins that install some other way, such as by running a
|
|
71
|
+
// command. Those stay out of the catalog, and they must not stop the rest of it from building.
|
|
72
|
+
const entries: MarketplacePluginEntry[] = []
|
|
73
|
+
const unsupportedEntries: string[] = []
|
|
74
|
+
for (const [index, value] of plugins.entries()) {
|
|
75
|
+
try {
|
|
76
|
+
entries.push(parsePluginEntry(value, source, index))
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!(error instanceof SourceAdapterError) || error.code !== 'PLUGIN_SOURCE_KIND_UNSUPPORTED')
|
|
79
|
+
throw error
|
|
80
|
+
unsupportedEntries.push(error.details.join(': '))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (unsupportedEntries.length > 0)
|
|
84
|
+
console.error(
|
|
85
|
+
`${source.sourceId}: left out plugins with unsupported source kinds: ${unsupportedEntries.join(', ')}`
|
|
86
|
+
)
|
|
66
87
|
const duplicateNames = new Set<string>()
|
|
67
88
|
for (const entry of entries) {
|
|
68
89
|
if (duplicateNames.has(entry.name))
|
|
@@ -72,8 +93,9 @@ export function parseMarketplaceManifest(input: unknown, source: SourceConfig):
|
|
|
72
93
|
return {
|
|
73
94
|
marketplaceName: stringValue(root.name) ?? source.displayName,
|
|
74
95
|
marketplaceDescription: stringValue(root.description) ?? '',
|
|
75
|
-
owner:
|
|
96
|
+
owner: authorNames(root.owner),
|
|
76
97
|
entries,
|
|
98
|
+
...(unsupportedEntries.length > 0 ? { unsupportedEntries } : {}),
|
|
77
99
|
raw: root,
|
|
78
100
|
}
|
|
79
101
|
}
|
|
@@ -302,18 +324,8 @@ function requiredString(input: unknown, code: string, detail: string): string {
|
|
|
302
324
|
return result
|
|
303
325
|
}
|
|
304
326
|
|
|
305
|
-
function stringValue(input: unknown): string | undefined {
|
|
306
|
-
return typeof input === 'string' && input.trim() ? input.trim() : undefined
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function isString(input: unknown): input is string {
|
|
310
|
-
return typeof input === 'string' && input.trim().length > 0
|
|
311
|
-
}
|
|
312
|
-
|
|
313
327
|
function uniqueStrings(input: readonly string[]): string[] {
|
|
314
|
-
return [...new Set(input.map((value) => value.trim()).filter(Boolean))].toSorted(
|
|
315
|
-
a.localeCompare(b)
|
|
316
|
-
)
|
|
328
|
+
return [...new Set(input.map((value) => value.trim()).filter(Boolean))].toSorted(ordinalCompare)
|
|
317
329
|
}
|
|
318
330
|
|
|
319
331
|
function authorNames(input: unknown): string[] {
|
|
@@ -327,10 +339,6 @@ function authorNames(input: unknown): string[] {
|
|
|
327
339
|
return []
|
|
328
340
|
}
|
|
329
341
|
|
|
330
|
-
function ownerNames(input: unknown): string[] {
|
|
331
|
-
return authorNames(input)
|
|
332
|
-
}
|
|
333
|
-
|
|
334
342
|
function safeOptionalUrl(input: unknown): string | undefined {
|
|
335
343
|
if (!isString(input)) return undefined
|
|
336
344
|
try {
|
|
@@ -344,17 +352,3 @@ function safeOptionalUrl(input: unknown): string | undefined {
|
|
|
344
352
|
function looksLikeUrl(input: string): boolean {
|
|
345
353
|
return /^https?:\/\//i.test(input)
|
|
346
354
|
}
|
|
347
|
-
|
|
348
|
-
function digest(value: unknown): string {
|
|
349
|
-
return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function canonicalJson(value: unknown): string {
|
|
353
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
354
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
355
|
-
const object = value as Record<string, unknown>
|
|
356
|
-
return `{${Object.keys(object)
|
|
357
|
-
.toSorted()
|
|
358
|
-
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
|
|
359
|
-
.join(',')}}`
|
|
360
|
-
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createArtifacts, type ConsumerIndexOptions, type GeneratedArtifacts } from './artifacts.js'
|
|
2
|
+
import { buildCatalogInternal, type BuildCatalogInput } from './build.js'
|
|
3
|
+
import { mapWithConcurrency } from './concurrency.js'
|
|
4
|
+
import { digest } from './digest.js'
|
|
5
|
+
import type { SkippedPlugin } from './plugin-skips.js'
|
|
6
|
+
import type { CatalogPolicy } from './policy.js'
|
|
7
|
+
import type { Catalog, CatalogSource, SourceLockEntry, SourcesLock } from './schema.js'
|
|
8
|
+
import { FixtureSnapshotLoader, NetworkSnapshotLoader, type SnapshotLoader } from './snapshots.js'
|
|
9
|
+
import { createSourcesLock, resolveSource, sourceLockChanged } from './source-resolution.js'
|
|
10
|
+
import type { SourceConfig } from './sources.js'
|
|
11
|
+
import { verifyArtifacts } from './verify-artifacts.js'
|
|
12
|
+
|
|
13
|
+
export interface SyncInput
|
|
14
|
+
extends
|
|
15
|
+
ConsumerIndexOptions,
|
|
16
|
+
Omit<BuildCatalogInput, 'resolvedSources' | 'snapshotLoader' | 'resolveExternalRefs'> {
|
|
17
|
+
readonly sources: readonly SourceConfig[]
|
|
18
|
+
readonly policy: CatalogPolicy
|
|
19
|
+
readonly mode?: 'live' | 'offline'
|
|
20
|
+
readonly fixtureRoot?: string
|
|
21
|
+
readonly sourceId?: string
|
|
22
|
+
readonly dryRun?: boolean
|
|
23
|
+
readonly existingLock?: SourcesLock
|
|
24
|
+
readonly existingCatalog?: Catalog
|
|
25
|
+
readonly fromLock?: SourcesLock
|
|
26
|
+
readonly snapshotLoader?: SnapshotLoader
|
|
27
|
+
/** Rebuild even when source pins are unchanged (used for explicit format migrations). */
|
|
28
|
+
readonly forceRebuild?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SyncResult {
|
|
32
|
+
readonly changed: boolean
|
|
33
|
+
readonly dryRun: boolean
|
|
34
|
+
readonly sourceHeads: readonly { sourceId: string; commitSha: string }[]
|
|
35
|
+
readonly catalog?: Catalog
|
|
36
|
+
readonly lock?: SourcesLock
|
|
37
|
+
readonly artifacts?: GeneratedArtifacts
|
|
38
|
+
readonly changeReport: ChangeReport
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ChangeReport {
|
|
42
|
+
readonly schemaVersion: 1
|
|
43
|
+
readonly changedSources: readonly string[]
|
|
44
|
+
readonly addedPlugins: readonly string[]
|
|
45
|
+
readonly removedPlugins: readonly string[]
|
|
46
|
+
readonly changedPlugins: readonly string[]
|
|
47
|
+
readonly permissionSensitiveChanges: readonly string[]
|
|
48
|
+
readonly skippedPlugins: readonly SkippedPlugin[]
|
|
49
|
+
readonly contentResolution: 'complete' | 'complete-with-skips' | 'metadata-only'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function synchronize(input: SyncInput): Promise<SyncResult> {
|
|
53
|
+
const mode = input.mode ?? 'live'
|
|
54
|
+
const configs = input.sources.filter(
|
|
55
|
+
(source) => !input.sourceId || source.sourceId === input.sourceId
|
|
56
|
+
)
|
|
57
|
+
if (configs.length === 0) throw new Error(`SOURCE_NOT_FOUND: ${input.sourceId ?? 'none'}`)
|
|
58
|
+
const lockToUse = input.fromLock ?? input.existingLock
|
|
59
|
+
const resolved = await mapWithConcurrency(configs, 4, (config) =>
|
|
60
|
+
resolveSource(config, mode, input.fixtureRoot, lockToUse, input.fromLock !== undefined)
|
|
61
|
+
)
|
|
62
|
+
const sourceHeads = resolved.map(({ config, commitSha }) => ({
|
|
63
|
+
sourceId: config.sourceId,
|
|
64
|
+
commitSha,
|
|
65
|
+
}))
|
|
66
|
+
const lock = createSourcesLock(resolved)
|
|
67
|
+
const unchanged =
|
|
68
|
+
input.fromLock === undefined &&
|
|
69
|
+
input.forceRebuild !== true &&
|
|
70
|
+
input.existingLock !== undefined &&
|
|
71
|
+
resolved.length === input.existingLock.sources.length &&
|
|
72
|
+
lock.sources.every(
|
|
73
|
+
(source) =>
|
|
74
|
+
!sourceChanged(
|
|
75
|
+
source,
|
|
76
|
+
resolved.find((entry) => entry.config.sourceId === source.sourceId)?.config,
|
|
77
|
+
input.existingLock,
|
|
78
|
+
input.existingCatalog
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
if (unchanged) {
|
|
82
|
+
return {
|
|
83
|
+
changed: false,
|
|
84
|
+
dryRun: input.dryRun ?? false,
|
|
85
|
+
sourceHeads,
|
|
86
|
+
changeReport: {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
changedSources: [],
|
|
89
|
+
addedPlugins: [],
|
|
90
|
+
removedPlugins: [],
|
|
91
|
+
changedPlugins: [],
|
|
92
|
+
permissionSensitiveChanges: [],
|
|
93
|
+
skippedPlugins: [],
|
|
94
|
+
contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { catalog, skippedPlugins } = await buildCatalogInternal({
|
|
100
|
+
...input,
|
|
101
|
+
resolvedSources: resolved,
|
|
102
|
+
snapshotLoader:
|
|
103
|
+
input.snapshotLoader ??
|
|
104
|
+
(mode === 'offline'
|
|
105
|
+
? new FixtureSnapshotLoader(input.fixtureRoot ?? 'fixtures', configs)
|
|
106
|
+
: new NetworkSnapshotLoader(input.policy)),
|
|
107
|
+
resolveExternalRefs: mode === 'live',
|
|
108
|
+
})
|
|
109
|
+
// An offline build replays fixtures, so its curation findings are noise.
|
|
110
|
+
const artifacts = createArtifacts(catalog, lock, {
|
|
111
|
+
...input,
|
|
112
|
+
curationDiagnostics: mode !== 'offline',
|
|
113
|
+
})
|
|
114
|
+
verifyArtifacts(artifacts)
|
|
115
|
+
const report = createChangeReport(
|
|
116
|
+
catalog,
|
|
117
|
+
lock,
|
|
118
|
+
input.existingLock,
|
|
119
|
+
input.existingCatalog,
|
|
120
|
+
skippedPlugins
|
|
121
|
+
)
|
|
122
|
+
if (input.policy.publishRequiresCompleteContent && input.metadataOnly) {
|
|
123
|
+
throw new Error('CATALOG_CONTENT_INCOMPLETE: publication requires complete content')
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
changed: true,
|
|
127
|
+
dryRun: input.dryRun ?? false,
|
|
128
|
+
sourceHeads,
|
|
129
|
+
catalog,
|
|
130
|
+
lock,
|
|
131
|
+
artifacts,
|
|
132
|
+
changeReport: report,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether a source differs from the last published lock and catalog. */
|
|
137
|
+
function sourceChanged(
|
|
138
|
+
source: SourceLockEntry,
|
|
139
|
+
current: Pick<CatalogSource, 'displayName' | 'trustClassification'> | undefined,
|
|
140
|
+
existingLock: SourcesLock | undefined,
|
|
141
|
+
existingCatalog: Catalog | undefined
|
|
142
|
+
): boolean {
|
|
143
|
+
const previous = existingLock?.sources.find((entry) => entry.sourceId === source.sourceId)
|
|
144
|
+
if (!previous || sourceLockChanged(previous, source)) return true
|
|
145
|
+
if (!existingCatalog) return false
|
|
146
|
+
const prior = existingCatalog.sources.find((entry) => entry.sourceId === source.sourceId)
|
|
147
|
+
return (
|
|
148
|
+
!prior ||
|
|
149
|
+
!current ||
|
|
150
|
+
prior.displayName !== current.displayName ||
|
|
151
|
+
prior.trustClassification !== current.trustClassification
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function createChangeReport(
|
|
156
|
+
catalog: Catalog,
|
|
157
|
+
lock: SourcesLock,
|
|
158
|
+
existingLock?: SourcesLock,
|
|
159
|
+
existingCatalog?: Catalog,
|
|
160
|
+
skippedPlugins: readonly SkippedPlugin[] = []
|
|
161
|
+
): ChangeReport {
|
|
162
|
+
const changedSources = lock.sources
|
|
163
|
+
.filter(
|
|
164
|
+
(source) =>
|
|
165
|
+
existingLock === undefined ||
|
|
166
|
+
sourceChanged(
|
|
167
|
+
source,
|
|
168
|
+
catalog.sources.find((entry) => entry.sourceId === source.sourceId),
|
|
169
|
+
existingLock,
|
|
170
|
+
existingCatalog
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
.map((source) => source.sourceId)
|
|
174
|
+
const previousPlugins = new Map(
|
|
175
|
+
existingCatalog?.plugins.map((plugin) => [plugin.pluginId, plugin]) ?? []
|
|
176
|
+
)
|
|
177
|
+
const currentPlugins = new Map(catalog.plugins.map((plugin) => [plugin.pluginId, plugin]))
|
|
178
|
+
const addedPlugins = catalog.plugins
|
|
179
|
+
.filter((plugin) => !previousPlugins.has(plugin.pluginId))
|
|
180
|
+
.map((plugin) => plugin.pluginId)
|
|
181
|
+
const removedPlugins = [...previousPlugins.keys()].filter(
|
|
182
|
+
(pluginId) => !currentPlugins.has(pluginId)
|
|
183
|
+
)
|
|
184
|
+
const changedPlugins = catalog.plugins
|
|
185
|
+
.filter((plugin) => {
|
|
186
|
+
const previous = previousPlugins.get(plugin.pluginId)
|
|
187
|
+
return previous !== undefined && digest(previous) !== digest(plugin)
|
|
188
|
+
})
|
|
189
|
+
.map((plugin) => plugin.pluginId)
|
|
190
|
+
return {
|
|
191
|
+
schemaVersion: 1,
|
|
192
|
+
changedSources: changedSources.toSorted(),
|
|
193
|
+
addedPlugins: addedPlugins.toSorted(),
|
|
194
|
+
removedPlugins: removedPlugins.toSorted(),
|
|
195
|
+
changedPlugins: changedPlugins.toSorted(),
|
|
196
|
+
skippedPlugins,
|
|
197
|
+
permissionSensitiveChanges: catalog.plugins
|
|
198
|
+
.flatMap((plugin) =>
|
|
199
|
+
plugin.securityClassification.permissionSensitiveChanges.map(
|
|
200
|
+
(type) => `${plugin.pluginId}:${type}`
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
.toSorted(),
|
|
204
|
+
contentResolution:
|
|
205
|
+
skippedPlugins.length > 0
|
|
206
|
+
? 'complete-with-skips'
|
|
207
|
+
: catalog.plugins.some(
|
|
208
|
+
(plugin) => plugin.securityClassification.contentResolution === 'metadata-only'
|
|
209
|
+
)
|
|
210
|
+
? 'metadata-only'
|
|
211
|
+
: 'complete',
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/taxonomy.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { contentId } from './digest.js'
|
|
2
|
+
|
|
3
|
+
export interface CategoryMap {
|
|
4
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
5
|
+
readonly fallback: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ProductAliases {
|
|
9
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function stablePluginId(sourceId: string, upstreamName: string): string {
|
|
13
|
+
return `plugin:${slug(sourceId)}:${slug(upstreamName)}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stableReleaseId(
|
|
17
|
+
repositoryUrl: string,
|
|
18
|
+
subdirectory: string,
|
|
19
|
+
commitSha: string,
|
|
20
|
+
contentDigest: string
|
|
21
|
+
): string {
|
|
22
|
+
return contentId('release', { repositoryUrl, subdirectory, commitSha, contentDigest })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeCategory(value: string, map: CategoryMap): string {
|
|
26
|
+
const key = value.trim().toLocaleLowerCase()
|
|
27
|
+
return map.aliases[key] ?? (slug(key) || map.fallback)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function productGroupingKey(name: string, aliases: ProductAliases): string {
|
|
31
|
+
const key = name.trim().toLocaleLowerCase()
|
|
32
|
+
return aliases.aliases[key] ?? slug(key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Orders plugins so each category's curated leading products come first, in
|
|
37
|
+
* the configured order. Consumers preview the head of each category, so this
|
|
38
|
+
* ordering decides what users see before expanding.
|
|
39
|
+
*/
|
|
40
|
+
export function orderLeadingPlugins<
|
|
41
|
+
T extends {
|
|
42
|
+
readonly plugins: readonly {
|
|
43
|
+
categories: readonly string[]
|
|
44
|
+
upstreamPluginName: string
|
|
45
|
+
}[]
|
|
46
|
+
},
|
|
47
|
+
>(catalog: T, leading?: Record<string, readonly string[]>): T {
|
|
48
|
+
if (!leading) return catalog
|
|
49
|
+
const leadRank = new Map<string, number>()
|
|
50
|
+
for (const [category, names] of Object.entries(leading)) {
|
|
51
|
+
names.forEach((name, index) => {
|
|
52
|
+
const key = `${category}::${name}`
|
|
53
|
+
if (!leadRank.has(key)) leadRank.set(key, index)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
const rank = (plugin: T['plugins'][number]): number => {
|
|
57
|
+
let best = Number.POSITIVE_INFINITY
|
|
58
|
+
for (const category of plugin.categories) {
|
|
59
|
+
const value = leadRank.get(`${category}::${plugin.upstreamPluginName}`)
|
|
60
|
+
if (value !== undefined && value < best) best = value
|
|
61
|
+
}
|
|
62
|
+
return best
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
...catalog,
|
|
66
|
+
plugins: catalog.plugins.toSorted((left, right) => rank(left) - rank(right)),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function trimSlugEdges(value: string): string {
|
|
71
|
+
let start = 0
|
|
72
|
+
let end = value.length
|
|
73
|
+
while (start < end && value[start] === '-') start += 1
|
|
74
|
+
while (end > start && value[end - 1] === '-') end -= 1
|
|
75
|
+
return value.slice(start, end)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function slug(value: string): string {
|
|
79
|
+
const result =
|
|
80
|
+
trimSlugEdges(
|
|
81
|
+
value
|
|
82
|
+
.trim()
|
|
83
|
+
.toLocaleLowerCase()
|
|
84
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
85
|
+
).slice(0, 128) || 'unknown'
|
|
86
|
+
return result.length < 2 ? `${result}-plugin` : result
|
|
87
|
+
}
|