@adea-ai/plugins 3.0.10 → 4.0.1
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/README.md +18 -11
- package/dist/agent-plugins-catalog.d.ts +2 -3
- package/dist/agent-plugins-catalog.js +2 -10
- package/dist/agent-plugins-catalog.js.map +1 -1
- package/dist/catalog-store.d.ts +81 -0
- package/dist/catalog-store.js +487 -0
- package/dist/catalog-store.js.map +1 -0
- package/dist/consumer-index.d.ts +1 -1
- package/dist/consumer-index.js +17 -7
- package/dist/consumer-index.js.map +1 -1
- package/dist/display-name.js +3 -2
- package/dist/display-name.js.map +1 -1
- package/dist/harness.d.ts +50 -9
- package/dist/harness.js +190 -147
- package/dist/harness.js.map +1 -1
- package/dist/icon-mirror.d.ts +2 -2
- package/dist/icon-mirror.js +38 -25
- package/dist/icon-mirror.js.map +1 -1
- package/dist/icons.js +5 -27
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.js +103 -88
- package/dist/index.js.map +1 -1
- package/dist/publication.d.ts +8 -11
- package/dist/publication.js +8 -11
- package/dist/publication.js.map +1 -1
- package/dist/schema.d.ts +0 -40
- package/dist/schema.js +0 -36
- package/dist/schema.js.map +1 -1
- package/dist/site-icons.d.ts +2 -0
- package/dist/site-icons.js +46 -9
- package/dist/site-icons.js.map +1 -1
- package/dist/source-digest.d.ts +2 -0
- package/dist/source-digest.js +12 -0
- package/dist/source-digest.js.map +1 -0
- package/dist/sources.js +3 -3
- package/dist/sources.js.map +1 -1
- package/native/build-provenance.txt +5 -5
- package/native/staged-reader-win32-arm64.node +0 -0
- package/native/staged-reader-win32-x64.node +0 -0
- package/package.json +4 -9
- package/src/agent-plugins-catalog.ts +3 -10
- package/src/artifacts.test.ts +80 -0
- package/src/catalog-installation.test.ts +1 -1
- package/src/catalog-store.ts +546 -0
- package/src/consumer-index.test.ts +125 -0
- package/src/consumer-index.ts +23 -8
- package/src/default-head.test.ts +143 -0
- package/src/display-name.test.ts +8 -0
- package/src/display-name.ts +3 -2
- package/src/{agent-plugins-harness.test.ts → harness.test.ts} +1 -1
- package/src/harness.ts +249 -170
- package/src/icon-mirror.ts +36 -23
- package/src/icons.test.ts +5 -0
- package/src/icons.ts +6 -29
- package/src/index.test.ts +272 -40
- package/src/index.ts +131 -94
- package/src/manifest-retry.test.ts +49 -0
- package/src/portable-catalog.test.ts +8 -5
- package/src/publication.ts +8 -11
- package/src/schema.ts +0 -40
- package/src/site-icon-fallback.test.ts +36 -19
- package/src/site-icons.test.ts +104 -0
- package/src/site-icons.ts +47 -9
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +101 -0
- package/src/source-digest.ts +14 -0
- package/src/sources-json.test.ts +53 -0
- package/src/sources.ts +3 -3
- package/src/upstream-headers.test.ts +31 -7
- package/src/write-artifacts.test.ts +375 -0
- package/dist/agent-plugins-harness.d.ts +0 -54
- package/dist/agent-plugins-harness.js +0 -212
- package/dist/agent-plugins-harness.js.map +0 -1
- package/src/agent-plugins-harness.ts +0 -281
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
1
4
|
import { describe, expect, test } from 'bun:test'
|
|
2
5
|
import {
|
|
3
6
|
buildCatalog,
|
|
@@ -12,7 +15,7 @@ import {
|
|
|
12
15
|
} from './index.js'
|
|
13
16
|
import { buildConsumerIndex } from './consumer-index.js'
|
|
14
17
|
import { resolveIconSources, stageIconAssets } from './icon-mirror.js'
|
|
15
|
-
import { selectIconPath } from './icons.js'
|
|
18
|
+
import { iconAssetName, selectIconPath } from './icons.js'
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
21
|
* End-to-end cover for the brand-mark fallback: a plugin that ships no mark of
|
|
@@ -23,8 +26,8 @@ import { selectIconPath } from './icons.js'
|
|
|
23
26
|
const PNG = new Uint8Array([
|
|
24
27
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
25
28
|
])
|
|
26
|
-
const HOME = 'https://vendor.example/'
|
|
27
|
-
const APPLE_ICON = 'https://
|
|
29
|
+
const HOME = 'https://fc-vendor.example/'
|
|
30
|
+
const APPLE_ICON = 'https://fd-cdn.example/apple-icon.png'
|
|
28
31
|
|
|
29
32
|
const policy: CatalogPolicy = {
|
|
30
33
|
allowedRepositoryHosts: ['github.com'],
|
|
@@ -98,10 +101,14 @@ function stubFetch(handler: (url: string) => Response) {
|
|
|
98
101
|
return { calls, restore: () => void (globalThis.fetch = original) }
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
async function build(
|
|
104
|
+
async function build(
|
|
105
|
+
entryName: string,
|
|
106
|
+
homepage?: string,
|
|
107
|
+
options: { live?: boolean; iconAssetsDirectory?: string } = {}
|
|
108
|
+
) {
|
|
102
109
|
const stub = stubFetch((url) => {
|
|
103
110
|
if (url === HOME)
|
|
104
|
-
return new Response(`<head><link rel="apple-touch-icon" href="
|
|
111
|
+
return new Response(`<head><link rel="apple-touch-icon" href="${APPLE_ICON}"></head>`, {
|
|
105
112
|
status: 200,
|
|
106
113
|
})
|
|
107
114
|
if (url === APPLE_ICON) return new Response(PNG, { status: 200 })
|
|
@@ -116,6 +123,7 @@ async function build(entryName: string, homepage?: string, options: { live?: boo
|
|
|
116
123
|
metadataOnly: false,
|
|
117
124
|
snapshotLoader: loader({ 'README.md': new TextEncoder().encode('no mark here') }),
|
|
118
125
|
resolveExternalRefs: options.live ?? true,
|
|
126
|
+
...(options.iconAssetsDirectory ? { iconAssetsDirectory: options.iconAssetsDirectory } : {}),
|
|
119
127
|
})
|
|
120
128
|
return { catalog, calls: stub.calls }
|
|
121
129
|
} finally {
|
|
@@ -124,18 +132,26 @@ async function build(entryName: string, homepage?: string, options: { live?: boo
|
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
describe('product site icon fallback', () => {
|
|
127
|
-
test('records the site icon for a plugin that ships no mark', async () => {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
test('records and stages the site icon for a plugin that ships no mark', async () => {
|
|
136
|
+
const directory = await mkdtemp(join(tmpdir(), 'catalog-icons-'))
|
|
137
|
+
try {
|
|
138
|
+
const { catalog, calls } = await build('connector', HOME, { iconAssetsDirectory: directory })
|
|
139
|
+
const release = catalog.plugins[0]!.availableReleases[0]!
|
|
140
|
+
expect(release.icon).toEqual({
|
|
141
|
+
kind: 'favicon',
|
|
142
|
+
url: APPLE_ICON,
|
|
143
|
+
homepage: HOME,
|
|
144
|
+
contentType: 'image/png',
|
|
145
|
+
digest: byteDigest(PNG),
|
|
146
|
+
bytes: PNG.byteLength,
|
|
147
|
+
})
|
|
148
|
+
expect(calls).toEqual([HOME, APPLE_ICON])
|
|
149
|
+
expect(await readFile(join(directory, iconAssetName(byteDigest(PNG), 'image/png')))).toEqual(
|
|
150
|
+
Buffer.from(PNG)
|
|
151
|
+
)
|
|
152
|
+
} finally {
|
|
153
|
+
await rm(directory, { recursive: true, force: true })
|
|
154
|
+
}
|
|
139
155
|
})
|
|
140
156
|
|
|
141
157
|
test('prefers the plugin content and never contacts the site when a mark exists', async () => {
|
|
@@ -148,15 +164,16 @@ describe('product site icon fallback', () => {
|
|
|
148
164
|
policy,
|
|
149
165
|
metadataOnly: false,
|
|
150
166
|
snapshotLoader: loader({
|
|
151
|
-
'assets/
|
|
167
|
+
'assets/brand.svg': new TextEncoder().encode(
|
|
152
168
|
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>'
|
|
153
169
|
),
|
|
170
|
+
'assets/icon.png': PNG,
|
|
154
171
|
}),
|
|
155
172
|
resolveExternalRefs: true,
|
|
156
173
|
})
|
|
157
174
|
expect(catalog.plugins[0]!.availableReleases[0]!.icon).toMatchObject({
|
|
158
175
|
kind: 'content',
|
|
159
|
-
path: 'assets/
|
|
176
|
+
path: 'assets/icon.png',
|
|
160
177
|
})
|
|
161
178
|
expect(stub.calls).toEqual([])
|
|
162
179
|
} finally {
|
package/src/site-icons.test.ts
CHANGED
|
@@ -71,6 +71,10 @@ describe('vendor homepage eligibility', () => {
|
|
|
71
71
|
expect(isPublicHostname('bare-hostname')).toBe(false)
|
|
72
72
|
expect(isPublicHostname('::1')).toBe(false)
|
|
73
73
|
expect(isPublicHostname('fd00::1')).toBe(false)
|
|
74
|
+
expect(isPublicHostname('[fc00::1]')).toBe(false)
|
|
75
|
+
expect(isPublicHostname('fe80::1')).toBe(false)
|
|
76
|
+
expect(isPublicHostname('fc-vendor.example')).toBe(true)
|
|
77
|
+
expect(isPublicHostname('fd-cdn.example')).toBe(true)
|
|
74
78
|
})
|
|
75
79
|
})
|
|
76
80
|
|
|
@@ -92,6 +96,68 @@ describe('site icon discovery', () => {
|
|
|
92
96
|
])
|
|
93
97
|
})
|
|
94
98
|
|
|
99
|
+
test('resolves relative icon links against the first document base URL', () => {
|
|
100
|
+
const baseHtml = `<!-- <base href="https://ignored.example/"> <script> -->
|
|
101
|
+
<script>const sample = '<!-- <base href="https://script.example/">'</script>
|
|
102
|
+
<meta content="<base href='https://attribute.example/'>">
|
|
103
|
+
<base target="_blank"><base href="/assets/icons/">
|
|
104
|
+
<base href="https://later.example/">
|
|
105
|
+
<link rel="icon" href="mark.png">`
|
|
106
|
+
expect(discoverSiteIcons(baseHtml, 'https://vendor.example/docs/page')).toEqual([
|
|
107
|
+
'https://vendor.example/assets/icons/mark.png',
|
|
108
|
+
'https://vendor.example/favicon.ico',
|
|
109
|
+
])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('ignores bases and links inside text and template content', () => {
|
|
113
|
+
for (const inert of [
|
|
114
|
+
'<title><base href="https://title.example/"></title>',
|
|
115
|
+
'<textarea><base href="https://textarea.example/"></textarea>',
|
|
116
|
+
`<template><template><base href="https://nested.example/"></template>
|
|
117
|
+
<link rel="icon" href="https://template.example/icon.png"></template>`,
|
|
118
|
+
]) {
|
|
119
|
+
expect(
|
|
120
|
+
discoverSiteIcons(
|
|
121
|
+
`${inert}<base href="/real/"><link rel="icon" href="mark.png">`,
|
|
122
|
+
'https://vendor.example/'
|
|
123
|
+
)
|
|
124
|
+
).toEqual(['https://vendor.example/real/mark.png', 'https://vendor.example/favicon.ico'])
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('decodes HTML character references in URLs', () => {
|
|
129
|
+
expect(
|
|
130
|
+
discoverSiteIcons(
|
|
131
|
+
'<base href="/icons/?a=1&b=2"><link rel="icon" href="mark.png?x=1&y=2">',
|
|
132
|
+
'https://vendor.example/'
|
|
133
|
+
)
|
|
134
|
+
).toEqual([
|
|
135
|
+
'https://vendor.example/icons/mark.png?x=1&y=2',
|
|
136
|
+
'https://vendor.example/favicon.ico',
|
|
137
|
+
])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('retains the homepage fallback and URL policy with a document base', () => {
|
|
141
|
+
expect(
|
|
142
|
+
discoverSiteIcons(
|
|
143
|
+
'<base href="https://cdn.example/icons/"><link rel="icon" href="mark.png">',
|
|
144
|
+
'https://vendor.example/docs/page'
|
|
145
|
+
)
|
|
146
|
+
).toEqual(['https://cdn.example/icons/mark.png', 'https://vendor.example/favicon.ico'])
|
|
147
|
+
expect(
|
|
148
|
+
discoverSiteIcons(
|
|
149
|
+
'<base href="http://vendor.example/assets/"><link rel="icon" href="mark.png">',
|
|
150
|
+
'https://vendor.example/docs/page'
|
|
151
|
+
)
|
|
152
|
+
).toEqual(['https://vendor.example/favicon.ico'])
|
|
153
|
+
expect(
|
|
154
|
+
discoverSiteIcons(
|
|
155
|
+
'<base href="http://["><base href="/later/"><link rel="icon" href="mark.png">',
|
|
156
|
+
'https://vendor.example/docs/page'
|
|
157
|
+
)
|
|
158
|
+
).toEqual(['https://vendor.example/docs/mark.png', 'https://vendor.example/favicon.ico'])
|
|
159
|
+
})
|
|
160
|
+
|
|
95
161
|
test('prefers the larger declared size within a group', () => {
|
|
96
162
|
const sizes = `<link rel="icon" sizes="32x32" href="/small.png">
|
|
97
163
|
<link rel="icon" sizes="512x512" href="/large.png">`
|
|
@@ -100,6 +166,33 @@ describe('site icon discovery', () => {
|
|
|
100
166
|
)
|
|
101
167
|
})
|
|
102
168
|
|
|
169
|
+
test('accepts unquoted HTML attributes without reading the next attribute as rel', () => {
|
|
170
|
+
expect(
|
|
171
|
+
discoverSiteIcons('<link rel=icon href=/mark.svg sizes=32x32>', 'https://vendor.example/')
|
|
172
|
+
).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('only reads actual link attributes and complete relation tokens', () => {
|
|
176
|
+
const attributesHtml = `<link data-rel="icon" href="/data-rel.png">
|
|
177
|
+
<link rel="not-icon" href="/not-icon.png">
|
|
178
|
+
<link rel="stylesheet" title="rel=icon" href="/style.css">
|
|
179
|
+
<link-preview rel="icon" href="/custom.png">
|
|
180
|
+
<link rel="icon" data-href="/data-href.png" href="/real.png">`
|
|
181
|
+
expect(discoverSiteIcons(attributesHtml, 'https://vendor.example/')).toEqual([
|
|
182
|
+
'https://vendor.example/real.png',
|
|
183
|
+
'https://vendor.example/favicon.ico',
|
|
184
|
+
])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('does not end a link tag at a greater-than character inside a quoted attribute', () => {
|
|
188
|
+
expect(
|
|
189
|
+
discoverSiteIcons(
|
|
190
|
+
'<link title="a > b" rel="icon" href="/mark.svg">',
|
|
191
|
+
'https://vendor.example/'
|
|
192
|
+
)
|
|
193
|
+
).toEqual(['https://vendor.example/mark.svg', 'https://vendor.example/favicon.ico'])
|
|
194
|
+
})
|
|
195
|
+
|
|
103
196
|
test('falls back to /favicon.ico and refuses unsafe candidates', () => {
|
|
104
197
|
expect(discoverSiteIcons('<html><head></head></html>', 'https://vendor.example/page')).toEqual([
|
|
105
198
|
'https://vendor.example/favicon.ico',
|
|
@@ -132,6 +225,17 @@ describe('site icon resolution', () => {
|
|
|
132
225
|
expect(io.byteCalls).toEqual(['https://vendor.example/apple-icon.png'])
|
|
133
226
|
})
|
|
134
227
|
|
|
228
|
+
test('fetches a relative icon from the document base', async () => {
|
|
229
|
+
const page = 'https://vendor.example/docs/page'
|
|
230
|
+
const baseHtml = '<head><base href="/assets/"><link rel="icon" href="mark.png"></head>'
|
|
231
|
+
const io = fetcher({ [page]: baseHtml, 'https://vendor.example/assets/mark.png': PNG })
|
|
232
|
+
expect(await resolveSiteIcon(page, io)).toMatchObject({
|
|
233
|
+
url: 'https://vendor.example/assets/mark.png',
|
|
234
|
+
homepage: page,
|
|
235
|
+
})
|
|
236
|
+
expect(io.byteCalls).toEqual(['https://vendor.example/assets/mark.png'])
|
|
237
|
+
})
|
|
238
|
+
|
|
135
239
|
test('accepts an icon container and skips a candidate that fails inspection', async () => {
|
|
136
240
|
const ico = fetcher({ [homepage]: html, 'https://vendor.example/apple-icon.png': ICO })
|
|
137
241
|
expect(await resolveSiteIcon(homepage, ico)).toMatchObject({ contentType: 'image/x-icon' })
|
package/src/site-icons.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseFragment, type DefaultTreeAdapterTypes } from 'parse5'
|
|
1
2
|
import { inspectIconBytes, MAX_ICON_BYTES, type IconContentType } from './icons.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -52,7 +53,10 @@ export function isPublicHostname(hostname: string): boolean {
|
|
|
52
53
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
53
54
|
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return false
|
|
54
55
|
if (host.endsWith('.internal') || host.endsWith('.home.arpa')) return false
|
|
55
|
-
if (
|
|
56
|
+
if (
|
|
57
|
+
host.includes(':') &&
|
|
58
|
+
(host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))
|
|
59
|
+
)
|
|
56
60
|
return false
|
|
57
61
|
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
58
62
|
if (ipv4) {
|
|
@@ -73,25 +77,59 @@ export function isPublicHostname(hostname: string): boolean {
|
|
|
73
77
|
* `apple-touch-icon` is preferred because it is a square PNG sized for a home
|
|
74
78
|
* screen; declared `icon` links follow in declared order; `/favicon.ico` is the
|
|
75
79
|
* conventional last resort when nothing is declared.
|
|
80
|
+
* Relative links use the document's first base href when present; the fallback
|
|
81
|
+
* stays on the homepage origin.
|
|
76
82
|
*/
|
|
77
83
|
export function discoverSiteIcons(html: string, baseUrl: string): readonly string[] {
|
|
78
84
|
const candidates: { href: string; rank: number }[] = []
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
const elements: DefaultTreeAdapterTypes.Element[] = []
|
|
86
|
+
const nodes: DefaultTreeAdapterTypes.ChildNode[] = parseFragment(html).childNodes.toReversed()
|
|
87
|
+
while (nodes.length > 0) {
|
|
88
|
+
const node = nodes.pop()!
|
|
89
|
+
if (!('tagName' in node)) continue
|
|
90
|
+
if (node.namespaceURI === 'http://www.w3.org/1999/xhtml') elements.push(node)
|
|
91
|
+
// Template contents are a separate, inert document fragment.
|
|
92
|
+
if (node.tagName !== 'template') nodes.push(...node.childNodes.toReversed())
|
|
93
|
+
}
|
|
94
|
+
let resolutionBase = baseUrl
|
|
95
|
+
for (const element of elements) {
|
|
96
|
+
if (element.tagName !== 'base') continue
|
|
97
|
+
const attributes = new Map(element.attrs.map(({ name, value }) => [name, value]))
|
|
98
|
+
if (!attributes.has('href')) continue
|
|
99
|
+
try {
|
|
100
|
+
resolutionBase = new URL(attributes.get('href')!, baseUrl).toString()
|
|
101
|
+
} catch {
|
|
102
|
+
// An invalid first base falls back to the document URL.
|
|
103
|
+
}
|
|
104
|
+
break
|
|
105
|
+
}
|
|
106
|
+
for (const element of elements) {
|
|
107
|
+
if (element.tagName !== 'link') continue
|
|
108
|
+
const attributes = new Map(element.attrs.map(({ name, value }) => [name, value]))
|
|
109
|
+
const rel = new Set((attributes.get('rel') ?? '').toLowerCase().split(/\s+/))
|
|
110
|
+
if (
|
|
111
|
+
!['apple-touch-icon', 'apple-touch-icon-precomposed', 'icon', 'mask-icon'].some((name) =>
|
|
112
|
+
rel.has(name)
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
continue
|
|
116
|
+
const href = attributes.get('href')
|
|
84
117
|
if (!href) continue
|
|
85
|
-
const sizes =
|
|
118
|
+
const sizes = /^(\d+x\d+)(?:\s|$)/i.exec(attributes.get('sizes') ?? '')?.[1]
|
|
86
119
|
const edge = sizes ? Number.parseInt(sizes, 10) : 180
|
|
87
120
|
// Apple touch icons lead; larger declared sizes lead within a group.
|
|
88
|
-
const rank =
|
|
121
|
+
const rank =
|
|
122
|
+
rel.has('apple-touch-icon') || rel.has('apple-touch-icon-precomposed')
|
|
123
|
+
? 0
|
|
124
|
+
: rel.has('mask-icon')
|
|
125
|
+
? 2
|
|
126
|
+
: 1
|
|
89
127
|
candidates.push({ href, rank: rank * 1000 + Math.max(0, 512 - (edge || 180)) })
|
|
90
128
|
}
|
|
91
129
|
const resolved: { url: string; rank: number }[] = []
|
|
92
130
|
for (const candidate of candidates.toSorted((left, right) => left.rank - right.rank)) {
|
|
93
131
|
try {
|
|
94
|
-
const url = new URL(candidate.href,
|
|
132
|
+
const url = new URL(candidate.href, resolutionBase)
|
|
95
133
|
if (url.protocol !== 'https:' || !isPublicHostname(url.hostname)) continue
|
|
96
134
|
resolved.push({ url: url.toString(), rank: candidate.rank })
|
|
97
135
|
} catch {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { copyFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { expect, test } from 'bun:test'
|
|
4
|
+
import { synchronizePortable } from './portable-catalog.js'
|
|
5
|
+
import { withPortableFixture } from '../test-support/portable-fixture.js'
|
|
6
|
+
|
|
7
|
+
test('rebuilds and reports source metadata changes when source commits stay fixed', () =>
|
|
8
|
+
withPortableFixture(async (input, root) => {
|
|
9
|
+
const first = await synchronizePortable(input)
|
|
10
|
+
const old = input.sources[0]!
|
|
11
|
+
await copyFile(
|
|
12
|
+
join(root, 'fixtures/source/marketplace.json'),
|
|
13
|
+
join(root, 'fixtures/source/alternate.json')
|
|
14
|
+
)
|
|
15
|
+
const cases = [
|
|
16
|
+
{ ...old, displayName: 'Renamed source' },
|
|
17
|
+
{ ...old, defaultBranch: 'stable' },
|
|
18
|
+
{ ...old, manifestPath: 'alternate.json' },
|
|
19
|
+
{ ...old, marketplaceDialect: 'openai' as const },
|
|
20
|
+
]
|
|
21
|
+
for (const source of cases) {
|
|
22
|
+
const result = await synchronizePortable({
|
|
23
|
+
...input,
|
|
24
|
+
sources: [source],
|
|
25
|
+
existingLock: first.lock!,
|
|
26
|
+
existingCatalog: first.catalog!,
|
|
27
|
+
})
|
|
28
|
+
expect(result.changed).toBe(true)
|
|
29
|
+
expect(result.changeReport.changedSources).toContain(source.sourceId)
|
|
30
|
+
expect(result.catalog?.sources[0]?.displayName).toBe(source.displayName)
|
|
31
|
+
expect(result.lock?.sources[0]?.defaultBranch).toBe(source.defaultBranch)
|
|
32
|
+
expect(result.lock?.sources[0]?.manifestPath).toBe(source.manifestPath)
|
|
33
|
+
expect(result.lock?.sources[0]?.marketplaceDialect).toBe(source.marketplaceDialect)
|
|
34
|
+
const repeated = await synchronizePortable({
|
|
35
|
+
...input,
|
|
36
|
+
sources: [source],
|
|
37
|
+
existingLock: result.lock!,
|
|
38
|
+
existingCatalog: result.catalog!,
|
|
39
|
+
})
|
|
40
|
+
expect(repeated.changed).toBe(false)
|
|
41
|
+
}
|
|
42
|
+
const equivalent = await synchronizePortable({
|
|
43
|
+
...input,
|
|
44
|
+
sources: [{ ...old, repositoryUrl: `${old.repositoryUrl}.git` }],
|
|
45
|
+
existingLock: first.lock!,
|
|
46
|
+
existingCatalog: first.catalog!,
|
|
47
|
+
})
|
|
48
|
+
expect(equivalent.changed).toBe(false)
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
test('rebuilds if the marketplace repository changes while external plugin pins stay equal', () =>
|
|
52
|
+
withPortableFixture(async (input, root) => {
|
|
53
|
+
const { writeFile } = await import('node:fs/promises')
|
|
54
|
+
await writeFile(
|
|
55
|
+
join(root, 'fixtures/source/marketplace.json'),
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
plugins: [
|
|
58
|
+
{ name: 'remote', source: { source: 'git', url: 'https://github.com/example/external' } },
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
const snapshotLoader = {
|
|
63
|
+
load: async () => ({
|
|
64
|
+
files: new Map([['plugin.json', new TextEncoder().encode('{"name":"Remote"}')]]),
|
|
65
|
+
symlinks: [],
|
|
66
|
+
}),
|
|
67
|
+
}
|
|
68
|
+
const options = { ...input, snapshotLoader }
|
|
69
|
+
const first = await synchronizePortable(options)
|
|
70
|
+
const source = {
|
|
71
|
+
...input.sources[0]!,
|
|
72
|
+
repositoryUrl: 'https://github.com/example/moved-marketplace',
|
|
73
|
+
}
|
|
74
|
+
const result = await synchronizePortable({
|
|
75
|
+
...options,
|
|
76
|
+
sources: [source],
|
|
77
|
+
existingLock: first.lock!,
|
|
78
|
+
})
|
|
79
|
+
expect(result.changed).toBe(true)
|
|
80
|
+
expect(result.lock?.sources[0]?.repositoryUrl).toBe(source.repositoryUrl)
|
|
81
|
+
expect(result.catalog?.sources[0]?.repositoryUrl).toBe(source.repositoryUrl)
|
|
82
|
+
expect(result.changeReport.changedSources).toEqual([source.sourceId])
|
|
83
|
+
expect(result.lock?.sources[0]?.pluginPins).toEqual(first.lock?.sources[0]?.pluginPins)
|
|
84
|
+
}))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { expect, test } from 'bun:test'
|
|
6
|
+
|
|
7
|
+
// Exercise the public source identity functions as a Node consumer, where the
|
|
8
|
+
// default ICU locale follows the machine rather than Bun's fixed default.
|
|
9
|
+
test('source digests agree across consumer locales and equivalent Unicode path order', async () => {
|
|
10
|
+
const directory = await mkdtemp(join(import.meta.dirname, '.source-digest-'))
|
|
11
|
+
try {
|
|
12
|
+
const entry = join(directory, 'entry.ts')
|
|
13
|
+
const output = join(directory, 'consumer.mjs')
|
|
14
|
+
await writeFile(
|
|
15
|
+
entry,
|
|
16
|
+
`export { bytesDigest } from '../index.ts'\nexport { sourceDigest } from '../agent-plugins-catalog.ts'\n`
|
|
17
|
+
)
|
|
18
|
+
const build = await Bun.build({ entrypoints: [entry], target: 'node', packages: 'external' })
|
|
19
|
+
expect(build.success).toBe(true)
|
|
20
|
+
await Bun.write(output, build.outputs[0]!)
|
|
21
|
+
const script = `
|
|
22
|
+
import { bytesDigest, sourceDigest } from ${JSON.stringify(pathToFileURL(output).href)};
|
|
23
|
+
const paths = ['z.txt', 'ä.txt', 'é.txt', 'e\\u0301.txt'];
|
|
24
|
+
const entries = paths.map((path) => [path, new TextEncoder().encode(path)]);
|
|
25
|
+
const forward = new Map(entries), reverse = new Map([...entries].reverse());
|
|
26
|
+
console.log(JSON.stringify([
|
|
27
|
+
bytesDigest(forward), bytesDigest(reverse), sourceDigest(forward), sourceDigest(reverse)
|
|
28
|
+
]));
|
|
29
|
+
`
|
|
30
|
+
const results = ['en_US.UTF-8', 'sv_SE.UTF-8'].map((locale) => {
|
|
31
|
+
const result = spawnSync('node', ['--input-type=module', '-e', script], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
env: { ...process.env, LANG: locale, LC_ALL: locale },
|
|
34
|
+
})
|
|
35
|
+
if (result.error) throw result.error
|
|
36
|
+
if (result.status !== 0) throw new Error(result.stderr)
|
|
37
|
+
expect(result.status).toBe(0)
|
|
38
|
+
return JSON.parse(result.stdout) as string[]
|
|
39
|
+
})
|
|
40
|
+
expect(new Set(results.flat()).size).toBe(1)
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(directory, { recursive: true, force: true })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('source lock identity is independent of the consumer locale', async () => {
|
|
47
|
+
const directory = await mkdtemp(join(import.meta.dirname, '.source-lock-'))
|
|
48
|
+
try {
|
|
49
|
+
const source = join(directory, 'fixture', 'source')
|
|
50
|
+
await mkdir(source, { recursive: true })
|
|
51
|
+
await writeFile(
|
|
52
|
+
join(source, 'marketplace.json'),
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
plugins: [
|
|
55
|
+
{ name: 'a-plugin', source: './a' },
|
|
56
|
+
{ name: 'ä-plugin', source: './u' },
|
|
57
|
+
{ name: 'z-plugin', source: './z' },
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
const entry = join(directory, 'entry.ts')
|
|
62
|
+
const output = join(directory, 'consumer.mjs')
|
|
63
|
+
await writeFile(entry, "export { synchronize } from '../index.ts'\n")
|
|
64
|
+
const build = await Bun.build({ entrypoints: [entry], target: 'node', packages: 'external' })
|
|
65
|
+
expect(build.success).toBe(true)
|
|
66
|
+
await Bun.write(output, build.outputs[0]!)
|
|
67
|
+
const script = `
|
|
68
|
+
import { synchronize } from ${JSON.stringify(pathToFileURL(output).href)};
|
|
69
|
+
const result = await synchronize({
|
|
70
|
+
mode: 'offline', fixtureRoot: ${JSON.stringify(join(directory, 'fixture'))},
|
|
71
|
+
sources: [{sourceId:'test-source',displayName:'Test',marketplaceDialect:'claude',
|
|
72
|
+
repositoryUrl:'https://github.com/example/plugins',manifestPath:'marketplace.json',
|
|
73
|
+
defaultBranch:'main',trustClassification:'official',fixturePath:'source',
|
|
74
|
+
fixtureCommitSha:'a'.repeat(40)}],
|
|
75
|
+
categoryMap:{aliases:{},fallback:'other'},productAliases:{aliases:{}},
|
|
76
|
+
policy:{allowedRepositoryProtocols:['https:'],allowedRepositoryHosts:['github.com'],
|
|
77
|
+
maxFilesPerPlugin:100,maxBytesPerPlugin:100000,maxFileBytes:20000,
|
|
78
|
+
maxMarketplacePlugins:100,denyExecutableLifecycleScripts:true,
|
|
79
|
+
publishRequiresCompleteContent:false,sensitiveCapabilityTypes:[]},metadataOnly:true
|
|
80
|
+
});
|
|
81
|
+
console.log(JSON.stringify({lockId:result.lock.lockId,pins:result.lock.sources[0].pluginPins}));
|
|
82
|
+
`
|
|
83
|
+
const results = ['en_US.UTF-8', 'sv_SE.UTF-8'].map((locale) => {
|
|
84
|
+
const result = spawnSync('node', ['--input-type=module', '-e', script], {
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
env: { ...process.env, LANG: locale, LC_ALL: locale },
|
|
87
|
+
})
|
|
88
|
+
if (result.error) throw result.error
|
|
89
|
+
if (result.status !== 0) throw new Error(result.stderr)
|
|
90
|
+
return JSON.parse(result.stdout) as { lockId: string; pins: { pluginName: string }[] }
|
|
91
|
+
})
|
|
92
|
+
expect(results[0]).toEqual(results[1])
|
|
93
|
+
expect(results[0]!.pins.map((pin) => pin.pluginName)).toEqual([
|
|
94
|
+
'a-plugin',
|
|
95
|
+
'z-plugin',
|
|
96
|
+
'ä-plugin',
|
|
97
|
+
])
|
|
98
|
+
} finally {
|
|
99
|
+
await rm(directory, { recursive: true, force: true })
|
|
100
|
+
}
|
|
101
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** Shared source-byte identity for catalog provenance and canonical package recipes. */
|
|
4
|
+
export function sourceDigest(files: ReadonlyMap<string, Uint8Array>): string {
|
|
5
|
+
const hash = createHash('sha256')
|
|
6
|
+
// English matches the compiler runtime; ordinal ties distinguish equivalent Unicode names.
|
|
7
|
+
for (const [path, bytes] of [...files].toSorted(
|
|
8
|
+
([a], [b]) => a.localeCompare(b, 'en') || (a < b ? -1 : a > b ? 1 : 0)
|
|
9
|
+
)) {
|
|
10
|
+
hash.update(`${path.length}:${path}:${bytes.byteLength}:`)
|
|
11
|
+
hash.update(bytes)
|
|
12
|
+
}
|
|
13
|
+
return `sha256:${hash.digest('hex')}`
|
|
14
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { parseJsonDocument, parseMarketplaceManifest, type SourceConfig } from './sources.js'
|
|
3
|
+
|
|
4
|
+
describe('JSON document whitespace', () => {
|
|
5
|
+
test('accepts the four JSON whitespace characters around nested values', () => {
|
|
6
|
+
const text = ' \t\r\n{ "plugins"\t:\r[\ntrue, false, null, 1, "text", {} ] }\r\n'
|
|
7
|
+
expect(parseJsonDocument(text)).toEqual(JSON.parse(text))
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test.each(['\u000b', '\u000c', '\u00a0', '\u1680', '\u2028', '\u2029', '\ufeff'])(
|
|
11
|
+
'rejects non-JSON whitespace %j outside strings',
|
|
12
|
+
(space) => {
|
|
13
|
+
for (const text of [
|
|
14
|
+
`${space}{}`,
|
|
15
|
+
`{}${space}`,
|
|
16
|
+
`{${space}"plugins":[]}`,
|
|
17
|
+
`{"plugins"${space}:[]}`,
|
|
18
|
+
`{"plugins":${space}[]}`,
|
|
19
|
+
`[1${space},2]`,
|
|
20
|
+
`[1,${space}2]`,
|
|
21
|
+
]) {
|
|
22
|
+
expect(() => JSON.parse(text)).toThrow()
|
|
23
|
+
expect(() => parseJsonDocument(text)).toThrow('JSON_MALFORMED')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
test('preserves Unicode whitespace inside JSON strings', () => {
|
|
29
|
+
const text = JSON.stringify({ value: '\u00a0\u1680\u2028\u2029\ufeff' })
|
|
30
|
+
expect(parseJsonDocument(text)).toEqual(JSON.parse(text))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('continues rejecting escaped duplicate keys', () => {
|
|
34
|
+
expect(() => parseJsonDocument('{"name":1,"na\\u006de":2}')).toThrow('JSON_DUPLICATE_KEY')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('sorts marketplace metadata independently of the host locale', () => {
|
|
39
|
+
const source: SourceConfig = {
|
|
40
|
+
sourceId: 'test',
|
|
41
|
+
displayName: 'Test',
|
|
42
|
+
repositoryUrl: 'https://github.com/example/plugins',
|
|
43
|
+
marketplaceDialect: 'openai',
|
|
44
|
+
manifestPath: 'marketplace.json',
|
|
45
|
+
defaultBranch: 'main',
|
|
46
|
+
trustClassification: 'official',
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseMarketplaceManifest(
|
|
49
|
+
{ plugins: [{ name: 'test', source: './test', keywords: ['ä', 'z', 'a', 'ä'] }] },
|
|
50
|
+
source
|
|
51
|
+
)
|
|
52
|
+
expect(parsed.entries[0]?.keywords).toEqual(['a', 'z', 'ä'])
|
|
53
|
+
})
|
package/src/sources.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function parseMarketplaceManifest(input: unknown, source: SourceConfig):
|
|
|
82
82
|
export function parseJsonDocument(text: string): unknown {
|
|
83
83
|
let index = 0
|
|
84
84
|
const skip = () => {
|
|
85
|
-
while (
|
|
85
|
+
while (/[ \t\r\n]/.test(text[index] ?? '')) index += 1
|
|
86
86
|
}
|
|
87
87
|
const string = (): string => {
|
|
88
88
|
const start = index
|
|
@@ -158,7 +158,7 @@ export function parseJsonDocument(text: string): unknown {
|
|
|
158
158
|
}
|
|
159
159
|
if (character === '"') return string()
|
|
160
160
|
const start = index
|
|
161
|
-
while (index < text.length && !/[\
|
|
161
|
+
while (index < text.length && !/[ \t\r\n,\]}]/.test(text[index] ?? '')) index += 1
|
|
162
162
|
const token = text.slice(start, index)
|
|
163
163
|
try {
|
|
164
164
|
return JSON.parse(token) as unknown
|
|
@@ -312,7 +312,7 @@ function isString(input: unknown): input is string {
|
|
|
312
312
|
|
|
313
313
|
function uniqueStrings(input: readonly string[]): string[] {
|
|
314
314
|
return [...new Set(input.map((value) => value.trim()).filter(Boolean))].toSorted((a, b) =>
|
|
315
|
-
a
|
|
315
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
316
316
|
)
|
|
317
317
|
}
|
|
318
318
|
|
|
@@ -132,17 +132,41 @@ describe('site-icon fetcher bounds', () => {
|
|
|
132
132
|
}
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
test('honours a caller deadline that has already passed', async () => {
|
|
136
|
-
const mock = captureFetch()
|
|
135
|
+
test('honours a caller deadline that has already passed or expires during backoff', async () => {
|
|
136
|
+
const mock = captureFetch(() => {
|
|
137
|
+
throw new DOMException('Aborted', 'AbortError')
|
|
138
|
+
})
|
|
137
139
|
try {
|
|
138
140
|
const fetcher = createSiteIconFetcher(AbortSignal.abort())
|
|
139
|
-
await fetcher.fetchBytes(VENDOR_ICON)
|
|
141
|
+
await expect(fetcher.fetchBytes(VENDOR_ICON)).rejects.toThrow()
|
|
142
|
+
expect(mock.inits).toHaveLength(1)
|
|
140
143
|
} finally {
|
|
141
144
|
mock.restore()
|
|
142
145
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
|
|
147
|
+
const controller = new AbortController()
|
|
148
|
+
let cancelled = false
|
|
149
|
+
const retry = captureFetch(
|
|
150
|
+
() =>
|
|
151
|
+
new Response(
|
|
152
|
+
new ReadableStream({
|
|
153
|
+
cancel() {
|
|
154
|
+
cancelled = true
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
{ status: 503, headers: { 'retry-after': '0.05' } }
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
const timer = setTimeout(() => controller.abort(), 5)
|
|
161
|
+
try {
|
|
162
|
+
await expect(
|
|
163
|
+
createSiteIconFetcher(controller.signal).fetchBytes(VENDOR_ICON)
|
|
164
|
+
).rejects.toThrow()
|
|
165
|
+
expect(retry.inits).toHaveLength(1)
|
|
166
|
+
expect(cancelled).toBe(true)
|
|
167
|
+
} finally {
|
|
168
|
+
clearTimeout(timer)
|
|
169
|
+
retry.restore()
|
|
170
|
+
}
|
|
147
171
|
})
|
|
148
172
|
})
|