@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.
Files changed (142) hide show
  1. package/dist/agent-plugins-catalog.d.ts +2 -4
  2. package/dist/agent-plugins-catalog.js +42 -50
  3. package/dist/agent-plugins-catalog.js.map +1 -1
  4. package/dist/agent-plugins-schema.d.ts +8 -0
  5. package/dist/agent-plugins-schema.js +10 -1
  6. package/dist/agent-plugins-schema.js.map +1 -1
  7. package/dist/artifacts.d.ts +57 -0
  8. package/dist/artifacts.js +159 -0
  9. package/dist/artifacts.js.map +1 -0
  10. package/dist/build.d.ts +20 -0
  11. package/dist/build.js +103 -0
  12. package/dist/build.js.map +1 -0
  13. package/dist/capabilities.d.ts +7 -0
  14. package/dist/capabilities.js +116 -0
  15. package/dist/capabilities.js.map +1 -0
  16. package/dist/catalog-store.d.ts +0 -5
  17. package/dist/catalog-store.js +1 -25
  18. package/dist/catalog-store.js.map +1 -1
  19. package/dist/concurrency.d.ts +1 -0
  20. package/dist/concurrency.js +15 -0
  21. package/dist/concurrency.js.map +1 -0
  22. package/dist/consumer-index.d.ts +6 -28
  23. package/dist/consumer-index.js +7 -8
  24. package/dist/consumer-index.js.map +1 -1
  25. package/dist/digest.d.ts +13 -0
  26. package/dist/digest.js +47 -0
  27. package/dist/digest.js.map +1 -0
  28. package/dist/fs-atomic.d.ts +7 -0
  29. package/dist/fs-atomic.js +30 -0
  30. package/dist/fs-atomic.js.map +1 -0
  31. package/dist/harness.js +2 -1
  32. package/dist/harness.js.map +1 -1
  33. package/dist/icon-mirror.d.ts +3 -2
  34. package/dist/icon-mirror.js +51 -63
  35. package/dist/icon-mirror.js.map +1 -1
  36. package/dist/icons.d.ts +2 -2
  37. package/dist/icons.js +4 -12
  38. package/dist/icons.js.map +1 -1
  39. package/dist/index.d.ts +16 -315
  40. package/dist/index.js +12 -1905
  41. package/dist/index.js.map +1 -1
  42. package/dist/integrity-manifest.d.ts +43 -13
  43. package/dist/integrity-manifest.js +28 -17
  44. package/dist/integrity-manifest.js.map +1 -1
  45. package/dist/json-values.d.ts +3 -0
  46. package/dist/json-values.js +10 -0
  47. package/dist/json-values.js.map +1 -0
  48. package/dist/plugin-release.d.ts +38 -0
  49. package/dist/plugin-release.js +321 -0
  50. package/dist/plugin-release.js.map +1 -0
  51. package/dist/plugin-skips.d.ts +19 -0
  52. package/dist/plugin-skips.js +65 -0
  53. package/dist/plugin-skips.js.map +1 -0
  54. package/dist/policy.d.ts +13 -0
  55. package/dist/policy.js +26 -0
  56. package/dist/policy.js.map +1 -0
  57. package/dist/portable-catalog.d.ts +4 -4
  58. package/dist/portable-catalog.js +14 -126
  59. package/dist/portable-catalog.js.map +1 -1
  60. package/dist/portable-verify.d.ts +2 -0
  61. package/dist/portable-verify.js +68 -0
  62. package/dist/portable-verify.js.map +1 -0
  63. package/dist/publication.d.ts +6 -3
  64. package/dist/publication.js +25 -12
  65. package/dist/publication.js.map +1 -1
  66. package/dist/published-output.d.ts +17 -0
  67. package/dist/published-output.js +49 -0
  68. package/dist/published-output.js.map +1 -0
  69. package/dist/site-icons.d.ts +2 -3
  70. package/dist/site-icons.js +4 -3
  71. package/dist/site-icons.js.map +1 -1
  72. package/dist/snapshots.d.ts +39 -0
  73. package/dist/snapshots.js +238 -0
  74. package/dist/snapshots.js.map +1 -0
  75. package/dist/source-resolution.d.ts +28 -0
  76. package/dist/source-resolution.js +158 -0
  77. package/dist/source-resolution.js.map +1 -0
  78. package/dist/sources.d.ts +5 -1
  79. package/dist/sources.js +21 -27
  80. package/dist/sources.js.map +1 -1
  81. package/dist/synchronize.d.ts +44 -0
  82. package/dist/synchronize.js +118 -0
  83. package/dist/synchronize.js.map +1 -0
  84. package/dist/taxonomy.d.ts +23 -0
  85. package/dist/taxonomy.js +62 -0
  86. package/dist/taxonomy.js.map +1 -0
  87. package/dist/upstream.d.ts +42 -0
  88. package/dist/upstream.js +179 -0
  89. package/dist/upstream.js.map +1 -0
  90. package/dist/verify-artifacts.d.ts +36 -0
  91. package/dist/verify-artifacts.js +286 -0
  92. package/dist/verify-artifacts.js.map +1 -0
  93. package/native/build-provenance.txt +5 -5
  94. package/native/staged-reader-win32-arm64.node +0 -0
  95. package/native/staged-reader-win32-x64.node +0 -0
  96. package/package.json +2 -4
  97. package/src/agent-plugins-catalog.test.ts +2 -2
  98. package/src/agent-plugins-catalog.ts +46 -49
  99. package/src/agent-plugins-schema.ts +11 -1
  100. package/src/artifacts.ts +234 -0
  101. package/src/build.ts +131 -0
  102. package/src/capabilities.ts +153 -0
  103. package/src/catalog-store.ts +1 -28
  104. package/src/concurrency.ts +19 -0
  105. package/src/consumer-index.test.ts +27 -21
  106. package/src/consumer-index.ts +12 -35
  107. package/src/digest.ts +55 -0
  108. package/src/fs-atomic.ts +32 -0
  109. package/src/harness.test.ts +4 -32
  110. package/src/harness.ts +2 -1
  111. package/src/icon-mirror.ts +57 -64
  112. package/src/icons.test.ts +0 -4
  113. package/src/icons.ts +4 -4
  114. package/src/index.test.ts +52 -48
  115. package/src/index.ts +50 -2583
  116. package/src/integrity-manifest.ts +41 -21
  117. package/src/json-values.ts +11 -0
  118. package/src/plugin-release.ts +457 -0
  119. package/src/plugin-skips.ts +110 -0
  120. package/src/policy.ts +39 -0
  121. package/src/portable-catalog.test.ts +0 -5
  122. package/src/portable-catalog.ts +16 -143
  123. package/src/portable-verify.ts +71 -0
  124. package/src/publication.test.ts +29 -1
  125. package/src/publication.ts +32 -13
  126. package/src/published-output.ts +60 -0
  127. package/src/site-icons.test.ts +0 -1
  128. package/src/site-icons.ts +4 -4
  129. package/src/snapshots.ts +287 -0
  130. package/src/source-digest.test.ts +3 -7
  131. package/src/source-resolution.ts +233 -0
  132. package/src/sources-json.test.ts +0 -1
  133. package/src/sources.ts +27 -33
  134. package/src/synchronize.ts +213 -0
  135. package/src/taxonomy.ts +87 -0
  136. package/src/upstream.ts +194 -0
  137. package/src/verify-artifacts.ts +365 -0
  138. package/src/write-artifacts.test.ts +0 -1
  139. package/dist/source-digest.d.ts +0 -2
  140. package/dist/source-digest.js +0 -12
  141. package/dist/source-digest.js.map +0 -1
  142. package/src/source-digest.ts +0 -14
@@ -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
+ }
@@ -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
+ }