@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.
Files changed (42) hide show
  1. package/dist/catalog-store.d.ts +81 -0
  2. package/dist/catalog-store.js +487 -0
  3. package/dist/catalog-store.js.map +1 -0
  4. package/dist/consumer-index.d.ts +1 -1
  5. package/dist/consumer-index.js +17 -7
  6. package/dist/consumer-index.js.map +1 -1
  7. package/dist/icon-mirror.d.ts +2 -2
  8. package/dist/icon-mirror.js +38 -25
  9. package/dist/icon-mirror.js.map +1 -1
  10. package/dist/index.d.ts +7 -1
  11. package/dist/index.js +56 -51
  12. package/dist/index.js.map +1 -1
  13. package/dist/publication.d.ts +8 -11
  14. package/dist/publication.js +8 -11
  15. package/dist/publication.js.map +1 -1
  16. package/dist/site-icons.d.ts +2 -0
  17. package/dist/site-icons.js +34 -8
  18. package/dist/site-icons.js.map +1 -1
  19. package/dist/sources.js +1 -1
  20. package/dist/sources.js.map +1 -1
  21. package/native/build-provenance.txt +5 -5
  22. package/native/staged-reader-win32-arm64.node +0 -0
  23. package/native/staged-reader-win32-x64.node +0 -0
  24. package/package.json +6 -4
  25. package/src/artifacts.test.ts +80 -0
  26. package/src/catalog-store.ts +546 -0
  27. package/src/consumer-index.test.ts +125 -0
  28. package/src/consumer-index.ts +23 -8
  29. package/src/default-head.test.ts +143 -0
  30. package/src/icon-mirror.ts +36 -23
  31. package/src/index.test.ts +122 -0
  32. package/src/index.ts +82 -53
  33. package/src/manifest-retry.test.ts +49 -0
  34. package/src/publication.ts +8 -11
  35. package/src/site-icons.test.ts +73 -0
  36. package/src/site-icons.ts +27 -10
  37. package/src/source-config-sync.test.ts +84 -0
  38. package/src/source-digest.test.ts +58 -1
  39. package/src/sources-json.test.ts +18 -1
  40. package/src/sources.ts +1 -1
  41. package/src/write-artifacts.test.ts +375 -0
  42. 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, dirname, join, relative, resolve } from 'node:path'
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
- resolved.every((source) => {
357
+ resolvedLock.sources.every((source) => {
347
358
  const previous = input.existingLock?.sources.find(
348
- (entry) => entry.sourceId === source.config.sourceId
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
- previous.resolvedCommitSha === source.commitSha &&
353
- previous.sourceManifestDigest === source.manifestDigest &&
354
- digest(previous.pluginPins) === digest(source.pluginPins)
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 = createSourcesLock(resolved)
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
- const parent = dirname(generatedDirectory)
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?.resolvedCommitSha !== source.resolvedCommitSha ||
1878
- previous.sourceManifestDigest !== source.sourceManifestDigest ||
1879
- digest(previous.pluginPins) !== digest(source.pluginPins)
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) => left.pluginName.localeCompare(right.pluginName))
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 result = spawn('git', ['ls-remote', canonicalRepositoryUrl(repositoryUrl), ref], {
2062
- stdio: ['ignore', 'pipe', 'pipe'],
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 sha = stdout.trim().split(/\s+/)[0]
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
- const response = await fetch(url, { headers: upstreamRequestHeaders(url) })
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
+ }))
@@ -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 — so a
13
- * pinned URL is immutable by construction, exactly as a content-addressed
14
- * release tag was, and `raw.githubusercontent.com` serves it with permissive
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
- * A content-addressed path removes the comparison the release audit used to
67
- * perform: a snapshot published at `catalogs/<catalogId>/` can only hold this
68
- * catalog's bytes, because any different build has a different `catalogId` and
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 its own bytes, so this is also what makes the
99
- * snapshot path immutable: any build that produced different content has a
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)
@@ -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&amp;b=2"><link rel="icon" href="mark.png?x=1&amp;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 linkTags = html.match(/<link(?=[ \t\r\n\f/>])(?:"[^"]*"|'[^']*'|[^'">])*>/gi) ?? []
83
- for (const tag of linkTags) {
84
- const attributes = new Map<string, string>()
85
- for (const attribute of tag.matchAll(
86
- /\s+([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
87
- )) {
88
- const name = attribute[1]!.toLowerCase()
89
- if (!attributes.has(name))
90
- attributes.set(name, attribute[2] ?? attribute[3] ?? attribute[4] ?? '')
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, baseUrl)
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
+ })
@@ -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.localeCompare(b)
315
+ a < b ? -1 : a > b ? 1 : 0
316
316
  )
317
317
  }
318
318