@adea-ai/plugins 4.0.0 → 4.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-plugins-catalog.d.ts +2 -4
- package/dist/agent-plugins-catalog.js +42 -50
- package/dist/agent-plugins-catalog.js.map +1 -1
- package/dist/agent-plugins-schema.d.ts +8 -0
- package/dist/agent-plugins-schema.js +10 -1
- package/dist/agent-plugins-schema.js.map +1 -1
- package/dist/artifacts.d.ts +57 -0
- package/dist/artifacts.js +159 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/build.d.ts +20 -0
- package/dist/build.js +103 -0
- package/dist/build.js.map +1 -0
- package/dist/capabilities.d.ts +7 -0
- package/dist/capabilities.js +116 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog-store.d.ts +76 -0
- package/dist/catalog-store.js +463 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/concurrency.d.ts +1 -0
- package/dist/concurrency.js +15 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/consumer-index.d.ts +7 -29
- package/dist/consumer-index.js +24 -15
- package/dist/consumer-index.js.map +1 -1
- package/dist/digest.d.ts +13 -0
- package/dist/digest.js +47 -0
- package/dist/digest.js.map +1 -0
- package/dist/fs-atomic.d.ts +7 -0
- package/dist/fs-atomic.js +30 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/harness.js +2 -1
- package/dist/harness.js.map +1 -1
- package/dist/icon-mirror.d.ts +5 -4
- package/dist/icon-mirror.js +73 -72
- package/dist/icon-mirror.js.map +1 -1
- package/dist/icons.d.ts +2 -2
- package/dist/icons.js +4 -12
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +16 -309
- package/dist/index.js +12 -1900
- package/dist/index.js.map +1 -1
- package/dist/integrity-manifest.d.ts +43 -13
- package/dist/integrity-manifest.js +28 -17
- package/dist/integrity-manifest.js.map +1 -1
- package/dist/json-values.d.ts +3 -0
- package/dist/json-values.js +10 -0
- package/dist/json-values.js.map +1 -0
- package/dist/plugin-release.d.ts +38 -0
- package/dist/plugin-release.js +321 -0
- package/dist/plugin-release.js.map +1 -0
- package/dist/plugin-skips.d.ts +19 -0
- package/dist/plugin-skips.js +65 -0
- package/dist/plugin-skips.js.map +1 -0
- package/dist/policy.d.ts +13 -0
- package/dist/policy.js +26 -0
- package/dist/policy.js.map +1 -0
- package/dist/portable-catalog.d.ts +4 -4
- package/dist/portable-catalog.js +14 -126
- package/dist/portable-catalog.js.map +1 -1
- package/dist/portable-verify.d.ts +2 -0
- package/dist/portable-verify.js +68 -0
- package/dist/portable-verify.js.map +1 -0
- package/dist/publication.d.ts +12 -12
- package/dist/publication.js +31 -21
- package/dist/publication.js.map +1 -1
- package/dist/published-output.d.ts +17 -0
- package/dist/published-output.js +49 -0
- package/dist/published-output.js.map +1 -0
- package/dist/site-icons.d.ts +4 -3
- package/dist/site-icons.js +38 -11
- package/dist/site-icons.js.map +1 -1
- package/dist/snapshots.d.ts +39 -0
- package/dist/snapshots.js +238 -0
- package/dist/snapshots.js.map +1 -0
- package/dist/source-resolution.d.ts +28 -0
- package/dist/source-resolution.js +158 -0
- package/dist/source-resolution.js.map +1 -0
- package/dist/sources.d.ts +5 -1
- package/dist/sources.js +21 -27
- package/dist/sources.js.map +1 -1
- package/dist/synchronize.d.ts +44 -0
- package/dist/synchronize.js +118 -0
- package/dist/synchronize.js.map +1 -0
- package/dist/taxonomy.d.ts +23 -0
- package/dist/taxonomy.js +62 -0
- package/dist/taxonomy.js.map +1 -0
- package/dist/upstream.d.ts +42 -0
- package/dist/upstream.js +179 -0
- package/dist/upstream.js.map +1 -0
- package/dist/verify-artifacts.d.ts +36 -0
- package/dist/verify-artifacts.js +286 -0
- package/dist/verify-artifacts.js.map +1 -0
- package/native/build-provenance.txt +5 -5
- package/native/staged-reader-win32-arm64.node +0 -0
- package/native/staged-reader-win32-x64.node +0 -0
- package/package.json +3 -3
- package/src/agent-plugins-catalog.test.ts +2 -2
- package/src/agent-plugins-catalog.ts +46 -49
- package/src/agent-plugins-schema.ts +11 -1
- package/src/artifacts.test.ts +80 -0
- package/src/artifacts.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +519 -0
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +147 -16
- package/src/consumer-index.ts +35 -43
- package/src/default-head.test.ts +143 -0
- package/src/digest.ts +55 -0
- package/src/fs-atomic.ts +32 -0
- package/src/harness.test.ts +4 -32
- package/src/harness.ts +2 -1
- package/src/icon-mirror.ts +78 -72
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +172 -46
- package/src/index.ts +58 -2562
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -0
- package/src/manifest-retry.test.ts +49 -0
- package/src/plugin-release.ts +457 -0
- package/src/plugin-skips.ts +110 -0
- package/src/policy.ts +39 -0
- package/src/portable-catalog.test.ts +0 -5
- package/src/portable-catalog.ts +16 -143
- package/src/portable-verify.ts +71 -0
- package/src/publication.test.ts +29 -1
- package/src/publication.ts +38 -22
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +73 -1
- package/src/site-icons.ts +31 -14
- package/src/snapshots.ts +287 -0
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +61 -8
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +18 -2
- package/src/sources.ts +27 -33
- package/src/synchronize.ts +213 -0
- package/src/taxonomy.ts +87 -0
- package/src/upstream.ts +194 -0
- package/src/verify-artifacts.ts +365 -0
- package/src/write-artifacts.test.ts +374 -0
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/native/README.md +0 -24
- package/src/source-digest.ts +0 -14
package/src/artifacts.test.ts
CHANGED
|
@@ -186,6 +186,15 @@ describe('artifact shards', () => {
|
|
|
186
186
|
plugin({ sourceId: 'claude-official', name: 'sales', categories: ['business-operations'] }),
|
|
187
187
|
]
|
|
188
188
|
|
|
189
|
+
test('index options can change companion artifacts without changing the catalog ID', () => {
|
|
190
|
+
const base = catalog(plugins)
|
|
191
|
+
const first = createArtifacts(base, lockFor(plugins) as never, { topCount: 1 })
|
|
192
|
+
const second = createArtifacts(base, lockFor(plugins) as never, { topCount: 2 })
|
|
193
|
+
expect(first['catalog.v1.json']).toBe(second['catalog.v1.json'])
|
|
194
|
+
expect(first['catalog-index.v1.json']).not.toBe(second['catalog-index.v1.json'])
|
|
195
|
+
expect(first['integrity.json']).not.toBe(second['integrity.json'])
|
|
196
|
+
})
|
|
197
|
+
|
|
189
198
|
test('publishes navigation, one index, and a shelf and list per category', () => {
|
|
190
199
|
const { artifacts } = artifactsOf(plugins)
|
|
191
200
|
const names = Object.keys(artifacts).toSorted()
|
|
@@ -478,6 +487,77 @@ describe('artifact shards', () => {
|
|
|
478
487
|
})
|
|
479
488
|
|
|
480
489
|
describe('icon mirroring', () => {
|
|
490
|
+
test('tries another pinned source when a shared icon cannot be fetched', async () => {
|
|
491
|
+
const { base, index } = artifactsOf([
|
|
492
|
+
plugin({
|
|
493
|
+
sourceId: 'cursor-official',
|
|
494
|
+
name: 'first',
|
|
495
|
+
categories: ['productivity'],
|
|
496
|
+
icon: { path: 'first/logo.svg', bytes: CLEAN_SVG },
|
|
497
|
+
}),
|
|
498
|
+
plugin({
|
|
499
|
+
sourceId: 'claude-official',
|
|
500
|
+
name: 'second',
|
|
501
|
+
categories: ['productivity'],
|
|
502
|
+
icon: { path: 'second/logo.svg', bytes: CLEAN_SVG },
|
|
503
|
+
}),
|
|
504
|
+
])
|
|
505
|
+
const sources = resolveIconSources(base, index)
|
|
506
|
+
const directory = await mkdtemp(join(tmpdir(), 'adea-icons-fallback-'))
|
|
507
|
+
try {
|
|
508
|
+
const calls: string[] = []
|
|
509
|
+
const result = await stageIconAssets({
|
|
510
|
+
sources,
|
|
511
|
+
assetsDirectory: directory,
|
|
512
|
+
readBytes: async (source) => {
|
|
513
|
+
if (source.kind !== 'content') throw new Error('unexpected favicon')
|
|
514
|
+
calls.push(source.path)
|
|
515
|
+
if (source.path === 'first/logo.svg') throw new Error('source unavailable')
|
|
516
|
+
return CLEAN_SVG
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
expect(calls).toEqual(['first/logo.svg', 'second/logo.svg'])
|
|
520
|
+
expect(result.staged.map((asset) => asset.name)).toEqual([svgAsset])
|
|
521
|
+
expect(result.skipped).toEqual([])
|
|
522
|
+
expect(await fs.readFile(join(directory, svgAsset))).toEqual(Buffer.from(CLEAN_SVG))
|
|
523
|
+
await fs.rm(join(directory, svgAsset))
|
|
524
|
+
const successfulCalls: string[] = []
|
|
525
|
+
const successful = await stageIconAssets({
|
|
526
|
+
sources,
|
|
527
|
+
assetsDirectory: directory,
|
|
528
|
+
readBytes: async (source) => {
|
|
529
|
+
if (source.kind !== 'content') throw new Error('unexpected favicon')
|
|
530
|
+
successfulCalls.push(source.path)
|
|
531
|
+
return CLEAN_SVG
|
|
532
|
+
},
|
|
533
|
+
})
|
|
534
|
+
expect(successfulCalls).toEqual(['first/logo.svg'])
|
|
535
|
+
expect(successful.staged).toHaveLength(1)
|
|
536
|
+
expect(successful.skipped).toEqual([])
|
|
537
|
+
await fs.rm(join(directory, svgAsset))
|
|
538
|
+
const mismatched = await stageIconAssets({
|
|
539
|
+
sources,
|
|
540
|
+
assetsDirectory: directory,
|
|
541
|
+
readBytes: async (source) =>
|
|
542
|
+
source.kind === 'content' && source.path === 'first/logo.svg' ? OTHER_SVG : CLEAN_SVG,
|
|
543
|
+
})
|
|
544
|
+
expect(mismatched.staged.map((asset) => asset.name)).toEqual([svgAsset])
|
|
545
|
+
expect(mismatched.skipped).toEqual([])
|
|
546
|
+
await fs.rm(join(directory, svgAsset))
|
|
547
|
+
const failed = await stageIconAssets({
|
|
548
|
+
sources,
|
|
549
|
+
assetsDirectory: directory,
|
|
550
|
+
readBytes: async () => {
|
|
551
|
+
throw new Error('unavailable')
|
|
552
|
+
},
|
|
553
|
+
})
|
|
554
|
+
expect(failed.staged).toEqual([])
|
|
555
|
+
expect(failed.skipped).toEqual([{ asset: svgAsset, reason: 'FETCH_FAILED: unavailable' }])
|
|
556
|
+
} finally {
|
|
557
|
+
await fs.rm(directory, { recursive: true, force: true })
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
481
561
|
test('resolves each asset to the commit it was published from', () => {
|
|
482
562
|
const plugins = [
|
|
483
563
|
plugin({
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { publishCatalog, type PublishOptions } from './catalog-store.js'
|
|
2
|
+
import {
|
|
3
|
+
buildConsumerIndex,
|
|
4
|
+
CATALOG_INDEX_ARTIFACT,
|
|
5
|
+
iconCoverage,
|
|
6
|
+
type ConsumerIndex,
|
|
7
|
+
type ProductPreference,
|
|
8
|
+
} from './consumer-index.js'
|
|
9
|
+
import { digest } from './digest.js'
|
|
10
|
+
import { publishedAssetOf, type PublishedAsset } from './integrity-manifest.js'
|
|
11
|
+
import { catalogIdSuffix, immutableAssetUrl } from './publication.js'
|
|
12
|
+
import type { Catalog, SourcesLock } from './schema.js'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Published artifacts by file name. The core six are always present; the
|
|
16
|
+
* consumer shards (`catalog-index.v1.json`, `shelf-<category>.v1.json` and
|
|
17
|
+
* `category-<category>.v1.json`) follow the configured categories.
|
|
18
|
+
*/
|
|
19
|
+
export interface GeneratedArtifacts {
|
|
20
|
+
readonly [name: string]: string
|
|
21
|
+
readonly 'catalog.v1.json': string
|
|
22
|
+
readonly 'catalog-summary.v1.json': string
|
|
23
|
+
readonly 'sources.lock.json': string
|
|
24
|
+
readonly 'compatibility.v1.json': string
|
|
25
|
+
readonly 'categories.v1.json': string
|
|
26
|
+
readonly 'integrity.json': string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ConsumerIndexOptions {
|
|
30
|
+
readonly leading?: Readonly<Record<string, readonly string[]>>
|
|
31
|
+
readonly topCount?: number
|
|
32
|
+
readonly productPreference?: ProductPreference
|
|
33
|
+
/**
|
|
34
|
+
* Where this catalog is published. When known, product records carry an
|
|
35
|
+
* absolute, immutable URL for their brand mark so no client composes one.
|
|
36
|
+
*/
|
|
37
|
+
readonly publicationRepositoryUrl?: string
|
|
38
|
+
/**
|
|
39
|
+
* Base URL of the committed asset directory that browsers fetch directly.
|
|
40
|
+
*
|
|
41
|
+
* GitHub release assets answer a cross-origin `fetch` with no CORS header and
|
|
42
|
+
* redirect through a URL that cannot be cached, so a browser neither reads the
|
|
43
|
+
* index nor reuses an icon. The same files served from the repository are
|
|
44
|
+
* CORS-enabled, direct and cacheable.
|
|
45
|
+
*/
|
|
46
|
+
readonly publicationAssetsBaseUrl?: string
|
|
47
|
+
/**
|
|
48
|
+
* Base URL of the content-addressed snapshot directory, one level below the
|
|
49
|
+
* asset branch. Catalog artifacts are addressed by catalog identity, so they
|
|
50
|
+
* live in `catalogs/<catalogId>/` while digest-addressed brand marks sit at
|
|
51
|
+
* the branch root.
|
|
52
|
+
*/
|
|
53
|
+
readonly publicationCatalogsBaseUrl?: string
|
|
54
|
+
/**
|
|
55
|
+
* Whether curated-name drift is published in the consumer index. An offline
|
|
56
|
+
* build replays fixtures rather than the upstream catalogs, so its curation
|
|
57
|
+
* findings are noise and are omitted by default.
|
|
58
|
+
*/
|
|
59
|
+
readonly curationDiagnostics?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createArtifacts(
|
|
63
|
+
catalog: Catalog,
|
|
64
|
+
lock: SourcesLock,
|
|
65
|
+
options: ConsumerIndexOptions = {}
|
|
66
|
+
): GeneratedArtifacts {
|
|
67
|
+
const index = buildConsumerIndex({ ...options, catalog })
|
|
68
|
+
const summary = {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
catalogId: catalog.catalogId,
|
|
71
|
+
generatedAt: catalog.generatedAt,
|
|
72
|
+
sourceCount: catalog.sources.length,
|
|
73
|
+
pluginCount: catalog.plugins.length,
|
|
74
|
+
categories: [...new Set(catalog.plugins.flatMap((plugin) => plugin.categories))].toSorted(),
|
|
75
|
+
productGroups: [
|
|
76
|
+
...new Set(catalog.plugins.map((plugin) => plugin.productGroupingKey)),
|
|
77
|
+
].toSorted(),
|
|
78
|
+
search: catalog.plugins
|
|
79
|
+
.map((plugin) => ({
|
|
80
|
+
pluginId: plugin.pluginId,
|
|
81
|
+
text: `${plugin.displayName} ${plugin.description} ${plugin.keywords.join(' ')}`.trim(),
|
|
82
|
+
}))
|
|
83
|
+
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)),
|
|
84
|
+
}
|
|
85
|
+
const compatibility = {
|
|
86
|
+
schemaVersion: 1,
|
|
87
|
+
catalogId: catalog.catalogId,
|
|
88
|
+
plugins: catalog.plugins.map((plugin) => ({
|
|
89
|
+
pluginId: plugin.pluginId,
|
|
90
|
+
harnessCompatibility: plugin.harnessCompatibility,
|
|
91
|
+
})),
|
|
92
|
+
}
|
|
93
|
+
// Navigation stays small and points at the shards a client then fetches:
|
|
94
|
+
// `shelf-*` for a default view, `category-*` for "view more", and
|
|
95
|
+
// `catalog-index.v1.json` for the complete product list.
|
|
96
|
+
const navigation = {
|
|
97
|
+
schemaVersion: 1,
|
|
98
|
+
catalogId: catalog.catalogId,
|
|
99
|
+
generatedAt: catalog.generatedAt,
|
|
100
|
+
topCount: index.topCount,
|
|
101
|
+
sourcePreference: index.sourcePreference,
|
|
102
|
+
counts: index.counts,
|
|
103
|
+
catalogIndexArtifact: CATALOG_INDEX_ARTIFACT,
|
|
104
|
+
iconCoverage: iconCoverage(index),
|
|
105
|
+
// The index URL rides in the navigation artifact so a client can fetch the
|
|
106
|
+
// browsing index from the publication it was told about, verify it against
|
|
107
|
+
// the digest in integrity.json, and never compose a URL or need this
|
|
108
|
+
// service to proxy it. Absent for an unpublished build.
|
|
109
|
+
...(options.publicationCatalogsBaseUrl
|
|
110
|
+
? {
|
|
111
|
+
catalogIndexUrl: `${options.publicationCatalogsBaseUrl}/${catalogIdSuffix(catalog.catalogId)}/${CATALOG_INDEX_ARTIFACT}`,
|
|
112
|
+
}
|
|
113
|
+
: options.publicationAssetsBaseUrl
|
|
114
|
+
? { catalogIndexUrl: `${options.publicationAssetsBaseUrl}/${CATALOG_INDEX_ARTIFACT}` }
|
|
115
|
+
: options.publicationRepositoryUrl
|
|
116
|
+
? {
|
|
117
|
+
catalogIndexUrl: immutableAssetUrl(
|
|
118
|
+
options.publicationRepositoryUrl,
|
|
119
|
+
catalog.catalogId,
|
|
120
|
+
CATALOG_INDEX_ARTIFACT
|
|
121
|
+
),
|
|
122
|
+
}
|
|
123
|
+
: {}),
|
|
124
|
+
// Marks ride in the navigation artifact so a client that already receives it
|
|
125
|
+
// renders compiled brand marks without fetching the browsing index. Keys are
|
|
126
|
+
// sorted for a stable artifact.
|
|
127
|
+
brandMarks: Object.fromEntries(
|
|
128
|
+
Object.keys(index.products)
|
|
129
|
+
.toSorted()
|
|
130
|
+
.flatMap((productKey) => {
|
|
131
|
+
const assetUrl = index.products[productKey]?.icon?.assetUrl
|
|
132
|
+
return assetUrl ? [[productKey, assetUrl] as const] : []
|
|
133
|
+
})
|
|
134
|
+
),
|
|
135
|
+
categories: index.categories,
|
|
136
|
+
diagnostics: options.curationDiagnostics === false ? [] : index.diagnostics,
|
|
137
|
+
}
|
|
138
|
+
const shards: Record<string, string> = {
|
|
139
|
+
[CATALOG_INDEX_ARTIFACT]: json({
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
catalogId: catalog.catalogId,
|
|
142
|
+
generatedAt: catalog.generatedAt,
|
|
143
|
+
topCount: index.topCount,
|
|
144
|
+
sourcePreference: index.sourcePreference,
|
|
145
|
+
counts: index.counts,
|
|
146
|
+
products: index.products,
|
|
147
|
+
categories: index.categories.map((category) => ({
|
|
148
|
+
category: category.category,
|
|
149
|
+
productCount: category.productCount,
|
|
150
|
+
productKeys: category.productKeys,
|
|
151
|
+
})),
|
|
152
|
+
}),
|
|
153
|
+
}
|
|
154
|
+
for (const category of index.categories) {
|
|
155
|
+
shards[category.shelfArtifact] = json({
|
|
156
|
+
schemaVersion: 1,
|
|
157
|
+
catalogId: catalog.catalogId,
|
|
158
|
+
category: category.category,
|
|
159
|
+
topCount: index.topCount,
|
|
160
|
+
products: category.topProductKeys.map((productKey) => index.products[productKey]),
|
|
161
|
+
})
|
|
162
|
+
shards[category.listArtifact] = json({
|
|
163
|
+
schemaVersion: 1,
|
|
164
|
+
catalogId: catalog.catalogId,
|
|
165
|
+
category: category.category,
|
|
166
|
+
productCount: category.productCount,
|
|
167
|
+
pluginCount: category.pluginCount,
|
|
168
|
+
products: category.productKeys.map((productKey) => index.products[productKey]),
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
const files: Record<string, string> = {
|
|
172
|
+
'catalog.v1.json': json(catalog),
|
|
173
|
+
'catalog-summary.v1.json': json(summary),
|
|
174
|
+
'sources.lock.json': json(lock),
|
|
175
|
+
'compatibility.v1.json': json(compatibility),
|
|
176
|
+
'categories.v1.json': json(navigation),
|
|
177
|
+
...shards,
|
|
178
|
+
}
|
|
179
|
+
const integrityBody = {
|
|
180
|
+
schemaVersion: 1,
|
|
181
|
+
catalogId: catalog.catalogId,
|
|
182
|
+
// An artifact's digest is the canonical digest of its text, not a hash of
|
|
183
|
+
// the file bytes: consumers read the artifact as a string and compare it
|
|
184
|
+
// with `canonicalDigest`, so a checker hashing the raw bytes will disagree
|
|
185
|
+
// with every published release. Mirrored marks below are the byte case,
|
|
186
|
+
// because a client stores those bytes.
|
|
187
|
+
files: Object.fromEntries(
|
|
188
|
+
Object.entries(files)
|
|
189
|
+
.toSorted()
|
|
190
|
+
.map(([name, content]) => [name, digest(content)])
|
|
191
|
+
),
|
|
192
|
+
// Mirrored brand marks are published next to the artifacts, never inside
|
|
193
|
+
// them: the digest and size let a consumer or the release check verify the
|
|
194
|
+
// bytes it fetched without trusting the release listing.
|
|
195
|
+
assets: iconAssets(index),
|
|
196
|
+
}
|
|
197
|
+
return { ...files, 'integrity.json': json(integrityBody) } as GeneratedArtifacts
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function iconAssets(index: ConsumerIndex): readonly PublishedAsset[] {
|
|
201
|
+
const assets = new Map<string, PublishedAsset>()
|
|
202
|
+
for (const product of Object.values(index.products)) {
|
|
203
|
+
if (!product.icon) continue
|
|
204
|
+
assets.set(product.icon.asset, publishedAssetOf(product.icon))
|
|
205
|
+
}
|
|
206
|
+
return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Publishes a verified artifact set to `generatedDirectory` under the output's
|
|
211
|
+
* coordination lock. See `catalog-store.ts` for the publication contract.
|
|
212
|
+
*/
|
|
213
|
+
export async function writeArtifacts(
|
|
214
|
+
generatedDirectory: string,
|
|
215
|
+
artifacts: GeneratedArtifacts,
|
|
216
|
+
options: PublishOptions = {}
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
await publishCatalog(generatedDirectory, artifacts as Record<string, string>, options)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sortArtifactKeys(value: unknown): unknown {
|
|
222
|
+
if (Array.isArray(value)) return value.map(sortArtifactKeys)
|
|
223
|
+
if (value !== null && typeof value === 'object')
|
|
224
|
+
return Object.fromEntries(
|
|
225
|
+
Object.keys(value as Record<string, unknown>)
|
|
226
|
+
.toSorted()
|
|
227
|
+
.map((key) => [key, sortArtifactKeys((value as Record<string, unknown>)[key])])
|
|
228
|
+
)
|
|
229
|
+
return value
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function json(value: unknown): string {
|
|
233
|
+
return `${JSON.stringify(sortArtifactKeys(value), null, 2)}\n`
|
|
234
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mapWithConcurrency } from './concurrency.js'
|
|
2
|
+
import { contentId } from './digest.js'
|
|
3
|
+
import { normalizePlugin, type ReleaseOptions } from './plugin-release.js'
|
|
4
|
+
import {
|
|
5
|
+
createSkippedPlugin,
|
|
6
|
+
PluginSafetyError,
|
|
7
|
+
UPSTREAM_WEATHER_SKIP_CODES,
|
|
8
|
+
type SkippedPlugin,
|
|
9
|
+
} from './plugin-skips.js'
|
|
10
|
+
import { assertRepositoryAllowed, DEFAULT_POLICY, type CatalogPolicy } from './policy.js'
|
|
11
|
+
import {
|
|
12
|
+
CatalogSchema,
|
|
13
|
+
PluginSchema,
|
|
14
|
+
type Catalog,
|
|
15
|
+
type CatalogSource,
|
|
16
|
+
type Plugin,
|
|
17
|
+
} from './schema.js'
|
|
18
|
+
import type { ResolvedSource } from './source-resolution.js'
|
|
19
|
+
import { canonicalRepositoryUrl } from './sources.js'
|
|
20
|
+
import { orderLeadingPlugins } from './taxonomy.js'
|
|
21
|
+
|
|
22
|
+
export interface BuildCatalogInput extends Omit<
|
|
23
|
+
ReleaseOptions,
|
|
24
|
+
'policy' | 'metadataOnly' | 'resolveExternalRefs'
|
|
25
|
+
> {
|
|
26
|
+
readonly resolvedSources: readonly ResolvedSource[]
|
|
27
|
+
/** Each category's curated products, which head the catalog in this order. */
|
|
28
|
+
readonly leading?: Readonly<Record<string, readonly string[]>>
|
|
29
|
+
readonly policy?: CatalogPolicy
|
|
30
|
+
readonly metadataOnly?: boolean
|
|
31
|
+
readonly resolveExternalRefs?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BuildCatalogResult {
|
|
35
|
+
readonly catalog: Catalog
|
|
36
|
+
readonly skippedPlugins: readonly SkippedPlugin[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function buildCatalog(input: BuildCatalogInput): Promise<Catalog> {
|
|
40
|
+
return (await buildCatalogInternal(input)).catalog
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function buildCatalogInternal(input: BuildCatalogInput): Promise<BuildCatalogResult> {
|
|
44
|
+
const policy = input.policy ?? DEFAULT_POLICY
|
|
45
|
+
const options: ReleaseOptions = {
|
|
46
|
+
...input,
|
|
47
|
+
policy,
|
|
48
|
+
metadataOnly: input.metadataOnly ?? false,
|
|
49
|
+
resolveExternalRefs: input.resolveExternalRefs ?? false,
|
|
50
|
+
}
|
|
51
|
+
for (const source of input.resolvedSources) {
|
|
52
|
+
assertRepositoryAllowed(source.config.repositoryUrl, policy)
|
|
53
|
+
for (const entry of source.parsed.entries) {
|
|
54
|
+
if (entry.source.kind === 'git') assertRepositoryAllowed(entry.source.repositoryUrl, policy)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const sourceRecords: CatalogSource[] = input.resolvedSources
|
|
58
|
+
.map(({ config, commitSha, retrievedAt, manifestDigest }) => ({
|
|
59
|
+
sourceId: config.sourceId,
|
|
60
|
+
displayName: config.displayName,
|
|
61
|
+
repositoryUrl: canonicalRepositoryUrl(config.repositoryUrl),
|
|
62
|
+
marketplaceDialect: config.marketplaceDialect,
|
|
63
|
+
manifestPath: config.manifestPath,
|
|
64
|
+
defaultBranch: config.defaultBranch,
|
|
65
|
+
resolvedCommitSha: commitSha,
|
|
66
|
+
retrievalTimestamp: retrievedAt,
|
|
67
|
+
trustClassification: config.trustClassification,
|
|
68
|
+
sourceManifestDigest: manifestDigest,
|
|
69
|
+
synchronizationStatus: 'synchronized' as const,
|
|
70
|
+
}))
|
|
71
|
+
.toSorted((left, right) => left.sourceId.localeCompare(right.sourceId))
|
|
72
|
+
let plugins: Plugin[] = []
|
|
73
|
+
let skippedPlugins: SkippedPlugin[] = []
|
|
74
|
+
for (const source of input.resolvedSources) {
|
|
75
|
+
if (source.parsed.entries.length > policy.maxMarketplacePlugins)
|
|
76
|
+
throw new Error('MARKETPLACE_TOO_MANY_PLUGINS')
|
|
77
|
+
const normalized = await mapWithConcurrency(source.parsed.entries, 8, async (entry) => {
|
|
78
|
+
try {
|
|
79
|
+
const plugin = await normalizePlugin(source, entry, options)
|
|
80
|
+
try {
|
|
81
|
+
return { plugin: PluginSchema.parse(plugin) }
|
|
82
|
+
} catch {
|
|
83
|
+
throw new PluginSafetyError('PLUGIN_SCHEMA_INVALID', [])
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const skipped = createSkippedPlugin(source, entry, error)
|
|
87
|
+
if (!skipped) throw error
|
|
88
|
+
return { skipped }
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
const sourcePlugins: Plugin[] = []
|
|
92
|
+
const sourceSkipped: SkippedPlugin[] = []
|
|
93
|
+
for (const result of normalized) {
|
|
94
|
+
if (result.plugin) sourcePlugins.push(result.plugin)
|
|
95
|
+
if (result.skipped) sourceSkipped.push(result.skipped)
|
|
96
|
+
}
|
|
97
|
+
const weatherSkips = sourceSkipped.filter((skipped) =>
|
|
98
|
+
UPSTREAM_WEATHER_SKIP_CODES.has(skipped.reasonCode)
|
|
99
|
+
)
|
|
100
|
+
if (source.parsed.entries.length > 0 && weatherSkips.length === source.parsed.entries.length)
|
|
101
|
+
throw new PluginSafetyError('PLUGIN_SOURCE_UNAVAILABLE', [
|
|
102
|
+
canonicalRepositoryUrl(source.config.repositoryUrl),
|
|
103
|
+
])
|
|
104
|
+
for (const plugin of sourcePlugins) plugins.push(plugin)
|
|
105
|
+
for (const skipped of sourceSkipped) skippedPlugins.push(skipped)
|
|
106
|
+
}
|
|
107
|
+
plugins = plugins.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId))
|
|
108
|
+
skippedPlugins = skippedPlugins.toSorted(
|
|
109
|
+
(left, right) =>
|
|
110
|
+
left.pluginId.localeCompare(right.pluginId) ||
|
|
111
|
+
left.reasonCode.localeCompare(right.reasonCode) ||
|
|
112
|
+
left.paths.join('\0').localeCompare(right.paths.join('\0'))
|
|
113
|
+
)
|
|
114
|
+
const body = {
|
|
115
|
+
schemaVersion: 1 as const,
|
|
116
|
+
generatedAt:
|
|
117
|
+
sourceRecords
|
|
118
|
+
.map((s) => s.retrievalTimestamp)
|
|
119
|
+
.toSorted()
|
|
120
|
+
.at(-1) ?? new Date(0).toISOString(),
|
|
121
|
+
sources: sourceRecords,
|
|
122
|
+
plugins: orderLeadingPlugins({ plugins }, input.leading).plugins,
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
catalog: CatalogSchema.parse({
|
|
126
|
+
...body,
|
|
127
|
+
catalogId: contentId('catalog', body),
|
|
128
|
+
}),
|
|
129
|
+
skippedPlugins,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { DEFAULT_POLICY, type CatalogPolicy } from './policy.js'
|
|
3
|
+
import type { MarketplaceDialect } from './sources.js'
|
|
4
|
+
import {
|
|
5
|
+
HarnessSchema,
|
|
6
|
+
type Capability,
|
|
7
|
+
type Harness,
|
|
8
|
+
type HarnessCompatibility,
|
|
9
|
+
type SecurityClassification,
|
|
10
|
+
} from './schema.js'
|
|
11
|
+
|
|
12
|
+
export function classifyCapabilities(
|
|
13
|
+
files: ReadonlyMap<string, Uint8Array>,
|
|
14
|
+
metadata?: Record<string, unknown>,
|
|
15
|
+
policy: CatalogPolicy = DEFAULT_POLICY
|
|
16
|
+
): Capability[] {
|
|
17
|
+
const groups = new Map<string, string[]>()
|
|
18
|
+
const add = (type: Capability['type'], path: string) =>
|
|
19
|
+
groups.set(type, [...(groups.get(type) ?? []), path])
|
|
20
|
+
for (const path of files.keys()) {
|
|
21
|
+
const lower = path.toLocaleLowerCase()
|
|
22
|
+
const file = basename(lower)
|
|
23
|
+
if (file === 'skill.md' || lower.includes('/skills/')) add('skill', path)
|
|
24
|
+
if (file === '.mcp.json' || file === 'mcp.json' || lower.includes('/mcp-servers/'))
|
|
25
|
+
add('mcp-server', path)
|
|
26
|
+
if (lower.includes('/commands/') || lower.startsWith('commands/')) add('command', path)
|
|
27
|
+
if (lower.includes('/agents/') || lower.startsWith('agents/') || lower.includes('/subagents/'))
|
|
28
|
+
add('agent', path)
|
|
29
|
+
if (lower.includes('/hooks/') || lower.startsWith('hooks/') || file === 'hooks.json')
|
|
30
|
+
add('hook', path)
|
|
31
|
+
if (
|
|
32
|
+
lower.includes('/rules/') ||
|
|
33
|
+
lower.startsWith('rules/') ||
|
|
34
|
+
file === 'agents.md' ||
|
|
35
|
+
file === 'claude.md' ||
|
|
36
|
+
lower.includes('.cursor/rules/')
|
|
37
|
+
)
|
|
38
|
+
add('rule', path)
|
|
39
|
+
if (lower.includes('browser') || lower.includes('playwright') || lower.includes('chrome'))
|
|
40
|
+
add('browser', path)
|
|
41
|
+
if (lower.includes('/schedule') || lower.includes('/cron') || file.includes('schedule'))
|
|
42
|
+
add('scheduled-task', path)
|
|
43
|
+
if (lower.includes('/ui/') || lower.startsWith('ui/') || lower.includes('/components/'))
|
|
44
|
+
add('ui-component', path)
|
|
45
|
+
if (
|
|
46
|
+
lower.startsWith('bin/') ||
|
|
47
|
+
lower.startsWith('scripts/') ||
|
|
48
|
+
/\.(sh|bash|py|exe|bin)$/.test(file)
|
|
49
|
+
)
|
|
50
|
+
add('executable', path)
|
|
51
|
+
}
|
|
52
|
+
if (metadata && (metadata.mcpServers !== undefined || metadata.mcp !== undefined))
|
|
53
|
+
add('mcp-server', 'plugin-manifest')
|
|
54
|
+
if (metadata && (metadata.hooks !== undefined || metadata.hook !== undefined))
|
|
55
|
+
add('hook', 'plugin-manifest')
|
|
56
|
+
if (groups.size === 0) groups.set('unknown', ['plugin-root'])
|
|
57
|
+
return [...groups.entries()].map(([type, paths]) => ({
|
|
58
|
+
type: type as Capability['type'],
|
|
59
|
+
name: type,
|
|
60
|
+
paths: [...new Set(paths)].toSorted(),
|
|
61
|
+
metadata: {},
|
|
62
|
+
securityImpact: policy.sensitiveCapabilityTypes.includes(type) ? 'sensitive' : 'none',
|
|
63
|
+
}))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function capabilitySummary(
|
|
67
|
+
capabilities: readonly Capability[]
|
|
68
|
+
): Record<Capability['type'], number> {
|
|
69
|
+
const summary = {} as Record<Capability['type'], number>
|
|
70
|
+
for (const capability of capabilities) summary[capability.type] = capability.paths.length
|
|
71
|
+
return summary
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createCompatibility(
|
|
75
|
+
dialect: MarketplaceDialect,
|
|
76
|
+
capabilities: readonly Capability[],
|
|
77
|
+
policy: CatalogPolicy = DEFAULT_POLICY
|
|
78
|
+
): Record<Harness, HarnessCompatibility> {
|
|
79
|
+
const types = [...new Set(capabilities.map((capability) => capability.type))].toSorted()
|
|
80
|
+
const sensitive = types.filter((type) => policy.sensitiveCapabilityTypes.includes(type))
|
|
81
|
+
const make = (
|
|
82
|
+
status: HarnessCompatibility['status'],
|
|
83
|
+
reasons: string[]
|
|
84
|
+
): HarnessCompatibility => ({
|
|
85
|
+
status,
|
|
86
|
+
reasons: [...new Set(reasons)].toSorted(),
|
|
87
|
+
responsibleCapabilities: types,
|
|
88
|
+
})
|
|
89
|
+
const result = {} as Record<Harness, HarnessCompatibility>
|
|
90
|
+
for (const harness of HarnessSchema.options) {
|
|
91
|
+
const native =
|
|
92
|
+
(harness === 'claude-code' && dialect === 'claude') ||
|
|
93
|
+
(harness === 'cursor' && dialect === 'cursor') ||
|
|
94
|
+
(harness === 'codex' && dialect === 'openai')
|
|
95
|
+
if (types.includes('executable'))
|
|
96
|
+
result[harness] = make('unsupported', [
|
|
97
|
+
'Executable components require harness-specific execution authority.',
|
|
98
|
+
])
|
|
99
|
+
else if (types.includes('unknown'))
|
|
100
|
+
result[harness] = make('unknown', [
|
|
101
|
+
'The plugin content was not classified into a supported component type.',
|
|
102
|
+
])
|
|
103
|
+
else if (native && sensitive.length === 0)
|
|
104
|
+
result[harness] = make('native', [
|
|
105
|
+
`The ${dialect} marketplace dialect matches the ${harness} harness.`,
|
|
106
|
+
])
|
|
107
|
+
else if (sensitive.length > 0 && ['pi', 'hermes', 'opencode'].includes(harness))
|
|
108
|
+
result[harness] = make('partially-supported', [
|
|
109
|
+
`Sensitive capabilities require ${harness} policy and runtime review.`,
|
|
110
|
+
...sensitive.map((type) => `Capability ${type} is not granted by this catalog.`),
|
|
111
|
+
])
|
|
112
|
+
else if (
|
|
113
|
+
types.every((type) => ['skill', 'rule', 'mcp-server', 'command', 'agent'].includes(type))
|
|
114
|
+
)
|
|
115
|
+
result[harness] = make(
|
|
116
|
+
native ? 'native' : 'portable',
|
|
117
|
+
native
|
|
118
|
+
? [`The ${dialect} marketplace dialect matches the ${harness} harness.`]
|
|
119
|
+
: ['Files can be copied or translated into the harness layout.']
|
|
120
|
+
)
|
|
121
|
+
else
|
|
122
|
+
result[harness] = make('requires-translation', [
|
|
123
|
+
'One or more capabilities need a harness-specific projection.',
|
|
124
|
+
])
|
|
125
|
+
}
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createSecurity(
|
|
130
|
+
capabilities: readonly Capability[],
|
|
131
|
+
policy: CatalogPolicy,
|
|
132
|
+
metadataOnly: boolean
|
|
133
|
+
): SecurityClassification {
|
|
134
|
+
const sensitive = [
|
|
135
|
+
...new Set(
|
|
136
|
+
capabilities
|
|
137
|
+
.filter((capability) => policy.sensitiveCapabilityTypes.includes(capability.type))
|
|
138
|
+
.map((capability) => capability.type)
|
|
139
|
+
),
|
|
140
|
+
].toSorted()
|
|
141
|
+
const reasons =
|
|
142
|
+
sensitive.length > 0
|
|
143
|
+
? sensitive.map((type) => `Contains ${type} capability metadata.`)
|
|
144
|
+
: metadataOnly
|
|
145
|
+
? ['Content was not fetched in metadata-only mode.']
|
|
146
|
+
: ['No permission-sensitive executable capability was detected.']
|
|
147
|
+
return {
|
|
148
|
+
level: sensitive.length > 0 ? 'sensitive' : metadataOnly ? 'review' : 'low',
|
|
149
|
+
reasons,
|
|
150
|
+
permissionSensitiveChanges: sensitive,
|
|
151
|
+
contentResolution: metadataOnly ? 'metadata-only' : 'complete',
|
|
152
|
+
}
|
|
153
|
+
}
|