uniweb 0.13.17 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +15 -13
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
@@ -23,7 +23,7 @@ import {
23
23
  diffSiteUnits,
24
24
  describeSiteDiff,
25
25
  computeUnitHashes,
26
- collectUnitUuids,
26
+ collectUnitUuids
27
27
  } from '@uniweb/build/uwx'
28
28
 
29
29
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -66,7 +66,9 @@ async function explainStaleSiteContent({ client, siteDir, localBuffer, uuid }) {
66
66
  const remoteDoc = entityDocFromUwx(Buffer.from(await res.arrayBuffer()))
67
67
  const localDoc = entityDocFromUwx(localBuffer)
68
68
  if (!remoteDoc || !localDoc) return []
69
- return describeSiteDiff(diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir)))
69
+ return describeSiteDiff(
70
+ diffSiteUnits(localDoc, remoteDoc, readUnitBases(siteDir))
71
+ )
70
72
  } catch {
71
73
  return []
72
74
  }
@@ -98,7 +100,7 @@ export function extractFinalized(payload) {
98
100
  // resubmit returns the value we already hold, and anything else is theirs
99
101
  // to report, not ours to derive.
100
102
  version: typeof d?.version === 'string' ? d.version : null,
101
- document: d?.document ?? null,
103
+ document: d?.document ?? null
102
104
  }))
103
105
  .filter((e) => Number.isInteger(e.index) && e.uuid)
104
106
  }
@@ -109,7 +111,13 @@ export function extractFinalized(payload) {
109
111
  // envelope the update/folder lanes return (the site entity is submitted alone, so its
110
112
  // minted uuid is the first finalized entry). Returns null if none is present.
111
113
  export function extractMintedSiteUuid(payload) {
112
- if (typeof payload?.siteContentUuid === 'string') return payload.siteContentUuid
114
+ // `POST /dev/site` (the empty-site create) answers with the snake_case spelling;
115
+ // the content-lane CREATE has historically used the others. Both are the same
116
+ // identity — the uuid `/dev/site/content/pull/{uuid}` accepts.
117
+ if (typeof payload?.site_content_uuid === 'string')
118
+ return payload.site_content_uuid
119
+ if (typeof payload?.siteContentUuid === 'string')
120
+ return payload.siteContentUuid
113
121
  if (typeof payload?.$uuid === 'string') return payload.$uuid
114
122
  if (typeof payload?.uuid === 'string') return payload.uuid
115
123
  const finalized = extractFinalized(payload)
@@ -181,6 +189,106 @@ const readMap = (siteDir, key) => {
181
189
  return v && typeof v === 'object' ? v : {}
182
190
  }
183
191
 
192
+ /**
193
+ * Drop every cache map that describes a BACKEND site, when this clone is bound to
194
+ * none. Returns the names dropped (empty when there was nothing to do).
195
+ *
196
+ * `.uniweb/sync-cache.json` holds four maps keyed by *unit path* — item uuids,
197
+ * content hashes, entity base versions, unit bases — and a unit path (`site.yml`,
198
+ * `pages/about/about.md`) is the same string for every site. So the cache does not
199
+ * self-invalidate when `site.yml::$uuid` goes away: it keeps describing the site
200
+ * this folder used to be.
201
+ *
202
+ * Which is a state we ACTIVELY TELL PEOPLE TO ENTER. The 404 guidance on a
203
+ * uuid-bound lane says to clear `$uuid` to re-publish as a new site — the documented
204
+ * recovery after a site is deleted in the app. Following it left the stale maps in
205
+ * place, and the next publish failed two ways:
206
+ *
207
+ * - **item uuids** — the new site's document carried the OLD site's item
208
+ * identities, and the backend correctly refused: *"item uuid … is already
209
+ * stored on entity 156 — item uuids are globally unique; cross-entity move is
210
+ * not supported"*. A raw 400 for following our own advice.
211
+ * - **content hashes** — worse, because it is silent. Send-only-changed would
212
+ * skip every entity whose content had not changed since the OLD site's last
213
+ * push, so the NEW site would come up **missing exactly the content that did
214
+ * not change** — a partial site, published successfully, with nothing to
215
+ * indicate it.
216
+ *
217
+ * Call this BEFORE `ensureSiteExists`, which mints a uuid and would otherwise make
218
+ * the clone look bound before the check runs.
219
+ */
220
+ /**
221
+ * Drop the four remote-derived maps unconditionally, stamping the site they now
222
+ * describe (or clearing the stamp when there is none). Shared by the pre-flight
223
+ * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
224
+ * decides WHAT, and they must not drift.
225
+ */
226
+ export function clearRemoteSyncState(siteDir, siteUuid = null) {
227
+ const prior = readSyncCacheFile(siteDir)
228
+ const dropped = ['itemUuids', 'hashes', 'baseVersions', 'unitBases'].filter(
229
+ (k) => prior[k] && Object.keys(prior[k]).length
230
+ )
231
+ updateSyncCache(siteDir, {
232
+ itemUuids: {},
233
+ hashes: {},
234
+ baseVersions: {},
235
+ unitBases: {},
236
+ siteUuid: siteUuid || null
237
+ })
238
+ return dropped
239
+ }
240
+
241
+ export function clearRemoteSyncStateIfUnbound(siteDir) {
242
+ let current = null
243
+ try {
244
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
245
+ if (typeof y?.$uuid === 'string') current = y.$uuid
246
+ } catch {
247
+ /* unreadable site.yml — treat as unbound and clear, which is the safe side */
248
+ }
249
+
250
+ const prior = readSyncCacheFile(siteDir)
251
+ const REMOTE_MAPS = ['itemUuids', 'hashes', 'baseVersions', 'unitBases']
252
+ const populated = REMOTE_MAPS.filter(
253
+ (k) => prior[k] && Object.keys(prior[k]).length
254
+ )
255
+ if (!populated.length) {
256
+ // Nothing to invalidate, but still stamp identity so a LATER divergence is
257
+ // detectable. A cache that never records its site can only be checked by the
258
+ // unbound rule, which misses the bound-but-wrong case below.
259
+ if (current && prior.siteUuid !== current) {
260
+ updateSyncCache(siteDir, { siteUuid: current })
261
+ }
262
+ return []
263
+ }
264
+
265
+ // Two ways the cache can describe a site this clone is not working with:
266
+ //
267
+ // 1. UNBOUND — no `$uuid` at all, so there is no backend site for any of it to
268
+ // be about. This is the state the documented "clear `$uuid` to re-publish as
269
+ // a new site" recovery puts you in.
270
+ // 2. BOUND TO A DIFFERENT SITE — `$uuid` names one site and the cache was
271
+ // written for another. Reachable in one step: the create mints a uuid and
272
+ // writes it BEFORE the push, so a push that then fails leaves exactly this.
273
+ // Without the identity stamp it is invisible, and every later publish fails
274
+ // the same way with no path out but deleting `.uniweb/` by hand.
275
+ //
276
+ // A cache with NO `siteUuid` and a bound site is left alone: that is every
277
+ // pre-existing clone, and assuming it matches is right far more often than
278
+ // wiping it would be.
279
+ const stale =
280
+ !current || (prior.siteUuid && prior.siteUuid !== current) ? populated : []
281
+ if (!stale.length) {
282
+ if (current && prior.siteUuid !== current) {
283
+ updateSyncCache(siteDir, { siteUuid: current })
284
+ }
285
+ return []
286
+ }
287
+
288
+ clearRemoteSyncState(siteDir, current)
289
+ return stale
290
+ }
291
+
184
292
  export function readSyncCache(siteDir) {
185
293
  return readMap(siteDir, 'hashes')
186
294
  }
@@ -223,11 +331,15 @@ export function readItemBaseVersions(siteDir) {
223
331
  }
224
332
  export function mergeItemBaseVersions(siteDir, versions) {
225
333
  if (!versions || !Object.keys(versions).length) return
226
- updateSyncCache(siteDir, { itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions } })
334
+ updateSyncCache(siteDir, {
335
+ itemBaseVersions: { ...readItemBaseVersions(siteDir), ...versions }
336
+ })
227
337
  }
228
338
  export function mergeBaseVersions(siteDir, versions) {
229
339
  if (!versions || !Object.keys(versions).length) return
230
- updateSyncCache(siteDir, { baseVersions: { ...readBaseVersions(siteDir), ...versions } })
340
+ updateSyncCache(siteDir, {
341
+ baseVersions: { ...readBaseVersions(siteDir), ...versions }
342
+ })
231
343
  }
232
344
 
233
345
  /**
@@ -278,6 +390,104 @@ export function writeItemUuids(siteDir, map) {
278
390
  updateSyncCache(siteDir, { itemUuids: map })
279
391
  }
280
392
 
393
+ /**
394
+ * Guarantee the site EXISTS on the backend before anything is uploaded against it.
395
+ *
396
+ * Ordering is the point, and it is load-bearing rather than incidental. Uploaded
397
+ * bytes are metered against an owning entity and reclaimed by deleting it, so an
398
+ * upload made before any site exists is charged and can never be freed — there is
399
+ * nothing to delete. Creating the site first turns the artifact of a failed first
400
+ * publish from *unfreeable bytes* into *an empty site*, which costs nothing to
401
+ * keep and can be cleared. (The reverse ordering was justified by a claim that
402
+ * `ensureItemUuids` mints on the backend, which it does not — see push.js.)
403
+ *
404
+ * A no-op when `site.yml::$uuid` is already set, so only the first publish of a
405
+ * site pays for it. The uuid is written back immediately, keeping the window in
406
+ * which a crash could strand a site as small as one file write.
407
+ *
408
+ * @returns {Promise<{ uuid: string|null, created: boolean, reason?: string }>}
409
+ * `uuid: null` means the site could not be created; the caller decides whether
410
+ * that is fatal (it is, for any flow that uploads).
411
+ */
412
+ export async function ensureSiteExists({
413
+ client,
414
+ siteDir,
415
+ name,
416
+ foundation,
417
+ asOrg,
418
+ note
419
+ }) {
420
+ // One read serves both the binding check and the create's defaults, so callers
421
+ // that have no reason to hold site.yml (push) need not load it just to pass it
422
+ // back. An explicit argument still wins — publish supplies the PINNED foundation
423
+ // ref from the bring-along, which site.yml may not carry.
424
+ let siteYml = {}
425
+ try {
426
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
427
+ if (y && typeof y === 'object') siteYml = y
428
+ } catch {
429
+ /* unreadable site.yml — treat as un-synced and let the create decide */
430
+ }
431
+ if (typeof siteYml.$uuid === 'string') {
432
+ return { uuid: siteYml.$uuid, created: false }
433
+ }
434
+
435
+ // Both are required by the create. Catching it here turns a 400 into a sentence
436
+ // naming the file and the key — the difference between "fix this line of
437
+ // site.yml" and reading a status code.
438
+ const siteName = name ?? siteYml.name
439
+ const siteFoundation = foundation ?? siteYml.foundation
440
+ const missing = [!siteName && 'name', !siteFoundation && 'foundation'].filter(
441
+ Boolean
442
+ )
443
+ if (missing.length) {
444
+ return {
445
+ uuid: null,
446
+ created: false,
447
+ reason: `site.yml is missing ${missing.join(' and ')} — the backend requires ${missing.length > 1 ? 'them' : 'it'} to create a site`
448
+ }
449
+ }
450
+
451
+ let res
452
+ try {
453
+ res = await client.createSite({
454
+ name: siteName,
455
+ foundation: siteFoundation,
456
+ asOrg
457
+ })
458
+ } catch (err) {
459
+ return { uuid: null, created: false, reason: err.message }
460
+ }
461
+ if (!res?.ok) {
462
+ const body = await res?.text?.().catch(() => '')
463
+ return {
464
+ uuid: null,
465
+ created: false,
466
+ // 404 here means the backend predates the route, which is a materially
467
+ // different problem from being refused — say which.
468
+ reason:
469
+ res?.status === 404
470
+ ? 'this backend has no /dev/site route (it predates the empty-site create)'
471
+ : `HTTP ${res?.status} ${res?.statusText || ''}${body ? ` — ${body.slice(0, 200)}` : ''}`
472
+ }
473
+ }
474
+ const minted = extractMintedSiteUuid(await res.json().catch(() => null))
475
+ if (!minted) {
476
+ return {
477
+ uuid: null,
478
+ created: false,
479
+ reason: 'the create returned no site uuid'
480
+ }
481
+ }
482
+ writeSiteEntityUuid(siteDir, minted)
483
+ // Stamp the cache with the site it now describes, so a push that fails after
484
+ // this point cannot leave a cache pointing at a different site with no way to
485
+ // detect it.
486
+ updateSyncCache(siteDir, { siteUuid: minted })
487
+ note?.(`Created the site on the backend (recorded $uuid in site.yml).`)
488
+ return { uuid: minted, created: true }
489
+ }
490
+
281
491
  /**
282
492
  * Guarantee we have per-item identity before an identity-bearing push.
283
493
  *
@@ -297,7 +507,9 @@ export async function ensureItemUuids({ client, siteDir, note }) {
297
507
  try {
298
508
  const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
299
509
  siteContentUuid = typeof y?.$uuid === 'string' ? y.$uuid : null
300
- } catch { /* unreadable site.yml — nothing to recover against */ }
510
+ } catch {
511
+ /* unreadable site.yml — nothing to recover against */
512
+ }
301
513
  if (!siteContentUuid) return cached
302
514
  try {
303
515
  const res = await client.pullSiteContent(siteContentUuid)
@@ -307,7 +519,9 @@ export async function ensureItemUuids({ client, siteDir, note }) {
307
519
  const harvested = collectUnitUuids(doc)
308
520
  if (Object.keys(harvested).length) {
309
521
  writeItemUuids(siteDir, harvested)
310
- note?.(`Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`)
522
+ note?.(
523
+ `Recovered identity for ${Object.keys(harvested).length} item(s) from the backend.`
524
+ )
311
525
  }
312
526
  return harvested
313
527
  } catch {
@@ -323,7 +537,9 @@ export function readUnitBases(siteDir) {
323
537
  }
324
538
  export function writeUnitBases(siteDir, patch) {
325
539
  if (!patch) return
326
- updateSyncCache(siteDir, { unitBases: { ...readUnitBases(siteDir), ...patch } })
540
+ updateSyncCache(siteDir, {
541
+ unitBases: { ...readUnitBases(siteDir), ...patch }
542
+ })
327
543
  }
328
544
 
329
545
  /**
@@ -343,9 +559,10 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
343
559
  const pkg = await emitSyncPackages(siteDir, {
344
560
  resolveModel: makeModelResolver({ client: null, offline: true }),
345
561
  priorHashes,
346
- sendAll,
562
+ sendAll
347
563
  })
348
- const changed = (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
564
+ const changed =
565
+ (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
349
566
  return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
350
567
  }
351
568
 
@@ -364,7 +581,13 @@ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
364
581
  * @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
365
582
  * exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
366
583
  */
367
- export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report }) {
584
+ export async function pushSyncPackages({
585
+ client,
586
+ siteDir,
587
+ pkg,
588
+ asOrg,
589
+ report
590
+ }) {
368
591
  const { siteContent, collections, siteContentUuid, hashes } = pkg
369
592
  const { info, note, error } = report
370
593
  const dim = report.dim || ((s) => s)
@@ -377,7 +600,16 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
377
600
  // request fires). The client carries `collision=force` (last-push-wins) + the optional
378
601
  // `--as-org`. Returns the parsed payload, or null on any transport/HTTP/parse failure
379
602
  // (already reported).
380
- const postLane = async (label, doRequest, explainStale = async () => []) => {
603
+ // `boundUuid` is set only for the lanes that address an EXISTING site by uuid
604
+ // (content UPDATE, folder push) — never for the CREATE, which has no uuid to be
605
+ // wrong about. It is what lets a 404 be read as "the site this clone is bound to
606
+ // is gone" rather than "the route is missing".
607
+ const postLane = async (
608
+ label,
609
+ doRequest,
610
+ explainStale = async () => [],
611
+ { boundUuid = null } = {}
612
+ ) => {
381
613
  info(`Pushing ${label} to ${dim(client.origin)} …`)
382
614
  let res
383
615
  try {
@@ -393,33 +625,82 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
393
625
  // `reason` — never on `detail`, which is prose the backend may reword.
394
626
  let problem = null
395
627
  if ((res.status === 409 || res.status === 400) && body) {
396
- try { problem = JSON.parse(body) } catch { /* not a problem document */ }
628
+ try {
629
+ problem = JSON.parse(body)
630
+ } catch {
631
+ /* not a problem document */
632
+ }
397
633
  }
398
634
  // The package carried no identity for records the backend already stores, so
399
635
  // applying it would replace every one of them. `ensureItemUuids` is supposed
400
636
  // to make this unreachable, so reaching it means that recovery failed — say so
401
637
  // rather than surfacing a raw 400.
638
+ // The cache is offering another site's item identities. Recoverable without
639
+ // the user knowing what a sync cache is: the stale maps are unambiguously
640
+ // wrong (they describe a different entity), so clear them and say re-run.
641
+ //
642
+ // Reachable only for a clone broken BEFORE the pre-flight guard existed —
643
+ // an unstamped legacy cache on a bound site, which the guard deliberately
644
+ // leaves alone rather than wiping every existing clone. This is the exit.
645
+ if (problem?.reason === 'item_uuid_conflict') {
646
+ const dropped = clearRemoteSyncState(siteDir, boundUuid)
647
+ error(
648
+ `${label} push refused — this copy's sync cache is describing a different site.`
649
+ )
650
+ note('Nothing was written.')
651
+ if (Number.isInteger(problem.stored_entity_id)) {
652
+ note(
653
+ `Item ${problem.item_uuid ?? '(unnamed)'} already belongs to entity ${problem.stored_entity_id}; this push is for entity ${problem.document_entity_id}.`
654
+ )
655
+ }
656
+ if (dropped.length) {
657
+ note(
658
+ `Cleared the stale cache (${dropped.join(', ')}) — re-run the same command and it will push cleanly.`
659
+ )
660
+ } else {
661
+ note(
662
+ 'The cache held nothing to clear, so this is not the usual cause — please report it.'
663
+ )
664
+ }
665
+ return null
666
+ }
402
667
  if (problem?.reason === 'identity_required') {
403
- error(`${label} push refused — this copy has no record of the site's item identity.`)
404
- note('Nothing was written. Pushing without it would have replaced the identity of every stored item.')
405
- note('The recovery normally runs automatically, so it likely could not reach the backend.')
406
- note('Check your connection and re-run; `uniweb pull` also restores it.')
668
+ error(
669
+ `${label} push refused this copy has no record of the site's item identity.`
670
+ )
671
+ note(
672
+ 'Nothing was written. Pushing without it would have replaced the identity of every stored item.'
673
+ )
674
+ note(
675
+ 'The recovery normally runs automatically, so it likely could not reach the backend.'
676
+ )
677
+ note(
678
+ 'Check your connection and re-run; `uniweb pull` also restores it.'
679
+ )
407
680
  return null
408
681
  }
409
682
  if (problem?.reason === 'stale_base') {
410
- error(`${label} push refused — the backend has newer content than your last pull.`)
683
+ error(
684
+ `${label} push refused — the backend has newer content than your last pull.`
685
+ )
411
686
  // Page-level account, when we can get one. The gate is entity-grained, so
412
687
  // without this the user only learns "the document moved" and has no basis
413
688
  // for choosing between pulling and forcing.
414
689
  const detail = await explainStale()
415
690
  for (const line of detail) note(line)
416
691
  if (!detail.length) {
417
- const stale = Array.isArray(problem.stale_entities) ? problem.stale_entities : []
692
+ const stale = Array.isArray(problem.stale_entities)
693
+ ? problem.stale_entities
694
+ : []
418
695
  if (stale.length) {
419
- note(`Changed upstream: ${stale.length} entit${stale.length === 1 ? 'y' : 'ies'} (${stale.join(', ')})`)
696
+ note(
697
+ `Changed upstream: ${stale.length} entit${stale.length === 1 ? 'y' : 'ies'} (${stale.join(', ')})`
698
+ )
420
699
  }
421
700
  }
422
- note('Nothing was written — the whole push was refused before any change.')
701
+ note(
702
+ 'Nothing was written — the whole push was refused before any change.'
703
+ )
423
704
  // Say what will actually work from HERE. Anyone hitting this has local
424
705
  // work — it is why they pushed — and if it is uncommitted then `pull`
425
706
  // refuses too, so bare "run pull" advice walks them into a dead end.
@@ -427,17 +708,38 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
427
708
  // `--merge` is the recovery that keeps both sides: most of these are two
428
709
  // people editing different parts of one section, which merges silently.
429
710
  // Offer it first, and keep the take-theirs route for anyone who wants it.
430
- note('Commit your changes, then `uniweb pull --merge` to combine them with the backend\'s.')
431
- note('(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)')
711
+ note(
712
+ "Commit your changes, then `uniweb pull --merge` to combine them with the backend's."
713
+ )
714
+ note(
715
+ '(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)'
716
+ )
432
717
  } else {
433
- note('Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.')
718
+ note(
719
+ 'Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.'
720
+ )
434
721
  }
435
- note('To overwrite them anyway, re-run with --force (this discards the upstream edits).')
722
+ note(
723
+ 'To overwrite them anyway, re-run with --force (this discards the upstream edits).'
724
+ )
436
725
  return null
437
726
  }
438
727
  error(`${label} push rejected: HTTP ${res.status} ${res.statusText}`)
439
728
  if (res.status === 401 || res.status === 403) {
440
- note("Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN).")
729
+ note(
730
+ "Credentials weren't accepted — supply a bearer with --token <bearer> (or UNIWEB_TOKEN)."
731
+ )
732
+ } else if (res.status === 404 && boundUuid) {
733
+ // The clone is bound to a site the backend does not have. There is no CLI
734
+ // verb that could have removed it — a site is deleted in the Uniweb app,
735
+ // and that is what severs the sync — so the raw 404 leaves the user with
736
+ // no idea that the fix is local and one line.
737
+ note(
738
+ `The backend has no site with uuid ${boundUuid} — the usual cause is that it was deleted in the Uniweb app.`
739
+ )
740
+ note(
741
+ 'Deleting this folder removes only your local copy; clearing `$uuid` from site.yml re-publishes it as a new site.'
742
+ )
441
743
  } else if (res.status === 409) {
442
744
  // The site's @uniweb/folder is genesis-owned: its structure is fixed on first
443
745
  // deploy and not reconciled in place (the v1 rule — see gotcha #20's mode switch).
@@ -462,12 +764,14 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
462
764
  // POST a lane that round-trips entity uuids (content UPDATE + the folder): parse the
463
765
  // finalized list (for record back-fill + the changed summary). Returns the finalized
464
766
  // array, or null on failure (already reported).
465
- const pushLane = async (label, doRequest, explainStale) => {
466
- const payload = await postLane(label, doRequest, explainStale)
767
+ const pushLane = async (label, doRequest, explainStale, opts) => {
768
+ const payload = await postLane(label, doRequest, explainStale, opts)
467
769
  if (payload === null) return null
468
770
  const finalized = extractFinalized(payload)
469
771
  if (!finalized) {
470
- error(`The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`)
772
+ error(
773
+ `The ${label} response carried no recognizable finalized list (expected report.finalized[] with index + uuid).`
774
+ )
471
775
  note(JSON.stringify(payload).slice(0, 800))
472
776
  return null
473
777
  }
@@ -487,7 +791,8 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
487
791
  // already moved past, and refuse a push the user just made.
488
792
  const newVersions = {}
489
793
  const harvest = (finalized) => {
490
- for (const f of finalized || []) if (f.uuid && f.version) newVersions[f.uuid] = f.version
794
+ for (const f of finalized || [])
795
+ if (f.uuid && f.version) newVersions[f.uuid] = f.version
491
796
  }
492
797
  // The backend's post-write copy of the site-content document, kept for the
493
798
  // remote-side unit base (see writeUnitBases).
@@ -496,8 +801,18 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
496
801
  if (siteContentUuid) {
497
802
  const finalized = await pushLane(
498
803
  'site-content',
499
- () => client.updateSiteContent(siteContentUuid, siteContent.buffer, { asOrg }),
500
- () => explainStaleSiteContent({ client, siteDir, localBuffer: siteContent.buffer, uuid: siteContentUuid })
804
+ () =>
805
+ client.updateSiteContent(siteContentUuid, siteContent.buffer, {
806
+ asOrg
807
+ }),
808
+ () =>
809
+ explainStaleSiteContent({
810
+ client,
811
+ siteDir,
812
+ localBuffer: siteContent.buffer,
813
+ uuid: siteContentUuid
814
+ }),
815
+ { boundUuid: siteContentUuid }
501
816
  )
502
817
  if (!finalized) {
503
818
  mergeBaseVersions(siteDir, newVersions)
@@ -507,18 +822,20 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
507
822
  siteFinalizedDoc = finalized[0]?.document || null
508
823
  finalizedTotal += finalized.length
509
824
  } else {
510
- const payload = await postLane(
511
- 'site-content',
512
- () => client.createSiteContent(siteContent.buffer, { asOrg })
825
+ const payload = await postLane('site-content', () =>
826
+ client.createSiteContent(siteContent.buffer, { asOrg })
513
827
  )
514
828
  if (payload === null) return { exitCode: 1, finalizedTotal, wrote }
515
829
  const minted = extractMintedSiteUuid(payload)
516
830
  if (!minted) {
517
- error('The create response carried no minted site-content uuid — cannot record the site identity or push its folder.')
831
+ error(
832
+ 'The create response carried no minted site-content uuid — cannot record the site identity or push its folder.'
833
+ )
518
834
  note(JSON.stringify(payload).slice(0, 800))
519
835
  return { exitCode: 1, finalizedTotal, wrote }
520
836
  }
521
837
  writeSiteEntityUuid(siteDir, minted)
838
+ updateSyncCache(siteDir, { siteUuid: minted })
522
839
  boundSiteUuid = minted
523
840
  wrote.push('recorded site $uuid in site.yml')
524
841
  const createdFinalized = extractFinalized(payload)
@@ -534,12 +851,16 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
534
851
  // itself has no uuid (the backend owns it).
535
852
  if (collections) {
536
853
  if (!boundSiteUuid) {
537
- error('Cannot push collections — the site has no uuid yet. Push the site-content lane first.')
854
+ error(
855
+ 'Cannot push collections — the site has no uuid yet. Push the site-content lane first.'
856
+ )
538
857
  return { exitCode: 1, finalizedTotal, wrote }
539
858
  }
540
859
  const finalized = await pushLane(
541
860
  'collections',
542
- () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg })
861
+ () => client.pushFolder(boundSiteUuid, collections.buffer, { asOrg }),
862
+ undefined,
863
+ { boundUuid: boundSiteUuid }
543
864
  )
544
865
  if (!finalized) {
545
866
  mergeBaseVersions(siteDir, newVersions)
@@ -549,7 +870,8 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
549
870
  const bf = backfillEntityUuids({ index: collections.index, finalized })
550
871
  for (const w of bf.warnings) note(`! ${w}`)
551
872
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
552
- if (bf.updated.length) wrote.push(`wrote ${bf.updated.length} record file(s)`)
873
+ if (bf.updated.length)
874
+ wrote.push(`wrote ${bf.updated.length} record file(s)`)
553
875
  finalizedTotal += finalized.length
554
876
  }
555
877