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.
- package/README.md +1 -1
- package/package.json +7 -7
- package/partials/agents.md +51 -0
- package/src/backend/client.js +173 -40
- package/src/backend/data-bundle.js +15 -3
- package/src/backend/foundation-bring-along.js +76 -21
- package/src/backend/payment-handoff.js +28 -9
- package/src/backend/site-media.js +147 -19
- package/src/backend/site-sync.js +362 -40
- package/src/commands/add.js +575 -273
- package/src/commands/build.js +160 -57
- package/src/commands/clone.js +79 -26
- package/src/commands/content.js +11 -7
- package/src/commands/deploy.js +80 -32
- package/src/commands/dev.js +39 -17
- package/src/commands/docs.js +44 -16
- package/src/commands/doctor.js +338 -82
- package/src/commands/export.js +15 -7
- package/src/commands/handoff.js +6 -2
- package/src/commands/i18n.js +248 -99
- package/src/commands/inspect.js +48 -19
- package/src/commands/invite.js +9 -3
- package/src/commands/org.js +19 -5
- package/src/commands/publish.js +229 -60
- package/src/commands/pull.js +157 -48
- package/src/commands/push.js +144 -42
- package/src/commands/refresh.js +37 -10
- package/src/commands/register.js +235 -64
- package/src/commands/rename.js +126 -46
- package/src/commands/runtime.js +74 -24
- package/src/commands/status.js +48 -15
- package/src/commands/sync.js +7 -2
- package/src/commands/template.js +12 -4
- package/src/commands/update.js +263 -87
- package/src/commands/validate.js +115 -32
- package/src/framework-index.json +15 -13
- package/src/index.js +298 -145
- package/src/templates/fetchers/github.js +7 -7
- package/src/templates/fetchers/npm.js +3 -3
- package/src/templates/fetchers/release.js +21 -18
- package/src/templates/index.js +34 -12
- package/src/templates/processor.js +44 -12
- package/src/templates/resolver.js +42 -15
- package/src/templates/validator.js +14 -7
- package/src/utils/asset-upload.js +90 -22
- package/src/utils/code-upload.js +62 -37
- package/src/utils/config.js +31 -10
- package/src/utils/dep-survey.js +33 -7
- package/src/utils/destination-prompt.js +27 -17
- package/src/utils/git.js +66 -22
- package/src/utils/host-prompt.js +4 -3
- package/src/utils/install-integrity.js +8 -4
- package/src/utils/interactive.js +3 -3
- package/src/utils/json-file.js +6 -2
- package/src/utils/names.js +15 -6
- package/src/utils/placement.js +16 -8
- package/src/utils/registry-auth.js +185 -57
- package/src/utils/registry-orgs.js +142 -53
- package/src/utils/runtime-upload.js +52 -14
- package/src/utils/scaffold.js +55 -14
- package/src/utils/site-content-refs.js +3 -1
- package/src/utils/update-check.js +43 -17
- package/src/utils/workspace.js +13 -7
- package/src/versions.js +18 -7
- package/templates/site/_gitignore +5 -0
package/src/backend/site-sync.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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 {
|
|
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?.(
|
|
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, {
|
|
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 =
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
note(
|
|
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(
|
|
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)
|
|
692
|
+
const stale = Array.isArray(problem.stale_entities)
|
|
693
|
+
? problem.stale_entities
|
|
694
|
+
: []
|
|
418
695
|
if (stale.length) {
|
|
419
|
-
note(
|
|
696
|
+
note(
|
|
697
|
+
`Changed upstream: ${stale.length} entit${stale.length === 1 ? 'y' : 'ies'} (${stale.join(', ')})`
|
|
698
|
+
)
|
|
420
699
|
}
|
|
421
700
|
}
|
|
422
|
-
note(
|
|
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(
|
|
431
|
-
|
|
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(
|
|
718
|
+
note(
|
|
719
|
+
'Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.'
|
|
720
|
+
)
|
|
434
721
|
}
|
|
435
|
-
note(
|
|
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(
|
|
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(
|
|
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 || [])
|
|
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
|
-
() =>
|
|
500
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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)
|
|
873
|
+
if (bf.updated.length)
|
|
874
|
+
wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
553
875
|
finalizedTotal += finalized.length
|
|
554
876
|
}
|
|
555
877
|
|