@adea-ai/plugins 1.0.0

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 (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. package/src/upstream-headers.test.ts +148 -0
@@ -0,0 +1,186 @@
1
+ import { it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { writeFile, mkdir } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { synchronize, verifyArtifacts, digest } from './index.js'
6
+ import {
7
+ synchronizePortable,
8
+ verifyPortableCatalog,
9
+ PACKAGE_METADATA_KEY,
10
+ } from './portable-catalog.js'
11
+ import { verifyAgentPackage, materializeAgentPackage } from './agent-plugins-catalog.js'
12
+ import { snapshotFromDirectory } from './index.js'
13
+ import { withPortableFixture } from '../test-support/portable-fixture.js'
14
+
15
+ it('normalizes a real offline catalog and preserves source/release identity', () =>
16
+ withPortableFixture(async (input, root) => {
17
+ const legacy = await synchronize(input)
18
+ const portable = await synchronizePortable(input)
19
+ assert.ok(portable.catalog && portable.artifacts && legacy.catalog)
20
+ verifyArtifacts(portable.artifacts)
21
+ verifyPortableCatalog(portable.catalog, true)
22
+ assert.equal(portable.catalog.schemaVersion, 1)
23
+ const plugin = portable.catalog.plugins[0]!
24
+ const release = plugin.availableReleases[0]!
25
+ assert.equal(release.releaseId, legacy.catalog.plugins[0]?.currentReleaseId)
26
+ assert.equal(
27
+ release.canonicalContentDigest,
28
+ legacy.catalog.plugins[0]?.availableReleases[0]?.canonicalContentDigest
29
+ )
30
+ assert.equal(plugin.harnessCompatibility['claude-code'].status, 'unknown')
31
+ const pkg = verifyAgentPackage(release.releaseMetadata[PACKAGE_METADATA_KEY])
32
+ const snapshot = await snapshotFromDirectory(
33
+ join(root, 'fixtures/source'),
34
+ 'plugins/review-kit'
35
+ )
36
+ assert.ok(
37
+ materializeAgentPackage(pkg, snapshot.files).has('skills/review/references/checklist.md')
38
+ )
39
+ }))
40
+ it('produces deterministic catalog and integrity artifacts', () =>
41
+ withPortableFixture(async (input) => {
42
+ assert.deepEqual(
43
+ (await synchronizePortable(input)).artifacts,
44
+ (await synchronizePortable(input)).artifacts
45
+ )
46
+ }))
47
+ it('migrates unchanged v1 pins once, then returns unchanged for the canonical contract', () =>
48
+ withPortableFixture(async (input) => {
49
+ const legacy = await synchronize(input)
50
+ const migrated = await synchronizePortable({
51
+ ...input,
52
+ existingLock: legacy.lock!,
53
+ existingCatalog: legacy.catalog!,
54
+ })
55
+ assert.equal(migrated.changed, true)
56
+ assert.deepEqual(migrated.lock, legacy.lock)
57
+ assert.deepEqual(migrated.changeReport.changedSources, [])
58
+ assert.deepEqual(migrated.changeReport.changedPlugins, ['plugin:test-source:review-kit'])
59
+ const unchanged = await synchronizePortable({
60
+ ...input,
61
+ existingLock: migrated.lock!,
62
+ existingCatalog: migrated.catalog!,
63
+ })
64
+ assert.equal(unchanged.changed, false)
65
+ }))
66
+ it('never loads plugin content for metadata-only or dry-run', () =>
67
+ withPortableFixture(async (input) => {
68
+ for (const flags of [{ metadataOnly: true }, { dryRun: true }]) {
69
+ const result = await synchronizePortable({
70
+ ...input,
71
+ ...flags,
72
+ snapshotLoader: {
73
+ async load() {
74
+ throw new Error('Must not retrieve content')
75
+ },
76
+ },
77
+ })
78
+ assert.ok(result.catalog)
79
+ assert.equal(
80
+ result.catalog.plugins[0]?.availableReleases[0]?.contentResolution,
81
+ 'metadata-only'
82
+ )
83
+ assert.equal(
84
+ result.catalog.plugins[0]?.availableReleases[0]?.releaseMetadata[PACKAGE_METADATA_KEY],
85
+ undefined
86
+ )
87
+ assert.throws(() => verifyPortableCatalog(result.catalog!, true), /COMPLETE_SOURCE_REQUIRED/)
88
+ }
89
+ }))
90
+ it('reports nonportable extras while keeping valid skills available', () =>
91
+ withPortableFixture(async (input, root) => {
92
+ await mkdir(join(root, 'fixtures/source/plugins/review-kit/hooks'))
93
+ await writeFile(join(root, 'fixtures/source/plugins/review-kit/hooks/hooks.json'), '{}')
94
+ const result = await synchronizePortable(input)
95
+ const pkg = verifyAgentPackage(
96
+ result.catalog!.plugins[0]!.availableReleases[0]!.releaseMetadata[PACKAGE_METADATA_KEY]
97
+ )
98
+ assert.equal(pkg.status, 'partial')
99
+ assert.equal(pkg.skills.length, 1)
100
+ assert.ok(pkg.nonPortable.some((item) => item.kind === 'hooks'))
101
+ }))
102
+ it('rejects metadata tampering before taking the unchanged-source fast path', () =>
103
+ withPortableFixture(async (input) => {
104
+ const first = await synchronizePortable(input)
105
+ const catalog = structuredClone(first.catalog!)
106
+ catalog.plugins[0]!.description = 'tampered'
107
+ await assert.rejects(
108
+ synchronizePortable({ ...input, existingLock: first.lock!, existingCatalog: catalog }),
109
+ /CATALOG_ID_MISMATCH/
110
+ )
111
+ }))
112
+ it('rejects canonical recipes attached to another source digest', () =>
113
+ withPortableFixture(async (input) => {
114
+ const result = await synchronizePortable(input)
115
+ const catalog = structuredClone(result.catalog!)
116
+ const release = catalog.plugins[0]!.availableReleases[0]!
117
+ release.canonicalContentDigest = `sha256:${'f'.repeat(64)}`
118
+ const { catalogId: _oldId, ...body } = catalog
119
+ catalog.catalogId = `catalog:${digest(body).slice(7)}`
120
+ assert.throws(() => verifyPortableCatalog(catalog), /PACKAGE_PROVENANCE_MISMATCH/)
121
+ }))
122
+
123
+ it('normalizes each fetched snapshot once without a second pass over source bytes', () =>
124
+ withPortableFixture(async (input, root) => {
125
+ let reads = 0
126
+ let transforms = 0
127
+ const result = await synchronizePortable({
128
+ ...input,
129
+ snapshotLoader: {
130
+ async load(_repository, _commit, subdirectory) {
131
+ reads += 1
132
+ return snapshotFromDirectory(join(root, 'fixtures/source'), subdirectory)
133
+ },
134
+ },
135
+ transformRelease(release) {
136
+ transforms += 1
137
+ return release
138
+ },
139
+ })
140
+ assert.equal(reads, 1)
141
+ assert.equal(transforms, 1)
142
+ assert.ok(result.catalog)
143
+ verifyPortableCatalog(result.catalog, true)
144
+ }))
145
+
146
+ it('does not allow a composed release transform to rewrite immutable source identity', () =>
147
+ withPortableFixture(async (input) => {
148
+ await assert.rejects(
149
+ synchronizePortable({
150
+ ...input,
151
+ transformRelease(release) {
152
+ return { ...release, resolvedCommitSha: 'b'.repeat(40) }
153
+ },
154
+ }),
155
+ /SOURCE_IDENTITY_CHANGED_BY_TRANSFORM/
156
+ )
157
+ }))
158
+
159
+ it('makes the root manifest authoritative even when nested legacy manifests are malformed', () =>
160
+ withPortableFixture(async (input, root) => {
161
+ const directory = join(root, 'fixtures/source/plugins/review-kit/examples/.claude-plugin')
162
+ await mkdir(directory, { recursive: true })
163
+ await writeFile(join(directory, 'plugin.json'), '{not-a-manifest')
164
+ const result = await synchronizePortable(input)
165
+ assert.equal(result.catalog?.plugins.length, 1)
166
+ assert.equal(result.changeReport.skippedPlugins.length, 0)
167
+ verifyPortableCatalog(result.catalog!, true)
168
+ }))
169
+
170
+ it('advertises where it is published once the publication is known', () =>
171
+ withPortableFixture(async (input) => {
172
+ // The default compiler replaces the artifacts the legacy pass produced, so it
173
+ // must repeat every option it needs: dropping this one silently strips the
174
+ // brand-mark and browsing-index URLs from a published catalog.
175
+ const result = await synchronizePortable({
176
+ ...input,
177
+ publicationRepositoryUrl: 'https://github.com/adea-ai/plugins',
178
+ })
179
+ const navigation = JSON.parse(result.artifacts!['categories.v1.json']) as {
180
+ catalogIndexUrl?: string
181
+ brandMarks?: Record<string, string>
182
+ }
183
+ assert.match(navigation.catalogIndexUrl ?? '', /\/catalog-assets\/catalogs\/[a-f0-9]{64}\//)
184
+ // The fixture plugin ships no mark, so the map is present and empty.
185
+ assert.deepEqual(navigation.brandMarks, {})
186
+ }))
@@ -0,0 +1,203 @@
1
+ import { CatalogSchema, HarnessSchema, type Catalog, type Plugin } from './schema.js'
2
+ import {
3
+ orderLeadingPlugins,
4
+ synchronize,
5
+ createArtifacts,
6
+ verifyArtifacts,
7
+ digest,
8
+ type SyncInput,
9
+ type SyncResult,
10
+ type ReleaseTransform,
11
+ } from './index.js'
12
+ import {
13
+ compileAgentPackage,
14
+ verifyAgentPackage,
15
+ NORMALIZER_VERSION,
16
+ } from './agent-plugins-catalog.js'
17
+ import { CATALOG_CONTRACT_VERSION } from './index.js'
18
+
19
+ export const PACKAGE_METADATA_KEY = 'agentPlugins'
20
+ function currentContract(catalog: Catalog | undefined): boolean {
21
+ return (
22
+ !!catalog &&
23
+ catalog.plugins.every((plugin) =>
24
+ plugin.availableReleases.every((release) => {
25
+ const value = release.releaseMetadata[PACKAGE_METADATA_KEY]
26
+ return (
27
+ release.contentResolution === 'complete' &&
28
+ // A release compiled by an earlier catalog format is not current, so a
29
+ // format change republishes instead of waiting for an upstream commit.
30
+ release.releaseMetadata.catalogContract === CATALOG_CONTRACT_VERSION &&
31
+ value !== null &&
32
+ typeof value === 'object' &&
33
+ 'normalizerVersion' in value &&
34
+ value.normalizerVersion === NORMALIZER_VERSION
35
+ )
36
+ })
37
+ )
38
+ )
39
+ }
40
+ function compatibility(
41
+ pkg: ReturnType<typeof compileAgentPackage>
42
+ ): Plugin['harnessCompatibility'] {
43
+ // Catalog discovery has no running harness. A brand/source marketplace cannot prove compatibility.
44
+ return Object.fromEntries(
45
+ HarnessSchema.options.map((harness) => [
46
+ harness,
47
+ {
48
+ status:
49
+ pkg.status === 'unavailable'
50
+ ? 'unsupported'
51
+ : pkg.status === 'partial'
52
+ ? 'partially-supported'
53
+ : 'unknown',
54
+ reasons: [
55
+ pkg.status === 'unavailable'
56
+ ? 'No valid canonical package is available.'
57
+ : 'Agent Plugins core is parsed; negotiate the actual runtime capability profile in a v2 installation plan. Policy approval is separate.',
58
+ ],
59
+ responsibleCapabilities: [
60
+ ...new Set([
61
+ ...(pkg.skills.length ? ['skill' as const] : []),
62
+ ...(Object.keys(pkg.mcpServers).length ? ['mcp-server' as const] : []),
63
+ ]),
64
+ ],
65
+ },
66
+ ])
67
+ ) as Plugin['harnessCompatibility']
68
+ }
69
+
70
+ /** Default compiler path: immutable upstream catalog + canonical portable package recipes.
71
+ * The existing v1 catalog envelope remains stable; the extension is versioned inside
72
+ * releaseMetadata, which v1 deliberately defines as extensible JSON.
73
+ */
74
+ export async function synchronizePortable(options: SyncInput): Promise<SyncResult> {
75
+ if (options.existingCatalog) verifyPortableCatalog(options.existingCatalog)
76
+ const transformRelease: ReleaseTransform = (original, snapshot, name) => {
77
+ const release = options.transformRelease?.(original, snapshot, name) ?? original
78
+ if (
79
+ release.releaseId !== original.releaseId ||
80
+ release.canonicalContentDigest !== original.canonicalContentDigest ||
81
+ release.resolvedRepositoryUrl !== original.resolvedRepositoryUrl ||
82
+ release.resolvedCommitSha !== original.resolvedCommitSha ||
83
+ release.pluginSubdirectory !== original.pluginSubdirectory ||
84
+ release.contentResolution !== original.contentResolution
85
+ )
86
+ throw new Error('SOURCE_IDENTITY_CHANGED_BY_TRANSFORM')
87
+ const pkg = compileAgentPackage({
88
+ ...snapshot,
89
+ name,
90
+ sourceDigest: original.canonicalContentDigest,
91
+ })
92
+ return {
93
+ ...release,
94
+ releaseMetadata: { ...release.releaseMetadata, [PACKAGE_METADATA_KEY]: pkg },
95
+ }
96
+ }
97
+ // Compile while each upstream snapshot is already in scope. Do not retain all
98
+ // marketplace file bytes or fetch every package a second time after cataloging.
99
+ const input = {
100
+ ...options,
101
+ metadataOnly: options.metadataOnly === true || options.dryRun === true,
102
+ transformRelease,
103
+ }
104
+ let result = await synchronize(input)
105
+ if (
106
+ !result.changed &&
107
+ !input.metadataOnly &&
108
+ !currentContract(input.existingCatalog) &&
109
+ input.existingLock
110
+ ) {
111
+ // Replay the already verified pins when the compiler contract changes. Source heads
112
+ // need not change for a format migration, and dry runs still never publish.
113
+ result = await synchronize({ ...input, fromLock: input.existingLock })
114
+ }
115
+ if (!result.catalog || !result.lock || input.metadataOnly) return result
116
+ const plugins: Plugin[] = []
117
+ for (const plugin of result.catalog.plugins) {
118
+ const releases = plugin.availableReleases
119
+ const selected = releases.find((release) => release.releaseId === plugin.currentReleaseId)
120
+ const pkg = selected?.releaseMetadata[PACKAGE_METADATA_KEY]
121
+ plugins.push({
122
+ ...plugin,
123
+ availableReleases: releases,
124
+ ...(pkg ? { harnessCompatibility: compatibility(verifyAgentPackage(pkg)) } : {}),
125
+ })
126
+ }
127
+ const { catalogId: _oldId, ...old } = CatalogSchema.parse(result.catalog)
128
+ // Ordering is applied before the catalog ID is computed so the emitted
129
+ // identifier digests the final plugin order: each category's leading
130
+ // products head the list.
131
+ const ordered = orderLeadingPlugins({ ...old, plugins }, options.leading)
132
+ const catalog = CatalogSchema.parse({
133
+ ...ordered,
134
+ catalogId: `catalog:${digest(ordered).slice(7)}`,
135
+ })
136
+ verifyPortableCatalog(catalog)
137
+ const artifacts = createArtifacts(catalog, result.lock, {
138
+ ...(options.leading ? { leading: options.leading } : {}),
139
+ ...(options.topCount !== undefined ? { topCount: options.topCount } : {}),
140
+ ...(options.productPreference ? { productPreference: options.productPreference } : {}),
141
+ // Without this the shipped artifacts cannot advertise where they are
142
+ // published, so brand marks and the browsing index lose their URLs. This
143
+ // path replaces the artifacts the legacy compiler already produced, which is
144
+ // why it must repeat every option it needs.
145
+ ...(options.publicationRepositoryUrl
146
+ ? { publicationRepositoryUrl: options.publicationRepositoryUrl }
147
+ : {}),
148
+ ...(options.publicationAssetsBaseUrl
149
+ ? { publicationAssetsBaseUrl: options.publicationAssetsBaseUrl }
150
+ : {}),
151
+ curationDiagnostics: (options.mode ?? 'live') !== 'offline',
152
+ })
153
+ verifyArtifacts(artifacts)
154
+ const changed = catalog.plugins
155
+ .filter((plugin) => {
156
+ const prior = input.existingCatalog?.plugins.find((item) => item.pluginId === plugin.pluginId)
157
+ return prior && digest(prior) !== digest(plugin)
158
+ })
159
+ .map((plugin) => plugin.pluginId)
160
+ return {
161
+ ...result,
162
+ catalog,
163
+ artifacts,
164
+ changeReport: {
165
+ ...result.changeReport,
166
+ changedPlugins: [...new Set([...result.changeReport.changedPlugins, ...changed])].toSorted(),
167
+ },
168
+ }
169
+ }
170
+
171
+ export function verifyPortableCatalog(catalog: Catalog, requirePortable = false): void {
172
+ const { catalogId, ...body } = CatalogSchema.parse(catalog)
173
+ if (catalogId !== `catalog:${digest(body).slice(7)}`) throw new Error('CATALOG_ID_MISMATCH')
174
+ const ids = new Set<string>()
175
+ for (const plugin of catalog.plugins) {
176
+ if (ids.has(plugin.pluginId)) throw new Error('DUPLICATE_PLUGIN_ID')
177
+ ids.add(plugin.pluginId)
178
+ const releases = new Set(plugin.availableReleases.map((release) => release.releaseId))
179
+ if (releases.size !== plugin.availableReleases.length || !releases.has(plugin.currentReleaseId))
180
+ throw new Error('RELEASE_SELECTION_INVALID')
181
+ }
182
+ for (const plugin of catalog.plugins)
183
+ for (const release of plugin.availableReleases) {
184
+ if (requirePortable && release.contentResolution !== 'complete')
185
+ throw new Error('COMPLETE_SOURCE_REQUIRED')
186
+ const descriptor = release.releaseMetadata[PACKAGE_METADATA_KEY]
187
+ if (descriptor === undefined) {
188
+ if (requirePortable && release.contentResolution === 'complete')
189
+ throw new Error(`PORTABLE_RESYNC_REQUIRED: ${plugin.pluginId}`)
190
+ continue
191
+ }
192
+ const pkg = verifyAgentPackage(descriptor)
193
+ if (
194
+ release.contentResolution !== 'complete' ||
195
+ pkg.sourceDigest !== release.canonicalContentDigest
196
+ )
197
+ throw new Error(`PACKAGE_PROVENANCE_MISMATCH: ${plugin.pluginId}`)
198
+ const sourcePaths = new Set(release.fileIndex)
199
+ for (const file of pkg.files)
200
+ if (file.action === 'copy' && !sourcePaths.has(file.sourcePath))
201
+ throw new Error(`PACKAGE_SOURCE_FILE_MISSING: ${plugin.pluginId}`)
202
+ }
203
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ auditStagedAssets,
4
+ catalogAssetsBaseUrl,
5
+ catalogIdSuffix,
6
+ catalogSnapshotBaseUrl,
7
+ createPublicationPlan,
8
+ immutableAssetUrl,
9
+ latestPointerUrl,
10
+ publishedArtifactNames,
11
+ } from './publication.js'
12
+
13
+ const repositoryUrl = 'https://github.com/adea-ai/plugins'
14
+ const catalogId = `catalog:${'c'.repeat(64)}`
15
+ const catalogAsset = { name: 'catalog.v1.json', digest: `sha256:${'1'.repeat(64)}`, bytes: 10 }
16
+
17
+ describe('catalog identity', () => {
18
+ test('a catalog identity addresses itself by its own digest', () => {
19
+ expect(catalogIdSuffix(catalogId)).toBe('c'.repeat(64))
20
+ })
21
+
22
+ test('an identity that is not a digest is refused', () => {
23
+ expect(() => catalogIdSuffix('catalog:not-a-digest')).toThrow('CATALOG_ID_INVALID')
24
+ expect(() => catalogIdSuffix(`catalog:${'C'.repeat(64)}`)).toThrow('CATALOG_ID_INVALID')
25
+ expect(() => catalogIdSuffix(`catalog:${'c'.repeat(63)}`)).toThrow('CATALOG_ID_INVALID')
26
+ })
27
+ })
28
+
29
+ describe('catalog publication addresses', () => {
30
+ test('snapshots are content-addressed on the catalog-assets branch', () => {
31
+ expect(catalogAssetsBaseUrl(repositoryUrl)).toBe(
32
+ 'https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets'
33
+ )
34
+ expect(catalogSnapshotBaseUrl(repositoryUrl, catalogId)).toBe(
35
+ `https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets/catalogs/${'c'.repeat(64)}`
36
+ )
37
+ })
38
+
39
+ test('the pointer is the only mutable path and it sits at the branch root', () => {
40
+ expect(latestPointerUrl(repositoryUrl)).toBe(
41
+ 'https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets/catalog-latest.v1.json'
42
+ )
43
+ })
44
+
45
+ test('a snapshot path is stable for its catalog, so a pinned URL caches forever', () => {
46
+ const first = immutableAssetUrl(repositoryUrl, catalogId, 'catalog.v1.json')
47
+ const second = immutableAssetUrl(`${repositoryUrl}.git`, catalogId, 'catalog.v1.json')
48
+ expect(first).toBe(second)
49
+ expect(first).toBe(
50
+ `https://raw.githubusercontent.com/adea-ai/plugins/catalog-assets/catalogs/${'c'.repeat(64)}/catalog.v1.json`
51
+ )
52
+ })
53
+
54
+ test('a different catalog never addresses the same path', () => {
55
+ expect(immutableAssetUrl(repositoryUrl, catalogId, 'catalog.v1.json')).not.toBe(
56
+ immutableAssetUrl(repositoryUrl, `catalog:${'d'.repeat(64)}`, 'catalog.v1.json')
57
+ )
58
+ })
59
+
60
+ test('an asset name that could escape the snapshot directory is refused', () => {
61
+ expect(() => immutableAssetUrl(repositoryUrl, catalogId, '../secrets.json')).toThrow(
62
+ 'ASSET_NAME_INVALID'
63
+ )
64
+ expect(() => immutableAssetUrl(repositoryUrl, catalogId, 'a/b.json')).toThrow(
65
+ 'ASSET_NAME_INVALID'
66
+ )
67
+ })
68
+
69
+ test('only a GitHub repository can be addressed', () => {
70
+ expect(() => catalogAssetsBaseUrl('https://example.com/adea-ai/plugins')).toThrow(
71
+ 'REPOSITORY_URL_UNSUPPORTED'
72
+ )
73
+ expect(() => catalogAssetsBaseUrl('not a url')).toThrow('REPOSITORY_URL_INVALID')
74
+ expect(() => catalogAssetsBaseUrl('https://github.com/adea-ai')).toThrow(
75
+ 'REPOSITORY_URL_INVALID'
76
+ )
77
+ })
78
+
79
+ test('the plan names the branch paths a publication has to write', () => {
80
+ const plan = createPublicationPlan({ repositoryUrl, catalogId: `catalog:${'a'.repeat(64)}` })
81
+ expect(plan.snapshotPath).toBe(`catalogs/${'a'.repeat(64)}`)
82
+ expect(plan.latestPointerName).toBe('catalog-latest.v1.json')
83
+ expect(plan.latestPointerUrl).toBe(latestPointerUrl(repositoryUrl))
84
+ expect(plan.immutableCatalogUrl).toBe(
85
+ immutableAssetUrl(repositoryUrl, `catalog:${'a'.repeat(64)}`, 'catalog.v1.json')
86
+ )
87
+ expect(plan.artifactNames).toEqual(publishedArtifactNames)
88
+ })
89
+ })
90
+
91
+ describe('staged asset audit', () => {
92
+ test('declared bytes this build has are publishable', () => {
93
+ const audit = auditStagedAssets({ declared: [catalogAsset] })
94
+ expect(audit.agrees).toBe(true)
95
+ expect(audit.unstaged).toEqual([])
96
+ expect(audit.blocked).toBeUndefined()
97
+ })
98
+
99
+ test('a declared asset this build cannot stage is never taken on its word', () => {
100
+ const audit = auditStagedAssets({
101
+ declared: [catalogAsset, { name: 'icon-unstaged.png', digest: 'sha256:x', bytes: 5 }],
102
+ unstaged: ['icon-unstaged.png'],
103
+ })
104
+ expect(audit.unstaged).toEqual(['icon-unstaged.png'])
105
+ expect(audit.agrees).toBe(false)
106
+ expect(audit.blocked).toContain('no staged bytes')
107
+ expect(audit.blocked).toContain('mirror-icons')
108
+ })
109
+
110
+ test('the same unstaged name twice is one problem', () => {
111
+ const audit = auditStagedAssets({
112
+ declared: [catalogAsset],
113
+ unstaged: ['icon-unstaged.png', 'icon-unstaged.png'],
114
+ })
115
+ expect(audit.unstaged).toEqual(['icon-unstaged.png'])
116
+ })
117
+ })
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Where a published catalog lives, and the URLs that address it.
3
+ *
4
+ * The catalog is published to the `catalog-assets` branch rather than to a
5
+ * GitHub Release. That is what lets the repository's Releases carry a plain
6
+ * `vX.Y.Z` version and stay readable: GitHub marks exactly one release
7
+ * "latest", and a content-addressed catalog that has to be discoverable through
8
+ * `releases/latest/download/…` permanently outbids every versioned release for
9
+ * that slot, so no version is ever the one a visitor sees first.
10
+ *
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
15
+ * CORS so a browser can fetch it directly.
16
+ */
17
+
18
+ export const publishedArtifactNames = [
19
+ 'catalog.v1.json',
20
+ 'catalog-summary.v1.json',
21
+ 'categories.v1.json',
22
+ 'compatibility.v1.json',
23
+ 'integrity.json',
24
+ 'sources.lock.json',
25
+ ] as const
26
+
27
+ export type PublishedArtifactName = (typeof publishedArtifactNames)[number]
28
+
29
+ /** The branch every catalog snapshot and mirrored brand mark is published to. */
30
+ export const catalogAssetsBranch = 'catalog-assets' as const
31
+
32
+ /** Directory under the branch holding one immutable snapshot per catalog. */
33
+ export const catalogSnapshotDirectory = 'catalogs' as const
34
+
35
+ /**
36
+ * The mutable pointer, byte-identical to the current snapshot's
37
+ * `catalog.v1.json`. It is the only catalog path that changes after a
38
+ * publication, so consumers must treat it as a short-TTL cache entry and
39
+ * resolve everything else from the `catalogId` it names.
40
+ */
41
+ export const latestPointerName = 'catalog-latest.v1.json' as const
42
+
43
+ /** One artifact this build declares, with the digest of the bytes it staged. */
44
+ export interface DeclaredAsset {
45
+ readonly name: string
46
+ readonly digest: string
47
+ readonly bytes: number
48
+ }
49
+
50
+ export interface StagedAssetAudit {
51
+ /** Declared assets this build has no staged bytes for. */
52
+ readonly unstaged: readonly string[]
53
+ /**
54
+ * Why the snapshot cannot be published, when it cannot. A checkout that
55
+ * cannot supply the declared bytes cannot publish a snapshot from them
56
+ * either, and a declaration is not a substitute for them.
57
+ */
58
+ readonly blocked?: string
59
+ /** True when every declared asset has staged bytes behind it. */
60
+ readonly agrees: boolean
61
+ }
62
+
63
+ /**
64
+ * Audits the bytes this build staged against what it declared.
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.
71
+ */
72
+ export function auditStagedAssets(input: {
73
+ readonly declared: readonly DeclaredAsset[]
74
+ readonly unstaged?: readonly string[]
75
+ }): StagedAssetAudit {
76
+ const unstaged = [...new Set(input.unstaged ?? [])].toSorted()
77
+ if (unstaged.length > 0)
78
+ return {
79
+ unstaged,
80
+ blocked: `this checkout has no staged bytes for ${unstaged.length} declared asset(s) (${unstaged.slice(0, 3).join(', ')}${unstaged.length > 3 ? ', …' : ''}); stage them with \`bun run catalog mirror-icons\` or publish from a snapshot that carries them, because a snapshot cannot be checked against a declaration alone`,
81
+ agrees: false,
82
+ }
83
+ return { unstaged: [], agrees: true }
84
+ }
85
+
86
+ export interface PublicationPlan {
87
+ /** Branch-relative directory holding this catalog's immutable artifacts. */
88
+ readonly snapshotPath: string
89
+ readonly latestPointerName: typeof latestPointerName
90
+ readonly latestPointerUrl: string
91
+ readonly immutableCatalogUrl: string
92
+ readonly artifactNames: readonly PublishedArtifactName[]
93
+ }
94
+
95
+ /**
96
+ * The 64-hex digest a `catalog:<hex>` identity is addressed by.
97
+ *
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.
101
+ */
102
+ export function catalogIdSuffix(catalogId: string): string {
103
+ const match = /^catalog:([a-f0-9]{64})$/.exec(catalogId)
104
+ if (!match) throw new Error(`CATALOG_ID_INVALID: ${catalogId}`)
105
+ return match[1] as string
106
+ }
107
+
108
+ /**
109
+ * Root of the branch that carries mirrored brand marks and the mutable
110
+ * catalog pointer.
111
+ */
112
+ export function catalogAssetsBaseUrl(repositoryUrl: string): string {
113
+ return `https://raw.githubusercontent.com/${repositorySlug(repositoryUrl)}/${catalogAssetsBranch}`
114
+ }
115
+
116
+ /** Root of one catalog's immutable artifacts, content-addressed by identity. */
117
+ export function catalogSnapshotBaseUrl(repositoryUrl: string, catalogId: string): string {
118
+ return `${catalogAssetsBaseUrl(repositoryUrl)}/${catalogSnapshotDirectory}/${catalogIdSuffix(catalogId)}`
119
+ }
120
+
121
+ /** The mutable pointer, re-pointed at the newest catalog on every publication. */
122
+ export function latestPointerUrl(repositoryUrl: string): string {
123
+ return `${catalogAssetsBaseUrl(repositoryUrl)}/${latestPointerName}`
124
+ }
125
+
126
+ export function createPublicationPlan(input: {
127
+ readonly repositoryUrl: string
128
+ readonly catalogId: string
129
+ }): PublicationPlan {
130
+ const base = catalogSnapshotBaseUrl(input.repositoryUrl, input.catalogId)
131
+ return {
132
+ snapshotPath: `${catalogSnapshotDirectory}/${catalogIdSuffix(input.catalogId)}`,
133
+ latestPointerName,
134
+ latestPointerUrl: latestPointerUrl(input.repositoryUrl),
135
+ immutableCatalogUrl: `${base}/catalog.v1.json`,
136
+ artifactNames: publishedArtifactNames,
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Immutable URL for one artifact of one catalog.
142
+ *
143
+ * The path is derived from the catalog identity, so this URL never changes for
144
+ * a given catalog and can be cached forever. Deployments that cannot serve the
145
+ * branch host directly publish the same artifact names behind their own base
146
+ * and substitute it for `catalogAssetsBaseUrl`.
147
+ */
148
+ export function immutableAssetUrl(
149
+ repositoryUrl: string,
150
+ catalogId: string,
151
+ assetName: string
152
+ ): string {
153
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(assetName))
154
+ throw new Error(`ASSET_NAME_INVALID: ${assetName}`)
155
+ return `${catalogSnapshotBaseUrl(repositoryUrl, catalogId)}/${assetName}`
156
+ }
157
+
158
+ function repositorySlug(repositoryUrl: string): string {
159
+ let parsed: URL
160
+ try {
161
+ parsed = new URL(repositoryUrl)
162
+ } catch {
163
+ throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
164
+ }
165
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'github.com')
166
+ throw new Error(`REPOSITORY_URL_UNSUPPORTED: ${repositoryUrl}`)
167
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '')
168
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path))
169
+ throw new Error(`REPOSITORY_URL_INVALID: ${repositoryUrl}`)
170
+ return path
171
+ }