uniweb 0.13.5 → 0.13.6
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/package.json +7 -7
- package/partials/agents.md +40 -0
- package/src/backend/site-sync.js +326 -14
- package/src/commands/deploy.js +6 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/publish.js +25 -1
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +43 -3
- package/src/commands/refresh.js +176 -0
- package/src/commands/sync.js +75 -0
- package/src/framework-index.json +18 -9
- package/src/index.js +16 -0
- package/src/utils/git.js +203 -0
package/src/commands/pull.js
CHANGED
|
@@ -12,9 +12,25 @@
|
|
|
12
12
|
* pages/**, layout/**), and
|
|
13
13
|
* - folder lane → `collectionsToProject` (the folder + record files).
|
|
14
14
|
*
|
|
15
|
-
* Pull is git-pull-like
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Pull is a CHECKOUT, not a merge — the "git-pull-like" it used to claim here was
|
|
16
|
+
* misleading. It reconciles the working tree to the backend: section bodies are
|
|
17
|
+
* rewritten from the fetched document, and pages/sections the backend no longer has
|
|
18
|
+
* are DELETED (toggle off with `--no-delete`). The deletion is guarded so an
|
|
19
|
+
* empty/partial payload never wipes the tree.
|
|
20
|
+
*
|
|
21
|
+
* Because it overwrites rather than merges, it REFUSES when there is uncommitted
|
|
22
|
+
* work under the directories it owns — that was the one remaining way the CLI could
|
|
23
|
+
* destroy a user's work without them agreeing to it. Commit or stash first, or pass
|
|
24
|
+
* `--force`. Outside a git repository there is nothing to fall back on, so it asks
|
|
25
|
+
* instead (and refuses when it cannot).
|
|
26
|
+
*
|
|
27
|
+
* `--merge` is the third option, and usually the one you want after a refused push:
|
|
28
|
+
* three-way merge local work with what the backend sends, instead of choosing
|
|
29
|
+
* between them. Most "conflicts" are two people editing different paragraphs of one
|
|
30
|
+
* section, which merges silently; only genuine overlaps get conflict markers. The
|
|
31
|
+
* common ancestor is the COMMITTED version of each file — the backend keeps no
|
|
32
|
+
* per-version history and does not need to — so `--merge` requires a repo, and the
|
|
33
|
+
* merge itself is `git merge-file`.
|
|
18
34
|
*
|
|
19
35
|
* `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
|
|
20
36
|
*
|
|
@@ -22,6 +38,8 @@
|
|
|
22
38
|
* uniweb pull GET both lanes, project to files, prune orphans
|
|
23
39
|
* uniweb pull --no-collections Pull pages only; skip the folder (collections) lane
|
|
24
40
|
* uniweb pull --no-delete Project, but keep files with no backend item
|
|
41
|
+
* uniweb pull --merge Three-way merge local changes with the backend's
|
|
42
|
+
* uniweb pull --force Pull over uncommitted local changes (discards them)
|
|
25
43
|
* uniweb pull --dry-run Report what it would GET; write nothing
|
|
26
44
|
* uniweb pull --registry <url> Override the backend origin
|
|
27
45
|
* uniweb pull --token <bearer> Read with this bearer; skips `uniweb login`
|
|
@@ -38,16 +56,29 @@
|
|
|
38
56
|
* against the playground backend, 2026-06-17.
|
|
39
57
|
*/
|
|
40
58
|
|
|
41
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
42
|
-
import {
|
|
59
|
+
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
|
60
|
+
import { tmpdir } from 'node:os'
|
|
61
|
+
import { createHash } from 'node:crypto'
|
|
62
|
+
import { createInterface } from 'node:readline/promises'
|
|
63
|
+
import { join, dirname, relative } from 'node:path'
|
|
43
64
|
import yaml from 'js-yaml'
|
|
44
65
|
import {
|
|
45
66
|
siteContentDocumentToProject,
|
|
46
67
|
collectionsToProject,
|
|
47
68
|
resolveCollectionsConfig,
|
|
48
69
|
readZip,
|
|
70
|
+
computeUnitHashes,
|
|
71
|
+
collectUnitUuids,
|
|
49
72
|
} from '@uniweb/build/uwx'
|
|
50
73
|
import { makeModelResolver } from './push.js'
|
|
74
|
+
import {
|
|
75
|
+
mergeBaseVersions,
|
|
76
|
+
mergeItemBaseVersions,
|
|
77
|
+
writeUnitBases,
|
|
78
|
+
writeItemUuids,
|
|
79
|
+
} from '../backend/site-sync.js'
|
|
80
|
+
import { uncommittedUnder, siteContentRoots, showAtHead, mergeFile } from '../utils/git.js'
|
|
81
|
+
import { isNonInteractive } from '../utils/interactive.js'
|
|
51
82
|
import { BackendClient } from '../backend/client.js'
|
|
52
83
|
import { resolveSiteDir as defaultResolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
53
84
|
|
|
@@ -167,11 +198,280 @@ export function readPullDocuments(buf) {
|
|
|
167
198
|
return single ? [single] : []
|
|
168
199
|
}
|
|
169
200
|
|
|
201
|
+
// Read the per-entity optimistic-concurrency tokens out of a pull lane's manifest.
|
|
202
|
+
// The backend stamps each entity entry with a top-level `version` (opaque RFC3339);
|
|
203
|
+
// we cache it and echo it back as `base_version` on the next push, so a push
|
|
204
|
+
// against a base the backend has moved past is refused instead of silently
|
|
205
|
+
// overwriting. Returns a `{ <backend-uuid>: <version> }` map — empty for a JSON
|
|
206
|
+
// fallback body (no manifest ⇒ no tokens ⇒ that lane simply stays unconditional).
|
|
207
|
+
//
|
|
208
|
+
// TOP-LEVEL on the entry. The backend's `Entry::extra` is `#[serde(flatten)]`, so
|
|
209
|
+
// it never appears on the wire; reading `extra.version` finds nothing and silently
|
|
210
|
+
// leaves the gate disarmed (which is exactly what it did before 2026-07-26).
|
|
211
|
+
//
|
|
212
|
+
// Keyed by `entries[].uuid`, which on a PULL manifest is the real backend uuid.
|
|
213
|
+
// Note that is NOT symmetric with what we EMIT: on the push side that field is our
|
|
214
|
+
// `$id` handle, and the backend correlates our `base_version` via the body's
|
|
215
|
+
// `$uuid` instead. Same field name, different meaning by direction.
|
|
216
|
+
//
|
|
217
|
+
// This reads the SAME manifest.json that readPullDocuments deliberately skips:
|
|
218
|
+
// there the entity files are the payload, here the manifest is the index we were
|
|
219
|
+
// previously discarding.
|
|
220
|
+
export function readPullVersions(buf) {
|
|
221
|
+
return readManifestTokens(buf).entity
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`, from the
|
|
226
|
+
* entry's `item_versions`.
|
|
227
|
+
*
|
|
228
|
+
* The entity token can only say "something in this document moved"; these say
|
|
229
|
+
* which records did, which is what lets two people editing different sections
|
|
230
|
+
* both land instead of one being refused. Echoed back as `item_base_versions`.
|
|
231
|
+
*
|
|
232
|
+
* Keyed by record uuid rather than by our file path deliberately: the token then
|
|
233
|
+
* joins to the body by the same identity the backend's reconcile uses, and a file
|
|
234
|
+
* rename cannot silently re-target it. Absent for an entity with no items, and
|
|
235
|
+
* absent from an older backend — both mean "no per-item precondition", fall back
|
|
236
|
+
* to the entity grain.
|
|
237
|
+
*/
|
|
238
|
+
export function readPullItemVersions(buf) {
|
|
239
|
+
return readManifestTokens(buf).item
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// One manifest read for both grains — they arrive on the same entry and must not
|
|
243
|
+
// drift apart by being parsed in two places.
|
|
244
|
+
function readManifestTokens(buf) {
|
|
245
|
+
const out = { entity: {}, item: {} }
|
|
246
|
+
if (!(buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b)) return out
|
|
247
|
+
for (const [name, data] of readZip(buf)) {
|
|
248
|
+
if (name !== 'manifest.json') continue
|
|
249
|
+
try {
|
|
250
|
+
const manifest = JSON.parse(data.toString('utf8'))
|
|
251
|
+
for (const entry of manifest?.entries || []) {
|
|
252
|
+
if (entry?.uuid && typeof entry.version === 'string') out.entity[entry.uuid] = entry.version
|
|
253
|
+
const items = entry?.item_versions
|
|
254
|
+
if (items && typeof items === 'object') {
|
|
255
|
+
for (const [uuid, v] of Object.entries(items)) {
|
|
256
|
+
if (typeof v === 'string') out.item[uuid] = v
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
/* an unreadable manifest just means no tokens this pull */
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
return out
|
|
266
|
+
}
|
|
267
|
+
|
|
170
268
|
/**
|
|
171
269
|
* @param {string[]} args
|
|
172
270
|
* @param {object} [deps] - injectable seams for testing: `fetch` (default global
|
|
173
271
|
* fetch), `resolveSiteDir`, `getToken` (skip auth).
|
|
174
272
|
*/
|
|
273
|
+
/**
|
|
274
|
+
* Refuse a pull that would overwrite unsaved work. Returns a result object to
|
|
275
|
+
* return from `pull`, or null to proceed.
|
|
276
|
+
*
|
|
277
|
+
* With a repo: uncommitted work under pull's roots blocks, naming the files.
|
|
278
|
+
* Without one: there is nothing standing behind the tree, so ask — and in a
|
|
279
|
+
* non-interactive context refuse rather than assume consent.
|
|
280
|
+
*/
|
|
281
|
+
async function checkWorkingTree(siteDir, args) {
|
|
282
|
+
const roots = siteContentRoots(siteDir)
|
|
283
|
+
let dirty = uncommittedUnder(siteDir, roots)
|
|
284
|
+
|
|
285
|
+
if (dirty === null) {
|
|
286
|
+
// Not a git work tree. Nothing to fall back on if this goes wrong.
|
|
287
|
+
if (isNonInteractive(args)) {
|
|
288
|
+
error('Refusing to pull: this site is not in a git repository.')
|
|
289
|
+
note('Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.')
|
|
290
|
+
note('Without version control there is no way back, and this session cannot ask.')
|
|
291
|
+
note('Re-run with --force to accept that, or put the site under git first.')
|
|
292
|
+
return { exitCode: 1 }
|
|
293
|
+
}
|
|
294
|
+
const ok = await confirm(`Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`)
|
|
295
|
+
if (!ok) {
|
|
296
|
+
info('Nothing pulled.')
|
|
297
|
+
return { exitCode: 0 }
|
|
298
|
+
}
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const written = readWritten(siteDir)
|
|
303
|
+
dirty = dirty.filter((f) => !isPullOutput(siteDir, f, written))
|
|
304
|
+
if (!dirty.length) return null
|
|
305
|
+
|
|
306
|
+
error(`Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`)
|
|
307
|
+
for (const f of dirty.slice(0, 12)) note(` ${f}`)
|
|
308
|
+
if (dirty.length > 12) note(` … and ${dirty.length - 12} more`)
|
|
309
|
+
note('Pull reconciles the working tree to the backend, so these would be overwritten or deleted.')
|
|
310
|
+
note('Commit or stash them first — then pull, and your work is still in git either way.')
|
|
311
|
+
note('To discard them and take the backend version, re-run with --force.')
|
|
312
|
+
return { exitCode: 1 }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// What the last pull wrote: `{ <repo-relative path>: <sha256 of the bytes it wrote> }`.
|
|
316
|
+
//
|
|
317
|
+
// Without this the guard cries wolf: pull rewrites the tree, so the very next pull
|
|
318
|
+
// sees its OWN output as uncommitted work and refuses, listing files the user never
|
|
319
|
+
// touched. A guard that fires on nothing teaches people to reach for --force, which
|
|
320
|
+
// is the destructive option — the same failure mode as a gate that refuses disjoint
|
|
321
|
+
// edits. A file still byte-identical to what pull last wrote is not user work.
|
|
322
|
+
function writtenCachePath(siteDir) {
|
|
323
|
+
return join(siteDir, '.uniweb', 'pull-written.json')
|
|
324
|
+
}
|
|
325
|
+
function readWritten(siteDir) {
|
|
326
|
+
try {
|
|
327
|
+
const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
|
|
328
|
+
return {
|
|
329
|
+
files: o && typeof o.files === 'object' ? o.files : {},
|
|
330
|
+
deleted: Array.isArray(o?.deleted) ? o.deleted : [],
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
return { files: {}, deleted: [] }
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function recordWritten(siteDir, absPaths, deletedAbs = []) {
|
|
337
|
+
// MERGE, don't replace. A conditional pull that 304s writes nothing, and a
|
|
338
|
+
// partial pull writes only some lanes — in both cases the previous record is
|
|
339
|
+
// still "the last thing pull wrote there". Replacing would forget those paths and
|
|
340
|
+
// the next pull would see them as the user's work again, which is the false alarm
|
|
341
|
+
// this cache exists to prevent. A stale entry for a file that no longer exists is
|
|
342
|
+
// harmless: the hash read fails and it counts as a local change.
|
|
343
|
+
const prior = readWritten(siteDir)
|
|
344
|
+
const files = prior.files
|
|
345
|
+
for (const abs of absPaths) {
|
|
346
|
+
try {
|
|
347
|
+
files[relative(siteDir, abs)] = createHash('sha256').update(readFileSync(abs)).digest('hex')
|
|
348
|
+
} catch { /* deleted or unreadable — nothing to remember */ }
|
|
349
|
+
}
|
|
350
|
+
// Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
|
|
351
|
+
// Without recording them, pull's own pruning reads as the user having deleted
|
|
352
|
+
// files — the same false alarm as its writes, arriving by the other door.
|
|
353
|
+
const deleted = [...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])]
|
|
354
|
+
try {
|
|
355
|
+
mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
|
|
356
|
+
writeFileSync(writtenCachePath(siteDir), JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n')
|
|
357
|
+
} catch { /* best-effort: losing it only costs a spurious refusal */ }
|
|
358
|
+
}
|
|
359
|
+
// Is this dirty path just pull's own untouched output?
|
|
360
|
+
function isPullOutput(siteDir, relPath, written) {
|
|
361
|
+
let exists = true
|
|
362
|
+
let hash = null
|
|
363
|
+
try {
|
|
364
|
+
hash = createHash('sha256').update(readFileSync(join(siteDir, relPath))).digest('hex')
|
|
365
|
+
} catch {
|
|
366
|
+
exists = false
|
|
367
|
+
}
|
|
368
|
+
// Absent because pull pruned it — not because the user deleted it.
|
|
369
|
+
if (!exists) return written.deleted.includes(relPath)
|
|
370
|
+
return written.files[relPath] === hash
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `--merge`: keep local work instead of refusing, by three-way merging it with what
|
|
375
|
+
* the backend sends.
|
|
376
|
+
*
|
|
377
|
+
* Without this the recovery from a same-section conflict is commit → pull (which
|
|
378
|
+
* overwrites your version) → re-apply by hand → push. Yet most such "conflicts"
|
|
379
|
+
* aren't: two people edited different paragraphs of one section, and a real merge
|
|
380
|
+
* takes both. Only genuine overlaps need a human.
|
|
381
|
+
*
|
|
382
|
+
* Works by capturing local content BEFORE the projection overwrites it, then
|
|
383
|
+
* merging afterwards — so the projector stays a plain writer and needs no hook, and
|
|
384
|
+
* nothing is lost in between because "mine" is already in memory.
|
|
385
|
+
*
|
|
386
|
+
* Requires a repo, and that is not a mandate creeping in: the ancestor IS the
|
|
387
|
+
* committed version, and the merge is git's. There is nothing to merge against
|
|
388
|
+
* without one.
|
|
389
|
+
*
|
|
390
|
+
* @returns {Map<string, {content: Buffer, inHead: boolean}>|null} captured work, or
|
|
391
|
+
* null when merging isn't possible here (the caller reports and stops).
|
|
392
|
+
*/
|
|
393
|
+
function captureLocalWork(siteDir) {
|
|
394
|
+
const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
|
|
395
|
+
if (dirty === null) return null
|
|
396
|
+
const written = readWritten(siteDir)
|
|
397
|
+
const out = new Map()
|
|
398
|
+
for (const rel of dirty) {
|
|
399
|
+
if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
|
|
400
|
+
try {
|
|
401
|
+
out.set(rel, { content: readFileSync(join(siteDir, rel)), inHead: showAtHead(siteDir, rel) !== null })
|
|
402
|
+
} catch {
|
|
403
|
+
// Locally deleted. Pull will restore the backend's copy, which is the
|
|
404
|
+
// sensible reading of "I removed this and then asked for their version".
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Merge captured work back over what the projection just wrote. Returns a report.
|
|
411
|
+
function mergeLocalWork(siteDir, captured) {
|
|
412
|
+
const clean = []
|
|
413
|
+
const conflicted = []
|
|
414
|
+
const kept = []
|
|
415
|
+
for (const [rel, { content: mine, inHead }] of captured) {
|
|
416
|
+
const abs = join(siteDir, rel)
|
|
417
|
+
let theirs = null
|
|
418
|
+
try { theirs = readFileSync(abs) } catch { /* pruned by the pull */ }
|
|
419
|
+
|
|
420
|
+
if (theirs === null) {
|
|
421
|
+
// The backend no longer has it, but we changed it. Restoring is the
|
|
422
|
+
// non-destructive reading; the alternative silently discards local work to
|
|
423
|
+
// honour a deletion the user never saw.
|
|
424
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
425
|
+
writeFileSync(abs, mine)
|
|
426
|
+
kept.push(`${rel} (removed upstream, kept yours)`)
|
|
427
|
+
continue
|
|
428
|
+
}
|
|
429
|
+
if (theirs.equals(mine)) continue // both sides agree already
|
|
430
|
+
|
|
431
|
+
const base = inHead ? showAtHead(siteDir, rel) : null
|
|
432
|
+
if (!base) {
|
|
433
|
+
// No committed ancestor — a file added locally and never committed. There is
|
|
434
|
+
// no third input, so a three-way merge is not defined. Keep ours rather than
|
|
435
|
+
// guess, and say so.
|
|
436
|
+
writeFileSync(abs, mine)
|
|
437
|
+
kept.push(`${rel} (no committed ancestor, kept yours)`)
|
|
438
|
+
continue
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
|
|
442
|
+
try {
|
|
443
|
+
const basePath = join(dir, 'base')
|
|
444
|
+
const theirsPath = join(dir, 'theirs')
|
|
445
|
+
writeFileSync(basePath, base)
|
|
446
|
+
writeFileSync(theirsPath, theirs)
|
|
447
|
+
writeFileSync(abs, mine) // merge-file rewrites this in place
|
|
448
|
+
const r = mergeFile(siteDir, abs, basePath, theirsPath)
|
|
449
|
+
if (!r.merged) {
|
|
450
|
+
writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
|
|
451
|
+
kept.push(`${rel} (merge unavailable, kept yours)`)
|
|
452
|
+
} else if (r.conflicted) {
|
|
453
|
+
conflicted.push(rel)
|
|
454
|
+
} else {
|
|
455
|
+
clean.push(rel)
|
|
456
|
+
}
|
|
457
|
+
} finally {
|
|
458
|
+
rmSync(dir, { recursive: true, force: true })
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return { clean, conflicted, kept }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Minimal yes/no prompt; defaults to no, because the default must not destroy.
|
|
465
|
+
async function confirm(question) {
|
|
466
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
467
|
+
try {
|
|
468
|
+
const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase()
|
|
469
|
+
return a === 'y' || a === 'yes'
|
|
470
|
+
} finally {
|
|
471
|
+
rl.close()
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
175
475
|
export async function pull(args = [], deps = {}) {
|
|
176
476
|
const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
|
|
177
477
|
|
|
@@ -179,8 +479,36 @@ export async function pull(args = [], deps = {}) {
|
|
|
179
479
|
const tokenFlag = flagValue(args, '--token')
|
|
180
480
|
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
181
481
|
const noCollections = args.includes('--no-collections') || args.includes('--content-only')
|
|
482
|
+
const force = args.includes('--force')
|
|
483
|
+
const mergeMode = args.includes('--merge')
|
|
182
484
|
|
|
183
485
|
const siteDir = await resolveSiteDir(args, 'pull')
|
|
486
|
+
|
|
487
|
+
// Don't overwrite work that isn't saved anywhere. Pull reconciles the working
|
|
488
|
+
// tree to the backend — it rewrites section bodies from the fetched document and
|
|
489
|
+
// prunes what the backend doesn't have — so uncommitted work under the
|
|
490
|
+
// directories it owns is destroyed with no way back. That is the one place the
|
|
491
|
+
// CLI could still lose a user's work without them agreeing to it.
|
|
492
|
+
//
|
|
493
|
+
// Checked coarsely, by root, on purpose: pull rewrites essentially every file
|
|
494
|
+
// under those roots, so an exact per-file intersection would refuse the same
|
|
495
|
+
// cases while being able to miss one.
|
|
496
|
+
// `--merge` keeps local work instead of refusing it: capture what is dirty now,
|
|
497
|
+
// let the projection write the backend's version, then merge ours back over it.
|
|
498
|
+
let captured = null
|
|
499
|
+
let mergeReport = null
|
|
500
|
+
if (!dryRun && mergeMode) {
|
|
501
|
+
captured = captureLocalWork(siteDir)
|
|
502
|
+
if (captured === null) {
|
|
503
|
+
error('Cannot merge: this site is not in a git repository.')
|
|
504
|
+
note('The common ancestor a merge needs is the committed version of each file.')
|
|
505
|
+
note('Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.')
|
|
506
|
+
return { exitCode: 1 }
|
|
507
|
+
}
|
|
508
|
+
} else if (!dryRun && !force) {
|
|
509
|
+
const blocked = await checkWorkingTree(siteDir, args)
|
|
510
|
+
if (blocked) return blocked
|
|
511
|
+
}
|
|
184
512
|
const siteBackend = await resolveSiteBackend(siteDir)
|
|
185
513
|
const client = new BackendClient({
|
|
186
514
|
originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
|
|
@@ -236,7 +564,13 @@ export async function pull(args = [], deps = {}) {
|
|
|
236
564
|
}
|
|
237
565
|
try {
|
|
238
566
|
const etag = res.headers?.get?.('etag') ?? null
|
|
239
|
-
const
|
|
567
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
568
|
+
const docs = readPullDocuments(buf)
|
|
569
|
+
// Bank the per-entity staleness tokens this lane carried, so the next push
|
|
570
|
+
// is gated against the state we just took. A 304 skips this — correctly:
|
|
571
|
+
// unchanged upstream means the token we already hold is still current.
|
|
572
|
+
mergeBaseVersions(siteDir, readPullVersions(buf))
|
|
573
|
+
mergeItemBaseVersions(siteDir, readPullItemVersions(buf))
|
|
240
574
|
return { docs, etag }
|
|
241
575
|
} catch (err) {
|
|
242
576
|
error(`Could not read the ${label} response: ${err.message}`)
|
|
@@ -244,6 +578,10 @@ export async function pull(args = [], deps = {}) {
|
|
|
244
578
|
}
|
|
245
579
|
}
|
|
246
580
|
|
|
581
|
+
// Every file the projection writes, so the next pull can tell its own output
|
|
582
|
+
// apart from the user's work (see readWritten).
|
|
583
|
+
const wrote = []
|
|
584
|
+
const removed = []
|
|
247
585
|
let pages = 0
|
|
248
586
|
let sections = 0
|
|
249
587
|
let records = 0
|
|
@@ -261,7 +599,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
261
599
|
if (content && !content.notModified) {
|
|
262
600
|
const siteDoc = content.docs && (content.docs.find((d) => d?.info || d?.$model) || content.docs[0] || null)
|
|
263
601
|
if (siteDoc) {
|
|
602
|
+
// Re-base the page attribution. The pulled document IS the backend's own
|
|
603
|
+
// representation, so it becomes the remote base directly. We deliberately
|
|
604
|
+
// CLEAR the local base rather than reuse it: our source files were just
|
|
605
|
+
// rewritten from this document, and what we would emit from them is not
|
|
606
|
+
// byte-identical to it, so the old local base no longer describes anything.
|
|
607
|
+
// The next push re-establishes it; until then our side reports as unknown,
|
|
608
|
+
// which is honest rather than wrong.
|
|
609
|
+
writeUnitBases(siteDir, { remote: computeUnitHashes(siteDoc), local: {} })
|
|
610
|
+
// Per-item identity for the next push. Without it the backend reads our
|
|
611
|
+
// records as new and re-mints every page and section row.
|
|
612
|
+
writeItemUuids(siteDir, collectUnitUuids(siteDoc))
|
|
264
613
|
const report = siteContentDocumentToProject({ document: siteDoc, siteRoot: siteDir, prune })
|
|
614
|
+
wrote.push(...report.pages, ...report.sections, ...report.layout)
|
|
615
|
+
removed.push(...(report.deleted || []), ...(report.renamed || []).map((r) => r.from))
|
|
265
616
|
pages += report.pages.length
|
|
266
617
|
sections += report.sections.length
|
|
267
618
|
deleted += report.deleted.length
|
|
@@ -302,9 +653,38 @@ export async function pull(args = [], deps = {}) {
|
|
|
302
653
|
|
|
303
654
|
// Persist the ETags so the next pull is conditional (304 when unchanged).
|
|
304
655
|
writePullCache(siteDir, { content: etagContent, folder: etagFolder })
|
|
656
|
+
// Config files are rewritten every pull whether or not they changed; include the
|
|
657
|
+
// ones the projector owns so a pull-then-pull doesn't trip on them either.
|
|
658
|
+
// Merge captured local work back over what the projection just wrote. Runs after
|
|
659
|
+
// BOTH lanes, so a file either lane produced is merged the same way.
|
|
660
|
+
if (captured && captured.size) {
|
|
661
|
+
mergeReport = mergeLocalWork(siteDir, captured)
|
|
662
|
+
const r = mergeReport
|
|
663
|
+
if (r.clean.length) {
|
|
664
|
+
info(`Merged your changes into ${r.clean.length} file(s):`)
|
|
665
|
+
for (const f of r.clean) note(` ${f}`)
|
|
666
|
+
}
|
|
667
|
+
for (const f of r.kept) note(`\u21b7 ${f}`)
|
|
668
|
+
if (r.conflicted.length) {
|
|
669
|
+
error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
|
|
670
|
+
for (const f of r.conflicted) note(` ${f}`)
|
|
671
|
+
note('Conflict markers are in place. Resolve them, then commit and push.')
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
recordWritten(
|
|
676
|
+
siteDir,
|
|
677
|
+
[...wrote, ...['site.yml', 'theme.yml', 'head.html', 'collections.yml'].map((f) => join(siteDir, f))],
|
|
678
|
+
removed
|
|
679
|
+
)
|
|
305
680
|
|
|
306
681
|
success(
|
|
307
682
|
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` + (deleted ? `, ${deleted} deleted` : '')
|
|
308
683
|
)
|
|
309
|
-
|
|
684
|
+
// Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
|
|
685
|
+
// The pull itself worked; there is work left for a human. This is what makes a
|
|
686
|
+
// chained `uniweb pull --merge && uniweb push` safe by construction — without it,
|
|
687
|
+
// the obvious one-liner pushes conflict markers into live content.
|
|
688
|
+
const conflicts = mergeReport?.conflicted?.length ?? 0
|
|
689
|
+
return { exitCode: conflicts ? 1 : 0, merge: mergeReport }
|
|
310
690
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
* UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
|
|
13
13
|
* the backend owns the site's `@uniweb/folder`, so the framework never holds a
|
|
14
14
|
* folder uuid. Records still round-trip their own `$uuid`
|
|
15
|
-
* (back-filled into their source files). site-content
|
|
16
|
-
*
|
|
15
|
+
* (back-filled into their source files). site-content items carry a per-item `$uuid`
|
|
16
|
+
* too, stamped at emit from the identity cache rather than from author files — without
|
|
17
|
+
* it the backend reads every record as new and recreates every page and section row.
|
|
18
|
+
* Push-only, and gated on the backend's per-entity `version`
|
|
19
|
+
* (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
|
|
17
20
|
*
|
|
18
21
|
* Order: content first (CREATE or UPDATE — the site must exist before its folder),
|
|
19
22
|
* then the folder, keyed by the site's uuid. On a brand-new site the backend creates
|
|
@@ -30,6 +33,14 @@
|
|
|
30
33
|
* uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
|
|
31
34
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
32
35
|
* uniweb push --all Send every record (bypass the changed-only cache)
|
|
36
|
+
* uniweb push --force Overwrite upstream changes (drop the staleness gate)
|
|
37
|
+
*
|
|
38
|
+
* Pushes are GATED by default: each entity carries the backend `version` this clone
|
|
39
|
+
* last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
|
|
40
|
+
* atomically — before any write — if its stored version has moved. That prevents a
|
|
41
|
+
* developer who hasn't pulled from silently destroying an app author's edits (the
|
|
42
|
+
* backend's reconcile deletes items absent from the package, so an author's NEW page
|
|
43
|
+
* would be hard-deleted). `--force` omits the token and restores last-push-wins.
|
|
33
44
|
*
|
|
34
45
|
* Backend: via BackendClient (the content + folder sync lanes). Origin from
|
|
35
46
|
* --registry > UNIWEB_REGISTER_URL > the local default.
|
|
@@ -46,7 +57,15 @@ import { resolve } from 'node:path'
|
|
|
46
57
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
47
58
|
import { BackendClient } from '../backend/client.js'
|
|
48
59
|
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
49
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
makeModelResolver,
|
|
62
|
+
readSyncCache,
|
|
63
|
+
readBaseVersions,
|
|
64
|
+
readItemBaseVersions,
|
|
65
|
+
readItemUuids,
|
|
66
|
+
ensureItemUuids,
|
|
67
|
+
pushSyncPackages,
|
|
68
|
+
} from '../backend/site-sync.js'
|
|
50
69
|
|
|
51
70
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
52
71
|
// helpers from this module — their canonical home is now ../backend/site-sync.js.
|
|
@@ -77,6 +96,12 @@ export async function push(args = []) {
|
|
|
77
96
|
const asOrg = flagValue(args, '--as-org')
|
|
78
97
|
const foundationDir = flagValue(args, '--foundation')
|
|
79
98
|
const sendAll = args.includes('--all') // bypass the send-only-changed cache
|
|
99
|
+
// --force drops the optimistic-concurrency precondition, making the push
|
|
100
|
+
// unconditional (the backend then falls back to its `collision` policy). It is
|
|
101
|
+
// deliberately NOT "send collision=force": when a base_version is present the
|
|
102
|
+
// backend consults it and never looks at `collision`, so forcing has to mean
|
|
103
|
+
// OMITTING the token — the HTTP If-Match idiom.
|
|
104
|
+
const force = args.includes('--force')
|
|
80
105
|
|
|
81
106
|
const siteDir = await resolveSiteDir(args, 'push')
|
|
82
107
|
const siteBackend = await resolveSiteBackend(siteDir)
|
|
@@ -98,6 +123,17 @@ export async function push(args = []) {
|
|
|
98
123
|
// position. Non-local Models are fetched from the registry on demand. `priorHashes`
|
|
99
124
|
// (the .uniweb push-cache) drives "send only changed" across both lanes; --all bypasses.
|
|
100
125
|
const priorHashes = readSyncCache(siteDir)
|
|
126
|
+
// Per-item identity, without which the backend reads every record as new and
|
|
127
|
+
// recreates every page and section row.
|
|
128
|
+
//
|
|
129
|
+
// An offline preview (`-o` / `--dry-run`) still stamps from the CACHE — that is a
|
|
130
|
+
// local file read, so it stays offline, and it keeps the emitted `.uwx` faithful
|
|
131
|
+
// to what a real push would send. Only the network RECOVERY is skipped, so a
|
|
132
|
+
// preview never reaches the backend. (Emitting `{}` here instead would make the
|
|
133
|
+
// preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
|
|
134
|
+
const itemUuids = (output || dryRun)
|
|
135
|
+
? readItemUuids(siteDir)
|
|
136
|
+
: await ensureItemUuids({ client, siteDir, note })
|
|
101
137
|
let pkg
|
|
102
138
|
try {
|
|
103
139
|
pkg = await emitSyncPackages(siteDir, {
|
|
@@ -105,6 +141,10 @@ export async function push(args = []) {
|
|
|
105
141
|
resolveModel: makeModelResolver({ client, offline: Boolean(output) || dryRun }),
|
|
106
142
|
priorHashes,
|
|
107
143
|
sendAll,
|
|
144
|
+
itemUuids,
|
|
145
|
+
// Both grains are dropped together by --force: one flag, one meaning,
|
|
146
|
+
// no partial-force mode.
|
|
147
|
+
...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
|
|
108
148
|
})
|
|
109
149
|
} catch (err) {
|
|
110
150
|
error(`Could not build the sync package: ${err.message}`)
|