@adea-ai/plugins 4.0.1 → 4.0.7
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 +0 -5
- package/dist/catalog-store.js +1 -25
- package/dist/catalog-store.js.map +1 -1
- 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 +6 -28
- package/dist/consumer-index.js +7 -8
- 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 +3 -2
- package/dist/icon-mirror.js +51 -63
- 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 -315
- package/dist/index.js +12 -1905
- 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 +6 -3
- package/dist/publication.js +25 -12
- 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 +2 -3
- package/dist/site-icons.js +4 -3
- 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 +2 -4
- 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.ts +234 -0
- package/src/build.ts +131 -0
- package/src/capabilities.ts +153 -0
- package/src/catalog-store.ts +1 -28
- package/src/concurrency.ts +19 -0
- package/src/consumer-index.test.ts +27 -21
- package/src/consumer-index.ts +12 -35
- 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 +57 -64
- package/src/icons.test.ts +0 -4
- package/src/icons.ts +4 -4
- package/src/index.test.ts +52 -48
- package/src/index.ts +50 -2583
- package/src/integrity-manifest.ts +41 -21
- package/src/json-values.ts +11 -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 +32 -13
- package/src/published-output.ts +60 -0
- package/src/site-icons.test.ts +0 -1
- package/src/site-icons.ts +4 -4
- package/src/snapshots.ts +287 -0
- package/src/source-digest.test.ts +3 -7
- package/src/source-resolution.ts +233 -0
- package/src/sources-json.test.ts +0 -1
- 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 +0 -1
- package/dist/source-digest.d.ts +0 -2
- package/dist/source-digest.js +0 -12
- package/dist/source-digest.js.map +0 -1
- package/src/source-digest.ts +0 -14
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
|
+
}
|
package/src/catalog-store.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { promises as fs } from 'node:fs'
|
|
|
31
31
|
import { hostname } from 'node:os'
|
|
32
32
|
import { basename, dirname, join, sep } from 'node:path'
|
|
33
33
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
34
|
+
import { writeDurably } from './fs-atomic.js'
|
|
34
35
|
|
|
35
36
|
export type PublicationStep = 'acquiring' | 'staged' | 'backed-up' | 'swapped' | 'cleaned'
|
|
36
37
|
|
|
@@ -336,16 +337,6 @@ async function readJournal(paths: CatalogPaths): Promise<Journal | undefined> {
|
|
|
336
337
|
throw new Error(`CATALOG_RECOVERY_REQUIRED: unrecognized journal ${paths.journal}`)
|
|
337
338
|
}
|
|
338
339
|
|
|
339
|
-
async function writeDurably(path: string, content: string | Uint8Array): Promise<void> {
|
|
340
|
-
const handle = await fs.open(path, 'wx')
|
|
341
|
-
try {
|
|
342
|
-
await handle.writeFile(content)
|
|
343
|
-
await handle.sync()
|
|
344
|
-
} finally {
|
|
345
|
-
await handle.close()
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
|
|
349
340
|
/** Best effort: directory fsync is unavailable on Windows and some filesystems. */
|
|
350
341
|
async function syncDirectory(path: string): Promise<void> {
|
|
351
342
|
let handle
|
|
@@ -526,21 +517,3 @@ export async function publishCatalog(
|
|
|
526
517
|
await lock.release()
|
|
527
518
|
}
|
|
528
519
|
}
|
|
529
|
-
|
|
530
|
-
/**
|
|
531
|
-
* Writes one file so a reader sees either the old or the new bytes. For
|
|
532
|
-
* content-addressed assets and the browsing index beside the catalog.
|
|
533
|
-
*/
|
|
534
|
-
export async function writeFileAtomically(
|
|
535
|
-
path: string,
|
|
536
|
-
content: string | Uint8Array
|
|
537
|
-
): Promise<void> {
|
|
538
|
-
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`)
|
|
539
|
-
try {
|
|
540
|
-
await writeDurably(temporary, content)
|
|
541
|
-
await fs.rename(temporary, path)
|
|
542
|
-
} catch (error) {
|
|
543
|
-
await fs.rm(temporary, { force: true })
|
|
544
|
-
throw error
|
|
545
|
-
}
|
|
546
|
-
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export async function mapWithConcurrency<T, U>(
|
|
2
|
+
values: readonly T[],
|
|
3
|
+
concurrency: number,
|
|
4
|
+
mapper: (value: T, index: number) => Promise<U>
|
|
5
|
+
): Promise<U[]> {
|
|
6
|
+
const results = Array.from<U>({ length: values.length })
|
|
7
|
+
let nextIndex = 0
|
|
8
|
+
const worker = async (): Promise<void> => {
|
|
9
|
+
while (true) {
|
|
10
|
+
const index = nextIndex++
|
|
11
|
+
if (index >= values.length) return
|
|
12
|
+
results[index] = await mapper(values[index]!, index)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
await Promise.all(
|
|
16
|
+
Array.from({ length: Math.min(Math.max(concurrency, 1), values.length) }, () => worker())
|
|
17
|
+
)
|
|
18
|
+
return results
|
|
19
|
+
}
|