@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
|
@@ -1,44 +1,64 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import {
|
|
2
|
+
import { isPublishedName } from './publication.js'
|
|
3
|
+
import { CatalogSchema, DIGEST_RE } from './schema.js'
|
|
4
|
+
|
|
5
|
+
/** A mirrored brand mark published beside the artifacts. */
|
|
6
|
+
export const PublishedAssetSchema = z.object({
|
|
7
|
+
name: z.string(),
|
|
8
|
+
digest: z.string().regex(DIGEST_RE),
|
|
9
|
+
bytes: z.number().int().positive(),
|
|
10
|
+
contentType: z.string(),
|
|
11
|
+
/** `content` mirrors a plugin file; `favicon` mirrors the product site's icon. */
|
|
12
|
+
kind: z.enum(['content', 'favicon']),
|
|
13
|
+
/** Upstream path or URL the bytes came from, for provenance. */
|
|
14
|
+
origin: z.string(),
|
|
15
|
+
})
|
|
16
|
+
export type PublishedAsset = Readonly<z.output<typeof PublishedAssetSchema>>
|
|
17
|
+
|
|
18
|
+
/** The integrity record for a mirrored mark, from its catalog-relative path or site URL. */
|
|
19
|
+
export function publishedAssetOf(icon: {
|
|
20
|
+
readonly asset: string
|
|
21
|
+
readonly digest: string
|
|
22
|
+
readonly bytes: number
|
|
23
|
+
readonly contentType: string
|
|
24
|
+
readonly kind: 'content' | 'favicon'
|
|
25
|
+
readonly path?: string | null
|
|
26
|
+
readonly url?: string | null
|
|
27
|
+
}): PublishedAsset {
|
|
28
|
+
return {
|
|
29
|
+
name: icon.asset,
|
|
30
|
+
digest: icon.digest,
|
|
31
|
+
bytes: icon.bytes,
|
|
32
|
+
contentType: icon.contentType,
|
|
33
|
+
kind: icon.kind,
|
|
34
|
+
origin: (icon.kind === 'content' ? icon.path : icon.url) ?? '',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
3
37
|
|
|
4
38
|
// Assets were added after the first v1 snapshots, so their absence remains valid.
|
|
5
39
|
const IntegrityManifestSchema = z.object({
|
|
6
40
|
schemaVersion: z.literal(1),
|
|
7
|
-
catalogId:
|
|
41
|
+
catalogId: CatalogSchema.shape.catalogId,
|
|
8
42
|
files: z.record(z.string(), z.string().regex(DIGEST_RE)),
|
|
9
|
-
assets: z
|
|
10
|
-
.array(
|
|
11
|
-
z.object({
|
|
12
|
-
name: z.string(),
|
|
13
|
-
digest: z.string().regex(DIGEST_RE),
|
|
14
|
-
bytes: z.number().int().positive(),
|
|
15
|
-
contentType: z.string(),
|
|
16
|
-
kind: z.enum(['content', 'favicon']),
|
|
17
|
-
origin: z.string(),
|
|
18
|
-
})
|
|
19
|
-
)
|
|
20
|
-
.optional(),
|
|
43
|
+
assets: z.array(PublishedAssetSchema).optional(),
|
|
21
44
|
})
|
|
45
|
+
type IntegrityManifest = z.output<typeof IntegrityManifestSchema>
|
|
22
46
|
|
|
23
47
|
/** Validate all names before using a manifest to address local files or URLs. */
|
|
24
|
-
export function parseIntegrityManifest(input: unknown) {
|
|
48
|
+
export function parseIntegrityManifest(input: unknown): IntegrityManifest {
|
|
25
49
|
const parsed = IntegrityManifestSchema.safeParse(input)
|
|
26
50
|
if (!parsed.success || Object.keys(parsed.data.files).length === 0)
|
|
27
51
|
throw new Error('INTEGRITY_MANIFEST_INVALID')
|
|
28
52
|
const manifest = parsed.data
|
|
29
53
|
for (const name of Object.keys(manifest.files)) {
|
|
30
|
-
if (!
|
|
54
|
+
if (!isPublishedName(name) || name === 'integrity.json')
|
|
31
55
|
throw new Error(`INTEGRITY_FILE_NAME_INVALID: ${name}`)
|
|
32
56
|
}
|
|
33
57
|
const assetNames = new Set<string>()
|
|
34
58
|
for (const asset of manifest.assets ?? []) {
|
|
35
|
-
if (!
|
|
59
|
+
if (!isPublishedName(asset.name)) throw new Error(`INTEGRITY_ASSET_NAME_INVALID: ${asset.name}`)
|
|
36
60
|
if (assetNames.has(asset.name)) throw new Error(`INTEGRITY_ASSET_DUPLICATE: ${asset.name}`)
|
|
37
61
|
assetNames.add(asset.name)
|
|
38
62
|
}
|
|
39
63
|
return manifest
|
|
40
64
|
}
|
|
41
|
-
|
|
42
|
-
function safeName(name: string): boolean {
|
|
43
|
-
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(name)
|
|
44
|
-
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function stringValue(input: unknown): string | undefined {
|
|
2
|
+
return typeof input === 'string' && input.trim() ? input.trim() : undefined
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isString(input: unknown): input is string {
|
|
6
|
+
return typeof input === 'string' && input.trim().length > 0
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function arrayValue(input: unknown): unknown[] {
|
|
10
|
+
return Array.isArray(input) ? input : []
|
|
11
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { expect, test } from 'bun:test'
|
|
4
|
+
import { FixtureSnapshotLoader, synchronize } from './index.js'
|
|
5
|
+
import { withPortableFixture } from '../test-support/portable-fixture.js'
|
|
6
|
+
|
|
7
|
+
test('retries transient marketplace manifest responses while preserving permanent failures', () =>
|
|
8
|
+
withPortableFixture(async (input, root) => {
|
|
9
|
+
const initial = await synchronize(input)
|
|
10
|
+
const manifest = await readFile(join(root, 'fixtures/source/marketplace.json'), 'utf8')
|
|
11
|
+
const realFetch = globalThis.fetch
|
|
12
|
+
let calls = 0
|
|
13
|
+
try {
|
|
14
|
+
globalThis.fetch = (async () => {
|
|
15
|
+
calls++
|
|
16
|
+
if (calls < 3)
|
|
17
|
+
return new Response('temporarily unavailable', {
|
|
18
|
+
status: 503,
|
|
19
|
+
headers: { 'retry-after': '0' },
|
|
20
|
+
})
|
|
21
|
+
return new Response(manifest, { status: 200 })
|
|
22
|
+
}) as unknown as typeof fetch
|
|
23
|
+
const options = {
|
|
24
|
+
...input,
|
|
25
|
+
mode: 'live' as const,
|
|
26
|
+
fromLock: initial.lock!,
|
|
27
|
+
snapshotLoader: new FixtureSnapshotLoader(input.fixtureRoot!, input.sources),
|
|
28
|
+
}
|
|
29
|
+
const recovered = await synchronize(options)
|
|
30
|
+
expect(recovered.catalog?.catalogId).toBe(initial.catalog?.catalogId)
|
|
31
|
+
expect(calls).toBe(3)
|
|
32
|
+
calls = 0
|
|
33
|
+
globalThis.fetch = (async () => {
|
|
34
|
+
calls++
|
|
35
|
+
return new Response('not found', { status: 404 })
|
|
36
|
+
}) as unknown as typeof fetch
|
|
37
|
+
await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 404')
|
|
38
|
+
expect(calls).toBe(1)
|
|
39
|
+
calls = 0
|
|
40
|
+
globalThis.fetch = (async () => {
|
|
41
|
+
calls++
|
|
42
|
+
return new Response('busy', { status: 429, headers: { 'retry-after': '0' } })
|
|
43
|
+
}) as unknown as typeof fetch
|
|
44
|
+
await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 429')
|
|
45
|
+
expect(calls).toBe(3)
|
|
46
|
+
} finally {
|
|
47
|
+
globalThis.fetch = realFetch
|
|
48
|
+
}
|
|
49
|
+
}))
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { byteDigest, digest, sourceDigest } from './digest.js'
|
|
2
|
+
import {
|
|
3
|
+
capabilitySummary,
|
|
4
|
+
classifyCapabilities,
|
|
5
|
+
createCompatibility,
|
|
6
|
+
createSecurity,
|
|
7
|
+
} from './capabilities.js'
|
|
8
|
+
import { normalizeDisplayName } from './display-name.js'
|
|
9
|
+
import { stageIconBytes } from './icon-mirror.js'
|
|
10
|
+
import {
|
|
11
|
+
iconAssetName,
|
|
12
|
+
iconOverrideFor,
|
|
13
|
+
inspectIconBytes,
|
|
14
|
+
selectIconPath,
|
|
15
|
+
type ProductIconOverrides,
|
|
16
|
+
} from './icons.js'
|
|
17
|
+
import { arrayValue, isString, stringValue } from './json-values.js'
|
|
18
|
+
import { PluginSafetyError } from './plugin-skips.js'
|
|
19
|
+
import type { CatalogPolicy } from './policy.js'
|
|
20
|
+
import type {
|
|
21
|
+
Capability,
|
|
22
|
+
Harness,
|
|
23
|
+
HarnessCompatibility,
|
|
24
|
+
LicenseMetadata,
|
|
25
|
+
Plugin,
|
|
26
|
+
PluginProvenance,
|
|
27
|
+
PluginRelease,
|
|
28
|
+
} from './schema.js'
|
|
29
|
+
import { isVendorHomepage, resolveSiteIcon, type SiteIcon } from './site-icons.js'
|
|
30
|
+
import type { Snapshot, SnapshotLoader } from './snapshots.js'
|
|
31
|
+
import {
|
|
32
|
+
resolvePluginSource,
|
|
33
|
+
type ResolvedPluginSource,
|
|
34
|
+
type ResolvedSource,
|
|
35
|
+
} from './source-resolution.js'
|
|
36
|
+
import {
|
|
37
|
+
canonicalRepositoryUrl,
|
|
38
|
+
parseJsonDocument,
|
|
39
|
+
safeRelativePath,
|
|
40
|
+
type MarketplacePluginEntry,
|
|
41
|
+
} from './sources.js'
|
|
42
|
+
import {
|
|
43
|
+
normalizeCategory,
|
|
44
|
+
productGroupingKey,
|
|
45
|
+
stablePluginId,
|
|
46
|
+
stableReleaseId,
|
|
47
|
+
type CategoryMap,
|
|
48
|
+
type ProductAliases,
|
|
49
|
+
} from './taxonomy.js'
|
|
50
|
+
import { createSiteIconFetcher, SITE_FETCH_TIMEOUT_MS } from './upstream.js'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Marks the catalog format this compiler emits.
|
|
54
|
+
*
|
|
55
|
+
* Bumping or first introducing this value makes a synchronization replay its
|
|
56
|
+
* verified pins and rebuild, which is how a format change reaches the published
|
|
57
|
+
* catalog when no upstream source has moved. It is deliberately separate from
|
|
58
|
+
* the agent-package normalizer version, which describes package normalization
|
|
59
|
+
* and would misreport a change here.
|
|
60
|
+
*/
|
|
61
|
+
export const CATALOG_CONTRACT_VERSION = 'adea-catalog/3'
|
|
62
|
+
|
|
63
|
+
/** Trusted compiler extension; never loaded from upstream plugin content. */
|
|
64
|
+
export type ReleaseTransform = (
|
|
65
|
+
release: PluginRelease,
|
|
66
|
+
snapshot: Snapshot,
|
|
67
|
+
pluginName: string
|
|
68
|
+
) => PluginRelease
|
|
69
|
+
|
|
70
|
+
/** Everything that shapes a release besides the marketplace entry itself. */
|
|
71
|
+
export interface ReleaseOptions {
|
|
72
|
+
readonly categoryMap: CategoryMap
|
|
73
|
+
readonly productCategories?: Record<string, string>
|
|
74
|
+
readonly productAliases: ProductAliases
|
|
75
|
+
readonly productIconOverrides?: ProductIconOverrides
|
|
76
|
+
/** Directory that receives mirrored brand marks as the catalog is built. */
|
|
77
|
+
readonly iconAssetsDirectory?: string
|
|
78
|
+
readonly policy: CatalogPolicy
|
|
79
|
+
readonly metadataOnly: boolean
|
|
80
|
+
readonly snapshotLoader: SnapshotLoader
|
|
81
|
+
/** Live builds resolve unpinned refs and may fetch vendor site icons. */
|
|
82
|
+
readonly resolveExternalRefs: boolean
|
|
83
|
+
readonly transformRelease?: ReleaseTransform
|
|
84
|
+
/** Compatibility derived from the final release, replacing the dialect heuristic. */
|
|
85
|
+
readonly releaseCompatibility?: ReleaseCompatibility
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ReleaseCompatibility = (
|
|
89
|
+
release: PluginRelease
|
|
90
|
+
) => Record<Harness, HarnessCompatibility> | undefined
|
|
91
|
+
|
|
92
|
+
export async function normalizePlugin(
|
|
93
|
+
source: ResolvedSource,
|
|
94
|
+
entry: MarketplacePluginEntry,
|
|
95
|
+
input: ReleaseOptions
|
|
96
|
+
): Promise<Plugin> {
|
|
97
|
+
const { policy } = input
|
|
98
|
+
const resolved = await resolvePluginSource(
|
|
99
|
+
entry.name,
|
|
100
|
+
entry.source,
|
|
101
|
+
source,
|
|
102
|
+
input.resolveExternalRefs
|
|
103
|
+
)
|
|
104
|
+
const snapshot = input.metadataOnly
|
|
105
|
+
? { files: new Map<string, Uint8Array>(), symlinks: [] }
|
|
106
|
+
: await input.snapshotLoader.load(
|
|
107
|
+
resolved.repositoryUrl,
|
|
108
|
+
resolved.commitSha,
|
|
109
|
+
resolved.subdirectory
|
|
110
|
+
)
|
|
111
|
+
if (snapshot.symlinks.length > 0)
|
|
112
|
+
throw new PluginSafetyError('SYMLINK_ESCAPE', [...snapshot.symlinks].toSorted())
|
|
113
|
+
validateSnapshot(snapshot, policy, entry.name)
|
|
114
|
+
const contentDigest = input.metadataOnly
|
|
115
|
+
? digest({
|
|
116
|
+
repositoryUrl: resolved.repositoryUrl,
|
|
117
|
+
subdirectory: resolved.subdirectory,
|
|
118
|
+
commitSha: resolved.commitSha,
|
|
119
|
+
entryDigest: entry.entryDigest,
|
|
120
|
+
})
|
|
121
|
+
: sourceDigest(snapshot.files)
|
|
122
|
+
const pluginManifest = findPluginManifest(snapshot)
|
|
123
|
+
const manifestDigest = pluginManifest ? digest(pluginManifest.metadata) : entry.entryDigest
|
|
124
|
+
const capabilities = classifyCapabilities(snapshot.files, pluginManifest?.metadata, policy)
|
|
125
|
+
const iconOverride = iconOverrideFor(
|
|
126
|
+
input.productIconOverrides,
|
|
127
|
+
productGroupingKey(entry.name, input.productAliases)
|
|
128
|
+
)
|
|
129
|
+
const contentIcon = resolveContentIcon(snapshot, iconOverride, input.metadataOnly)
|
|
130
|
+
const favicon =
|
|
131
|
+
contentIcon === undefined
|
|
132
|
+
? await resolveFavicon({
|
|
133
|
+
entry,
|
|
134
|
+
iconOverride,
|
|
135
|
+
metadataOnly: input.metadataOnly,
|
|
136
|
+
live: input.resolveExternalRefs,
|
|
137
|
+
})
|
|
138
|
+
: undefined
|
|
139
|
+
// Stage the mark while its bytes are in hand. Publication then needs no
|
|
140
|
+
// re-fetch, and a site icon cannot drift between resolution and release.
|
|
141
|
+
const mark = contentIcon
|
|
142
|
+
? { ...contentIcon, bytes: snapshot.files.get(contentIcon.path) }
|
|
143
|
+
: favicon
|
|
144
|
+
if (input.iconAssetsDirectory && mark?.bytes)
|
|
145
|
+
await stageIconBytes(
|
|
146
|
+
input.iconAssetsDirectory,
|
|
147
|
+
iconAssetName(mark.digest, mark.contentType),
|
|
148
|
+
mark.bytes
|
|
149
|
+
)
|
|
150
|
+
const releaseId = stableReleaseId(
|
|
151
|
+
resolved.repositoryUrl,
|
|
152
|
+
resolved.subdirectory,
|
|
153
|
+
resolved.commitSha,
|
|
154
|
+
contentDigest
|
|
155
|
+
)
|
|
156
|
+
const originalRelease = createRelease({
|
|
157
|
+
source,
|
|
158
|
+
entry,
|
|
159
|
+
resolved,
|
|
160
|
+
contentDigest,
|
|
161
|
+
manifestDigest,
|
|
162
|
+
capabilities,
|
|
163
|
+
snapshot,
|
|
164
|
+
...(pluginManifest ? { pluginManifest } : {}),
|
|
165
|
+
releaseId,
|
|
166
|
+
...(contentIcon ? { contentIcon } : {}),
|
|
167
|
+
...(favicon ? { favicon } : {}),
|
|
168
|
+
metadataOnly: input.metadataOnly,
|
|
169
|
+
})
|
|
170
|
+
const release =
|
|
171
|
+
input.transformRelease && !input.metadataOnly
|
|
172
|
+
? input.transformRelease(originalRelease, snapshot, entry.name)
|
|
173
|
+
: originalRelease
|
|
174
|
+
const compatibility =
|
|
175
|
+
input.releaseCompatibility?.(release) ??
|
|
176
|
+
createCompatibility(source.config.marketplaceDialect, capabilities, policy)
|
|
177
|
+
const security = createSecurity(capabilities, policy, input.metadataOnly)
|
|
178
|
+
const normalizedCategories = entry.categories.map((category) =>
|
|
179
|
+
normalizeCategory(category, input.categoryMap)
|
|
180
|
+
)
|
|
181
|
+
const productCategory = input.productCategories?.[entry.name]
|
|
182
|
+
const categories = [
|
|
183
|
+
...new Set(
|
|
184
|
+
(productCategory
|
|
185
|
+
? [productCategory, ...normalizedCategories.filter((category) => category !== 'other')]
|
|
186
|
+
: normalizedCategories.length > 0
|
|
187
|
+
? normalizedCategories
|
|
188
|
+
: [input.categoryMap.fallback]
|
|
189
|
+
).filter(Boolean)
|
|
190
|
+
),
|
|
191
|
+
].toSorted()
|
|
192
|
+
// The name a list renders: marketplace manifests are inconsistent (`Gmail`
|
|
193
|
+
// beside `zapier` beside `aws-agents-for-devsecops`), so the catalog
|
|
194
|
+
// publishes the written form once and every consumer shows the same thing.
|
|
195
|
+
const displayName = normalizeDisplayName(
|
|
196
|
+
entry.displayName ?? pluginManifest?.displayName ?? entry.name
|
|
197
|
+
)
|
|
198
|
+
const authors =
|
|
199
|
+
entry.authors.length > 0
|
|
200
|
+
? [...entry.authors]
|
|
201
|
+
: source.parsed.owner.length > 0
|
|
202
|
+
? [...source.parsed.owner]
|
|
203
|
+
: ['Unknown']
|
|
204
|
+
const keywords = [...new Set([...entry.keywords, ...entry.name.split(/[-_\s]+/g), ...categories])]
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.toSorted()
|
|
207
|
+
const provenance: PluginProvenance = {
|
|
208
|
+
sourceId: source.config.sourceId,
|
|
209
|
+
repositoryUrl: canonicalRepositoryUrl(source.config.repositoryUrl),
|
|
210
|
+
manifestPath: source.config.manifestPath,
|
|
211
|
+
pluginSubdirectory: resolved.subdirectory || '.',
|
|
212
|
+
resolvedCommitSha: resolved.commitSha,
|
|
213
|
+
sourceManifestDigest: source.manifestDigest,
|
|
214
|
+
upstreamEntryDigest: entry.entryDigest,
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
pluginId: stablePluginId(source.config.sourceId, entry.name),
|
|
218
|
+
displayName,
|
|
219
|
+
description: entry.description || pluginManifest?.description || '',
|
|
220
|
+
productGroupingKey: productGroupingKey(entry.name, input.productAliases),
|
|
221
|
+
categories,
|
|
222
|
+
keywords,
|
|
223
|
+
authors,
|
|
224
|
+
...(entry.homepage ? { homepage: entry.homepage } : {}),
|
|
225
|
+
icons: entry.icons.length > 0 ? [...entry.icons].toSorted() : (pluginManifest?.icons ?? []),
|
|
226
|
+
sourceId: source.config.sourceId,
|
|
227
|
+
upstreamPluginName: entry.name,
|
|
228
|
+
currentReleaseId: releaseId,
|
|
229
|
+
availableReleases: [release],
|
|
230
|
+
capabilitySummary: capabilitySummary(capabilities),
|
|
231
|
+
harnessCompatibility: compatibility,
|
|
232
|
+
license: findLicense(entry, pluginManifest?.metadata),
|
|
233
|
+
provenance,
|
|
234
|
+
securityClassification: security,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
interface CreateReleaseInput {
|
|
239
|
+
readonly source: ResolvedSource
|
|
240
|
+
readonly entry: MarketplacePluginEntry
|
|
241
|
+
readonly resolved: ResolvedPluginSource
|
|
242
|
+
readonly contentDigest: string
|
|
243
|
+
readonly manifestDigest: string
|
|
244
|
+
readonly capabilities: readonly Capability[]
|
|
245
|
+
readonly snapshot: Snapshot
|
|
246
|
+
readonly pluginManifest?: PluginManifest
|
|
247
|
+
readonly releaseId: string
|
|
248
|
+
readonly contentIcon?: PluginRelease['icon'] | undefined
|
|
249
|
+
readonly favicon?: SiteIcon | undefined
|
|
250
|
+
readonly metadataOnly: boolean
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createRelease(input: CreateReleaseInput): PluginRelease {
|
|
254
|
+
const requiredCredentials = new Set<string>()
|
|
255
|
+
const auth = input.entry.policy.authentication
|
|
256
|
+
if (typeof auth === 'string' && auth !== 'NONE' && auth !== 'DISABLED')
|
|
257
|
+
requiredCredentials.add('authentication')
|
|
258
|
+
for (const value of arrayValue(input.entry.raw.requiredCredentials))
|
|
259
|
+
if (typeof value === 'string') requiredCredentials.add(value)
|
|
260
|
+
const requiredConnectors = [
|
|
261
|
+
...new Set(arrayValue(input.entry.raw.connectors).filter(isString)),
|
|
262
|
+
].toSorted()
|
|
263
|
+
const permissionSensitiveChanges = input.capabilities
|
|
264
|
+
.filter((capability) => capability.securityImpact === 'sensitive')
|
|
265
|
+
.map((capability) => capability.type)
|
|
266
|
+
.toSorted()
|
|
267
|
+
const metadata: Record<string, unknown> = {
|
|
268
|
+
catalogContract: CATALOG_CONTRACT_VERSION,
|
|
269
|
+
marketplaceEntry: input.entry.raw,
|
|
270
|
+
...(input.pluginManifest ? { pluginManifest: input.pluginManifest.metadata } : {}),
|
|
271
|
+
sourceDialect: input.source.config.marketplaceDialect,
|
|
272
|
+
}
|
|
273
|
+
const version = input.pluginManifest?.version ?? stringValue(input.entry.raw.version)
|
|
274
|
+
return {
|
|
275
|
+
releaseId: input.releaseId,
|
|
276
|
+
...(version ? { upstreamVersion: version } : {}),
|
|
277
|
+
resolvedRepositoryUrl: input.resolved.repositoryUrl,
|
|
278
|
+
resolvedCommitSha: input.resolved.commitSha,
|
|
279
|
+
pluginSubdirectory: input.resolved.subdirectory || '.',
|
|
280
|
+
canonicalContentDigest: input.contentDigest,
|
|
281
|
+
manifestDigest: input.manifestDigest,
|
|
282
|
+
contentResolution: input.metadataOnly ? 'metadata-only' : 'complete',
|
|
283
|
+
releaseMetadata: metadata,
|
|
284
|
+
capabilities: [...input.capabilities].toSorted(
|
|
285
|
+
(left, right) => left.type.localeCompare(right.type) || left.name.localeCompare(right.name)
|
|
286
|
+
),
|
|
287
|
+
requiredConnectors,
|
|
288
|
+
requiredCredentials: [...requiredCredentials].toSorted(),
|
|
289
|
+
permissionSensitiveChanges: [...new Set(permissionSensitiveChanges)],
|
|
290
|
+
fileIndex: [...input.snapshot.files.keys()].toSorted(),
|
|
291
|
+
publicationTimestamp: input.source.retrievedAt,
|
|
292
|
+
...assembleIcon(input),
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Resolves the product site's icon, for a plugin whose content ships no mark.
|
|
298
|
+
*
|
|
299
|
+
* Only a live build with complete content attempts this: a metadata-only or
|
|
300
|
+
* offline run must not reach out to vendor sites, and neither may a product the
|
|
301
|
+
* curator has already decided about.
|
|
302
|
+
*/
|
|
303
|
+
async function resolveFavicon(input: {
|
|
304
|
+
readonly entry: MarketplacePluginEntry
|
|
305
|
+
readonly iconOverride?: IconOverride | undefined
|
|
306
|
+
readonly metadataOnly: boolean
|
|
307
|
+
readonly live: boolean
|
|
308
|
+
}): Promise<SiteIcon | undefined> {
|
|
309
|
+
if (!input.live || input.metadataOnly) return undefined
|
|
310
|
+
if (input.iconOverride?.forced === null || input.iconOverride?.forced) return undefined
|
|
311
|
+
const homepage = input.iconOverride?.site ?? input.entry.homepage
|
|
312
|
+
if (!isVendorHomepage(homepage)) return undefined
|
|
313
|
+
// Bound both fetches: a vendor site that never answers must not stall a
|
|
314
|
+
// catalogue build. A timeout simply leaves the product on its monogram.
|
|
315
|
+
const signal = AbortSignal.timeout(SITE_FETCH_TIMEOUT_MS)
|
|
316
|
+
const resolved = await resolveSiteIcon(homepage!, createSiteIconFetcher(signal))
|
|
317
|
+
return 'contentType' in resolved ? resolved : undefined
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Picks and vets the brand mark for a release.
|
|
322
|
+
*
|
|
323
|
+
* A file in the plugin's own content is preferred, because it is the mark the
|
|
324
|
+
* vendor ships with the plugin. When the content has none, a live build falls
|
|
325
|
+
* back to the declared homepage's site icon: many plugins are a thin wrapper
|
|
326
|
+
* over a vendor API and point at the vendor's site rather than shipping a logo.
|
|
327
|
+
* Whatever the source, an icon that fails inspection is absent rather than
|
|
328
|
+
* advertised: the catalog never publishes content it would refuse to mirror.
|
|
329
|
+
*/
|
|
330
|
+
function resolveContentIcon(
|
|
331
|
+
snapshot: Snapshot,
|
|
332
|
+
iconOverride: IconOverride | undefined,
|
|
333
|
+
metadataOnly: boolean
|
|
334
|
+
): ContentIcon | undefined {
|
|
335
|
+
if (metadataOnly || snapshot.files.size === 0) return undefined
|
|
336
|
+
// A curator override wins over every rule, including "publish no icon".
|
|
337
|
+
if (iconOverride?.forced === null) return undefined
|
|
338
|
+
const files = [...snapshot.files.keys()].toSorted()
|
|
339
|
+
const forced = iconOverride?.forced
|
|
340
|
+
if (forced && !files.includes(forced)) return undefined
|
|
341
|
+
const path = forced ?? selectIconPath(files)
|
|
342
|
+
if (!path) return undefined
|
|
343
|
+
const bytes = snapshot.files.get(path)
|
|
344
|
+
if (!bytes) return undefined
|
|
345
|
+
const inspected = inspectIconBytes(bytes)
|
|
346
|
+
if (!('contentType' in inspected)) return undefined
|
|
347
|
+
return {
|
|
348
|
+
kind: 'content',
|
|
349
|
+
path,
|
|
350
|
+
contentType: inspected.contentType,
|
|
351
|
+
digest: byteDigest(bytes),
|
|
352
|
+
bytes: bytes.byteLength,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
type IconOverride = ReturnType<typeof iconOverrideFor>
|
|
357
|
+
type ContentIcon = Extract<NonNullable<PluginRelease['icon']>, { kind: 'content' }>
|
|
358
|
+
|
|
359
|
+
function assembleIcon(
|
|
360
|
+
input: Pick<CreateReleaseInput, 'contentIcon' | 'favicon'>
|
|
361
|
+
): { icon: NonNullable<PluginRelease['icon']> } | Record<string, never> {
|
|
362
|
+
if (input.contentIcon) return { icon: input.contentIcon }
|
|
363
|
+
if (input.favicon)
|
|
364
|
+
return {
|
|
365
|
+
icon: {
|
|
366
|
+
kind: 'favicon',
|
|
367
|
+
url: input.favicon.url,
|
|
368
|
+
homepage: input.favicon.homepage,
|
|
369
|
+
contentType: input.favicon.contentType,
|
|
370
|
+
digest: input.favicon.digest,
|
|
371
|
+
bytes: input.favicon.bytes.byteLength,
|
|
372
|
+
},
|
|
373
|
+
}
|
|
374
|
+
return {}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function validateSnapshot(snapshot: Snapshot, policy: CatalogPolicy, pluginName: string): void {
|
|
378
|
+
if (snapshot.files.size > policy.maxFilesPerPlugin)
|
|
379
|
+
throw new PluginSafetyError('PLUGIN_TOO_MANY_FILES', [pluginName])
|
|
380
|
+
let total = 0
|
|
381
|
+
for (const [path, bytes] of snapshot.files) {
|
|
382
|
+
try {
|
|
383
|
+
safeRelativePath(path, 'SNAPSHOT_PATH_INVALID')
|
|
384
|
+
} catch {
|
|
385
|
+
throw new PluginSafetyError('SNAPSHOT_PATH_INVALID', [path])
|
|
386
|
+
}
|
|
387
|
+
if (bytes.byteLength > policy.maxFileBytes)
|
|
388
|
+
throw new PluginSafetyError('PLUGIN_FILE_TOO_LARGE', [path])
|
|
389
|
+
total += bytes.byteLength
|
|
390
|
+
}
|
|
391
|
+
if (total > policy.maxBytesPerPlugin)
|
|
392
|
+
throw new PluginSafetyError('PLUGIN_TOO_LARGE', [pluginName])
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
interface PluginManifest {
|
|
396
|
+
readonly metadata: Record<string, unknown>
|
|
397
|
+
readonly displayName?: string | undefined
|
|
398
|
+
readonly description?: string | undefined
|
|
399
|
+
readonly version?: string | undefined
|
|
400
|
+
readonly icons: string[]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function findPluginManifest(snapshot: Snapshot): PluginManifest | undefined {
|
|
404
|
+
const candidates = [...snapshot.files.keys()]
|
|
405
|
+
.filter((path) => {
|
|
406
|
+
const lower = path.toLocaleLowerCase()
|
|
407
|
+
return lower === 'plugin.json' || lower.endsWith('/plugin.json') || lower === 'package.json'
|
|
408
|
+
})
|
|
409
|
+
.toSorted(
|
|
410
|
+
(left, right) => manifestRank(left) - manifestRank(right) || left.localeCompare(right)
|
|
411
|
+
)
|
|
412
|
+
const path = candidates[0]
|
|
413
|
+
if (!path) return undefined
|
|
414
|
+
try {
|
|
415
|
+
const bytes = snapshot.files.get(path)
|
|
416
|
+
if (!bytes) return undefined
|
|
417
|
+
const metadata = parseJsonDocument(new TextDecoder().decode(bytes)) as Record<string, unknown>
|
|
418
|
+
return {
|
|
419
|
+
metadata,
|
|
420
|
+
...((stringValue(metadata.displayName) ?? stringValue(metadata.name))
|
|
421
|
+
? { displayName: stringValue(metadata.displayName) ?? stringValue(metadata.name) }
|
|
422
|
+
: {}),
|
|
423
|
+
...(stringValue(metadata.description)
|
|
424
|
+
? { description: stringValue(metadata.description) }
|
|
425
|
+
: {}),
|
|
426
|
+
...(stringValue(metadata.version) ? { version: stringValue(metadata.version) } : {}),
|
|
427
|
+
icons: [metadata.icon, ...(Array.isArray(metadata.icons) ? metadata.icons : [])]
|
|
428
|
+
.filter(isString)
|
|
429
|
+
.toSorted(),
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
throw new PluginSafetyError('PLUGIN_MANIFEST_INVALID', [path])
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function manifestRank(path: string): number {
|
|
437
|
+
// A root manifest is the plugin's own declaration and outranks harness copies.
|
|
438
|
+
if (path === 'plugin.json') return -1
|
|
439
|
+
const lower = path.toLocaleLowerCase()
|
|
440
|
+
if (lower.endsWith('/.claude-plugin/plugin.json')) return 0
|
|
441
|
+
if (lower.endsWith('/.cursor-plugin/plugin.json')) return 1
|
|
442
|
+
if (lower.endsWith('/.agents/plugin.json')) return 2
|
|
443
|
+
return lower === 'plugin.json' ? 3 : 4
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function findLicense(
|
|
447
|
+
entry: MarketplacePluginEntry,
|
|
448
|
+
metadata?: Record<string, unknown>
|
|
449
|
+
): LicenseMetadata {
|
|
450
|
+
const candidate = stringValue(metadata?.license) ?? stringValue(entry.raw.license)
|
|
451
|
+
if (!candidate) return { name: 'Unknown', source: 'unknown' }
|
|
452
|
+
return {
|
|
453
|
+
name: candidate,
|
|
454
|
+
...(candidate.startsWith('http') ? { url: candidate } : {}),
|
|
455
|
+
source: metadata?.license !== undefined ? 'plugin-manifest' : 'marketplace-entry',
|
|
456
|
+
}
|
|
457
|
+
}
|