@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.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +74 -0
  3. package/dist/agent-plugins-catalog.d.ts +26 -0
  4. package/dist/agent-plugins-catalog.js +699 -0
  5. package/dist/agent-plugins-catalog.js.map +1 -0
  6. package/dist/agent-plugins-harness.d.ts +54 -0
  7. package/dist/agent-plugins-harness.js +212 -0
  8. package/dist/agent-plugins-harness.js.map +1 -0
  9. package/dist/agent-plugins-schema.d.ts +402 -0
  10. package/dist/agent-plugins-schema.js +200 -0
  11. package/dist/agent-plugins-schema.js.map +1 -0
  12. package/dist/consumer-index.d.ts +217 -0
  13. package/dist/consumer-index.js +358 -0
  14. package/dist/consumer-index.js.map +1 -0
  15. package/dist/display-name.d.ts +8 -0
  16. package/dist/display-name.js +160 -0
  17. package/dist/display-name.js.map +1 -0
  18. package/dist/harness.d.ts +13 -0
  19. package/dist/harness.js +169 -0
  20. package/dist/harness.js.map +1 -0
  21. package/dist/icon-mirror.d.ts +69 -0
  22. package/dist/icon-mirror.js +147 -0
  23. package/dist/icon-mirror.js.map +1 -0
  24. package/dist/icons.d.ts +65 -0
  25. package/dist/icons.js +268 -0
  26. package/dist/icons.js.map +1 -0
  27. package/dist/index.d.ts +304 -0
  28. package/dist/index.js +1877 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/portable-catalog.d.ts +9 -0
  31. package/dist/portable-catalog.js +175 -0
  32. package/dist/portable-catalog.js.map +1 -0
  33. package/dist/publication.d.ts +98 -0
  34. package/dist/publication.js +121 -0
  35. package/dist/publication.js.map +1 -0
  36. package/dist/schema.d.ts +705 -0
  37. package/dist/schema.js +267 -0
  38. package/dist/schema.js.map +1 -0
  39. package/dist/site-icons.d.ts +55 -0
  40. package/dist/site-icons.js +155 -0
  41. package/dist/site-icons.js.map +1 -0
  42. package/dist/sources.d.ts +54 -0
  43. package/dist/sources.js +306 -0
  44. package/dist/sources.js.map +1 -0
  45. package/package.json +102 -0
  46. package/schemas/agent-package.v1.json +387 -0
  47. package/schemas/catalog.v1.schema.json +407 -0
  48. package/schemas/compatibility.v1.schema.json +23 -0
  49. package/schemas/harness-profile.v1.json +75 -0
  50. package/schemas/installation-plan.v2.json +695 -0
  51. package/schemas/sources-lock.v1.schema.json +61 -0
  52. package/src/agent-plugins-catalog.test.ts +402 -0
  53. package/src/agent-plugins-catalog.ts +800 -0
  54. package/src/agent-plugins-harness.test.ts +272 -0
  55. package/src/agent-plugins-harness.ts +281 -0
  56. package/src/agent-plugins-schema.test.ts +27 -0
  57. package/src/agent-plugins-schema.ts +219 -0
  58. package/src/artifacts.test.ts +545 -0
  59. package/src/catalog-installation.test.ts +42 -0
  60. package/src/consumer-index.test.ts +465 -0
  61. package/src/consumer-index.ts +592 -0
  62. package/src/display-name.test.ts +56 -0
  63. package/src/display-name.ts +157 -0
  64. package/src/harness.ts +202 -0
  65. package/src/icon-mirror.ts +196 -0
  66. package/src/icons.test.ts +185 -0
  67. package/src/icons.ts +297 -0
  68. package/src/index.test.ts +888 -0
  69. package/src/index.ts +2538 -0
  70. package/src/portable-catalog.test.ts +186 -0
  71. package/src/portable-catalog.ts +203 -0
  72. package/src/publication.test.ts +117 -0
  73. package/src/publication.ts +171 -0
  74. package/src/schema.ts +309 -0
  75. package/src/site-icon-fallback.test.ts +267 -0
  76. package/src/site-icons.test.ts +171 -0
  77. package/src/site-icons.ts +161 -0
  78. package/src/sources.ts +360 -0
  79. 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
+ }