uniweb 0.13.5 → 0.13.7

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.
@@ -297,6 +297,26 @@ export async function doctor(args = []) {
297
297
  continue
298
298
  }
299
299
 
300
+ // The agent index ships by default, but it can only carry absolute links
301
+ // when the site declares where it lives. Root-relative links still work
302
+ // for an agent that arrived via the index, so this warns rather than
303
+ // suppressing the artifact the way sitemap.xml does — a silently absent
304
+ // index is the exact failure the projections exist to prevent.
305
+ const agentsConfig = siteYml.agents
306
+ const indexEnabled = agentsConfig !== false && agentsConfig?.index !== false
307
+ if (indexEnabled && !siteYml.seo?.baseUrl) {
308
+ const issue = {
309
+ id: 'agents-index-relative-links',
310
+ type: 'warning',
311
+ site: siteName,
312
+ message: 'llms.txt will use root-relative links because seo.baseUrl is unset',
313
+ }
314
+ issues.push(issue)
315
+ warn(`[agents-index-relative-links] llms.txt links will be root-relative`)
316
+ log(` Set ${colors.green}seo.baseUrl${colors.reset} in site.yml so agents get absolute URLs`)
317
+ log(` (this also turns on sitemap.xml and robots.txt, which are skipped without it)`)
318
+ }
319
+
300
320
  const foundationName = siteYml.foundation
301
321
  if (!foundationName) {
302
322
  warn('No foundation specified in site.yml (using runtime loading?)')
@@ -27,6 +27,7 @@
27
27
  * uniweb publish Bring the foundation along, sync, and go live
28
28
  * uniweb publish --dry-run Resolve everything; POST nothing
29
29
  * uniweb publish --yes Skip confirmations (CI); never block on a prompt
30
+ * uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
30
31
  * uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
31
32
  * uniweb publish --backend <url> Override the backend origin
32
33
  * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
@@ -54,9 +55,17 @@ import { BackendClient } from '../backend/client.js'
54
55
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
55
56
  import { readFlagValue } from '../utils/args.js'
56
57
  import { isNonInteractive } from '../utils/interactive.js'
57
- import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
58
+ import { headProvenance } from '../utils/git.js'
59
+ import {
60
+ makeModelResolver,
61
+ readSyncCache,
62
+ readBaseVersions,
63
+ readItemBaseVersions,
64
+ ensureItemUuids,
65
+ pushSyncPackages,
66
+ } from '../backend/site-sync.js'
58
67
  import { uploadDataBundle } from '../backend/data-bundle.js'
59
- import { uploadSiteMedia } from '../backend/site-media.js'
68
+ import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
60
69
  import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
61
70
  import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
62
71
 
@@ -266,14 +275,29 @@ export async function publish(args = []) {
266
275
  if (mediaRefs.length) {
267
276
  say.info('Uploading media…')
268
277
  try {
269
- const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
278
+ const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
270
279
  onProgress: (m) => say.dim(` ${m}`),
271
280
  warn: (m) => say.dim(`! ${m}`),
272
281
  })
282
+ // A ref whose bytes did not land must NOT be published: the content would go
283
+ // out still pointing at the local path, so the site ships a broken image and
284
+ // the only trace is a warning nobody reads. A missing FILE is different —
285
+ // already broken before us, warned above, and not worth blocking a publish.
286
+ if (failed.length) {
287
+ say.err(`${failed.length} asset(s) failed to upload — not publishing.`)
288
+ for (const f of failed) say.dim(` ${f.path} (HTTP ${f.status})`)
289
+ return { exitCode: 1 }
290
+ }
273
291
  if (Object.keys(map).length) assetRewrite = map
274
292
  if (ballAssets.length) ball = rewriteBallAssets(ball, map)
275
293
  say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
276
294
  } catch (err) {
295
+ if (isStorageRefusal(err)) {
296
+ say.err('Storage quota reached — the media in this publish does not fit.')
297
+ say.dim(' Remove or shrink assets, or raise the plan limit, then re-run.')
298
+ say.dim(` ${err.message}`)
299
+ return { exitCode: 1 }
300
+ }
277
301
  say.err(`Media upload failed: ${err.message}`)
278
302
  return { exitCode: 1 }
279
303
  }
@@ -296,6 +320,13 @@ export async function publish(args = []) {
296
320
  // the SAME two-lane submission `uniweb push` uses — stamping
297
321
  // info.data_bundle and rewriting local media refs to backend serve URLs.
298
322
  const priorHashes = readSyncCache(siteDir)
323
+ // publish rides the same gated push as `uniweb push`: if an app author has
324
+ // edited since this clone last synced, the push is refused rather than
325
+ // overwriting them, and nothing goes live. `--force` drops the precondition.
326
+ const baseVersions = args.includes('--force') ? null : readBaseVersions(siteDir)
327
+ // Per-item identity, recovered from the backend when this clone has never seen it.
328
+ // Without it the backend re-mints every page and section row (see readItemUuids).
329
+ const itemUuids = await ensureItemUuids({ client, siteDir, note: (m) => say.dim(m) })
299
330
  // Stamp deploy-derived info on the site-content entity: the data-bundle URL,
300
331
  // and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
301
332
  // Delivery is version-pinned end-to-end (the gateway serves a foundation only
@@ -313,6 +344,8 @@ export async function publish(args = []) {
313
344
  ...(foundationDir ? { foundationDir } : {}),
314
345
  resolveModel,
315
346
  priorHashes,
347
+ itemUuids,
348
+ ...(baseVersions ? { baseVersions, itemBaseVersions: readItemBaseVersions(siteDir) } : {}),
316
349
  ...(Object.keys(injectInfo).length ? { injectInfo } : {}),
317
350
  ...(assetRewrite ? { assetRewrite } : {}),
318
351
  })
@@ -376,6 +409,7 @@ export async function publish(args = []) {
376
409
  // foundation version (the bring-along, §4).
377
410
  // Record the ref that actually went live: the pinned `@scope/name@version`
378
411
  // from the bring-along when present, else the site.yml ref verbatim.
412
+ const gitAt = headProvenance(siteDir)
379
413
  const siteYmlRef = typeof siteYml.foundation === 'string' ? siteYml.foundation : siteYml.foundation?.ref || null
380
414
  const recordedRef = fnd.ref || siteYmlRef
381
415
  await persistLastDeploy(siteDir, {
@@ -388,6 +422,11 @@ export async function publish(args = []) {
388
422
  lastDeploy: {
389
423
  at: new Date().toISOString(),
390
424
  host: 'uniweb',
425
+ // What was actually shipped. A version number can't answer that — two
426
+ // publishes of "0.1.0" are not the same content — and after the fact the
427
+ // working tree has moved on. `dirty` matters as much as the sha: it says the
428
+ // publish did NOT correspond to any commit, so the sha alone would mislead.
429
+ ...(gitAt ? { git: gitAt } : {}),
391
430
  backend: client.origin,
392
431
  siteUuid,
393
432
  url: serveUrl,
@@ -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: it reconciles the working tree to the backend, DELETING
16
- * pages/sections that no longer exist there (toggle off with `--no-delete`). The
17
- * deletion is guarded so an empty/partial payload never wipes the tree.
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 { join, dirname } from 'node:path'
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 docs = readPullDocuments(Buffer.from(await res.arrayBuffer()))
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
- return { exitCode: 0 }
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
  }