@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
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Catalog output coordination and publication.
3
+ *
4
+ * Contract (see docs/architecture.md, "Catalog publication"):
5
+ *
6
+ * - Every writer and every coordinated reader of one output directory uses a
7
+ * lock directory that sits beside the output, in the output's real parent.
8
+ * The lock is keyed by the output, never by `TMPDIR`, so processes with
9
+ * different temporary directories, users or working directories still meet.
10
+ * - Supported filesystems are local filesystems where `mkdir` and same-directory
11
+ * `rename` are atomic: Linux (ext4, xfs, btrfs, tmpfs, overlayfs), macOS
12
+ * (APFS, HFS+) and Windows (NTFS, ReFS). Network filesystems are not
13
+ * supported: their lock leases depend on clocks and caches this module does
14
+ * not control.
15
+ * - A sync reads a base snapshot under the lock, builds without it, and then
16
+ * publishes only if the output still has the same fingerprint (compare and
17
+ * swap). A writer that replaced the catalog in between makes the later
18
+ * publication fail with `CATALOG_BASE_CHANGED` instead of being overwritten.
19
+ * - Publication stages a complete directory, records a journal, moves the
20
+ * current output to `<output>.previous`, then renames the staged directory
21
+ * into place. Coordinated readers never observe the window between the two
22
+ * renames. A process killed at any point leaves a journal that the next lock
23
+ * holder (writer or reader) rolls back or forward before doing anything else.
24
+ * - A lock whose holder process is gone (same host) or whose heartbeat is older
25
+ * than the lease is broken automatically and safely: the stale directory is
26
+ * renamed aside first, so only one contender can break it, and a holder that
27
+ * lost its lock refuses to swap with `CATALOG_LOCK_LOST`.
28
+ */
29
+ import { createHash, randomUUID } from 'node:crypto'
30
+ import { promises as fs } from 'node:fs'
31
+ import { hostname } from 'node:os'
32
+ import { basename, dirname, join, sep } from 'node:path'
33
+ import { setTimeout as sleep } from 'node:timers/promises'
34
+
35
+ export type PublicationStep = 'acquiring' | 'staged' | 'backed-up' | 'swapped' | 'cleaned'
36
+
37
+ export interface CatalogCoordinationOptions {
38
+ /** How long to wait for another holder before failing; default 30 s. */
39
+ readonly waitMs?: number
40
+ /** Heartbeat age after which a holder is considered dead; default 120 s. */
41
+ readonly staleAfterMs?: number
42
+ /** Interval at which a holder refreshes its heartbeat; default 5 s. */
43
+ readonly heartbeatMs?: number
44
+ /** Interval between lock attempts while waiting; default 50 ms. */
45
+ readonly pollMs?: number
46
+ /** Test seam: called after each publication step while the lock is held. */
47
+ readonly onStep?: (step: PublicationStep) => void | Promise<void>
48
+ /** Test seam: called once a lock is held. */
49
+ readonly onLocked?: (mode: LockMode) => void | Promise<void>
50
+ }
51
+
52
+ export interface PublishOptions extends CatalogCoordinationOptions {
53
+ /**
54
+ * Fingerprint of the output the caller built from, as returned by
55
+ * {@link catalogFingerprint} inside {@link withCatalogRead}. When set, the
56
+ * publication fails with `CATALOG_BASE_CHANGED` if the output differs.
57
+ */
58
+ readonly expectedBase?: string
59
+ /** Runs after the swap while the lock is still held, e.g. to publish an index. */
60
+ readonly afterPublish?: () => Promise<void>
61
+ }
62
+
63
+ type LockMode = 'read' | 'write'
64
+
65
+ const LOCK_PREFIX = '.catalog-'
66
+ const ABSENT = 'absent'
67
+ const READ_ONLY_CODES = new Set(['EACCES', 'EPERM', 'EROFS'])
68
+
69
+ export interface CatalogPaths {
70
+ readonly output: string
71
+ readonly parent: string
72
+ readonly backup: string
73
+ readonly lock: string
74
+ readonly owner: string
75
+ readonly journal: string
76
+ readonly stagePrefix: string
77
+ }
78
+
79
+ /** Names the coordination files of one output, beside it in its real parent. */
80
+ export function catalogPathsIn(parent: string, name: string): CatalogPaths {
81
+ const normalized = name.normalize('NFC').toLowerCase()
82
+ // Case-insensitive and Unicode-normalized so that `Generated` and
83
+ // `generated` on macOS or Windows share one lock. On a case-sensitive
84
+ // filesystem such outputs are merely serialized together, which is harmless.
85
+ const hash = createHash('sha256').update(normalized).digest('hex').slice(0, 16)
86
+ const label = normalized.replace(/[^a-z0-9_-]/g, '_').slice(0, 32)
87
+ const stem = `${LOCK_PREFIX}${label}-${hash}`
88
+ const output = join(parent, name)
89
+ const lock = `${stem}.lock`
90
+ return {
91
+ output,
92
+ parent,
93
+ backup: `${output}.previous`,
94
+ lock: join(parent, lock),
95
+ owner: join(parent, lock, 'owner.json'),
96
+ journal: join(parent, `${stem}.journal.json`),
97
+ stagePrefix: join(parent, `${stem}.stage-`),
98
+ }
99
+ }
100
+
101
+ function isReservedName(name: string): boolean {
102
+ return name.startsWith(LOCK_PREFIX)
103
+ }
104
+
105
+ function reserved(component: string): boolean {
106
+ return isReservedName(component) || component.endsWith('.previous')
107
+ }
108
+
109
+ /** Resolves the output's real parent, optionally creating it. */
110
+ async function resolvePaths(output: string, create: boolean): Promise<CatalogPaths | undefined> {
111
+ // Not `resolve()`: it would collapse `link/..` lexically, while the
112
+ // filesystem resolves the symbolic link first. `realpath` below does that.
113
+ const name = basename(output)
114
+ const parent = dirname(output)
115
+ // Reading a backup or a coordination directory, for example while
116
+ // inspecting one by hand, is allowed but not coordinated; writing is not.
117
+ if (create && (name === '' || name === '.' || name === '..'))
118
+ throw new Error(`CATALOG_OUTPUT_INVALID: ${output}`)
119
+ if (reserved(name) || parent.split(/[\\/]/).some(reserved)) {
120
+ if (!create) return undefined
121
+ throw new Error(`CATALOG_WRITE_LOCK_RESERVED: ${output}`)
122
+ }
123
+ if (create) await fs.mkdir(parent, { recursive: true })
124
+ let actualParent: string
125
+ try {
126
+ actualParent = await fs.realpath(parent)
127
+ } catch (error) {
128
+ if (!create && (error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
129
+ throw error
130
+ }
131
+ // An output reached through a coordination directory, for example a
132
+ // symbolic link into another output's lock, would be destroyed when that
133
+ // lock is released or broken.
134
+ if (actualParent.split(sep).some(reserved)) {
135
+ if (!create) return undefined
136
+ throw new Error(`CATALOG_WRITE_LOCK_RESERVED: ${output}`)
137
+ }
138
+ return catalogPathsIn(actualParent, name)
139
+ }
140
+
141
+ interface Holder {
142
+ readonly token?: string
143
+ readonly pid?: number
144
+ readonly host?: string
145
+ readonly heartbeatAt: number
146
+ }
147
+
148
+ async function inspectHolder(lock: string): Promise<Holder | undefined> {
149
+ const owner = join(lock, 'owner.json')
150
+ try {
151
+ const [text, stat] = await Promise.all([fs.readFile(owner, 'utf8'), fs.stat(owner)])
152
+ const parsed = JSON.parse(text) as { token?: unknown; pid?: unknown; host?: unknown }
153
+ return {
154
+ ...(typeof parsed.token === 'string' ? { token: parsed.token } : {}),
155
+ ...(typeof parsed.pid === 'number' ? { pid: parsed.pid } : {}),
156
+ ...(typeof parsed.host === 'string' ? { host: parsed.host } : {}),
157
+ heartbeatAt: stat.mtimeMs,
158
+ }
159
+ } catch (error) {
160
+ const code = (error as NodeJS.ErrnoException).code
161
+ if (code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
162
+ }
163
+ // The holder created the directory but has not written its owner record yet
164
+ // (or was killed in between): the directory's own age is the heartbeat.
165
+ try {
166
+ return { heartbeatAt: (await fs.stat(lock)).mtimeMs }
167
+ } catch (error) {
168
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
169
+ throw error
170
+ }
171
+ }
172
+
173
+ function processIsAlive(pid: number): boolean {
174
+ try {
175
+ process.kill(pid, 0)
176
+ return true
177
+ } catch (error) {
178
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
179
+ }
180
+ }
181
+
182
+ function holderIsStale(holder: Holder, staleAfterMs: number): boolean {
183
+ if (holder.pid !== undefined && holder.host === hostname() && !processIsAlive(holder.pid))
184
+ return true
185
+ return Date.now() - holder.heartbeatAt > staleAfterMs
186
+ }
187
+
188
+ /**
189
+ * Breaks a stale lock. Renaming first means two contenders cannot both remove
190
+ * it; re-inspecting the renamed directory catches the case where the stale
191
+ * lock was replaced by a live one between inspection and rename.
192
+ */
193
+ async function breakLock(paths: CatalogPaths, staleAfterMs: number): Promise<void> {
194
+ const grave = `${paths.lock}.stale-${randomUUID()}`
195
+ try {
196
+ await fs.rename(paths.lock, grave)
197
+ } catch (error) {
198
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return
199
+ throw error
200
+ }
201
+ const taken = await inspectHolder(grave)
202
+ if (taken && !holderIsStale(taken, staleAfterMs)) {
203
+ // A live holder: give the lock back. If someone already took the name,
204
+ // the live holder will see CATALOG_LOCK_LOST before it swaps anything.
205
+ await fs.rename(grave, paths.lock).catch(() => undefined)
206
+ return
207
+ }
208
+ await fs.rm(grave, { recursive: true, force: true })
209
+ }
210
+
211
+ export class CatalogLock {
212
+ #timer: ReturnType<typeof setInterval> | undefined
213
+ #released = false
214
+
215
+ constructor(
216
+ readonly paths: CatalogPaths,
217
+ readonly token: string,
218
+ heartbeatMs: number
219
+ ) {
220
+ this.#timer = setInterval(() => {
221
+ const now = new Date()
222
+ fs.utimes(paths.owner, now, now).catch(() => undefined)
223
+ }, heartbeatMs)
224
+ this.#timer.unref?.()
225
+ }
226
+
227
+ /** Fails with `CATALOG_LOCK_LOST` if another process broke this lock. */
228
+ async assertHeld(): Promise<void> {
229
+ const holder = await inspectHolder(this.paths.lock)
230
+ if (holder?.token !== this.token) throw new Error(`CATALOG_LOCK_LOST: ${this.paths.output}`)
231
+ }
232
+
233
+ async release(): Promise<void> {
234
+ if (this.#released) return
235
+ this.#released = true
236
+ clearInterval(this.#timer)
237
+ const holder = await inspectHolder(this.paths.lock)
238
+ if (holder?.token !== this.token) return
239
+ await fs.rm(this.paths.lock, { recursive: true, force: true })
240
+ }
241
+ }
242
+
243
+ async function acquire(
244
+ paths: CatalogPaths,
245
+ mode: LockMode,
246
+ options: CatalogCoordinationOptions
247
+ ): Promise<CatalogLock | undefined> {
248
+ const waitMs = options.waitMs ?? 30_000
249
+ const staleAfterMs = options.staleAfterMs ?? 120_000
250
+ const pollMs = options.pollMs ?? 50
251
+ const deadline = Date.now() + waitMs
252
+ for (;;) {
253
+ try {
254
+ await fs.mkdir(paths.lock)
255
+ } catch (error) {
256
+ const code = (error as NodeJS.ErrnoException).code
257
+ // A read-only output cannot be published by anyone using this mount, so
258
+ // a reader proceeds without coordination there.
259
+ if (mode === 'read' && code !== undefined && READ_ONLY_CODES.has(code)) return undefined
260
+ if (code !== 'EEXIST') throw error
261
+ const holder = await inspectHolder(paths.lock)
262
+ if (holder === undefined) continue
263
+ if (holderIsStale(holder, staleAfterMs)) {
264
+ await breakLock(paths, staleAfterMs)
265
+ continue
266
+ }
267
+ if (Date.now() >= deadline) {
268
+ const who = holder.pid === undefined ? '' : ` (held by pid ${holder.pid} on ${holder.host})`
269
+ throw new Error(`CATALOG_WRITE_IN_PROGRESS: ${paths.output}${who}`, { cause: error })
270
+ }
271
+ await sleep(pollMs)
272
+ continue
273
+ }
274
+ const token = randomUUID()
275
+ try {
276
+ await fs.writeFile(
277
+ paths.owner,
278
+ `${JSON.stringify({ token, pid: process.pid, host: hostname(), mode, acquiredAt: new Date().toISOString() })}\n`,
279
+ { flag: 'wx' }
280
+ )
281
+ } catch (error) {
282
+ // A contender that misjudged a live lock as stale restored it over the
283
+ // directory just created here (see breakLock): the lock is not ours.
284
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue
285
+ throw error
286
+ }
287
+ const lock = new CatalogLock(paths, token, options.heartbeatMs ?? 5_000)
288
+ try {
289
+ await options.onLocked?.(mode)
290
+ } catch (error) {
291
+ await lock.release()
292
+ throw error
293
+ }
294
+ return lock
295
+ }
296
+ }
297
+
298
+ async function exists(path: string): Promise<boolean> {
299
+ try {
300
+ await fs.lstat(path)
301
+ return true
302
+ } catch (error) {
303
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
304
+ throw error
305
+ }
306
+ }
307
+
308
+ interface Journal {
309
+ readonly version: 1
310
+ readonly stage: string
311
+ }
312
+
313
+ async function readJournal(paths: CatalogPaths): Promise<Journal | undefined> {
314
+ let text: string
315
+ try {
316
+ text = await fs.readFile(paths.journal, 'utf8')
317
+ } catch (error) {
318
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
319
+ throw error
320
+ }
321
+ try {
322
+ const parsed = JSON.parse(text) as Partial<Journal>
323
+ if (
324
+ parsed.version === 1 &&
325
+ typeof parsed.stage === 'string' &&
326
+ join(paths.parent, parsed.stage).startsWith(paths.stagePrefix) &&
327
+ !parsed.stage.includes(sep) &&
328
+ !parsed.stage.includes('/')
329
+ )
330
+ return parsed as Journal
331
+ } catch {
332
+ // An empty or torn journal: the writer died while writing it, before it
333
+ // moved anything, so the output and backup have not been touched.
334
+ return { version: 1, stage: '' }
335
+ }
336
+ throw new Error(`CATALOG_RECOVERY_REQUIRED: unrecognized journal ${paths.journal}`)
337
+ }
338
+
339
+ async function writeDurably(path: string, content: string | Uint8Array): Promise<void> {
340
+ const handle = await fs.open(path, 'wx')
341
+ try {
342
+ await handle.writeFile(content)
343
+ await handle.sync()
344
+ } finally {
345
+ await handle.close()
346
+ }
347
+ }
348
+
349
+ /** Best effort: directory fsync is unavailable on Windows and some filesystems. */
350
+ async function syncDirectory(path: string): Promise<void> {
351
+ let handle
352
+ try {
353
+ handle = await fs.open(path, 'r')
354
+ await handle.sync()
355
+ } catch {
356
+ // Process-crash safety does not depend on it; power-loss durability does.
357
+ } finally {
358
+ await handle?.close().catch(() => undefined)
359
+ }
360
+ }
361
+
362
+ async function removeOrphanStages(paths: CatalogPaths, keep?: string): Promise<void> {
363
+ const prefix = basename(paths.stagePrefix)
364
+ for (const name of await fs.readdir(paths.parent))
365
+ if (name.startsWith(prefix) && name !== keep)
366
+ await fs.rm(join(paths.parent, name), { recursive: true, force: true })
367
+ }
368
+
369
+ /**
370
+ * Completes or undoes an interrupted publication. Must run under the lock.
371
+ *
372
+ * | output | backup | staged dir | meaning | action |
373
+ * | ------ | ------ | ---------- | -------------------------------- | -------------- |
374
+ * | yes | no | any | not swapped, or already cleaned | drop staging |
375
+ * | no | yes | any | died between the two renames | roll back |
376
+ * | yes | yes | no | swapped, backup not yet removed | roll forward |
377
+ * | no | no | any | first publication never swapped | drop staging |
378
+ * | yes | yes | yes | inconsistent | stop, preserve |
379
+ */
380
+ export async function recoverCatalogOutput(paths: CatalogPaths): Promise<void> {
381
+ const journal = await readJournal(paths)
382
+ if (journal === undefined) {
383
+ // Staging directories without a journal belong to a writer that died
384
+ // before it recorded anything; nothing else references them.
385
+ await removeOrphanStages(paths)
386
+ return
387
+ }
388
+ const stage = journal.stage === '' ? undefined : join(paths.parent, journal.stage)
389
+ const [hasOutput, hasBackup, hasStage] = await Promise.all([
390
+ exists(paths.output),
391
+ exists(paths.backup),
392
+ stage ? exists(stage) : Promise.resolve(false),
393
+ ])
394
+ if (stage === undefined && hasBackup)
395
+ // A torn journal is written before anything moves, so a backup next to it
396
+ // was not made by that writer.
397
+ throw new Error(`CATALOG_RECOVERY_REQUIRED: preserve and inspect ${paths.backup}`)
398
+ if (!hasOutput && hasBackup) await fs.rename(paths.backup, paths.output)
399
+ else if (hasOutput && hasBackup && !hasStage)
400
+ await fs.rm(paths.backup, { recursive: true, force: true })
401
+ else if (hasOutput && hasBackup)
402
+ throw new Error(`CATALOG_RECOVERY_REQUIRED: preserve and inspect ${paths.backup}`)
403
+ await removeOrphanStages(paths)
404
+ await syncDirectory(paths.parent)
405
+ await fs.rm(paths.journal, { force: true })
406
+ }
407
+
408
+ /**
409
+ * A digest of the output's entries and bytes, or `absent`. Run it under the
410
+ * lock (inside {@link withCatalogRead}) so it describes a published state.
411
+ */
412
+ export async function catalogFingerprint(output: string): Promise<string> {
413
+ let names: string[]
414
+ try {
415
+ names = (await fs.readdir(output)).toSorted()
416
+ } catch (error) {
417
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ABSENT
418
+ throw error
419
+ }
420
+ const hash = createHash('sha256')
421
+ for (const name of names) {
422
+ const path = join(output, name)
423
+ const stat = await fs.lstat(path)
424
+ hash.update(`${name}\0`)
425
+ if (stat.isFile()) hash.update('f\0').update(await fs.readFile(path))
426
+ else if (stat.isSymbolicLink()) hash.update('l\0').update(await fs.readlink(path))
427
+ else hash.update(`o\0${stat.mode}\0${stat.size}`)
428
+ hash.update('\0')
429
+ }
430
+ return `sha256:${hash.digest('hex')}`
431
+ }
432
+
433
+ /**
434
+ * Runs `read` while no writer can publish to `output`, after finishing any
435
+ * interrupted publication. Readers are serialized with writers and with each
436
+ * other; the lock is held only for the read itself.
437
+ */
438
+ export async function withCatalogRead<T>(
439
+ output: string,
440
+ read: () => Promise<T>,
441
+ options: CatalogCoordinationOptions = {}
442
+ ): Promise<T> {
443
+ const paths = await resolvePaths(output, false)
444
+ if (paths === undefined) return read()
445
+ const lock = await acquire(paths, 'read', options)
446
+ try {
447
+ if (lock) await recoverCatalogOutput(paths)
448
+ return await read()
449
+ } finally {
450
+ await lock?.release()
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Replaces `output` with exactly `files`. The previous output stays in place
456
+ * until the new one is complete, and a coordinated reader sees either one or
457
+ * the other.
458
+ */
459
+ export async function publishCatalog(
460
+ output: string,
461
+ files: Readonly<Record<string, string | Uint8Array>>,
462
+ options: PublishOptions = {}
463
+ ): Promise<void> {
464
+ const paths = await resolvePaths(output, true)
465
+ if (paths === undefined) throw new Error(`CATALOG_OUTPUT_UNRESOLVED: ${output}`)
466
+ await options.onStep?.('acquiring')
467
+ const lock = await acquire(paths, 'write', options)
468
+ if (lock === undefined) throw new Error(`CATALOG_OUTPUT_UNRESOLVED: ${output}`)
469
+ try {
470
+ await recoverCatalogOutput(paths)
471
+ // A backup without a journal predates coordinated publication or was made
472
+ // by hand. Its provenance is unknown, so it is never deleted or restored.
473
+ if (await exists(paths.backup))
474
+ throw new Error(`CATALOG_RECOVERY_REQUIRED: preserve and inspect ${paths.backup}`)
475
+ if (options.expectedBase !== undefined) {
476
+ const current = await catalogFingerprint(paths.output)
477
+ if (current !== options.expectedBase)
478
+ throw new Error(
479
+ `CATALOG_BASE_CHANGED: ${paths.output} was replaced after this sync read it; run the sync again`
480
+ )
481
+ }
482
+ const stage = await fs.mkdtemp(paths.stagePrefix)
483
+ try {
484
+ await writeDurably(
485
+ paths.journal,
486
+ `${JSON.stringify({ version: 1, stage: basename(stage) } satisfies Journal)}\n`
487
+ )
488
+ for (const [name, content] of Object.entries(files)) {
489
+ if (
490
+ name === '' ||
491
+ name.includes('/') ||
492
+ name.includes('\\') ||
493
+ name === '.' ||
494
+ name === '..'
495
+ )
496
+ throw new Error(`CATALOG_ARTIFACT_NAME_INVALID: ${name}`)
497
+ await writeDurably(join(stage, name), content)
498
+ }
499
+ await syncDirectory(stage)
500
+ await options.onStep?.('staged')
501
+ await lock.assertHeld()
502
+ try {
503
+ await fs.rename(paths.output, paths.backup)
504
+ } catch (error) {
505
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
506
+ }
507
+ await options.onStep?.('backed-up')
508
+ await fs.rename(stage, paths.output)
509
+ await syncDirectory(paths.parent)
510
+ await options.onStep?.('swapped')
511
+ await fs.rm(paths.backup, { recursive: true, force: true })
512
+ await fs.rm(paths.journal, { force: true })
513
+ await options.onStep?.('cleaned')
514
+ } catch (error) {
515
+ // Same state machine a later process would run after a crash, unless
516
+ // another process now holds the lock: then it owns the recovery.
517
+ const held = await lock.assertHeld().then(
518
+ () => true,
519
+ () => false
520
+ )
521
+ if (held) await recoverCatalogOutput(paths)
522
+ throw error
523
+ }
524
+ await options.afterPublish?.()
525
+ } finally {
526
+ await lock.release()
527
+ }
528
+ }
529
+
530
+ /**
531
+ * Writes one file so a reader sees either the old or the new bytes. For
532
+ * content-addressed assets and the browsing index beside the catalog.
533
+ */
534
+ export async function writeFileAtomically(
535
+ path: string,
536
+ content: string | Uint8Array
537
+ ): Promise<void> {
538
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`)
539
+ try {
540
+ await writeDurably(temporary, content)
541
+ await fs.rename(temporary, path)
542
+ } catch (error) {
543
+ await fs.rm(temporary, { force: true })
544
+ throw error
545
+ }
546
+ }
@@ -177,6 +177,39 @@ describe('product placement', () => {
177
177
  expect(index.products.slack!.pluginId).toBe('plugin:openai-official:slack')
178
178
  })
179
179
 
180
+ test('selects the canonical variant using the current release rather than an earlier release', () => {
181
+ const wrapper = plugin({
182
+ sourceId: 'claude-official',
183
+ name: 'slack',
184
+ categories: ['communication'],
185
+ })
186
+ const candidate = plugin({
187
+ sourceId: 'openai-official',
188
+ name: 'slack',
189
+ categories: ['communication'],
190
+ releaseRepositoryUrl: 'https://github.com/slackapi/slack-mcp-plugin',
191
+ })
192
+ const current = candidate.availableReleases[0]!
193
+ const oldWrapper = {
194
+ ...current,
195
+ releaseId: 'release:old-wrapper',
196
+ resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
197
+ }
198
+ const vendorNow = { ...candidate, availableReleases: [oldWrapper, current] }
199
+ const vendorIndex = buildConsumerIndex({ catalog: catalog([wrapper, vendorNow]), preference })
200
+ expect(vendorIndex.products.slack!.pluginId).toBe(candidate.pluginId)
201
+ expect(vendorIndex.products.slack!.release.releaseId).toBe(current.releaseId)
202
+
203
+ const currentWrapper = {
204
+ ...current,
205
+ resolvedRepositoryUrl: 'https://github.com/openai-official/marketplace',
206
+ }
207
+ const oldVendor = { ...current, releaseId: 'release:old-vendor' }
208
+ const wrapperNow = { ...candidate, availableReleases: [oldVendor, currentWrapper] }
209
+ const wrapperIndex = buildConsumerIndex({ catalog: catalog([wrapper, wrapperNow]), preference })
210
+ expect(wrapperIndex.products.slack!.pluginId).toBe(wrapper.pluginId)
211
+ })
212
+
180
213
  test('falls back to the configured source order when every variant is a wrapper', () => {
181
214
  const index = buildConsumerIndex({
182
215
  catalog: catalog([
@@ -233,6 +266,31 @@ describe('product placement', () => {
233
266
  describe('category shelves', () => {
234
267
  const names = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta']
235
268
 
269
+ test('rejects invalid shelf limits while allowing an empty shelf', () => {
270
+ const input = {
271
+ catalog: catalog(
272
+ names.map((name) =>
273
+ plugin({ sourceId: 'claude-official', name, categories: ['productivity'] })
274
+ )
275
+ ),
276
+ preference,
277
+ }
278
+ expect(buildConsumerIndex({ ...input, topCount: 0 }).categories[0]!.topProductKeys).toEqual([])
279
+ for (const topCount of [
280
+ -1,
281
+ 1.5,
282
+ Number.NaN,
283
+ Infinity,
284
+ Number.MAX_SAFE_INTEGER + 1,
285
+ null,
286
+ '3',
287
+ ]) {
288
+ expect(() => buildConsumerIndex({ ...input, topCount: topCount as number })).toThrow(
289
+ 'TOP_COUNT_INVALID'
290
+ )
291
+ }
292
+ })
293
+
236
294
  test('orders curated products first and fills the rest deterministically', () => {
237
295
  const index = buildConsumerIndex({
238
296
  catalog: catalog(
@@ -309,6 +367,73 @@ describe('category shelves', () => {
309
367
  expect(developer.topProductKeys).toEqual(['sentry', 'nightvision'])
310
368
  })
311
369
 
370
+ test('reports aliases outside the category and ambiguous curated names', () => {
371
+ const plugins = [
372
+ plugin({
373
+ sourceId: 'source-one',
374
+ name: 'shared',
375
+ categories: ['tools'],
376
+ productGroupingKey: 'first-product',
377
+ }),
378
+ plugin({
379
+ sourceId: 'source-two',
380
+ name: 'shared',
381
+ categories: ['tools'],
382
+ productGroupingKey: 'second-product',
383
+ }),
384
+ plugin({
385
+ sourceId: 'source-one',
386
+ name: 'renamed',
387
+ categories: ['other'],
388
+ productGroupingKey: 'actual-product',
389
+ }),
390
+ plugin({ sourceId: 'source-one', name: 'present', categories: ['tools'] }),
391
+ ]
392
+ for (const entries of [plugins, plugins.toReversed()]) {
393
+ const index = buildConsumerIndex({
394
+ catalog: catalog(entries),
395
+ leading: { tools: ['shared', 'renamed', 'present'] },
396
+ preference,
397
+ })
398
+ expect(index.diagnostics).toEqual([
399
+ { category: 'tools', name: 'renamed', reason: 'PRODUCT_NOT_IN_CATEGORY' },
400
+ { category: 'tools', name: 'shared', reason: 'PRODUCT_AMBIGUOUS' },
401
+ ])
402
+ expect(index.categories.find((item) => item.category === 'tools')!.productKeys[0]).toBe(
403
+ 'present'
404
+ )
405
+ }
406
+ })
407
+
408
+ test('curates a product when a differently named variant belongs to the category', () => {
409
+ const plugins = [
410
+ plugin({
411
+ sourceId: 'source-one',
412
+ name: 'named',
413
+ categories: ['other'],
414
+ productGroupingKey: 'shared-product',
415
+ }),
416
+ plugin({
417
+ sourceId: 'source-two',
418
+ name: 'alternate',
419
+ categories: ['tools'],
420
+ productGroupingKey: 'shared-product',
421
+ }),
422
+ plugin({ sourceId: 'source-one', name: 'alpha', categories: ['tools'] }),
423
+ ]
424
+ for (const entries of [plugins, plugins.toReversed()]) {
425
+ const index = buildConsumerIndex({
426
+ catalog: catalog(entries),
427
+ leading: { tools: ['named'] },
428
+ preference,
429
+ })
430
+ expect(index.diagnostics).toEqual([])
431
+ expect(index.categories.find((item) => item.category === 'tools')!.topProductKeys[0]).toBe(
432
+ 'shared-product'
433
+ )
434
+ }
435
+ })
436
+
312
437
  test('is deterministic for the same catalog in any input order', () => {
313
438
  const forward = names
314
439
  .slice(0, 3)