@adea-ai/plugins 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +74 -0
- package/dist/agent-plugins-catalog.d.ts +26 -0
- package/dist/agent-plugins-catalog.js +699 -0
- package/dist/agent-plugins-catalog.js.map +1 -0
- package/dist/agent-plugins-harness.d.ts +54 -0
- package/dist/agent-plugins-harness.js +212 -0
- package/dist/agent-plugins-harness.js.map +1 -0
- package/dist/agent-plugins-schema.d.ts +402 -0
- package/dist/agent-plugins-schema.js +200 -0
- package/dist/agent-plugins-schema.js.map +1 -0
- package/dist/consumer-index.d.ts +217 -0
- package/dist/consumer-index.js +358 -0
- package/dist/consumer-index.js.map +1 -0
- package/dist/display-name.d.ts +8 -0
- package/dist/display-name.js +160 -0
- package/dist/display-name.js.map +1 -0
- package/dist/harness.d.ts +13 -0
- package/dist/harness.js +169 -0
- package/dist/harness.js.map +1 -0
- package/dist/icon-mirror.d.ts +69 -0
- package/dist/icon-mirror.js +147 -0
- package/dist/icon-mirror.js.map +1 -0
- package/dist/icons.d.ts +65 -0
- package/dist/icons.js +268 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +304 -0
- package/dist/index.js +1877 -0
- package/dist/index.js.map +1 -0
- package/dist/portable-catalog.d.ts +9 -0
- package/dist/portable-catalog.js +175 -0
- package/dist/portable-catalog.js.map +1 -0
- package/dist/publication.d.ts +98 -0
- package/dist/publication.js +121 -0
- package/dist/publication.js.map +1 -0
- package/dist/schema.d.ts +705 -0
- package/dist/schema.js +267 -0
- package/dist/schema.js.map +1 -0
- package/dist/site-icons.d.ts +55 -0
- package/dist/site-icons.js +155 -0
- package/dist/site-icons.js.map +1 -0
- package/dist/sources.d.ts +54 -0
- package/dist/sources.js +306 -0
- package/dist/sources.js.map +1 -0
- package/package.json +102 -0
- package/schemas/agent-package.v1.json +387 -0
- package/schemas/catalog.v1.schema.json +407 -0
- package/schemas/compatibility.v1.schema.json +23 -0
- package/schemas/harness-profile.v1.json +75 -0
- package/schemas/installation-plan.v2.json +695 -0
- package/schemas/sources-lock.v1.schema.json +61 -0
- package/src/agent-plugins-catalog.test.ts +402 -0
- package/src/agent-plugins-catalog.ts +800 -0
- package/src/agent-plugins-harness.test.ts +272 -0
- package/src/agent-plugins-harness.ts +281 -0
- package/src/agent-plugins-schema.test.ts +27 -0
- package/src/agent-plugins-schema.ts +219 -0
- package/src/artifacts.test.ts +545 -0
- package/src/catalog-installation.test.ts +42 -0
- package/src/consumer-index.test.ts +465 -0
- package/src/consumer-index.ts +592 -0
- package/src/display-name.test.ts +56 -0
- package/src/display-name.ts +157 -0
- package/src/harness.ts +202 -0
- package/src/icon-mirror.ts +196 -0
- package/src/icons.test.ts +185 -0
- package/src/icons.ts +297 -0
- package/src/index.test.ts +888 -0
- package/src/index.ts +2538 -0
- package/src/portable-catalog.test.ts +186 -0
- package/src/portable-catalog.ts +203 -0
- package/src/publication.test.ts +117 -0
- package/src/publication.ts +171 -0
- package/src/schema.ts +309 -0
- package/src/site-icon-fallback.test.ts +267 -0
- package/src/site-icons.test.ts +171 -0
- package/src/site-icons.ts +161 -0
- package/src/sources.ts +360 -0
- package/src/upstream-headers.test.ts +148 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const DIGEST_RE = /^sha256:[a-f0-9]{64}$/
|
|
4
|
+
export const SHA_RE = /^[a-f0-9]{40}$/
|
|
5
|
+
|
|
6
|
+
export const DigestSchema = z.string().regex(DIGEST_RE)
|
|
7
|
+
export const ShaSchema = z.string().regex(SHA_RE)
|
|
8
|
+
export const TimestampSchema = z.string().datetime({ offset: true })
|
|
9
|
+
|
|
10
|
+
export const MarketplaceDialectSchema = z.enum(['openai', 'cursor', 'claude'])
|
|
11
|
+
export const TrustClassificationSchema = z.enum(['official'])
|
|
12
|
+
export const SyncStatusSchema = z.enum(['synchronized', 'unchanged', 'failed'])
|
|
13
|
+
|
|
14
|
+
export const CapabilityTypeSchema = z.enum([
|
|
15
|
+
'skill',
|
|
16
|
+
'mcp-server',
|
|
17
|
+
'connector',
|
|
18
|
+
'command',
|
|
19
|
+
'agent',
|
|
20
|
+
'hook',
|
|
21
|
+
'rule',
|
|
22
|
+
'browser',
|
|
23
|
+
'scheduled-task',
|
|
24
|
+
'ui-component',
|
|
25
|
+
'executable',
|
|
26
|
+
'unknown',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
export const HarnessSchema = z.enum([
|
|
30
|
+
'codex',
|
|
31
|
+
'claude-code',
|
|
32
|
+
'cursor',
|
|
33
|
+
'pi',
|
|
34
|
+
'hermes',
|
|
35
|
+
'opencode',
|
|
36
|
+
'generic-skill-mcp',
|
|
37
|
+
])
|
|
38
|
+
export type Harness = z.output<typeof HarnessSchema>
|
|
39
|
+
|
|
40
|
+
export const CompatibilityStatusSchema = z.enum([
|
|
41
|
+
'native',
|
|
42
|
+
'portable',
|
|
43
|
+
'requires-translation',
|
|
44
|
+
'partially-supported',
|
|
45
|
+
'unsupported',
|
|
46
|
+
'blocked-by-policy',
|
|
47
|
+
'unknown',
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
51
|
+
z.union([
|
|
52
|
+
z.string(),
|
|
53
|
+
z.number().finite(),
|
|
54
|
+
z.boolean(),
|
|
55
|
+
z.null(),
|
|
56
|
+
z.array(JsonValueSchema),
|
|
57
|
+
z.record(z.string(), JsonValueSchema),
|
|
58
|
+
])
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
export const JsonObjectSchema = z.record(z.string(), JsonValueSchema)
|
|
62
|
+
|
|
63
|
+
export const CatalogSourceSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
sourceId: z.string().regex(/^[a-z0-9][a-z0-9-]{1,63}$/),
|
|
66
|
+
displayName: z.string().min(1).max(160),
|
|
67
|
+
repositoryUrl: z.string().url(),
|
|
68
|
+
marketplaceDialect: MarketplaceDialectSchema,
|
|
69
|
+
manifestPath: z.string().min(1).max(512),
|
|
70
|
+
defaultBranch: z.string().regex(/^[A-Za-z0-9._/-]+$/),
|
|
71
|
+
resolvedCommitSha: ShaSchema,
|
|
72
|
+
retrievalTimestamp: TimestampSchema,
|
|
73
|
+
trustClassification: TrustClassificationSchema,
|
|
74
|
+
sourceManifestDigest: DigestSchema,
|
|
75
|
+
synchronizationStatus: SyncStatusSchema,
|
|
76
|
+
})
|
|
77
|
+
.strict()
|
|
78
|
+
|
|
79
|
+
export const CapabilitySchema = z
|
|
80
|
+
.object({
|
|
81
|
+
type: CapabilityTypeSchema,
|
|
82
|
+
name: z.string().min(1).max(200),
|
|
83
|
+
paths: z.array(z.string().min(1).max(512)).max(512),
|
|
84
|
+
metadata: JsonObjectSchema,
|
|
85
|
+
securityImpact: z.enum(['none', 'review', 'sensitive']),
|
|
86
|
+
})
|
|
87
|
+
.strict()
|
|
88
|
+
|
|
89
|
+
export const HarnessCompatibilitySchema = z
|
|
90
|
+
.object({
|
|
91
|
+
status: CompatibilityStatusSchema,
|
|
92
|
+
reasons: z.array(z.string().min(1).max(500)).max(64),
|
|
93
|
+
responsibleCapabilities: z.array(CapabilityTypeSchema).max(32),
|
|
94
|
+
})
|
|
95
|
+
.strict()
|
|
96
|
+
|
|
97
|
+
export const LicenseMetadataSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
name: z.string().min(1).max(200),
|
|
100
|
+
spdxId: z.string().min(1).max(100).optional(),
|
|
101
|
+
url: z.string().url().optional(),
|
|
102
|
+
source: z.enum(['plugin-manifest', 'package-manifest', 'marketplace-entry', 'unknown']),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
|
|
106
|
+
export const SecurityClassificationSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
level: z.enum(['low', 'review', 'sensitive']),
|
|
109
|
+
reasons: z.array(z.string().min(1).max(500)).max(64),
|
|
110
|
+
permissionSensitiveChanges: z.array(z.string().min(1).max(200)).max(64),
|
|
111
|
+
contentResolution: z.enum(['complete', 'metadata-only']),
|
|
112
|
+
})
|
|
113
|
+
.strict()
|
|
114
|
+
|
|
115
|
+
export const PluginProvenanceSchema = z
|
|
116
|
+
.object({
|
|
117
|
+
sourceId: z.string().min(1),
|
|
118
|
+
repositoryUrl: z.string().url(),
|
|
119
|
+
manifestPath: z.string().min(1),
|
|
120
|
+
pluginSubdirectory: z.string().min(1),
|
|
121
|
+
resolvedCommitSha: ShaSchema,
|
|
122
|
+
sourceManifestDigest: DigestSchema,
|
|
123
|
+
upstreamEntryDigest: DigestSchema,
|
|
124
|
+
})
|
|
125
|
+
.strict()
|
|
126
|
+
|
|
127
|
+
export const IconContentTypeSchema = z.enum([
|
|
128
|
+
'image/png',
|
|
129
|
+
'image/jpeg',
|
|
130
|
+
'image/gif',
|
|
131
|
+
'image/webp',
|
|
132
|
+
'image/svg+xml',
|
|
133
|
+
'image/x-icon',
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
export const PluginReleaseSchema = z
|
|
137
|
+
.object({
|
|
138
|
+
releaseId: z.string().regex(/^release:[a-f0-9]{64}$/),
|
|
139
|
+
upstreamVersion: z.string().min(1).max(128).optional(),
|
|
140
|
+
resolvedRepositoryUrl: z.string().url(),
|
|
141
|
+
resolvedCommitSha: ShaSchema,
|
|
142
|
+
pluginSubdirectory: z.string().min(1).max(512),
|
|
143
|
+
canonicalContentDigest: DigestSchema,
|
|
144
|
+
manifestDigest: DigestSchema,
|
|
145
|
+
contentResolution: z.enum(['complete', 'metadata-only']),
|
|
146
|
+
releaseMetadata: JsonObjectSchema,
|
|
147
|
+
capabilities: z.array(CapabilitySchema).max(1024),
|
|
148
|
+
requiredConnectors: z.array(z.string().min(1).max(200)).max(128),
|
|
149
|
+
requiredCredentials: z.array(z.string().min(1).max(200)).max(128),
|
|
150
|
+
permissionSensitiveChanges: z.array(z.string().min(1).max(200)).max(128),
|
|
151
|
+
fileIndex: z.array(z.string().min(1).max(512)).max(4096),
|
|
152
|
+
publicationTimestamp: TimestampSchema,
|
|
153
|
+
/**
|
|
154
|
+
* Brand mark resolved at compile time. `content` comes from the release's
|
|
155
|
+
* own files; `favicon` comes from the product's declared homepage, for
|
|
156
|
+
* products that ship no mark of their own.
|
|
157
|
+
*/
|
|
158
|
+
icon: z
|
|
159
|
+
.discriminatedUnion('kind', [
|
|
160
|
+
z
|
|
161
|
+
.object({
|
|
162
|
+
kind: z.literal('content'),
|
|
163
|
+
path: z.string().min(1).max(512),
|
|
164
|
+
contentType: IconContentTypeSchema,
|
|
165
|
+
digest: DigestSchema,
|
|
166
|
+
bytes: z.number().int().positive().max(1048576),
|
|
167
|
+
})
|
|
168
|
+
.strict(),
|
|
169
|
+
z
|
|
170
|
+
.object({
|
|
171
|
+
kind: z.literal('favicon'),
|
|
172
|
+
url: z.string().url().max(1024),
|
|
173
|
+
homepage: z.string().url().max(1024),
|
|
174
|
+
contentType: IconContentTypeSchema,
|
|
175
|
+
digest: DigestSchema,
|
|
176
|
+
bytes: z.number().int().positive().max(1048576),
|
|
177
|
+
})
|
|
178
|
+
.strict(),
|
|
179
|
+
])
|
|
180
|
+
.optional(),
|
|
181
|
+
})
|
|
182
|
+
.strict()
|
|
183
|
+
|
|
184
|
+
export const PluginSchema = z
|
|
185
|
+
.object({
|
|
186
|
+
pluginId: z.string().regex(/^plugin:[a-z0-9-]+:[a-z0-9][a-z0-9-]{1,127}$/),
|
|
187
|
+
displayName: z.string().min(1).max(200),
|
|
188
|
+
description: z.string().max(4096),
|
|
189
|
+
productGroupingKey: z.string().regex(/^[a-z0-9][a-z0-9-]{1,127}$/),
|
|
190
|
+
categories: z.array(z.string().regex(/^[a-z0-9][a-z0-9-]{1,63}$/)).max(32),
|
|
191
|
+
keywords: z.array(z.string().min(1).max(100)).max(128),
|
|
192
|
+
authors: z.array(z.string().min(1).max(200)).max(32),
|
|
193
|
+
homepage: z.string().url().optional(),
|
|
194
|
+
icons: z.array(z.string().min(1).max(1024)).max(16),
|
|
195
|
+
sourceId: z.string().min(1),
|
|
196
|
+
upstreamPluginName: z.string().min(1).max(200),
|
|
197
|
+
currentReleaseId: z.string().regex(/^release:[a-f0-9]{64}$/),
|
|
198
|
+
availableReleases: z.array(PluginReleaseSchema).min(1).max(128),
|
|
199
|
+
capabilitySummary: z.partialRecord(CapabilityTypeSchema, z.number().int().nonnegative()),
|
|
200
|
+
harnessCompatibility: z.record(HarnessSchema, HarnessCompatibilitySchema),
|
|
201
|
+
license: LicenseMetadataSchema,
|
|
202
|
+
provenance: PluginProvenanceSchema,
|
|
203
|
+
securityClassification: SecurityClassificationSchema,
|
|
204
|
+
})
|
|
205
|
+
.strict()
|
|
206
|
+
|
|
207
|
+
export const CatalogSchema = z
|
|
208
|
+
.object({
|
|
209
|
+
schemaVersion: z.literal(1),
|
|
210
|
+
catalogId: z.string().regex(/^catalog:[a-f0-9]{64}$/),
|
|
211
|
+
generatedAt: TimestampSchema,
|
|
212
|
+
sources: z.array(CatalogSourceSchema).min(1).max(64),
|
|
213
|
+
plugins: z.array(PluginSchema).max(4096),
|
|
214
|
+
})
|
|
215
|
+
.strict()
|
|
216
|
+
|
|
217
|
+
export const SourceLockEntrySchema = z
|
|
218
|
+
.object({
|
|
219
|
+
sourceId: z.string().min(1),
|
|
220
|
+
repositoryUrl: z.string().url(),
|
|
221
|
+
defaultBranch: z.string().min(1),
|
|
222
|
+
resolvedCommitSha: ShaSchema,
|
|
223
|
+
manifestPath: z.string().min(1),
|
|
224
|
+
marketplaceDialect: MarketplaceDialectSchema,
|
|
225
|
+
sourceManifestDigest: DigestSchema,
|
|
226
|
+
retrievedAt: TimestampSchema,
|
|
227
|
+
synchronizationStatus: SyncStatusSchema,
|
|
228
|
+
pluginPins: z
|
|
229
|
+
.array(
|
|
230
|
+
z
|
|
231
|
+
.object({
|
|
232
|
+
pluginName: z.string().min(1).max(200),
|
|
233
|
+
repositoryUrl: z.string().url(),
|
|
234
|
+
pluginSubdirectory: z.string().max(512),
|
|
235
|
+
resolvedCommitSha: ShaSchema,
|
|
236
|
+
})
|
|
237
|
+
.strict()
|
|
238
|
+
)
|
|
239
|
+
.max(4096),
|
|
240
|
+
})
|
|
241
|
+
.strict()
|
|
242
|
+
|
|
243
|
+
export const SourcesLockSchema = z
|
|
244
|
+
.object({
|
|
245
|
+
schemaVersion: z.literal(1),
|
|
246
|
+
lockId: z.string().regex(/^lock:[a-f0-9]{64}$/),
|
|
247
|
+
sources: z.array(SourceLockEntrySchema).min(1).max(64),
|
|
248
|
+
})
|
|
249
|
+
.strict()
|
|
250
|
+
|
|
251
|
+
export const MaterializationPlanSchema = z
|
|
252
|
+
.object({
|
|
253
|
+
planVersion: z.literal(1),
|
|
254
|
+
pluginId: PluginSchema.shape.pluginId,
|
|
255
|
+
releaseId: PluginReleaseSchema.shape.releaseId,
|
|
256
|
+
harness: HarnessSchema,
|
|
257
|
+
source: z
|
|
258
|
+
.object({
|
|
259
|
+
repositoryUrl: z.string().url(),
|
|
260
|
+
commitSha: ShaSchema,
|
|
261
|
+
pluginSubdirectory: z.string().min(1),
|
|
262
|
+
contentDigest: DigestSchema,
|
|
263
|
+
})
|
|
264
|
+
.strict(),
|
|
265
|
+
targetLayout: z
|
|
266
|
+
.object({
|
|
267
|
+
root: z.string().min(1),
|
|
268
|
+
notes: z.array(z.string().min(1)).max(32),
|
|
269
|
+
})
|
|
270
|
+
.strict(),
|
|
271
|
+
files: z
|
|
272
|
+
.array(
|
|
273
|
+
z
|
|
274
|
+
.object({
|
|
275
|
+
sourcePath: z.string().min(1),
|
|
276
|
+
targetPath: z.string().min(1),
|
|
277
|
+
action: z.enum(['copy', 'translate', 'ignore', 'unsupported']),
|
|
278
|
+
reason: z.string().min(1).max(500),
|
|
279
|
+
})
|
|
280
|
+
.strict()
|
|
281
|
+
)
|
|
282
|
+
.max(4096),
|
|
283
|
+
configuration: JsonObjectSchema,
|
|
284
|
+
requiredConnectors: z.array(z.string().min(1)).max(128),
|
|
285
|
+
requiredCredentials: z.array(z.string().min(1)).max(128),
|
|
286
|
+
policyConstraints: z.array(z.string().min(1)).max(128),
|
|
287
|
+
})
|
|
288
|
+
.strict()
|
|
289
|
+
|
|
290
|
+
export type CatalogSource = z.output<typeof CatalogSourceSchema>
|
|
291
|
+
export type Capability = z.output<typeof CapabilitySchema>
|
|
292
|
+
export type HarnessCompatibility = z.output<typeof HarnessCompatibilitySchema>
|
|
293
|
+
export type LicenseMetadata = z.output<typeof LicenseMetadataSchema>
|
|
294
|
+
export type SecurityClassification = z.output<typeof SecurityClassificationSchema>
|
|
295
|
+
export type PluginProvenance = z.output<typeof PluginProvenanceSchema>
|
|
296
|
+
export type PluginRelease = z.output<typeof PluginReleaseSchema>
|
|
297
|
+
export type Plugin = z.output<typeof PluginSchema>
|
|
298
|
+
export type Catalog = z.output<typeof CatalogSchema>
|
|
299
|
+
export type SourceLockEntry = z.output<typeof SourceLockEntrySchema>
|
|
300
|
+
export type SourcesLock = z.output<typeof SourcesLockSchema>
|
|
301
|
+
export type MaterializationPlan = z.output<typeof MaterializationPlanSchema>
|
|
302
|
+
|
|
303
|
+
export function parseCatalog(input: unknown): Catalog {
|
|
304
|
+
return CatalogSchema.parse(input)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function parseSourcesLock(input: unknown): SourcesLock {
|
|
308
|
+
return SourcesLockSchema.parse(input)
|
|
309
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
buildCatalog,
|
|
4
|
+
byteDigest,
|
|
5
|
+
digest,
|
|
6
|
+
type CatalogPolicy,
|
|
7
|
+
type CategoryMap,
|
|
8
|
+
type ProductAliases,
|
|
9
|
+
type ResolvedSource,
|
|
10
|
+
type Snapshot,
|
|
11
|
+
type SnapshotLoader,
|
|
12
|
+
} from './index.js'
|
|
13
|
+
import { buildConsumerIndex } from './consumer-index.js'
|
|
14
|
+
import { resolveIconSources, stageIconAssets } from './icon-mirror.js'
|
|
15
|
+
import { selectIconPath } from './icons.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* End-to-end cover for the brand-mark fallback: a plugin that ships no mark of
|
|
19
|
+
* its own gets the product site's icon, resolved during the build, recorded in
|
|
20
|
+
* the catalog and carried into the consumer index and its asset manifest.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PNG = new Uint8Array([
|
|
24
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
25
|
+
])
|
|
26
|
+
const HOME = 'https://vendor.example/'
|
|
27
|
+
const APPLE_ICON = 'https://vendor.example/apple-icon.png'
|
|
28
|
+
|
|
29
|
+
const policy: CatalogPolicy = {
|
|
30
|
+
allowedRepositoryHosts: ['github.com'],
|
|
31
|
+
allowedRepositoryProtocols: ['https:'],
|
|
32
|
+
maxFilesPerPlugin: 4096,
|
|
33
|
+
maxBytesPerPlugin: 50 * 1024 * 1024,
|
|
34
|
+
maxFileBytes: 5 * 1024 * 1024,
|
|
35
|
+
maxMarketplacePlugins: 4096,
|
|
36
|
+
denyExecutableLifecycleScripts: true,
|
|
37
|
+
publishRequiresCompleteContent: false,
|
|
38
|
+
sensitiveCapabilityTypes: ['mcp-server', 'hook', 'executable'],
|
|
39
|
+
}
|
|
40
|
+
const categoryMap: CategoryMap = { aliases: {}, fallback: 'other' }
|
|
41
|
+
const productAliases: ProductAliases = { aliases: {} }
|
|
42
|
+
|
|
43
|
+
/** Serves plugin content from memory, so no filesystem or tree fetch is needed. */
|
|
44
|
+
function loader(files: Record<string, Uint8Array>): SnapshotLoader {
|
|
45
|
+
return {
|
|
46
|
+
load: async (): Promise<Snapshot> => ({ files: new Map(Object.entries(files)), symlinks: [] }),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function source(entryName: string, homepage?: string): ResolvedSource {
|
|
51
|
+
return {
|
|
52
|
+
config: {
|
|
53
|
+
sourceId: 'test-source',
|
|
54
|
+
displayName: 'Test Source',
|
|
55
|
+
repositoryUrl: 'https://github.com/acme/marketplace',
|
|
56
|
+
marketplaceDialect: 'claude',
|
|
57
|
+
manifestPath: 'marketplace.json',
|
|
58
|
+
defaultBranch: 'main',
|
|
59
|
+
trustClassification: 'official',
|
|
60
|
+
},
|
|
61
|
+
commitSha: 'a'.repeat(40),
|
|
62
|
+
manifestText: '{}',
|
|
63
|
+
parsed: {
|
|
64
|
+
marketplaceName: 'Acme',
|
|
65
|
+
marketplaceDescription: 'Acme',
|
|
66
|
+
owner: ['Acme'],
|
|
67
|
+
entries: [
|
|
68
|
+
{
|
|
69
|
+
name: entryName,
|
|
70
|
+
description: `${entryName} plugin`,
|
|
71
|
+
categories: ['productivity'],
|
|
72
|
+
keywords: [],
|
|
73
|
+
authors: ['Acme'],
|
|
74
|
+
icons: [],
|
|
75
|
+
...(homepage ? { homepage } : {}),
|
|
76
|
+
source: { kind: 'local', path: '.' },
|
|
77
|
+
policy: {},
|
|
78
|
+
raw: {},
|
|
79
|
+
entryDigest: `sha256:${'e'.repeat(64)}`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
raw: {},
|
|
83
|
+
},
|
|
84
|
+
pluginPins: [],
|
|
85
|
+
retrievedAt: '2026-01-01T00:00:00.000Z',
|
|
86
|
+
manifestDigest: digest('{}'),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function stubFetch(handler: (url: string) => Response) {
|
|
91
|
+
const original = globalThis.fetch
|
|
92
|
+
const calls: string[] = []
|
|
93
|
+
globalThis.fetch = (async (input: unknown) => {
|
|
94
|
+
const url = String(input)
|
|
95
|
+
calls.push(url)
|
|
96
|
+
return handler(url)
|
|
97
|
+
}) as typeof fetch
|
|
98
|
+
return { calls, restore: () => void (globalThis.fetch = original) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function build(entryName: string, homepage?: string, options: { live?: boolean } = {}) {
|
|
102
|
+
const stub = stubFetch((url) => {
|
|
103
|
+
if (url === HOME)
|
|
104
|
+
return new Response(`<head><link rel="apple-touch-icon" href="/apple-icon.png"></head>`, {
|
|
105
|
+
status: 200,
|
|
106
|
+
})
|
|
107
|
+
if (url === APPLE_ICON) return new Response(PNG, { status: 200 })
|
|
108
|
+
return new Response('missing', { status: 404 })
|
|
109
|
+
})
|
|
110
|
+
try {
|
|
111
|
+
const catalog = await buildCatalog({
|
|
112
|
+
resolvedSources: [source(entryName, homepage)],
|
|
113
|
+
categoryMap,
|
|
114
|
+
productAliases,
|
|
115
|
+
policy,
|
|
116
|
+
metadataOnly: false,
|
|
117
|
+
snapshotLoader: loader({ 'README.md': new TextEncoder().encode('no mark here') }),
|
|
118
|
+
resolveExternalRefs: options.live ?? true,
|
|
119
|
+
})
|
|
120
|
+
return { catalog, calls: stub.calls }
|
|
121
|
+
} finally {
|
|
122
|
+
stub.restore()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('product site icon fallback', () => {
|
|
127
|
+
test('records the site icon for a plugin that ships no mark', async () => {
|
|
128
|
+
const { catalog, calls } = await build('connector', HOME)
|
|
129
|
+
const release = catalog.plugins[0]!.availableReleases[0]!
|
|
130
|
+
expect(release.icon).toEqual({
|
|
131
|
+
kind: 'favicon',
|
|
132
|
+
url: APPLE_ICON,
|
|
133
|
+
homepage: HOME,
|
|
134
|
+
contentType: 'image/png',
|
|
135
|
+
digest: byteDigest(PNG),
|
|
136
|
+
bytes: PNG.byteLength,
|
|
137
|
+
})
|
|
138
|
+
expect(calls).toEqual([HOME, APPLE_ICON])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('prefers the plugin content and never contacts the site when a mark exists', async () => {
|
|
142
|
+
const stub = stubFetch(() => new Response('unexpected', { status: 500 }))
|
|
143
|
+
try {
|
|
144
|
+
const catalog = await buildCatalog({
|
|
145
|
+
resolvedSources: [source('connector', HOME)],
|
|
146
|
+
categoryMap,
|
|
147
|
+
productAliases,
|
|
148
|
+
policy,
|
|
149
|
+
metadataOnly: false,
|
|
150
|
+
snapshotLoader: loader({
|
|
151
|
+
'assets/logo.svg': new TextEncoder().encode(
|
|
152
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>'
|
|
153
|
+
),
|
|
154
|
+
}),
|
|
155
|
+
resolveExternalRefs: true,
|
|
156
|
+
})
|
|
157
|
+
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toMatchObject({
|
|
158
|
+
kind: 'content',
|
|
159
|
+
path: 'assets/logo.svg',
|
|
160
|
+
})
|
|
161
|
+
expect(stub.calls).toEqual([])
|
|
162
|
+
} finally {
|
|
163
|
+
stub.restore()
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test('does not reach the network for a metadata-only build', async () => {
|
|
168
|
+
const { catalog, calls } = await build('connector', HOME, { live: false })
|
|
169
|
+
expect(calls).toEqual([])
|
|
170
|
+
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toBeUndefined()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('skips a repository homepage, which would only yield the forge logo', async () => {
|
|
174
|
+
const { catalog, calls } = await build('connector', 'https://github.com/acme/connector')
|
|
175
|
+
expect(calls).toEqual([])
|
|
176
|
+
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('carries the site icon into the index, the manifest and the mirror', async () => {
|
|
180
|
+
const { catalog } = await build('connector', HOME)
|
|
181
|
+
const index = buildConsumerIndex({ catalog })
|
|
182
|
+
const product = index.products.connector!
|
|
183
|
+
expect(product.icon).toMatchObject({ kind: 'favicon', url: APPLE_ICON, homepage: HOME })
|
|
184
|
+
expect(product.monogram.text).toBe('CO')
|
|
185
|
+
|
|
186
|
+
const sources = resolveIconSources(catalog, index)
|
|
187
|
+
expect(sources).toEqual([
|
|
188
|
+
{
|
|
189
|
+
kind: 'favicon',
|
|
190
|
+
asset: product.icon!.asset,
|
|
191
|
+
digest: byteDigest(PNG),
|
|
192
|
+
bytes: PNG.byteLength,
|
|
193
|
+
contentType: 'image/png',
|
|
194
|
+
url: APPLE_ICON,
|
|
195
|
+
homepage: HOME,
|
|
196
|
+
},
|
|
197
|
+
])
|
|
198
|
+
|
|
199
|
+
const staged = await stageIconAssets({
|
|
200
|
+
sources,
|
|
201
|
+
assetsDirectory: `${import.meta.dir}/.icon-mirror-test`,
|
|
202
|
+
readBytes: async () => PNG,
|
|
203
|
+
})
|
|
204
|
+
expect(staged.skipped).toEqual([])
|
|
205
|
+
expect(staged.staged.map((asset) => asset.origin)).toEqual([APPLE_ICON])
|
|
206
|
+
expect(staged.staged[0]!.kind).toBe('favicon')
|
|
207
|
+
await Bun.$`rm -rf ${`${import.meta.dir}/.icon-mirror-test`}`.quiet()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('a curated site override supplies the mark for a repository-only plugin', async () => {
|
|
211
|
+
const stub = stubFetch((url) => {
|
|
212
|
+
if (url === 'https://curated.example/')
|
|
213
|
+
return new Response('<head><link rel="apple-touch-icon" href="/mark.png"></head>')
|
|
214
|
+
if (url === 'https://curated.example/mark.png') return new Response(PNG)
|
|
215
|
+
return new Response('missing', { status: 404 })
|
|
216
|
+
})
|
|
217
|
+
try {
|
|
218
|
+
const catalog = await buildCatalog({
|
|
219
|
+
resolvedSources: [source('connector', 'https://github.com/acme/connector')],
|
|
220
|
+
categoryMap,
|
|
221
|
+
productAliases,
|
|
222
|
+
productIconOverrides: {
|
|
223
|
+
schemaVersion: 1,
|
|
224
|
+
overrides: { connector: 'https://curated.example/' },
|
|
225
|
+
},
|
|
226
|
+
policy,
|
|
227
|
+
metadataOnly: false,
|
|
228
|
+
snapshotLoader: loader({ 'README.md': new TextEncoder().encode('x') }),
|
|
229
|
+
resolveExternalRefs: true,
|
|
230
|
+
})
|
|
231
|
+
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toMatchObject({
|
|
232
|
+
kind: 'favicon',
|
|
233
|
+
url: 'https://curated.example/mark.png',
|
|
234
|
+
homepage: 'https://curated.example/',
|
|
235
|
+
})
|
|
236
|
+
expect(stub.calls).toEqual(['https://curated.example/', 'https://curated.example/mark.png'])
|
|
237
|
+
} finally {
|
|
238
|
+
stub.restore()
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('falls back to a monogram when the site yields no usable icon', async () => {
|
|
243
|
+
const stub = stubFetch((url) =>
|
|
244
|
+
url === HOME
|
|
245
|
+
? new Response('<head><link rel="icon" href="/broken.png"></head>', { status: 200 })
|
|
246
|
+
: new Response('not an image', { status: 200 })
|
|
247
|
+
)
|
|
248
|
+
try {
|
|
249
|
+
const catalog = await buildCatalog({
|
|
250
|
+
resolvedSources: [source('connector', HOME)],
|
|
251
|
+
categoryMap,
|
|
252
|
+
productAliases,
|
|
253
|
+
policy,
|
|
254
|
+
metadataOnly: false,
|
|
255
|
+
snapshotLoader: loader({ 'README.md': new TextEncoder().encode('x') }),
|
|
256
|
+
resolveExternalRefs: true,
|
|
257
|
+
})
|
|
258
|
+
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toBeUndefined()
|
|
259
|
+
const index = buildConsumerIndex({ catalog })
|
|
260
|
+
expect(index.products.connector!.icon).toBeNull()
|
|
261
|
+
expect(index.products.connector!.monogram).toEqual({ text: 'CO', hue: expect.any(Number) })
|
|
262
|
+
expect(selectIconPath(catalog.plugins[0]!.availableReleases[0]!.fileIndex!)).toBeNull()
|
|
263
|
+
} finally {
|
|
264
|
+
stub.restore()
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
})
|