@adea-ai/plugins 4.0.0 → 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/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/icon-mirror.d.ts +2 -2
- package/dist/icon-mirror.js +38 -25
- package/dist/icon-mirror.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +56 -51
- 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/site-icons.d.ts +2 -0
- package/dist/site-icons.js +34 -8
- package/dist/site-icons.js.map +1 -1
- package/dist/sources.js +1 -1
- 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 +6 -4
- package/src/artifacts.test.ts +80 -0
- 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/icon-mirror.ts +36 -23
- package/src/index.test.ts +122 -0
- package/src/index.ts +82 -53
- package/src/manifest-retry.test.ts +49 -0
- package/src/publication.ts +8 -11
- package/src/site-icons.test.ts +73 -0
- package/src/site-icons.ts +27 -10
- package/src/source-config-sync.test.ts +84 -0
- package/src/source-digest.test.ts +58 -1
- package/src/sources-json.test.ts +18 -1
- package/src/sources.ts +1 -1
- package/src/write-artifacts.test.ts +375 -0
- package/native/README.md +0 -24
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { promises as fs } from 'node:fs'
|
|
4
|
-
import { basename,
|
|
4
|
+
import { basename, join, relative, resolve } from 'node:path'
|
|
5
5
|
import { Readable } from 'node:stream'
|
|
6
6
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
7
7
|
import { createGunzip } from 'node:zlib'
|
|
8
8
|
import { extract } from 'tar-stream'
|
|
9
|
+
import { publishCatalog, type PublishOptions } from './catalog-store.js'
|
|
9
10
|
import {
|
|
10
11
|
CatalogSchema,
|
|
11
12
|
type Catalog,
|
|
@@ -63,6 +64,15 @@ import { immutableAssetUrl } from './publication.js'
|
|
|
63
64
|
import { sourceDigest as bytesDigest } from './source-digest.js'
|
|
64
65
|
|
|
65
66
|
export { bytesDigest }
|
|
67
|
+
export {
|
|
68
|
+
catalogFingerprint,
|
|
69
|
+
publishCatalog,
|
|
70
|
+
withCatalogRead,
|
|
71
|
+
writeFileAtomically,
|
|
72
|
+
type CatalogCoordinationOptions,
|
|
73
|
+
type PublicationStep,
|
|
74
|
+
type PublishOptions,
|
|
75
|
+
} from './catalog-store.js'
|
|
66
76
|
|
|
67
77
|
/**
|
|
68
78
|
* Marks the catalog format this compiler emits.
|
|
@@ -338,20 +348,26 @@ export async function synchronize(input: SyncInput): Promise<SyncResult> {
|
|
|
338
348
|
sourceId: config.sourceId,
|
|
339
349
|
commitSha,
|
|
340
350
|
}))
|
|
351
|
+
const resolvedLock = createSourcesLock(resolved)
|
|
341
352
|
const unchanged =
|
|
342
353
|
input.fromLock === undefined &&
|
|
343
354
|
input.forceRebuild !== true &&
|
|
344
355
|
input.existingLock !== undefined &&
|
|
345
356
|
resolved.length === input.existingLock.sources.length &&
|
|
346
|
-
|
|
357
|
+
resolvedLock.sources.every((source) => {
|
|
347
358
|
const previous = input.existingLock?.sources.find(
|
|
348
|
-
(entry) => entry.sourceId === source.
|
|
359
|
+
(entry) => entry.sourceId === source.sourceId
|
|
349
360
|
)
|
|
350
|
-
if (!previous) return false
|
|
361
|
+
if (!previous || sourceLockChanged(previous, source)) return false
|
|
362
|
+
if (!input.existingCatalog) return true
|
|
363
|
+
const priorCatalog = input.existingCatalog.sources.find(
|
|
364
|
+
(entry) => entry.sourceId === source.sourceId
|
|
365
|
+
)
|
|
366
|
+
const config = resolved.find((entry) => entry.config.sourceId === source.sourceId)!.config
|
|
351
367
|
return (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
368
|
+
!!priorCatalog &&
|
|
369
|
+
priorCatalog.displayName === config.displayName &&
|
|
370
|
+
priorCatalog.trustClassification === config.trustClassification
|
|
355
371
|
)
|
|
356
372
|
})
|
|
357
373
|
if (unchanged) {
|
|
@@ -391,7 +407,7 @@ export async function synchronize(input: SyncInput): Promise<SyncResult> {
|
|
|
391
407
|
...(input.transformRelease ? { transformRelease: input.transformRelease } : {}),
|
|
392
408
|
})
|
|
393
409
|
const catalog = buildResult.catalog
|
|
394
|
-
const lock =
|
|
410
|
+
const lock = resolvedLock
|
|
395
411
|
const artifacts = createArtifacts(orderLeadingPlugins(catalog, input.leading), lock, {
|
|
396
412
|
...(input.leading ? { leading: input.leading } : {}),
|
|
397
413
|
...(input.topCount !== undefined ? { topCount: input.topCount } : {}),
|
|
@@ -982,10 +998,7 @@ async function resolvePluginSource(
|
|
|
982
998
|
pin?.resolvedCommitSha ??
|
|
983
999
|
spec.sha ??
|
|
984
1000
|
(resolveExternalRefs
|
|
985
|
-
? await resolveGitRef(
|
|
986
|
-
spec.repositoryUrl,
|
|
987
|
-
spec.ref?.startsWith('refs/') ? spec.ref : `refs/heads/${spec.ref ?? 'main'}`
|
|
988
|
-
)
|
|
1001
|
+
? await resolveGitRef(spec.repositoryUrl, externalGitRef(spec.ref))
|
|
989
1002
|
: source.commitSha)
|
|
990
1003
|
return {
|
|
991
1004
|
repositoryUrl: canonicalUrl,
|
|
@@ -1241,6 +1254,18 @@ function findLicense(
|
|
|
1241
1254
|
}
|
|
1242
1255
|
}
|
|
1243
1256
|
|
|
1257
|
+
function sourceLockChanged(previous: SourceLockEntry, current: SourceLockEntry): boolean {
|
|
1258
|
+
return (
|
|
1259
|
+
previous.repositoryUrl !== current.repositoryUrl ||
|
|
1260
|
+
previous.defaultBranch !== current.defaultBranch ||
|
|
1261
|
+
previous.manifestPath !== current.manifestPath ||
|
|
1262
|
+
previous.marketplaceDialect !== current.marketplaceDialect ||
|
|
1263
|
+
previous.resolvedCommitSha !== current.resolvedCommitSha ||
|
|
1264
|
+
previous.sourceManifestDigest !== current.sourceManifestDigest ||
|
|
1265
|
+
digest(previous.pluginPins) !== digest(current.pluginPins)
|
|
1266
|
+
)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1244
1269
|
function createSourcesLock(sources: readonly ResolvedSource[]): SourcesLock {
|
|
1245
1270
|
const entries: SourceLockEntry[] = sources
|
|
1246
1271
|
.map((source) => ({
|
|
@@ -1479,35 +1504,16 @@ export function iconAssets(index: ConsumerIndex): readonly PublishedAsset[] {
|
|
|
1479
1504
|
return [...assets.values()].toSorted((left, right) => left.name.localeCompare(right.name))
|
|
1480
1505
|
}
|
|
1481
1506
|
|
|
1507
|
+
/**
|
|
1508
|
+
* Publishes a verified artifact set to `generatedDirectory` under the output's
|
|
1509
|
+
* coordination lock. See `catalog-store.ts` for the publication contract.
|
|
1510
|
+
*/
|
|
1482
1511
|
export async function writeArtifacts(
|
|
1483
1512
|
generatedDirectory: string,
|
|
1484
|
-
artifacts: GeneratedArtifacts
|
|
1513
|
+
artifacts: GeneratedArtifacts,
|
|
1514
|
+
options: PublishOptions = {}
|
|
1485
1515
|
): Promise<void> {
|
|
1486
|
-
|
|
1487
|
-
await fs.mkdir(parent, { recursive: true })
|
|
1488
|
-
const temporary = await fs.mkdtemp(join(parent, '.catalog-'))
|
|
1489
|
-
try {
|
|
1490
|
-
for (const [name, content] of Object.entries(artifacts))
|
|
1491
|
-
await fs.writeFile(join(temporary, name), content, 'utf8')
|
|
1492
|
-
const backup = `${generatedDirectory}.previous`
|
|
1493
|
-
await fs.rm(backup, { recursive: true, force: true })
|
|
1494
|
-
let movedExisting = false
|
|
1495
|
-
try {
|
|
1496
|
-
await fs.rename(generatedDirectory, backup)
|
|
1497
|
-
movedExisting = true
|
|
1498
|
-
} catch (error) {
|
|
1499
|
-
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
1500
|
-
}
|
|
1501
|
-
try {
|
|
1502
|
-
await fs.rename(temporary, generatedDirectory)
|
|
1503
|
-
} catch (error) {
|
|
1504
|
-
if (movedExisting) await fs.rename(backup, generatedDirectory)
|
|
1505
|
-
throw error
|
|
1506
|
-
}
|
|
1507
|
-
if (movedExisting) await fs.rm(backup, { recursive: true, force: true })
|
|
1508
|
-
} finally {
|
|
1509
|
-
await fs.rm(temporary, { recursive: true, force: true })
|
|
1510
|
-
}
|
|
1516
|
+
await publishCatalog(generatedDirectory, artifacts as Record<string, string>, options)
|
|
1511
1517
|
}
|
|
1512
1518
|
|
|
1513
1519
|
export function verifyArtifacts(artifacts: GeneratedArtifacts): void {
|
|
@@ -1872,11 +1878,20 @@ function createChangeReport(
|
|
|
1872
1878
|
const previous = existingLock.sources.find(
|
|
1873
1879
|
(candidate) => candidate.sourceId === source.sourceId
|
|
1874
1880
|
)
|
|
1881
|
+
const priorCatalog = existingCatalog?.sources.find(
|
|
1882
|
+
(entry) => entry.sourceId === source.sourceId
|
|
1883
|
+
)
|
|
1884
|
+
const currentCatalog = catalog.sources.find(
|
|
1885
|
+
(entry) => entry.sourceId === source.sourceId
|
|
1886
|
+
)
|
|
1875
1887
|
return (
|
|
1876
1888
|
!previous ||
|
|
1877
|
-
previous
|
|
1878
|
-
|
|
1879
|
-
|
|
1889
|
+
sourceLockChanged(previous, source) ||
|
|
1890
|
+
(existingCatalog !== undefined &&
|
|
1891
|
+
(!priorCatalog ||
|
|
1892
|
+
!currentCatalog ||
|
|
1893
|
+
priorCatalog.displayName !== currentCatalog.displayName ||
|
|
1894
|
+
priorCatalog.trustClassification !== currentCatalog.trustClassification))
|
|
1880
1895
|
)
|
|
1881
1896
|
})()
|
|
1882
1897
|
)
|
|
@@ -2015,17 +2030,19 @@ async function resolvePluginPins(input: {
|
|
|
2015
2030
|
const resolvedCommitSha =
|
|
2016
2031
|
entry.source.sha ??
|
|
2017
2032
|
(input.resolveExternalRefs
|
|
2018
|
-
? await resolveGitRef(
|
|
2019
|
-
repositoryUrl,
|
|
2020
|
-
entry.source.ref?.startsWith('refs/')
|
|
2021
|
-
? entry.source.ref
|
|
2022
|
-
: `refs/heads/${entry.source.ref ?? 'main'}`
|
|
2023
|
-
)
|
|
2033
|
+
? await resolveGitRef(repositoryUrl, externalGitRef(entry.source.ref))
|
|
2024
2034
|
: input.sourceCommitSha)
|
|
2025
2035
|
return { pluginName: entry.name, repositoryUrl, pluginSubdirectory, resolvedCommitSha }
|
|
2026
2036
|
}
|
|
2027
2037
|
)
|
|
2028
|
-
return pins.toSorted((left, right) =>
|
|
2038
|
+
return pins.toSorted((left, right) =>
|
|
2039
|
+
left.pluginName < right.pluginName ? -1 : left.pluginName > right.pluginName ? 1 : 0
|
|
2040
|
+
)
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
function externalGitRef(ref: string | undefined): string {
|
|
2044
|
+
if (ref === undefined) return 'HEAD'
|
|
2045
|
+
return ref.startsWith('refs/') ? ref : `refs/heads/${ref}`
|
|
2029
2046
|
}
|
|
2030
2047
|
|
|
2031
2048
|
async function mapWithConcurrency<T, U>(
|
|
@@ -2058,9 +2075,14 @@ async function readFixtureManifest(
|
|
|
2058
2075
|
}
|
|
2059
2076
|
|
|
2060
2077
|
async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string> {
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2078
|
+
const peeled = ref.startsWith('refs/tags/') && !ref.endsWith('^{}') ? `${ref}^{}` : undefined
|
|
2079
|
+
const result = spawn(
|
|
2080
|
+
'git',
|
|
2081
|
+
['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref, ...(peeled ? [peeled] : [])],
|
|
2082
|
+
{
|
|
2083
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2084
|
+
}
|
|
2085
|
+
)
|
|
2064
2086
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
2065
2087
|
new Promise<string>((resolveOutput, reject) => {
|
|
2066
2088
|
const chunks: Buffer[] = []
|
|
@@ -2081,7 +2103,13 @@ async function resolveGitRef(repositoryUrl: string, ref: string): Promise<string
|
|
|
2081
2103
|
])
|
|
2082
2104
|
if (exitCode !== 0)
|
|
2083
2105
|
throw new Error(`GIT_REF_LOOKUP_FAILED: ${repositoryUrl}:${ref}:${stderr.trim()}`)
|
|
2084
|
-
const
|
|
2106
|
+
const rows = stdout
|
|
2107
|
+
.trim()
|
|
2108
|
+
.split('\n')
|
|
2109
|
+
.map((line) => line.split(/\s+/))
|
|
2110
|
+
const sha =
|
|
2111
|
+
(peeled && rows.find((row) => row[1] === peeled)?.[0]) ??
|
|
2112
|
+
rows.find((row) => row[1] === ref)?.[0]
|
|
2085
2113
|
if (!sha || !/^[a-f0-9]{40}$/.test(sha))
|
|
2086
2114
|
throw new Error(`GIT_REF_NOT_FOUND: ${repositoryUrl}:${ref}`)
|
|
2087
2115
|
return sha
|
|
@@ -2108,7 +2136,8 @@ function rawUrl(repositoryUrl: string, commitSha: string, path: string): string
|
|
|
2108
2136
|
}
|
|
2109
2137
|
|
|
2110
2138
|
async function fetchText(url: string): Promise<string> {
|
|
2111
|
-
|
|
2139
|
+
// Manifest requests use the same bounded retry policy as the tree and archive.
|
|
2140
|
+
const response = await fetchUpstream(url, { headers: upstreamRequestHeaders(url) })
|
|
2112
2141
|
if (!response.ok) throw new Error(`HTTP_FETCH_FAILED: ${response.status}:${url}`)
|
|
2113
2142
|
return response.text()
|
|
2114
2143
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { expect, test } from 'bun:test'
|
|
4
|
+
import { FixtureSnapshotLoader, synchronize } from './index.js'
|
|
5
|
+
import { withPortableFixture } from '../test-support/portable-fixture.js'
|
|
6
|
+
|
|
7
|
+
test('retries transient marketplace manifest responses while preserving permanent failures', () =>
|
|
8
|
+
withPortableFixture(async (input, root) => {
|
|
9
|
+
const initial = await synchronize(input)
|
|
10
|
+
const manifest = await readFile(join(root, 'fixtures/source/marketplace.json'), 'utf8')
|
|
11
|
+
const realFetch = globalThis.fetch
|
|
12
|
+
let calls = 0
|
|
13
|
+
try {
|
|
14
|
+
globalThis.fetch = (async () => {
|
|
15
|
+
calls++
|
|
16
|
+
if (calls < 3)
|
|
17
|
+
return new Response('temporarily unavailable', {
|
|
18
|
+
status: 503,
|
|
19
|
+
headers: { 'retry-after': '0' },
|
|
20
|
+
})
|
|
21
|
+
return new Response(manifest, { status: 200 })
|
|
22
|
+
}) as unknown as typeof fetch
|
|
23
|
+
const options = {
|
|
24
|
+
...input,
|
|
25
|
+
mode: 'live' as const,
|
|
26
|
+
fromLock: initial.lock!,
|
|
27
|
+
snapshotLoader: new FixtureSnapshotLoader(input.fixtureRoot!, input.sources),
|
|
28
|
+
}
|
|
29
|
+
const recovered = await synchronize(options)
|
|
30
|
+
expect(recovered.catalog?.catalogId).toBe(initial.catalog?.catalogId)
|
|
31
|
+
expect(calls).toBe(3)
|
|
32
|
+
calls = 0
|
|
33
|
+
globalThis.fetch = (async () => {
|
|
34
|
+
calls++
|
|
35
|
+
return new Response('not found', { status: 404 })
|
|
36
|
+
}) as unknown as typeof fetch
|
|
37
|
+
await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 404')
|
|
38
|
+
expect(calls).toBe(1)
|
|
39
|
+
calls = 0
|
|
40
|
+
globalThis.fetch = (async () => {
|
|
41
|
+
calls++
|
|
42
|
+
return new Response('busy', { status: 429, headers: { 'retry-after': '0' } })
|
|
43
|
+
}) as unknown as typeof fetch
|
|
44
|
+
await expect(synchronize(options)).rejects.toThrow('HTTP_FETCH_FAILED: 429')
|
|
45
|
+
expect(calls).toBe(3)
|
|
46
|
+
} finally {
|
|
47
|
+
globalThis.fetch = realFetch
|
|
48
|
+
}
|
|
49
|
+
}))
|
package/src/publication.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* that slot, so no version is ever the one a visitor sees first.
|
|
10
10
|
*
|
|
11
11
|
* The branch gives each catalog an immutable path — `catalogs/<catalogId>/…`,
|
|
12
|
-
* where `catalogId` is the canonical digest of the catalog itself
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* where `catalogId` is the canonical digest of the catalog itself. The
|
|
13
|
+
* publisher must reject replacement of existing snapshot bytes, and
|
|
14
|
+
* `raw.githubusercontent.com` serves the branch with permissive
|
|
15
15
|
* CORS so a browser can fetch it directly.
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -63,11 +63,9 @@ export interface StagedAssetAudit {
|
|
|
63
63
|
/**
|
|
64
64
|
* Audits the bytes this build staged against what it declared.
|
|
65
65
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* therefore a different path. What survives is the check that cannot be
|
|
70
|
-
* delegated to the path — that the bytes exist here at all.
|
|
66
|
+
* This checks that the declared bytes exist here at all. Publication must
|
|
67
|
+
* separately compare the complete staged snapshot with any existing snapshot:
|
|
68
|
+
* companion artifacts can differ without changing the catalog identity.
|
|
71
69
|
*/
|
|
72
70
|
export function auditStagedAssets(input: {
|
|
73
71
|
readonly declared: readonly DeclaredAsset[]
|
|
@@ -95,9 +93,8 @@ export interface PublicationPlan {
|
|
|
95
93
|
/**
|
|
96
94
|
* The 64-hex digest a `catalog:<hex>` identity is addressed by.
|
|
97
95
|
*
|
|
98
|
-
* A catalog identity names
|
|
99
|
-
* snapshot path
|
|
100
|
-
* different identity and cannot write over this one.
|
|
96
|
+
* A catalog identity names the catalog content. The publisher must also guard
|
|
97
|
+
* the snapshot path against changes to companion artifacts under the same ID.
|
|
101
98
|
*/
|
|
102
99
|
export function catalogIdSuffix(catalogId: string): string {
|
|
103
100
|
const match = /^catalog:([a-f0-9]{64})$/.exec(catalogId)
|
package/src/site-icons.test.ts
CHANGED
|
@@ -96,6 +96,68 @@ describe('site icon discovery', () => {
|
|
|
96
96
|
])
|
|
97
97
|
})
|
|
98
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
|
+
|
|
99
161
|
test('prefers the larger declared size within a group', () => {
|
|
100
162
|
const sizes = `<link rel="icon" sizes="32x32" href="/small.png">
|
|
101
163
|
<link rel="icon" sizes="512x512" href="/large.png">`
|
|
@@ -163,6 +225,17 @@ describe('site icon resolution', () => {
|
|
|
163
225
|
expect(io.byteCalls).toEqual(['https://vendor.example/apple-icon.png'])
|
|
164
226
|
})
|
|
165
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
|
+
|
|
166
239
|
test('accepts an icon container and skips a candidate that fails inspection', async () => {
|
|
167
240
|
const ico = fetcher({ [homepage]: html, 'https://vendor.example/apple-icon.png': ICO })
|
|
168
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
|
/**
|
|
@@ -76,19 +77,35 @@ export function isPublicHostname(hostname: string): boolean {
|
|
|
76
77
|
* `apple-touch-icon` is preferred because it is a square PNG sized for a home
|
|
77
78
|
* screen; declared `icon` links follow in declared order; `/favicon.ico` is the
|
|
78
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.
|
|
79
82
|
*/
|
|
80
83
|
export function discoverSiteIcons(html: string, baseUrl: string): readonly string[] {
|
|
81
84
|
const candidates: { href: string; rank: number }[] = []
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
))
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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.
|
|
91
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]))
|
|
92
109
|
const rel = new Set((attributes.get('rel') ?? '').toLowerCase().split(/\s+/))
|
|
93
110
|
if (
|
|
94
111
|
!['apple-touch-icon', 'apple-touch-icon-precomposed', 'icon', 'mask-icon'].some((name) =>
|
|
@@ -112,7 +129,7 @@ export function discoverSiteIcons(html: string, baseUrl: string): readonly strin
|
|
|
112
129
|
const resolved: { url: string; rank: number }[] = []
|
|
113
130
|
for (const candidate of candidates.toSorted((left, right) => left.rank - right.rank)) {
|
|
114
131
|
try {
|
|
115
|
-
const url = new URL(candidate.href,
|
|
132
|
+
const url = new URL(candidate.href, resolutionBase)
|
|
116
133
|
if (url.protocol !== 'https:' || !isPublicHostname(url.hostname)) continue
|
|
117
134
|
resolved.push({ url: url.toString(), rank: candidate.rank })
|
|
118
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
|
+
}))
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
@@ -42,3 +42,60 @@ test('source digests agree across consumer locales and equivalent Unicode path o
|
|
|
42
42
|
await rm(directory, { recursive: true, force: true })
|
|
43
43
|
}
|
|
44
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
|
+
})
|
package/src/sources-json.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { parseJsonDocument } from './sources.js'
|
|
2
|
+
import { parseJsonDocument, parseMarketplaceManifest, type SourceConfig } from './sources.js'
|
|
3
3
|
|
|
4
4
|
describe('JSON document whitespace', () => {
|
|
5
5
|
test('accepts the four JSON whitespace characters around nested values', () => {
|
|
@@ -34,3 +34,20 @@ describe('JSON document whitespace', () => {
|
|
|
34
34
|
expect(() => parseJsonDocument('{"name":1,"na\\u006de":2}')).toThrow('JSON_DUPLICATE_KEY')
|
|
35
35
|
})
|
|
36
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
|
@@ -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
|
|