@adea-ai/plugins 4.0.0 → 4.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +76 -0
  17. package/dist/catalog-store.js +463 -0
  18. package/dist/catalog-store.js.map +1 -0
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +7 -29
  23. package/dist/consumer-index.js +24 -15
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +5 -4
  34. package/dist/icon-mirror.js +73 -72
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -309
  40. package/dist/index.js +12 -1900
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +12 -12
  64. package/dist/publication.js +31 -21
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +4 -3
  70. package/dist/site-icons.js +38 -11
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +3 -3
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.test.ts +80 -0
  101. package/src/artifacts.ts +234 -0
  102. package/src/build.ts +131 -0
  103. package/src/capabilities.ts +153 -0
  104. package/src/catalog-store.ts +519 -0
  105. package/src/concurrency.ts +19 -0
  106. package/src/consumer-index.test.ts +147 -16
  107. package/src/consumer-index.ts +35 -43
  108. package/src/default-head.test.ts +143 -0
  109. package/src/digest.ts +55 -0
  110. package/src/fs-atomic.ts +32 -0
  111. package/src/harness.test.ts +4 -32
  112. package/src/harness.ts +2 -1
  113. package/src/icon-mirror.ts +78 -72
  114. package/src/icons.test.ts +0 -4
  115. package/src/icons.ts +4 -4
  116. package/src/index.test.ts +172 -46
  117. package/src/index.ts +58 -2562
  118. package/src/integrity-manifest.ts +41 -21
  119. package/src/json-values.ts +11 -0
  120. package/src/manifest-retry.test.ts +49 -0
  121. package/src/plugin-release.ts +457 -0
  122. package/src/plugin-skips.ts +110 -0
  123. package/src/policy.ts +39 -0
  124. package/src/portable-catalog.test.ts +0 -5
  125. package/src/portable-catalog.ts +16 -143
  126. package/src/portable-verify.ts +71 -0
  127. package/src/publication.test.ts +29 -1
  128. package/src/publication.ts +38 -22
  129. package/src/published-output.ts +60 -0
  130. package/src/site-icons.test.ts +73 -1
  131. package/src/site-icons.ts +31 -14
  132. package/src/snapshots.ts +287 -0
  133. package/src/source-config-sync.test.ts +84 -0
  134. package/src/source-digest.test.ts +61 -8
  135. package/src/source-resolution.ts +233 -0
  136. package/src/sources-json.test.ts +18 -2
  137. package/src/sources.ts +27 -33
  138. package/src/synchronize.ts +213 -0
  139. package/src/taxonomy.ts +87 -0
  140. package/src/upstream.ts +194 -0
  141. package/src/verify-artifacts.ts +365 -0
  142. package/src/write-artifacts.test.ts +374 -0
  143. package/dist/source-digest.d.ts +0 -2
  144. package/dist/source-digest.js +0 -12
  145. package/dist/source-digest.js.map +0 -1
  146. package/native/README.md +0 -24
  147. package/src/source-digest.ts +0 -14
@@ -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
+ }
@@ -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 { createHash } from 'node:crypto'
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 = 'openai' | 'cursor' | 'claude'
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
- const entries = plugins.map((value, index) => parsePluginEntry(value, source, index))
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: ownerNames(root.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((a, b) =>
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
+ }
@@ -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
+ }