@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
discoverSiteIcons,
|
|
4
|
+
isPublicHostname,
|
|
5
|
+
isVendorHomepage,
|
|
6
|
+
resolveSiteIcon,
|
|
7
|
+
type SiteIconFetcher,
|
|
8
|
+
} from './site-icons.js'
|
|
9
|
+
import { byteDigest } from './index.js'
|
|
10
|
+
|
|
11
|
+
const PNG = new Uint8Array([
|
|
12
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
13
|
+
])
|
|
14
|
+
const ICO = new Uint8Array([0, 0, 1, 0, 1, 0, 16, 16, 0, 0, 1, 0, 32, 0])
|
|
15
|
+
const SVG = new TextEncoder().encode(
|
|
16
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><rect width="8" height="8"/></svg>'
|
|
17
|
+
)
|
|
18
|
+
const SCRIPTED_SVG = new TextEncoder().encode(
|
|
19
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
function toBytes(value: string | Uint8Array): Uint8Array {
|
|
23
|
+
return typeof value === 'string' ? new TextEncoder().encode(value) : value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fetcher(site: Record<string, string | Uint8Array>): SiteIconFetcher & {
|
|
27
|
+
textCalls: string[]
|
|
28
|
+
byteCalls: string[]
|
|
29
|
+
} {
|
|
30
|
+
const textCalls: string[] = []
|
|
31
|
+
const byteCalls: string[] = []
|
|
32
|
+
return {
|
|
33
|
+
textCalls,
|
|
34
|
+
byteCalls,
|
|
35
|
+
digest: (bytes) => byteDigest(bytes),
|
|
36
|
+
fetchText: async (url) => {
|
|
37
|
+
textCalls.push(url)
|
|
38
|
+
const value = site[url]
|
|
39
|
+
if (value === undefined) throw new Error('ENOENT')
|
|
40
|
+
return typeof value === 'string' ? value : new TextDecoder().decode(value)
|
|
41
|
+
},
|
|
42
|
+
fetchBytes: async (url) => {
|
|
43
|
+
byteCalls.push(url)
|
|
44
|
+
const value = site[url]
|
|
45
|
+
if (value === undefined) throw new Error('ENOENT')
|
|
46
|
+
return toBytes(value)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('vendor homepage eligibility', () => {
|
|
52
|
+
test('accepts a vendor site and rejects repository and non-public hosts', () => {
|
|
53
|
+
expect(isVendorHomepage('https://appwrite.io/')).toBe(true)
|
|
54
|
+
expect(isVendorHomepage('https://docs.voxel51.com/')).toBe(true)
|
|
55
|
+
expect(isVendorHomepage('https://github.com/foo/bar')).toBe(false)
|
|
56
|
+
expect(isVendorHomepage('https://raw.githubusercontent.com/foo/bar')).toBe(false)
|
|
57
|
+
expect(isVendorHomepage('https://gitlab.com/foo/bar')).toBe(false)
|
|
58
|
+
expect(isVendorHomepage('http://appwrite.io/')).toBe(false)
|
|
59
|
+
expect(isVendorHomepage('http://localhost:8080/')).toBe(false)
|
|
60
|
+
expect(isVendorHomepage('https://127.0.0.1/')).toBe(false)
|
|
61
|
+
expect(isVendorHomepage('https://10.0.0.5/')).toBe(false)
|
|
62
|
+
expect(isVendorHomepage('https://169.254.169.254/latest/meta-data')).toBe(false)
|
|
63
|
+
expect(isVendorHomepage('https://192.168.1.10/')).toBe(false)
|
|
64
|
+
expect(isVendorHomepage('https://service.internal/')).toBe(false)
|
|
65
|
+
expect(isVendorHomepage('not a url')).toBe(false)
|
|
66
|
+
expect(isVendorHomepage(undefined)).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('classifies hostnames', () => {
|
|
70
|
+
expect(isPublicHostname('example.com')).toBe(true)
|
|
71
|
+
expect(isPublicHostname('bare-hostname')).toBe(false)
|
|
72
|
+
expect(isPublicHostname('::1')).toBe(false)
|
|
73
|
+
expect(isPublicHostname('fd00::1')).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('site icon discovery', () => {
|
|
78
|
+
const html = `<!doctype html><html><head>
|
|
79
|
+
<link rel="icon" href="/favicon.ico">
|
|
80
|
+
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon.png">
|
|
81
|
+
<link rel="stylesheet" href="/app.css">
|
|
82
|
+
<link rel="mask-icon" href="/mask.svg">
|
|
83
|
+
<link rel="icon" type="image/svg+xml" href="https://cdn.example.com/icon.svg">
|
|
84
|
+
</head><body><img src="/hero.png"></body></html>`
|
|
85
|
+
|
|
86
|
+
test('ranks apple-touch-icon first and resolves relative URLs', () => {
|
|
87
|
+
expect(discoverSiteIcons(html, 'https://vendor.example/')).toEqual([
|
|
88
|
+
'https://vendor.example/apple-icon.png',
|
|
89
|
+
'https://vendor.example/favicon.ico',
|
|
90
|
+
'https://cdn.example.com/icon.svg',
|
|
91
|
+
'https://vendor.example/mask.svg',
|
|
92
|
+
])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('prefers the larger declared size within a group', () => {
|
|
96
|
+
const sizes = `<link rel="icon" sizes="32x32" href="/small.png">
|
|
97
|
+
<link rel="icon" sizes="512x512" href="/large.png">`
|
|
98
|
+
expect(discoverSiteIcons(sizes, 'https://vendor.example/')[0]).toBe(
|
|
99
|
+
'https://vendor.example/large.png'
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('falls back to /favicon.ico and refuses unsafe candidates', () => {
|
|
104
|
+
expect(discoverSiteIcons('<html><head></head></html>', 'https://vendor.example/page')).toEqual([
|
|
105
|
+
'https://vendor.example/favicon.ico',
|
|
106
|
+
])
|
|
107
|
+
expect(
|
|
108
|
+
discoverSiteIcons(
|
|
109
|
+
'<link rel="icon" href="http://vendor.example/icon.png">',
|
|
110
|
+
'https://vendor.example/'
|
|
111
|
+
)
|
|
112
|
+
).toEqual(['https://vendor.example/favicon.ico'])
|
|
113
|
+
expect(
|
|
114
|
+
discoverSiteIcons('<link rel="icon" href="http://localhost/icon.png">', 'https://v.example/')
|
|
115
|
+
).toEqual(['https://v.example/favicon.ico'])
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('site icon resolution', () => {
|
|
120
|
+
const homepage = 'https://vendor.example/'
|
|
121
|
+
const html = '<head><link rel="apple-touch-icon" href="/apple-icon.png"></head>'
|
|
122
|
+
|
|
123
|
+
test('returns the first usable icon with provenance', async () => {
|
|
124
|
+
const io = fetcher({ [homepage]: html, 'https://vendor.example/apple-icon.png': PNG })
|
|
125
|
+
const result = await resolveSiteIcon(homepage, io)
|
|
126
|
+
expect('contentType' in result && result).toMatchObject({
|
|
127
|
+
url: 'https://vendor.example/apple-icon.png',
|
|
128
|
+
homepage,
|
|
129
|
+
contentType: 'image/png',
|
|
130
|
+
digest: byteDigest(PNG),
|
|
131
|
+
})
|
|
132
|
+
expect(io.byteCalls).toEqual(['https://vendor.example/apple-icon.png'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('accepts an icon container and skips a candidate that fails inspection', async () => {
|
|
136
|
+
const ico = fetcher({ [homepage]: html, 'https://vendor.example/apple-icon.png': ICO })
|
|
137
|
+
expect(await resolveSiteIcon(homepage, ico)).toMatchObject({ contentType: 'image/x-icon' })
|
|
138
|
+
|
|
139
|
+
const scripted = fetcher({
|
|
140
|
+
[homepage]: html,
|
|
141
|
+
'https://vendor.example/apple-icon.png': SCRIPTED_SVG,
|
|
142
|
+
'https://vendor.example/favicon.ico': SVG,
|
|
143
|
+
})
|
|
144
|
+
expect(await resolveSiteIcon(homepage, scripted)).toMatchObject({
|
|
145
|
+
url: 'https://vendor.example/favicon.ico',
|
|
146
|
+
contentType: 'image/svg+xml',
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('reports a reason instead of throwing', async () => {
|
|
151
|
+
const missing = fetcher({})
|
|
152
|
+
expect(await resolveSiteIcon(homepage, missing)).toMatchObject({
|
|
153
|
+
failed: expect.stringContaining('HOMEPAGE_FETCH_FAILED') as never,
|
|
154
|
+
})
|
|
155
|
+
const noIcon = fetcher({ [homepage]: '<head></head>' })
|
|
156
|
+
expect(await resolveSiteIcon(homepage, noIcon)).toEqual({ failed: 'NO_USABLE_ICON' })
|
|
157
|
+
expect(await resolveSiteIcon('https://github.com/foo/bar', fetcher({}))).toEqual({
|
|
158
|
+
failed: 'NOT_VENDOR_HOMEPAGE',
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('parses only the document head of a large landing page', async () => {
|
|
163
|
+
const huge = `${html}${'x'.repeat(600 * 1024)}`
|
|
164
|
+
const io = fetcher({ [homepage]: huge, 'https://vendor.example/apple-icon.png': PNG })
|
|
165
|
+
const result = await resolveSiteIcon(homepage, io)
|
|
166
|
+
expect('contentType' in result).toBe(true)
|
|
167
|
+
|
|
168
|
+
const oversized = fetcher({ [homepage]: 'x'.repeat(5 * 1024 * 1024) })
|
|
169
|
+
expect(await resolveSiteIcon(homepage, oversized)).toEqual({ failed: 'HOMEPAGE_TOO_LARGE' })
|
|
170
|
+
})
|
|
171
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { inspectIconBytes, MAX_ICON_BYTES, type IconContentType } from './icons.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site icon discovery, for products that ship no brand mark of their own.
|
|
5
|
+
*
|
|
6
|
+
* Many plugins are a thin wrapper over a vendor's API: the content carries no
|
|
7
|
+
* logo, and the declared homepage is the only pointer to the vendor. Fetching
|
|
8
|
+
* that site's icon once at publish time, verifying it and mirroring it as a
|
|
9
|
+
* release asset replaces a per-render favicon lookup in the client.
|
|
10
|
+
*/
|
|
11
|
+
export const SITE_ICON_MAX_HTML_BYTES = 4 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Only the document head is parsed. Icon links are declared there, and vendor
|
|
15
|
+
* landing pages are frequently several megabytes of markup and script.
|
|
16
|
+
*/
|
|
17
|
+
export const SITE_ICON_HEAD_BYTES = 256 * 1024
|
|
18
|
+
|
|
19
|
+
/** Hosts that serve repository chrome rather than a product brand. */
|
|
20
|
+
const REPOSITORY_HOSTS =
|
|
21
|
+
/(^|\.)(github\.com|githubusercontent\.com|gitlab\.com|bitbucket\.org|codeberg\.org|sourceforge\.net|gitee\.com)$/
|
|
22
|
+
|
|
23
|
+
export interface SiteIcon {
|
|
24
|
+
readonly url: string
|
|
25
|
+
/** The product homepage the icon was discovered from. */
|
|
26
|
+
readonly homepage: string
|
|
27
|
+
readonly bytes: Uint8Array
|
|
28
|
+
readonly contentType: IconContentType
|
|
29
|
+
readonly digest: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether a homepage can stand in for a product brand.
|
|
34
|
+
*
|
|
35
|
+
* Only HTTPS sites qualify: a repository URL would yield the forge's own logo,
|
|
36
|
+
* and a non-routable host is a request forgery risk rather than a vendor site.
|
|
37
|
+
*/
|
|
38
|
+
export function isVendorHomepage(homepage: string | undefined): boolean {
|
|
39
|
+
if (!homepage) return false
|
|
40
|
+
let parsed: URL
|
|
41
|
+
try {
|
|
42
|
+
parsed = new URL(homepage)
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
if (parsed.protocol !== 'https:') return false
|
|
47
|
+
return isPublicHostname(parsed.hostname) && !REPOSITORY_HOSTS.test(parsed.hostname)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Rejects loopback, link-local, private and internal hostnames. */
|
|
51
|
+
export function isPublicHostname(hostname: string): boolean {
|
|
52
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
53
|
+
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return false
|
|
54
|
+
if (host.endsWith('.internal') || host.endsWith('.home.arpa')) return false
|
|
55
|
+
if (host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
|
56
|
+
return false
|
|
57
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
58
|
+
if (ipv4) {
|
|
59
|
+
const [a, b] = ipv4.slice(1).map(Number) as [number, number]
|
|
60
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) return false
|
|
61
|
+
if (a === 169 && b === 254) return false
|
|
62
|
+
if (a === 172 && b >= 16 && b <= 31) return false
|
|
63
|
+
if (a === 192 && b === 168) return false
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
// A bare hostname label has no public site behind it.
|
|
67
|
+
return host.includes('.')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extracts icon URLs from a document head, best candidate first.
|
|
72
|
+
*
|
|
73
|
+
* `apple-touch-icon` is preferred because it is a square PNG sized for a home
|
|
74
|
+
* screen; declared `icon` links follow in declared order; `/favicon.ico` is the
|
|
75
|
+
* conventional last resort when nothing is declared.
|
|
76
|
+
*/
|
|
77
|
+
export function discoverSiteIcons(html: string, baseUrl: string): readonly string[] {
|
|
78
|
+
const candidates: { href: string; rank: number }[] = []
|
|
79
|
+
const linkTags = html.match(/<link\b[^>]*>/gi) ?? []
|
|
80
|
+
for (const tag of linkTags) {
|
|
81
|
+
const rel = /\brel\s*=\s*["']?([^"'>]+)/i.exec(tag)?.[1]?.toLowerCase() ?? ''
|
|
82
|
+
if (!/\b(apple-touch-icon|icon|shortcut|mask-icon)\b/.test(rel)) continue
|
|
83
|
+
const href = /\bhref\s*=\s*["']([^"']+)["']/i.exec(tag)?.[1]
|
|
84
|
+
if (!href) continue
|
|
85
|
+
const sizes = /\bsizes\s*=\s*["']?([0-9]+x[0-9]+)/i.exec(tag)?.[1]
|
|
86
|
+
const edge = sizes ? Number.parseInt(sizes, 10) : 180
|
|
87
|
+
// Apple touch icons lead; larger declared sizes lead within a group.
|
|
88
|
+
const rank = rel.includes('apple-touch-icon') ? 0 : rel.includes('mask-icon') ? 2 : 1
|
|
89
|
+
candidates.push({ href, rank: rank * 1000 + Math.max(0, 512 - (edge || 180)) })
|
|
90
|
+
}
|
|
91
|
+
const resolved: { url: string; rank: number }[] = []
|
|
92
|
+
for (const candidate of candidates.toSorted((left, right) => left.rank - right.rank)) {
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(candidate.href, baseUrl)
|
|
95
|
+
if (url.protocol !== 'https:' || !isPublicHostname(url.hostname)) continue
|
|
96
|
+
resolved.push({ url: url.toString(), rank: candidate.rank })
|
|
97
|
+
} catch {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The conventional location is always the last candidate: a site whose
|
|
102
|
+
// declared icon turns out to be unusable can still resolve through it.
|
|
103
|
+
try {
|
|
104
|
+
const fallback = new URL('/favicon.ico', baseUrl)
|
|
105
|
+
if (fallback.protocol === 'https:' && isPublicHostname(fallback.hostname))
|
|
106
|
+
resolved.push({ url: fallback.toString(), rank: 5000 })
|
|
107
|
+
} catch {
|
|
108
|
+
// An unparseable base leaves the declared candidates only.
|
|
109
|
+
}
|
|
110
|
+
return [...new Set(resolved.toSorted((l, r) => l.rank - r.rank).map((item) => item.url))]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SiteIconFetcher {
|
|
114
|
+
readonly fetchText: (url: string) => Promise<string>
|
|
115
|
+
readonly fetchBytes: (url: string) => Promise<Uint8Array>
|
|
116
|
+
readonly digest: (bytes: Uint8Array) => string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolves the best usable icon for a vendor homepage.
|
|
121
|
+
*
|
|
122
|
+
* Every candidate is inspected with the same gate as an in-content mark, so a
|
|
123
|
+
* site that serves an oversized, unsupported or scripted image yields no icon
|
|
124
|
+
* rather than a mirrored liability.
|
|
125
|
+
*/
|
|
126
|
+
export async function resolveSiteIcon(
|
|
127
|
+
homepage: string,
|
|
128
|
+
fetcher: SiteIconFetcher
|
|
129
|
+
): Promise<SiteIcon | { readonly failed: string }> {
|
|
130
|
+
if (!isVendorHomepage(homepage)) return { failed: 'NOT_VENDOR_HOMEPAGE' }
|
|
131
|
+
let html: string
|
|
132
|
+
try {
|
|
133
|
+
html = await fetcher.fetchText(homepage)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { failed: `HOMEPAGE_FETCH_FAILED: ${message(error)}` }
|
|
136
|
+
}
|
|
137
|
+
if (html.length > SITE_ICON_MAX_HTML_BYTES) return { failed: 'HOMEPAGE_TOO_LARGE' }
|
|
138
|
+
for (const url of discoverSiteIcons(html.slice(0, SITE_ICON_HEAD_BYTES), homepage)) {
|
|
139
|
+
let bytes: Uint8Array
|
|
140
|
+
try {
|
|
141
|
+
bytes = await fetcher.fetchBytes(url)
|
|
142
|
+
} catch {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (bytes.byteLength > MAX_ICON_BYTES) continue
|
|
146
|
+
const inspected = inspectIconBytes(bytes)
|
|
147
|
+
if (!('contentType' in inspected)) continue
|
|
148
|
+
return {
|
|
149
|
+
url,
|
|
150
|
+
homepage,
|
|
151
|
+
bytes,
|
|
152
|
+
contentType: inspected.contentType,
|
|
153
|
+
digest: fetcher.digest(bytes),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { failed: 'NO_USABLE_ICON' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function message(error: unknown): string {
|
|
160
|
+
return error instanceof Error ? error.message : String(error)
|
|
161
|
+
}
|
package/src/sources.ts
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export type MarketplaceDialect = 'openai' | 'cursor' | 'claude'
|
|
4
|
+
|
|
5
|
+
export interface SourceConfig {
|
|
6
|
+
readonly sourceId: string
|
|
7
|
+
readonly displayName: string
|
|
8
|
+
readonly repositoryUrl: string
|
|
9
|
+
readonly marketplaceDialect: MarketplaceDialect
|
|
10
|
+
readonly manifestPath: string
|
|
11
|
+
readonly defaultBranch: string
|
|
12
|
+
readonly trustClassification: 'official'
|
|
13
|
+
readonly fixturePath?: string
|
|
14
|
+
readonly fixtureCommitSha?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PluginSourceSpec =
|
|
18
|
+
| { readonly kind: 'local'; readonly path: string }
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: 'git'
|
|
21
|
+
readonly repositoryUrl: string
|
|
22
|
+
readonly subdirectory: string
|
|
23
|
+
readonly ref?: string
|
|
24
|
+
readonly sha?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MarketplacePluginEntry {
|
|
28
|
+
readonly name: string
|
|
29
|
+
readonly displayName?: string | undefined
|
|
30
|
+
readonly description: string
|
|
31
|
+
readonly categories: readonly string[]
|
|
32
|
+
readonly keywords: readonly string[]
|
|
33
|
+
readonly authors: readonly string[]
|
|
34
|
+
readonly homepage?: string | undefined
|
|
35
|
+
readonly icons: readonly string[]
|
|
36
|
+
readonly source: PluginSourceSpec
|
|
37
|
+
readonly policy: Record<string, unknown>
|
|
38
|
+
readonly raw: Record<string, unknown>
|
|
39
|
+
readonly entryDigest: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ParsedMarketplace {
|
|
43
|
+
readonly marketplaceName: string
|
|
44
|
+
readonly marketplaceDescription: string
|
|
45
|
+
readonly owner: readonly string[]
|
|
46
|
+
readonly entries: readonly MarketplacePluginEntry[]
|
|
47
|
+
readonly raw: Record<string, unknown>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class SourceAdapterError extends Error {
|
|
51
|
+
constructor(
|
|
52
|
+
readonly code: string,
|
|
53
|
+
readonly details: readonly string[] = []
|
|
54
|
+
) {
|
|
55
|
+
super(`${code}${details.length > 0 ? `: ${details.join(', ')}` : ''}`)
|
|
56
|
+
this.name = 'SourceAdapterError'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseMarketplaceManifest(input: unknown, source: SourceConfig): ParsedMarketplace {
|
|
61
|
+
const root = record(input, 'MARKETPLACE_NOT_OBJECT')
|
|
62
|
+
const plugins = root.plugins
|
|
63
|
+
if (!Array.isArray(plugins)) throw new SourceAdapterError('MARKETPLACE_PLUGINS_REQUIRED')
|
|
64
|
+
|
|
65
|
+
const entries = plugins.map((value, index) => parsePluginEntry(value, source, index))
|
|
66
|
+
const duplicateNames = new Set<string>()
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (duplicateNames.has(entry.name))
|
|
69
|
+
throw new SourceAdapterError('DUPLICATE_PLUGIN_NAME', [entry.name])
|
|
70
|
+
duplicateNames.add(entry.name)
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
marketplaceName: stringValue(root.name) ?? source.displayName,
|
|
74
|
+
marketplaceDescription: stringValue(root.description) ?? '',
|
|
75
|
+
owner: ownerNames(root.owner),
|
|
76
|
+
entries,
|
|
77
|
+
raw: root,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse JSON while rejecting duplicate object keys instead of accepting last-key-wins input. */
|
|
82
|
+
export function parseJsonDocument(text: string): unknown {
|
|
83
|
+
let index = 0
|
|
84
|
+
const skip = () => {
|
|
85
|
+
while (/\s/.test(text[index] ?? '')) index += 1
|
|
86
|
+
}
|
|
87
|
+
const string = (): string => {
|
|
88
|
+
const start = index
|
|
89
|
+
if (text[index] !== '"') throw new SourceAdapterError('JSON_MALFORMED')
|
|
90
|
+
index += 1
|
|
91
|
+
while (index < text.length) {
|
|
92
|
+
const character = text[index]
|
|
93
|
+
if (character === '\\') {
|
|
94
|
+
index += 2
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (character === '"') {
|
|
98
|
+
index += 1
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(text.slice(start, index)) as string
|
|
101
|
+
} catch {
|
|
102
|
+
throw new SourceAdapterError('JSON_MALFORMED')
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (character && character < ' ') throw new SourceAdapterError('JSON_MALFORMED')
|
|
106
|
+
index += 1
|
|
107
|
+
}
|
|
108
|
+
throw new SourceAdapterError('JSON_MALFORMED')
|
|
109
|
+
}
|
|
110
|
+
const value = (): unknown => {
|
|
111
|
+
skip()
|
|
112
|
+
const character = text[index]
|
|
113
|
+
if (character === '{') {
|
|
114
|
+
index += 1
|
|
115
|
+
const object: Record<string, unknown> = {}
|
|
116
|
+
const keys = new Set<string>()
|
|
117
|
+
skip()
|
|
118
|
+
if (text[index] === '}') {
|
|
119
|
+
index += 1
|
|
120
|
+
return object
|
|
121
|
+
}
|
|
122
|
+
while (true) {
|
|
123
|
+
skip()
|
|
124
|
+
const key = string()
|
|
125
|
+
if (keys.has(key)) throw new SourceAdapterError('JSON_DUPLICATE_KEY', [key])
|
|
126
|
+
keys.add(key)
|
|
127
|
+
skip()
|
|
128
|
+
if (text[index] !== ':') throw new SourceAdapterError('JSON_MALFORMED')
|
|
129
|
+
index += 1
|
|
130
|
+
object[key] = value()
|
|
131
|
+
skip()
|
|
132
|
+
if (text[index] === '}') {
|
|
133
|
+
index += 1
|
|
134
|
+
return object
|
|
135
|
+
}
|
|
136
|
+
if (text[index] !== ',') throw new SourceAdapterError('JSON_MALFORMED')
|
|
137
|
+
index += 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (character === '[') {
|
|
141
|
+
index += 1
|
|
142
|
+
const array: unknown[] = []
|
|
143
|
+
skip()
|
|
144
|
+
if (text[index] === ']') {
|
|
145
|
+
index += 1
|
|
146
|
+
return array
|
|
147
|
+
}
|
|
148
|
+
while (true) {
|
|
149
|
+
array.push(value())
|
|
150
|
+
skip()
|
|
151
|
+
if (text[index] === ']') {
|
|
152
|
+
index += 1
|
|
153
|
+
return array
|
|
154
|
+
}
|
|
155
|
+
if (text[index] !== ',') throw new SourceAdapterError('JSON_MALFORMED')
|
|
156
|
+
index += 1
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (character === '"') return string()
|
|
160
|
+
const start = index
|
|
161
|
+
while (index < text.length && !/[\s,\]}]/.test(text[index] ?? '')) index += 1
|
|
162
|
+
const token = text.slice(start, index)
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(token) as unknown
|
|
165
|
+
} catch {
|
|
166
|
+
throw new SourceAdapterError('JSON_MALFORMED')
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const parsed = value()
|
|
170
|
+
skip()
|
|
171
|
+
if (index !== text.length) throw new SourceAdapterError('JSON_MALFORMED')
|
|
172
|
+
return parsed
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parsePluginEntry(
|
|
176
|
+
value: unknown,
|
|
177
|
+
source: SourceConfig,
|
|
178
|
+
index: number
|
|
179
|
+
): MarketplacePluginEntry {
|
|
180
|
+
const entry = record(value, 'PLUGIN_ENTRY_NOT_OBJECT', String(index))
|
|
181
|
+
const name = requiredString(entry.name, 'PLUGIN_NAME_REQUIRED', String(index))
|
|
182
|
+
const rawSource = entry.source
|
|
183
|
+
if (rawSource === undefined) throw new SourceAdapterError('PLUGIN_SOURCE_REQUIRED', [name])
|
|
184
|
+
const sourceSpec = parseSourceSpec(rawSource, source, name)
|
|
185
|
+
const category = stringValue(entry.category)
|
|
186
|
+
const categories = [
|
|
187
|
+
...(Array.isArray(entry.categories) ? entry.categories.filter(isString) : []),
|
|
188
|
+
...(category ? [category] : []),
|
|
189
|
+
]
|
|
190
|
+
return {
|
|
191
|
+
name,
|
|
192
|
+
...(stringValue(entry.displayName) ? { displayName: stringValue(entry.displayName) } : {}),
|
|
193
|
+
description: stringValue(entry.description) ?? '',
|
|
194
|
+
categories: uniqueStrings(categories),
|
|
195
|
+
keywords: uniqueStrings(Array.isArray(entry.keywords) ? entry.keywords.filter(isString) : []),
|
|
196
|
+
authors: authorNames(entry.author ?? entry.authors),
|
|
197
|
+
...(safeOptionalUrl(entry.homepage) ? { homepage: safeOptionalUrl(entry.homepage) } : {}),
|
|
198
|
+
icons: uniqueStrings(
|
|
199
|
+
[entry.icon, ...(Array.isArray(entry.icons) ? entry.icons : [])].filter(isString)
|
|
200
|
+
),
|
|
201
|
+
source: sourceSpec,
|
|
202
|
+
policy: recordOrEmpty(entry.policy),
|
|
203
|
+
raw: entry,
|
|
204
|
+
entryDigest: digest(entry),
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function parseSourceSpec(
|
|
209
|
+
value: unknown,
|
|
210
|
+
source: SourceConfig,
|
|
211
|
+
pluginName = 'unknown'
|
|
212
|
+
): PluginSourceSpec {
|
|
213
|
+
if (typeof value === 'string') {
|
|
214
|
+
if (looksLikeUrl(value)) {
|
|
215
|
+
return { kind: 'git', repositoryUrl: canonicalRepositoryUrl(value), subdirectory: '' }
|
|
216
|
+
}
|
|
217
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value))
|
|
218
|
+
throw new SourceAdapterError('PLUGIN_SOURCE_SCHEME_UNSUPPORTED', [pluginName, value])
|
|
219
|
+
return { kind: 'local', path: safeRelativePath(value, `PLUGIN_SOURCE_PATH:${pluginName}`) }
|
|
220
|
+
}
|
|
221
|
+
const object = record(value, 'PLUGIN_SOURCE_INVALID', pluginName)
|
|
222
|
+
const kind = stringValue(object.source) ?? stringValue(object.type)
|
|
223
|
+
if (kind === 'local') {
|
|
224
|
+
return {
|
|
225
|
+
kind: 'local',
|
|
226
|
+
path: safeRelativePath(requiredString(object.path, 'LOCAL_PATH_REQUIRED', pluginName)),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (kind === 'git-subdir' || kind === 'url' || kind === 'git') {
|
|
230
|
+
const url = canonicalRepositoryUrl(requiredString(object.url, 'GIT_URL_REQUIRED', pluginName))
|
|
231
|
+
const subdirectory =
|
|
232
|
+
kind === 'git-subdir'
|
|
233
|
+
? safeRelativePath(requiredString(object.path, 'GIT_PATH_REQUIRED', pluginName))
|
|
234
|
+
: ''
|
|
235
|
+
const ref = stringValue(object.ref)
|
|
236
|
+
const sha = stringValue(object.sha)
|
|
237
|
+
if (sha !== undefined && !/^[a-f0-9]{40}$/.test(sha)) {
|
|
238
|
+
throw new SourceAdapterError('GIT_SHA_INVALID', [pluginName, sha])
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
kind: 'git',
|
|
242
|
+
repositoryUrl: url,
|
|
243
|
+
subdirectory,
|
|
244
|
+
...(ref === undefined ? {} : { ref }),
|
|
245
|
+
...(sha === undefined ? {} : { sha }),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
throw new SourceAdapterError('PLUGIN_SOURCE_KIND_UNSUPPORTED', [pluginName, kind ?? 'missing'])
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function canonicalRepositoryUrl(input: string): string {
|
|
252
|
+
let parsed: URL
|
|
253
|
+
try {
|
|
254
|
+
parsed = new URL(input)
|
|
255
|
+
} catch {
|
|
256
|
+
throw new SourceAdapterError('GIT_URL_INVALID', [input])
|
|
257
|
+
}
|
|
258
|
+
if (parsed.protocol !== 'https:')
|
|
259
|
+
throw new SourceAdapterError('GIT_URL_PROTOCOL_UNSUPPORTED', [input])
|
|
260
|
+
if (parsed.username || parsed.password || parsed.port || parsed.search || parsed.hash) {
|
|
261
|
+
throw new SourceAdapterError('GIT_URL_CREDENTIALS_OR_QUERY_FORBIDDEN', [input])
|
|
262
|
+
}
|
|
263
|
+
const path = parsed.pathname
|
|
264
|
+
.replace(/\/+/g, '/')
|
|
265
|
+
.replace(/\/+$/, '')
|
|
266
|
+
.replace(/\.git$/, '')
|
|
267
|
+
if (!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path)) {
|
|
268
|
+
throw new SourceAdapterError('GIT_URL_REPOSITORY_PATH_INVALID', [input])
|
|
269
|
+
}
|
|
270
|
+
return `https://${parsed.hostname.toLowerCase()}${path}`
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function safeRelativePath(input: string, code = 'PATH_INVALID'): string {
|
|
274
|
+
if (!input || input.startsWith('/') || input.startsWith('\\') || /^[A-Za-z]:/.test(input)) {
|
|
275
|
+
throw new SourceAdapterError(code, [input])
|
|
276
|
+
}
|
|
277
|
+
const normalized = input.replaceAll('\\', '/').replace(/^(\.\/)+/, '')
|
|
278
|
+
if (!normalized) throw new SourceAdapterError(code, [input])
|
|
279
|
+
const parts = normalized.split('/')
|
|
280
|
+
if (parts.some((part) => part === '..' || part === '.'))
|
|
281
|
+
throw new SourceAdapterError('PATH_TRAVERSAL', [input])
|
|
282
|
+
if (parts.some((part) => part.length === 0)) throw new SourceAdapterError(code, [input])
|
|
283
|
+
return parts.join('/')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function record(input: unknown, code: string, detail?: string): Record<string, unknown> {
|
|
287
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
|
288
|
+
throw new SourceAdapterError(code, detail ? [detail] : [])
|
|
289
|
+
}
|
|
290
|
+
return input as Record<string, unknown>
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function recordOrEmpty(input: unknown): Record<string, unknown> {
|
|
294
|
+
return input && typeof input === 'object' && !Array.isArray(input)
|
|
295
|
+
? (input as Record<string, unknown>)
|
|
296
|
+
: {}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function requiredString(input: unknown, code: string, detail: string): string {
|
|
300
|
+
const result = stringValue(input)
|
|
301
|
+
if (!result) throw new SourceAdapterError(code, [detail])
|
|
302
|
+
return result
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function stringValue(input: unknown): string | undefined {
|
|
306
|
+
return typeof input === 'string' && input.trim() ? input.trim() : undefined
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isString(input: unknown): input is string {
|
|
310
|
+
return typeof input === 'string' && input.trim().length > 0
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function uniqueStrings(input: readonly string[]): string[] {
|
|
314
|
+
return [...new Set(input.map((value) => value.trim()).filter(Boolean))].toSorted((a, b) =>
|
|
315
|
+
a.localeCompare(b)
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function authorNames(input: unknown): string[] {
|
|
320
|
+
if (Array.isArray(input)) return uniqueStrings(input.flatMap((item) => authorNames(item)))
|
|
321
|
+
if (typeof input === 'string') return uniqueStrings([input])
|
|
322
|
+
if (input && typeof input === 'object') {
|
|
323
|
+
const object = input as Record<string, unknown>
|
|
324
|
+
const name = stringValue(object.name) ?? stringValue(object.email)
|
|
325
|
+
return name ? [name] : []
|
|
326
|
+
}
|
|
327
|
+
return []
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ownerNames(input: unknown): string[] {
|
|
331
|
+
return authorNames(input)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function safeOptionalUrl(input: unknown): string | undefined {
|
|
335
|
+
if (!isString(input)) return undefined
|
|
336
|
+
try {
|
|
337
|
+
const parsed = new URL(input)
|
|
338
|
+
return parsed.protocol === 'https:' ? parsed.toString() : undefined
|
|
339
|
+
} catch {
|
|
340
|
+
return undefined
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function looksLikeUrl(input: string): boolean {
|
|
345
|
+
return /^https?:\/\//i.test(input)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function digest(value: unknown): string {
|
|
349
|
+
return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function canonicalJson(value: unknown): string {
|
|
353
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
354
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
355
|
+
const object = value as Record<string, unknown>
|
|
356
|
+
return `{${Object.keys(object)
|
|
357
|
+
.toSorted()
|
|
358
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
|
|
359
|
+
.join(',')}}`
|
|
360
|
+
}
|