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.
- package/package.json +7 -7
- package/partials/agents.md +66 -1
- package/src/backend/site-media.js +34 -5
- 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 +42 -3
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +114 -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/doctor.js
CHANGED
|
@@ -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?)')
|
package/src/commands/publish.js
CHANGED
|
@@ -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 {
|
|
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,
|
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
|
}
|