@adea-ai/plugins 3.0.10 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +18 -11
  2. package/dist/agent-plugins-catalog.d.ts +2 -3
  3. package/dist/agent-plugins-catalog.js +2 -10
  4. package/dist/agent-plugins-catalog.js.map +1 -1
  5. package/dist/catalog-store.d.ts +81 -0
  6. package/dist/catalog-store.js +487 -0
  7. package/dist/catalog-store.js.map +1 -0
  8. package/dist/consumer-index.d.ts +1 -1
  9. package/dist/consumer-index.js +17 -7
  10. package/dist/consumer-index.js.map +1 -1
  11. package/dist/display-name.js +3 -2
  12. package/dist/display-name.js.map +1 -1
  13. package/dist/harness.d.ts +50 -9
  14. package/dist/harness.js +190 -147
  15. package/dist/harness.js.map +1 -1
  16. package/dist/icon-mirror.d.ts +2 -2
  17. package/dist/icon-mirror.js +38 -25
  18. package/dist/icon-mirror.js.map +1 -1
  19. package/dist/icons.js +5 -27
  20. package/dist/icons.js.map +1 -1
  21. package/dist/index.d.ts +9 -3
  22. package/dist/index.js +103 -88
  23. package/dist/index.js.map +1 -1
  24. package/dist/publication.d.ts +8 -11
  25. package/dist/publication.js +8 -11
  26. package/dist/publication.js.map +1 -1
  27. package/dist/schema.d.ts +0 -40
  28. package/dist/schema.js +0 -36
  29. package/dist/schema.js.map +1 -1
  30. package/dist/site-icons.d.ts +2 -0
  31. package/dist/site-icons.js +46 -9
  32. package/dist/site-icons.js.map +1 -1
  33. package/dist/source-digest.d.ts +2 -0
  34. package/dist/source-digest.js +12 -0
  35. package/dist/source-digest.js.map +1 -0
  36. package/dist/sources.js +3 -3
  37. package/dist/sources.js.map +1 -1
  38. package/native/build-provenance.txt +5 -5
  39. package/native/staged-reader-win32-arm64.node +0 -0
  40. package/native/staged-reader-win32-x64.node +0 -0
  41. package/package.json +4 -9
  42. package/src/agent-plugins-catalog.ts +3 -10
  43. package/src/artifacts.test.ts +80 -0
  44. package/src/catalog-installation.test.ts +1 -1
  45. package/src/catalog-store.ts +546 -0
  46. package/src/consumer-index.test.ts +125 -0
  47. package/src/consumer-index.ts +23 -8
  48. package/src/default-head.test.ts +143 -0
  49. package/src/display-name.test.ts +8 -0
  50. package/src/display-name.ts +3 -2
  51. package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
  52. package/src/harness.ts +249 -170
  53. package/src/icon-mirror.ts +36 -23
  54. package/src/icons.test.ts +5 -0
  55. package/src/icons.ts +6 -29
  56. package/src/index.test.ts +272 -40
  57. package/src/index.ts +131 -94
  58. package/src/manifest-retry.test.ts +49 -0
  59. package/src/portable-catalog.test.ts +8 -5
  60. package/src/publication.ts +8 -11
  61. package/src/schema.ts +0 -40
  62. package/src/site-icon-fallback.test.ts +36 -19
  63. package/src/site-icons.test.ts +104 -0
  64. package/src/site-icons.ts +47 -9
  65. package/src/source-config-sync.test.ts +84 -0
  66. package/src/source-digest.test.ts +101 -0
  67. package/src/source-digest.ts +14 -0
  68. package/src/sources-json.test.ts +53 -0
  69. package/src/sources.ts +3 -3
  70. package/src/upstream-headers.test.ts +31 -7
  71. package/src/write-artifacts.test.ts +375 -0
  72. package/dist/agent-plugins-harness.d.ts +0 -54
  73. package/dist/agent-plugins-harness.js +0 -212
  74. package/dist/agent-plugins-harness.js.map +0 -1
  75. package/src/agent-plugins-harness.ts +0 -281
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
3
  import { promises as fs } from 'node:fs'
4
- import { basename, dirname, join, relative, resolve } from 'node:path'
4
+ import { basename, join, relative, resolve } from 'node:path'
5
5
  import { Readable } from 'node:stream'
6
+ import { setTimeout as sleep } from 'node:timers/promises'
6
7
  import { createGunzip } from 'node:zlib'
7
8
  import { extract } from 'tar-stream'
9
+ import { publishCatalog, type PublishOptions } from './catalog-store.js'
8
10
  import {
9
11
  CatalogSchema,
10
12
  type Catalog,
@@ -59,6 +61,18 @@ import {
59
61
  } from './site-icons.js'
60
62
  import { normalizeDisplayName } from './display-name.js'
61
63
  import { immutableAssetUrl } from './publication.js'
64
+ import { sourceDigest as bytesDigest } from './source-digest.js'
65
+
66
+ export { bytesDigest }
67
+ export {
68
+ catalogFingerprint,
69
+ publishCatalog,
70
+ withCatalogRead,
71
+ writeFileAtomically,
72
+ type CatalogCoordinationOptions,
73
+ type PublicationStep,
74
+ type PublishOptions,
75
+ } from './catalog-store.js'
62
76
 
63
77
  /**
64
78
  * Marks the catalog format this compiler emits.
@@ -297,17 +311,6 @@ export function digest(value: unknown): string {
297
311
  return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
298
312
  }
299
313
 
300
- export function bytesDigest(files: ReadonlyMap<string, Uint8Array>): string {
301
- const hash = createHash('sha256')
302
- for (const [path, bytes] of [...files.entries()].toSorted(([left], [right]) =>
303
- left.localeCompare(right)
304
- )) {
305
- hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
306
- hash.update(bytes)
307
- }
308
- return `sha256:${hash.digest('hex')}`
309
- }
310
-
311
314
  export function stablePluginId(sourceId: string, upstreamName: string): string {
312
315
  return `plugin:${slug(sourceId)}:${slug(upstreamName)}`
313
316
  }
@@ -345,20 +348,26 @@ export async function synchronize(input: SyncInput): Promise<SyncResult> {
345
348
  sourceId: config.sourceId,
346
349
  commitSha,
347
350
  }))
351
+ const resolvedLock = createSourcesLock(resolved)
348
352
  const unchanged =
349
353
  input.fromLock === undefined &&
350
354
  input.forceRebuild !== true &&
351
355
  input.existingLock !== undefined &&
352
356
  resolved.length === input.existingLock.sources.length &&
353
- resolved.every((source) => {
357
+ resolvedLock.sources.every((source) => {
354
358
  const previous = input.existingLock?.sources.find(
355
- (entry) => entry.sourceId === source.config.sourceId
359
+ (entry) => entry.sourceId === source.sourceId
356
360
  )
357
- if (!previous) return false
361
+ if (!previous || sourceLockChanged(previous, source)) return false
362
+ if (!input.existingCatalog) return true
363
+ const priorCatalog = input.existingCatalog.sources.find(
364
+ (entry) => entry.sourceId === source.sourceId
365
+ )
366
+ const config = resolved.find((entry) => entry.config.sourceId === source.sourceId)!.config
358
367
  return (
359
- previous.resolvedCommitSha === source.commitSha &&
360
- previous.sourceManifestDigest === source.manifestDigest &&
361
- digest(previous.pluginPins) === digest(source.pluginPins)
368
+ !!priorCatalog &&
369
+ priorCatalog.displayName === config.displayName &&
370
+ priorCatalog.trustClassification === config.trustClassification
362
371
  )
363
372
  })
364
373
  if (unchanged) {
@@ -398,7 +407,7 @@ export async function synchronize(input: SyncInput): Promise<SyncResult> {
398
407
  ...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
399
408
  })
400
409
  const catalog = buildResult.catalog
401
- const lock = createSourcesLock(resolved)
410
+ const lock = resolvedLock
402
411
  const artifacts = createArtifacts(orderLeadingPlugins(catalog, input.leading), lock, {
403
412
  ...(input.leading ? { leading: input.leading } : {}),
404
413
  ...(input.topCount !== undefined ? { topCount: input.topCount } : {}),
@@ -463,6 +472,7 @@ export async function buildCatalog(input: {
463
472
  ...(input.productCategories ? { productCategories: input.productCategories } : {}),
464
473
  ...(input.leading ? { leading: input.leading } : {}),
465
474
  ...(input.productIconOverrides ? { productIconOverrides: input.productIconOverrides } : {}),
475
+ ...(input.iconAssetsDirectory ? { iconAssetsDirectory: input.iconAssetsDirectory } : {}),
466
476
  })
467
477
  ).catalog
468
478
  }
@@ -988,10 +998,7 @@ async function resolvePluginSource(
988
998
  pin?.resolvedCommitSha ??
989
999
  spec.sha ??
990
1000
  (resolveExternalRefs
991
- ? await resolveGitRef(
992
- spec.repositoryUrl,
993
- spec.ref?.startsWith('refs/') ? spec.ref : `refs/heads/${spec.ref ?? 'main'}`
994
- )
1001
+ ? await resolveGitRef(spec.repositoryUrl, externalGitRef(spec.ref))
995
1002
  : source.commitSha)
996
1003
  return {
997
1004
  repositoryUrl: canonicalUrl,
@@ -1247,6 +1254,18 @@ function findLicense(
1247
1254
  }
1248
1255
  }
1249
1256
 
1257
+ function sourceLockChanged(previous: SourceLockEntry, current: SourceLockEntry): boolean {
1258
+ return (
1259
+ previous.repositoryUrl !== current.repositoryUrl ||
1260
+ previous.defaultBranch !== current.defaultBranch ||
1261
+ previous.manifestPath !== current.manifestPath ||
1262
+ previous.marketplaceDialect !== current.marketplaceDialect ||
1263
+ previous.resolvedCommitSha !== current.resolvedCommitSha ||
1264
+ previous.sourceManifestDigest !== current.sourceManifestDigest ||
1265
+ digest(previous.pluginPins) !== digest(current.pluginPins)
1266
+ )
1267
+ }
1268
+
1250
1269
  function createSourcesLock(sources: readonly ResolvedSource[]): SourcesLock {
1251
1270
  const entries: SourceLockEntry[] = sources
1252
1271
  .map((source) => ({
@@ -1485,35 +1504,16 @@ export function iconAssets(index: ConsumerIndex): readonly PublishedAsset[] {
1485
1504
  return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name))
1486
1505
  }
1487
1506
 
1507
+ /**
1508
+ * Publishes a verified artifact set to `generatedDirectory` under the output's
1509
+ * coordination lock. See `catalog-store.ts` for the publication contract.
1510
+ */
1488
1511
  export async function writeArtifacts(
1489
1512
  generatedDirectory: string,
1490
- artifacts: GeneratedArtifacts
1513
+ artifacts: GeneratedArtifacts,
1514
+ options: PublishOptions = {}
1491
1515
  ): Promise<void> {
1492
- const parent = dirname(generatedDirectory)
1493
- await fs.mkdir(parent, { recursive: true })
1494
- const temporary = await fs.mkdtemp(join(parent, '.catalog-'))
1495
- try {
1496
- for (const [name, content] of Object.entries(artifacts))
1497
- await fs.writeFile(join(temporary, name), content, 'utf8')
1498
- const backup = `${generatedDirectory}.previous`
1499
- await fs.rm(backup, { recursive: true, force: true })
1500
- let movedExisting = false
1501
- try {
1502
- await fs.rename(generatedDirectory, backup)
1503
- movedExisting = true
1504
- } catch (error) {
1505
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
1506
- }
1507
- try {
1508
- await fs.rename(temporary, generatedDirectory)
1509
- } catch (error) {
1510
- if (movedExisting) await fs.rename(backup, generatedDirectory)
1511
- throw error
1512
- }
1513
- if (movedExisting) await fs.rm(backup, { recursive: true, force: true })
1514
- } finally {
1515
- await fs.rm(temporary, { recursive: true, force: true })
1516
- }
1516
+ await publishCatalog(generatedDirectory, artifacts as Record<string, string>, options)
1517
1517
  }
1518
1518
 
1519
1519
  export function verifyArtifacts(artifacts: GeneratedArtifacts): void {
@@ -1878,11 +1878,20 @@ function createChangeReport(
1878
1878
  const previous = existingLock.sources.find(
1879
1879
  (candidate) => candidate.sourceId === source.sourceId
1880
1880
  )
1881
+ const priorCatalog = existingCatalog?.sources.find(
1882
+ (entry) => entry.sourceId === source.sourceId
1883
+ )
1884
+ const currentCatalog = catalog.sources.find(
1885
+ (entry) => entry.sourceId === source.sourceId
1886
+ )
1881
1887
  return (
1882
1888
  !previous ||
1883
- previous?.resolvedCommitSha !== source.resolvedCommitSha ||
1884
- previous.sourceManifestDigest !== source.sourceManifestDigest ||
1885
- digest(previous.pluginPins) !== digest(source.pluginPins)
1889
+ sourceLockChanged(previous, source) ||
1890
+ (existingCatalog !== undefined &&
1891
+ (!priorCatalog ||
1892
+ !currentCatalog ||
1893
+ priorCatalog.displayName !== currentCatalog.displayName ||
1894
+ priorCatalog.trustClassification !== currentCatalog.trustClassification))
1886
1895
  )
1887
1896
  })()
1888
1897
  )
@@ -2002,7 +2011,10 @@ async function resolvePluginPins(input: {
2002
2011
 
2003
2012
  const repositoryUrl = canonicalRepositoryUrl(entry.source.repositoryUrl)
2004
2013
  const pluginSubdirectory = entry.source.subdirectory
2005
- const locked = input.lockedPins?.find((pin) => pin.pluginName === entry.name)
2014
+ // Existing locks describe the prior sync; only explicit replay freezes revisions.
2015
+ const locked = input.fromLock
2016
+ ? input.lockedPins?.find((pin) => pin.pluginName === entry.name)
2017
+ : undefined
2006
2018
  if (input.fromLock && !locked)
2007
2019
  throw new Error(`SOURCE_LOCK_PLUGIN_PIN_MISSING: ${input.config.sourceId}:${entry.name}`)
2008
2020
  if (locked) {
@@ -2018,17 +2030,19 @@ async function resolvePluginPins(input: {
2018
2030
  const resolvedCommitSha =
2019
2031
  entry.source.sha ??
2020
2032
  (input.resolveExternalRefs
2021
- ? await resolveGitRef(
2022
- repositoryUrl,
2023
- entry.source.ref?.startsWith('refs/')
2024
- ? entry.source.ref
2025
- : `refs/heads/${entry.source.ref ?? 'main'}`
2026
- )
2033
+ ? await resolveGitRef(repositoryUrl, externalGitRef(entry.source.ref))
2027
2034
  : input.sourceCommitSha)
2028
2035
  return { pluginName: entry.name, repositoryUrl, pluginSubdirectory, resolvedCommitSha }
2029
2036
  }
2030
2037
  )
2031
- return pins.toSorted((left, right) => left.pluginName.localeCompare(right.pluginName))
2038
+ return pins.toSorted((left, right) =>
2039
+ left.pluginName < right.pluginName ? -1 : left.pluginName > right.pluginName ? 1 : 0
2040
+ )
2041
+ }
2042
+
2043
+ function externalGitRef(ref: string | undefined): string {
2044
+ if (ref === undefined) return 'HEAD'
2045
+ return ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
2032
2046
  }
2033
2047
 
2034
2048
  async function mapWithConcurrency<T, U>(
@@ -2061,9 +2075,14 @@ async function readFixtureManifest(
2061
2075
  }
2062
2076
 
2063
2077
  async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string> {
2064
- const result = spawn('git', ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref], {
2065
- stdio: ['ignore', 'pipe', 'pipe'],
2066
- })
2078
+ const peeled = ref.startsWith('refs/tags/') && !ref.endsWith('^{}') ? `${ref}^{}` : undefined
2079
+ const result = spawn(
2080
+ 'git',
2081
+ ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref, ...(peeled ? [peeled] : [])],
2082
+ {
2083
+ stdio: ['ignore', 'pipe', 'pipe'],
2084
+ }
2085
+ )
2067
2086
  const [stdout, stderr, exitCode] = await Promise.all([
2068
2087
  new Promise<string>((resolveOutput, reject) => {
2069
2088
  const chunks: Buffer[] = []
@@ -2084,7 +2103,13 @@ async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string
2084
2103
  ])
2085
2104
  if (exitCode !== 0)
2086
2105
  throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`)
2087
- const sha = stdout.trim().split(/\s+/)[0]
2106
+ const rows = stdout
2107
+ .trim()
2108
+ .split('\n')
2109
+ .map((line) => line.split(/\s+/))
2110
+ const sha =
2111
+ (peeled && rows.find((row) => row[1] === peeled)?.[0]) ??
2112
+ rows.find((row) => row[1] === ref)?.[0]
2088
2113
  if (!sha || !/^[a-f0-9]{40}$/.test(sha))
2089
2114
  throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`)
2090
2115
  return sha
@@ -2111,7 +2136,8 @@ function rawUrl(repositoryUrl: string, commitSha: string, path: string): string
2111
2136
  }
2112
2137
 
2113
2138
  async function fetchText(url: string): Promise<string> {
2114
- const response = await fetch(url, { headers: upstreamRequestHeaders(url) })
2139
+ // Manifest requests use the same bounded retry policy as the tree and archive.
2140
+ const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2115
2141
  if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`)
2116
2142
  return response.text()
2117
2143
  }
@@ -2192,10 +2218,6 @@ const UPSTREAM_WEATHER_SKIP_CODES: ReadonlySet<PluginSkipReasonCode> = new Set([
2192
2218
  'PLUGIN_PATH_NOT_FOUND',
2193
2219
  ])
2194
2220
 
2195
- function sleep(ms: number): Promise<void> {
2196
- return new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
2197
- }
2198
-
2199
2221
  function upstreamRetryDelayMs(attempt: number, response?: Response): number {
2200
2222
  const retryAfter = response?.headers.get('retry-after')
2201
2223
  if (retryAfter !== null && retryAfter !== undefined) {
@@ -2230,8 +2252,8 @@ async function fetchUpstream(url: string, init: RequestInit): Promise<Response>
2230
2252
  try {
2231
2253
  response = await fetch(url, init)
2232
2254
  } catch (error) {
2233
- if (attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
2234
- await sleep(upstreamRetryDelayMs(attempt))
2255
+ if (init.signal?.aborted || attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1) throw error
2256
+ await sleep(upstreamRetryDelayMs(attempt), undefined, { signal: init.signal ?? undefined })
2235
2257
  attempt += 1
2236
2258
  continue
2237
2259
  }
@@ -2241,7 +2263,10 @@ async function fetchUpstream(url: string, init: RequestInit): Promise<Response>
2241
2263
  attempt >= UPSTREAM_FETCH_MAX_ATTEMPTS - 1
2242
2264
  )
2243
2265
  return response
2244
- await sleep(upstreamRetryDelayMs(attempt, response))
2266
+ await response.body?.cancel().catch(() => {})
2267
+ await sleep(upstreamRetryDelayMs(attempt, response), undefined, {
2268
+ signal: init.signal ?? undefined,
2269
+ })
2245
2270
  attempt += 1
2246
2271
  }
2247
2272
  }
@@ -2343,8 +2368,8 @@ export async function readArchiveFiles(
2343
2368
 
2344
2369
  export class NetworkSnapshotLoader implements SnapshotLoader {
2345
2370
  readonly #policy: CatalogPolicy
2346
- readonly #trees = new Map<string, readonly TreeEntry[]>()
2347
- readonly #archives = new Map<string, ReadonlyMap<string, Uint8Array>>()
2371
+ readonly #trees = new Map<string, Promise<readonly TreeEntry[]>>()
2372
+ readonly #archives = new Map<string, Promise<ReadonlyMap<string, Uint8Array>>>()
2348
2373
 
2349
2374
  constructor(policy: CatalogPolicy) {
2350
2375
  this.#policy = policy
@@ -2361,22 +2386,28 @@ export class NetworkSnapshotLoader implements SnapshotLoader {
2361
2386
  const [owner, repo] = parsed.pathname.split('/').filter(Boolean)
2362
2387
  if (!owner || !repo) throw new Error('GIT_REPOSITORY_INVALID')
2363
2388
  const key = `${owner}/${repo}@${commitSha}`
2364
- let tree = this.#trees.get(key)
2365
- if (!tree) {
2366
- const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`
2367
- const response = await fetchUpstream(treeUrl, {
2368
- headers: upstreamRequestHeaders(treeUrl),
2389
+ let pendingTree = this.#trees.get(key)
2390
+ if (!pendingTree) {
2391
+ pendingTree = (async () => {
2392
+ const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`
2393
+ const response = await fetchUpstream(treeUrl, {
2394
+ headers: upstreamRequestHeaders(treeUrl),
2395
+ })
2396
+ if (!response.ok)
2397
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2398
+ const payload = (await response.json()) as { truncated?: boolean; tree?: TreeEntry[] }
2399
+ if (payload.truncated) throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`)
2400
+ return (payload.tree ?? []).map((entry) => ({
2401
+ ...entry,
2402
+ path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
2403
+ }))
2404
+ })().catch((error: unknown) => {
2405
+ this.#trees.delete(key)
2406
+ throw error
2369
2407
  })
2370
- if (!response.ok)
2371
- throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2372
- const payload = (await response.json()) as { truncated?: boolean; tree?: TreeEntry[] }
2373
- if (payload.truncated) throw new Error(`GIT_TREE_TRUNCATED: ${owner}/${repo}@${commitSha}`)
2374
- tree = (payload.tree ?? []).map((entry) => ({
2375
- ...entry,
2376
- path: safeRelativePath(entry.path, 'TREE_PATH_INVALID'),
2377
- }))
2378
- this.#trees.set(key, tree)
2408
+ this.#trees.set(key, pendingTree)
2379
2409
  }
2410
+ const tree = await pendingTree
2380
2411
  const prefix = pluginSubdirectory ? `${safeRelativePath(pluginSubdirectory)}/` : ''
2381
2412
  const submodules = tree.filter(
2382
2413
  (entry) =>
@@ -2428,12 +2459,18 @@ export class NetworkSnapshotLoader implements SnapshotLoader {
2428
2459
  ): Promise<ReadonlyMap<string, Uint8Array>> {
2429
2460
  const cached = this.#archives.get(key)
2430
2461
  if (cached) return cached
2431
- const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`
2432
- const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2433
- if (!response.ok) throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2434
- const files = await readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy)
2435
- this.#archives.set(key, files)
2436
- return files
2462
+ const pending = (async () => {
2463
+ const url = `https://api.github.com/repos/${owner}/${repo}/tarball/${commitSha}`
2464
+ const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
2465
+ if (!response.ok)
2466
+ throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [`${owner}/${repo}`])
2467
+ return readArchiveFiles(new Uint8Array(await response.arrayBuffer()), this.#policy)
2468
+ })().catch((error: unknown) => {
2469
+ this.#archives.delete(key)
2470
+ throw error
2471
+ })
2472
+ this.#archives.set(key, pending)
2473
+ return pending
2437
2474
  }
2438
2475
  }
2439
2476
 
@@ -0,0 +1,49 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { expect, test } from 'bun:test'
4
+ import { FixtureSnapshotLoader, synchronize } from './index.js'
5
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
6
+
7
+ test('retries transient marketplace manifest responses while preserving permanent failures', () =>
8
+ withPortableFixture(async (input, root) => {
9
+ const initial = await synchronize(input)
10
+ const manifest = await readFile(join(root, 'fixtures/source/marketplace.json'), 'utf8')
11
+ const realFetch = globalThis.fetch
12
+ let calls = 0
13
+ try {
14
+ globalThis.fetch = (async () => {
15
+ calls++
16
+ if (calls < 3)
17
+ return new Response('temporarily unavailable', {
18
+ status: 503,
19
+ headers: { 'retry-after': '0' },
20
+ })
21
+ return new Response(manifest, { status: 200 })
22
+ }) as unknown as typeof fetch
23
+ const options = {
24
+ ...input,
25
+ mode: 'live' as const,
26
+ fromLock: initial.lock!,
27
+ snapshotLoader: new FixtureSnapshotLoader(input.fixtureRoot!, input.sources),
28
+ }
29
+ const recovered = await synchronize(options)
30
+ expect(recovered.catalog?.catalogId).toBe(initial.catalog?.catalogId)
31
+ expect(calls).toBe(3)
32
+ calls = 0
33
+ globalThis.fetch = (async () => {
34
+ calls++
35
+ return new Response('not found', { status: 404 })
36
+ }) as unknown as typeof fetch
37
+ await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 404')
38
+ expect(calls).toBe(1)
39
+ calls = 0
40
+ globalThis.fetch = (async () => {
41
+ calls++
42
+ return new Response('busy', { status: 429, headers: { 'retry-after': '0' } })
43
+ }) as unknown as typeof fetch
44
+ await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 429')
45
+ expect(calls).toBe(3)
46
+ } finally {
47
+ globalThis.fetch = realFetch
48
+ }
49
+ }))
@@ -12,7 +12,7 @@ import {
12
12
  PACKAGE_METADATA_KEY,
13
13
  } from './portable-catalog.js'
14
14
  import { verifyAgentPackage, materializeAgentPackage } from './agent-plugins-catalog.js'
15
- import { createCatalogInstallationPlan, createInstallationPlan } from './agent-plugins-harness.js'
15
+ import { createCatalogInstallationPlan, createInstallationPlan } from './harness.js'
16
16
  import type { HarnessProfile } from './agent-plugins-schema.js'
17
17
  import { snapshotFromDirectory } from './index.js'
18
18
  import {
@@ -351,7 +351,7 @@ it('refuses a malformed provenance descriptor with one typed line naming the plu
351
351
  }))
352
352
 
353
353
  const CONSUMER_SCRIPT = `import { readFileSync } from 'node:fs'
354
- import { createCatalogInstallationPlan } from '@adea-ai/plugins/agent-plugins/harness'
354
+ import { createCatalogInstallationPlan } from '@adea-ai/plugins/harness'
355
355
  import { synchronizePortable } from '@adea-ai/plugins/portable-catalog'
356
356
  import { digest } from '@adea-ai/plugins'
357
357
  const request = JSON.parse(readFileSync(process.argv[2], 'utf8'))
@@ -377,7 +377,7 @@ try {
377
377
  ok: true,
378
378
  plan,
379
379
  catalogId: result.catalog.catalogId,
380
- resolvedHarness: import.meta.resolve('@adea-ai/plugins/agent-plugins/harness'),
380
+ resolvedHarness: import.meta.resolve('@adea-ai/plugins/harness'),
381
381
  resolvedCatalog: import.meta.resolve('@adea-ai/plugins/portable-catalog'),
382
382
  })
383
383
  )
@@ -475,7 +475,7 @@ it('keeps the packed consumer inside the packed artifact and its own dependencie
475
475
  // The packed artifact was built from the CURRENT source: a stale build
476
476
  // would still carry the sentinel the rebuild removes.
477
477
  assert.equal(consumer.builtFromCurrentSource, true)
478
- assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'agent-plugins-harness.js')), true)
478
+ assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'harness.js')), true)
479
479
  // Every copied dependency really resolves INSIDE the consumer: the
480
480
  // workspace installs them as links into the package manager's store, so
481
481
  // a copy that kept the link would resolve outside the consumer.
@@ -487,7 +487,10 @@ it('keeps the packed consumer inside the packed artifact and its own dependencie
487
487
  // dependencies, and never the whole workspace.
488
488
  const packedManifest = JSON.parse(
489
489
  await readFile(join(consumer.packedRoot, 'package.json'), 'utf8')
490
- ) as { dependencies?: Record<string, string> }
490
+ ) as { dependencies?: Record<string, string>; exports: Record<string, unknown> }
491
+ assert.ok(packedManifest.exports['./harness'])
492
+ assert.equal(packedManifest.exports['./agent-plugins/harness'], undefined)
493
+ assert.equal(existsSync(join(consumer.packedRoot, 'dist', 'agent-plugins-harness.js')), false)
491
494
  const declared = Object.keys(packedManifest.dependencies ?? {})
492
495
  for (const name of declared) assert.ok(consumer.linkedDependencies.includes(name))
493
496
  const installed = await readdir(join(consumer.consumerRoot, 'node_modules'))
@@ -9,9 +9,9 @@
9
9
  * that slot, so no version is ever the one a visitor sees first.
10
10
  *
11
11
  * The branch gives each catalog an immutable path — `catalogs/<catalogId>/…`,
12
- * where `catalogId` is the canonical digest of the catalog itself — so a
13
- * pinned URL is immutable by construction, exactly as a content-addressed
14
- * release tag was, and `raw.githubusercontent.com` serves it with permissive
12
+ * where `catalogId` is the canonical digest of the catalog itself. The
13
+ * publisher must reject replacement of existing snapshot bytes, and
14
+ * `raw.githubusercontent.com` serves the branch with permissive
15
15
  * CORS so a browser can fetch it directly.
16
16
  */
17
17
 
@@ -63,11 +63,9 @@ export interface StagedAssetAudit {
63
63
  /**
64
64
  * Audits the bytes this build staged against what it declared.
65
65
  *
66
- * A content-addressed path removes the comparison the release audit used to
67
- * perform: a snapshot published at `catalogs/<catalogId>/` can only hold this
68
- * catalog's bytes, because any different build has a different `catalogId` and
69
- * therefore a different path. What survives is the check that cannot be
70
- * delegated to the path — that the bytes exist here at all.
66
+ * This checks that the declared bytes exist here at all. Publication must
67
+ * separately compare the complete staged snapshot with any existing snapshot:
68
+ * companion artifacts can differ without changing the catalog identity.
71
69
  */
72
70
  export function auditStagedAssets(input: {
73
71
  readonly declared: readonly DeclaredAsset[]
@@ -95,9 +93,8 @@ export interface PublicationPlan {
95
93
  /**
96
94
  * The 64-hex digest a `catalog:<hex>` identity is addressed by.
97
95
  *
98
- * A catalog identity names its own bytes, so this is also what makes the
99
- * snapshot path immutable: any build that produced different content has a
100
- * different identity and cannot write over this one.
96
+ * A catalog identity names the catalog content. The publisher must also guard
97
+ * the snapshot path against changes to companion artifacts under the same ID.
101
98
  */
102
99
  export function catalogIdSuffix(catalogId: string): string {
103
100
  const match = /^catalog:([a-f0-9]{64})$/.exec(catalogId)
package/src/schema.ts CHANGED
@@ -248,45 +248,6 @@ export const SourcesLockSchema = z
248
248
  })
249
249
  .strict()
250
250
 
251
- export const MaterializationPlanSchema = z
252
- .object({
253
- planVersion: z.literal(1),
254
- pluginId: PluginSchema.shape.pluginId,
255
- releaseId: PluginReleaseSchema.shape.releaseId,
256
- harness: HarnessSchema,
257
- source: z
258
- .object({
259
- repositoryUrl: z.string().url(),
260
- commitSha: ShaSchema,
261
- pluginSubdirectory: z.string().min(1),
262
- contentDigest: DigestSchema,
263
- })
264
- .strict(),
265
- targetLayout: z
266
- .object({
267
- root: z.string().min(1),
268
- notes: z.array(z.string().min(1)).max(32),
269
- })
270
- .strict(),
271
- files: z
272
- .array(
273
- z
274
- .object({
275
- sourcePath: z.string().min(1),
276
- targetPath: z.string().min(1),
277
- action: z.enum(['copy', 'translate', 'ignore', 'unsupported']),
278
- reason: z.string().min(1).max(500),
279
- })
280
- .strict()
281
- )
282
- .max(4096),
283
- configuration: JsonObjectSchema,
284
- requiredConnectors: z.array(z.string().min(1)).max(128),
285
- requiredCredentials: z.array(z.string().min(1)).max(128),
286
- policyConstraints: z.array(z.string().min(1)).max(128),
287
- })
288
- .strict()
289
-
290
251
  export type CatalogSource = z.output<typeof CatalogSourceSchema>
291
252
  export type Capability = z.output<typeof CapabilitySchema>
292
253
  export type HarnessCompatibility = z.output<typeof HarnessCompatibilitySchema>
@@ -298,7 +259,6 @@ export type Plugin = z.output<typeof PluginSchema>
298
259
  export type Catalog = z.output<typeof CatalogSchema>
299
260
  export type SourceLockEntry = z.output<typeof SourceLockEntrySchema>
300
261
  export type SourcesLock = z.output<typeof SourcesLockSchema>
301
- export type MaterializationPlan = z.output<typeof MaterializationPlanSchema>
302
262
 
303
263
  export function parseCatalog(input: unknown): Catalog {
304
264
  return CatalogSchema.parse(input)