uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,1257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb pull — bring the backend's copy of a site back to canonical files.
|
|
3
|
+
*
|
|
4
|
+
* The read-side mirror of `uniweb push`. The project's identity on a backend — its
|
|
5
|
+
* site-content uuid, in that backend's section of `sync.json` — keys BOTH pull lanes
|
|
6
|
+
* (the backend owns the site's `@uniweb/folder` and resolves it from the site-content
|
|
7
|
+
* uuid, so the framework never holds a folder uuid). It GETs the two lanes and projects
|
|
8
|
+
* the returned documents back to files via the framework's projection layer
|
|
9
|
+
* (`@uniweb/build/uwx`):
|
|
10
|
+
*
|
|
11
|
+
* - content lane → `siteContentDocumentToProject` (site.yml/theme.yml/head.html,
|
|
12
|
+
* pages/**, layout/**), and
|
|
13
|
+
* - folder lane → `recordsToProject` (the folder + record files).
|
|
14
|
+
*
|
|
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 version the last pull wrote, found in the repo's history —
|
|
32
|
+
* the backend keeps no per-version history and does not need to — so `--merge`
|
|
33
|
+
* requires a repo, and the merge itself is `git merge-file`.
|
|
34
|
+
*
|
|
35
|
+
* `uniweb login && uniweb pull`. Run from a site, or a workspace with one site.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* uniweb pull GET both lanes, project to files, prune orphans
|
|
39
|
+
* uniweb pull --no-records Pull pages only; skip the folder (records) lane
|
|
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)
|
|
43
|
+
* uniweb pull --dry-run Report what it would GET; write nothing
|
|
44
|
+
*
|
|
45
|
+
* The content + folder pull lanes are both keyed by the site's uuid on that backend
|
|
46
|
+
* (sync.json).
|
|
47
|
+
* Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
|
|
48
|
+
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
49
|
+
* No `--server` or `--token`: switching and signing in are `uniweb login`.
|
|
50
|
+
*
|
|
51
|
+
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
52
|
+
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
53
|
+
* `entities/<uuid>.json`); `readPullDocuments` reads the entity files out of it, with
|
|
54
|
+
* a tolerant JSON fallback (`extractDocument` / `splitRecordsPull`). Verified live
|
|
55
|
+
* against the playground backend, 2026-06-17.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
readFileSync,
|
|
60
|
+
writeFileSync,
|
|
61
|
+
mkdirSync,
|
|
62
|
+
mkdtempSync,
|
|
63
|
+
rmSync,
|
|
64
|
+
readdirSync,
|
|
65
|
+
statSync
|
|
66
|
+
} from 'node:fs'
|
|
67
|
+
import { tmpdir } from 'node:os'
|
|
68
|
+
import { createHash } from 'node:crypto'
|
|
69
|
+
import { createInterface } from 'node:readline/promises'
|
|
70
|
+
import { join, dirname, relative, resolve } from 'node:path'
|
|
71
|
+
import yaml from 'js-yaml'
|
|
72
|
+
import { downloadMissingAssets, downloadFileRecords } from '../backend/asset-download.js'
|
|
73
|
+
import {
|
|
74
|
+
readBackendState,
|
|
75
|
+
siteContentDocumentToProject,
|
|
76
|
+
dataBlockModels,
|
|
77
|
+
recordsToProject,
|
|
78
|
+
siteSelfScope,
|
|
79
|
+
readZip,
|
|
80
|
+
computeUnitHashes,
|
|
81
|
+
collectUnitUuids,
|
|
82
|
+
collectQueryUuids,
|
|
83
|
+
collectFolderItemUuids,
|
|
84
|
+
documentSchema
|
|
85
|
+
} from '@uniweb/build/uwx'
|
|
86
|
+
import {
|
|
87
|
+
readWritten,
|
|
88
|
+
recordWritten,
|
|
89
|
+
isPullOutput
|
|
90
|
+
} from '../utils/pull-written.js'
|
|
91
|
+
import {
|
|
92
|
+
makeModelResolver,
|
|
93
|
+
ensureRegisteredFoundation,
|
|
94
|
+
deploymentFields,
|
|
95
|
+
rebankSyncHashes,
|
|
96
|
+
writeQueryUuids,
|
|
97
|
+
mergeBaseVersions,
|
|
98
|
+
writeItemBaseVersions,
|
|
99
|
+
pulledItemVersions,
|
|
100
|
+
writeUnitBases,
|
|
101
|
+
writeItemUuids,
|
|
102
|
+
writeFolderItemUuids
|
|
103
|
+
} from '../backend/site-sync.js'
|
|
104
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
105
|
+
import {
|
|
106
|
+
uncommittedUnder,
|
|
107
|
+
siteContentRoots,
|
|
108
|
+
showAtHead,
|
|
109
|
+
findCommitted,
|
|
110
|
+
mergeFile
|
|
111
|
+
} from '../utils/git.js'
|
|
112
|
+
import { isNonInteractive } from '../utils/interactive.js'
|
|
113
|
+
import {
|
|
114
|
+
BackendClient,
|
|
115
|
+
describeRequestError,
|
|
116
|
+
refusalDetail,
|
|
117
|
+
WorkspaceMismatchError
|
|
118
|
+
} from '../backend/client.js'
|
|
119
|
+
import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
|
|
120
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
121
|
+
import {
|
|
122
|
+
syncedElsewhere
|
|
123
|
+
} from '../utils/site-identity.js'
|
|
124
|
+
|
|
125
|
+
const FOLDER_MODEL = '@uniweb/folder'
|
|
126
|
+
|
|
127
|
+
const colors = {
|
|
128
|
+
reset: '\x1b[0m',
|
|
129
|
+
bright: '\x1b[1m',
|
|
130
|
+
dim: '\x1b[2m',
|
|
131
|
+
red: '\x1b[31m',
|
|
132
|
+
green: '\x1b[32m',
|
|
133
|
+
blue: '\x1b[36m'
|
|
134
|
+
}
|
|
135
|
+
const log = console.log
|
|
136
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
137
|
+
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
138
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
139
|
+
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
140
|
+
|
|
141
|
+
function flagValue(args, name) {
|
|
142
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
143
|
+
if (eq) return eq.slice(name.length + 1)
|
|
144
|
+
const i = args.indexOf(name)
|
|
145
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
|
|
146
|
+
return args[i + 1]
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Whether this pull should fetch the asset bytes it does not have.
|
|
151
|
+
//
|
|
152
|
+
// Two levers, and they are deliberately not the same kind of thing:
|
|
153
|
+
// - `site.yml::assets.download: false` — the PROJECT's standing choice. "We do
|
|
154
|
+
// not keep the bytes here" is usually a property of a repo, not of a run, and
|
|
155
|
+
// a property of a repo belongs in a file every clone reads.
|
|
156
|
+
// - `--no-assets` — the RUN's override, for CI and one-off checkouts, where the
|
|
157
|
+
// project's answer is right and this invocation is the exception.
|
|
158
|
+
//
|
|
159
|
+
// The flag can only turn fetching OFF. A project that declared `download: false`
|
|
160
|
+
// meant it, and a flag that could silently re-enable it would make the declared
|
|
161
|
+
// setting advisory.
|
|
162
|
+
function shouldFetchAssets(args, siteDir) {
|
|
163
|
+
if (args.includes('--no-assets')) return false
|
|
164
|
+
try {
|
|
165
|
+
const cfg = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
|
|
166
|
+
if (cfg?.assets?.download === false) return false
|
|
167
|
+
} catch {
|
|
168
|
+
/* no site.yml, or unreadable — fetching is the default */
|
|
169
|
+
}
|
|
170
|
+
return true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Read a top-level `$uuid:` scalar from a YAML file.
|
|
174
|
+
//
|
|
175
|
+
// ⛔ THREE STATES, AND COLLAPSING THEM TO `null` IS A LIE TO THE USER.
|
|
176
|
+
// This returned `null` on any throw, so a site.yml that EXISTS and carries a
|
|
177
|
+
// `$uuid:` line but does not PARSE was reported as "this project has no $uuid
|
|
178
|
+
// yet. Run `uniweb push` first." — advice that is wrong, and actively harmful
|
|
179
|
+
// on a cloned site, since `push` would send an empty tree at the uuid the file
|
|
180
|
+
// already holds.
|
|
181
|
+
//
|
|
182
|
+
// It is also invisible: the user greps the file, sees `$uuid:`, and disbelieves
|
|
183
|
+
// the CLI. The clone suite's own comment records an earlier round of exactly
|
|
184
|
+
// this ("an unquoted `@…` scalar matched a regex here while the file did not
|
|
185
|
+
// parse, and `pull` then found no `$uuid`") — that CAUSE was fixed by quoting
|
|
186
|
+
// the ref; the SYMPTOM stayed reachable by any other parse error.
|
|
187
|
+
// Re-surfaced 2026-09-18 by the backend lane, whose clone wrote a `$uuid:` and
|
|
188
|
+
// whose pull then said there was none.
|
|
189
|
+
//
|
|
190
|
+
// ⇒ Name the state. A missing file and a `$uuid`-less file are "no uuid"; a
|
|
191
|
+
// file that will not parse is an error with the parser's own message.
|
|
192
|
+
function readYamlUuid(filePath) {
|
|
193
|
+
let text
|
|
194
|
+
try {
|
|
195
|
+
text = readFileSync(filePath, 'utf8')
|
|
196
|
+
} catch {
|
|
197
|
+
return { uuid: null } // no site.yml — genuinely nothing to pull
|
|
198
|
+
}
|
|
199
|
+
let obj
|
|
200
|
+
try {
|
|
201
|
+
obj = yaml.load(text)
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { uuid: null, unparsed: err.message.split('\n')[0] }
|
|
204
|
+
}
|
|
205
|
+
// Only whether site.yml parses. The uuid itself lives in sync.json now.
|
|
206
|
+
return { uuid: null }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Conditional-pull ETag cache (gitignored `.uniweb/pull-cache.json`): the last ETag
|
|
210
|
+
// seen per lane. The ETag is OPAQUE — cached and echoed verbatim in If-None-Match,
|
|
211
|
+
// never parsed or recomputed (the backend owns the hash; the client treats it as a
|
|
212
|
+
// token). A missing cache just means a full (unconditional) pull.
|
|
213
|
+
function pullCachePath(siteDir) {
|
|
214
|
+
return join(siteDir, '.uniweb', 'pull-cache.json')
|
|
215
|
+
}
|
|
216
|
+
// ⛔ VERSION 2 DISOWNS EVERY ETAG A VERSION-1 PULL WROTE. Before 2026-09-23 a
|
|
217
|
+
// `--merge` that kept a local file whole — every one, in the default layout (see
|
|
218
|
+
// `showAt`) — still cached the ETag of content it never took. Every later pull then
|
|
219
|
+
// answered 304 and the file never caught up. Ignoring those once costs one full pull.
|
|
220
|
+
const PULL_CACHE_VERSION = 2
|
|
221
|
+
function readPullCache(siteDir) {
|
|
222
|
+
try {
|
|
223
|
+
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
224
|
+
return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
|
|
225
|
+
} catch {
|
|
226
|
+
return {}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function writePullCache(siteDir, { content, folder }) {
|
|
230
|
+
const p = pullCachePath(siteDir)
|
|
231
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
232
|
+
writeFileSync(
|
|
233
|
+
p,
|
|
234
|
+
JSON.stringify({ version: PULL_CACHE_VERSION, content, folder }, null, 2) + '\n'
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Extract a single entity `$`-document from a pull response. Tolerant of a raw
|
|
239
|
+
// document, or a `{ document }` / `{ entity }` envelope. (Adjust at live e2e.)
|
|
240
|
+
export function extractDocument(payload) {
|
|
241
|
+
if (!payload || typeof payload !== 'object') return null
|
|
242
|
+
// ⛔ `$uuid` counts: every stored entity has one. Without it, a document naming no
|
|
243
|
+
// `$schema` stopped being a document at all — it vanished from the pull, which then
|
|
244
|
+
// reported "0 record(s)" and succeeded (2026-09-24, when `$model` left this test).
|
|
245
|
+
if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
|
|
246
|
+
return payload.document || payload.entity || null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Split a collections pull (the folder + the entities it references) into the
|
|
250
|
+
// folder document and the record documents. Tolerant of an array, an
|
|
251
|
+
// `{ entities }` / `{ documents }` list, or an explicit `{ folder, records }`.
|
|
252
|
+
export function splitRecordsPull(payload) {
|
|
253
|
+
if (payload?.folder)
|
|
254
|
+
return { folderDoc: payload.folder, recordDocs: payload.records || [] }
|
|
255
|
+
const list = Array.isArray(payload)
|
|
256
|
+
? payload
|
|
257
|
+
: Array.isArray(payload?.entities)
|
|
258
|
+
? payload.entities
|
|
259
|
+
: Array.isArray(payload?.documents)
|
|
260
|
+
? payload.documents
|
|
261
|
+
: null
|
|
262
|
+
if (!list) return { folderDoc: null, recordDocs: [] }
|
|
263
|
+
const docs = list.map(extractDocument).filter(Boolean)
|
|
264
|
+
return {
|
|
265
|
+
folderDoc: docs.find((d) => documentSchema(d) === FOLDER_MODEL) || null,
|
|
266
|
+
recordDocs: docs.filter((d) => documentSchema(d) !== FOLDER_MODEL)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Say why a pull did not place every record it was sent: a headline for an error
|
|
274
|
+
* and the lines under it.
|
|
275
|
+
*
|
|
276
|
+
* ⛔ A data schema named by id, or not named at all, is said first, and instead of the
|
|
277
|
+
* per-record reasons. The folder is found by its data schema's scoped name
|
|
278
|
+
* (`splitRecordsPull`) and a record is placed in `records/<schema>/` by its data
|
|
279
|
+
* schema's scoped name, so a response naming them by id — or naming none — places
|
|
280
|
+
* nothing, and every record is then skipped as "not in the folder", which hides the
|
|
281
|
+
* cause. Measured 2026-09-23: a backend answered the folder lane with ids for the
|
|
282
|
+
* folder and every record, under the key `$schema` replaced (`$model`).
|
|
283
|
+
*
|
|
284
|
+
* @param {{ docs: object[], taken: number, skipped: object[] }} lane - every document
|
|
285
|
+
* the lane carried, how many records were written or already current, and the
|
|
286
|
+
* projection's skips
|
|
287
|
+
* @returns {{ headline: string, lines: string[] }}
|
|
288
|
+
*/
|
|
289
|
+
export function describeUnplacedRecords({ docs, taken, skipped }) {
|
|
290
|
+
const none = taken === 0
|
|
291
|
+
const lead = none ? 'No record' : 'Not every record'
|
|
292
|
+
const placedBy =
|
|
293
|
+
"A record is placed in records/<schema>/ by its data schema's scoped name, so these cannot be placed."
|
|
294
|
+
const byId = [
|
|
295
|
+
...new Set((docs || []).map((d) => documentSchema(d)).filter((m) => m && UUID_RE.test(m)))
|
|
296
|
+
]
|
|
297
|
+
if (byId.length) {
|
|
298
|
+
return {
|
|
299
|
+
headline: `${lead} could be placed — the backend named their data schemas by id, not by scoped name.`,
|
|
300
|
+
lines: [
|
|
301
|
+
`Named by id: ${byId.slice(0, 3).join(', ')}${byId.length > 3 ? ', …' : ''}`,
|
|
302
|
+
placedBy,
|
|
303
|
+
'Your record files are unchanged.'
|
|
304
|
+
]
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const unnamed = (docs || []).filter((d) => !documentSchema(d)).length
|
|
308
|
+
if (unnamed) {
|
|
309
|
+
return {
|
|
310
|
+
headline: `${lead} could be placed — ${unnamed} of the backend's documents name no data schema (\`$schema\`).`,
|
|
311
|
+
lines: [placedBy, 'Your record files are unchanged.']
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const n = skipped.length
|
|
315
|
+
return {
|
|
316
|
+
headline: `${n} record${n === 1 ? '' : 's'} could not be placed — ${n === 1 ? 'its file is' : 'their files are'} unchanged.`,
|
|
317
|
+
lines: skipped.map((s) => `↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Read a pull lane's bytes into entity `$`-documents. The backend serves a `.uwx`
|
|
322
|
+
// (our Stored ZIP: `manifest.json` + `entities/<uuid>.json`); the entity files ARE the
|
|
323
|
+
// documents. Falls back to a JSON body (a raw doc, a `{document}`/`{entity}` envelope,
|
|
324
|
+
// or a list) so the lane survives a future envelope change. Returns an array (possibly
|
|
325
|
+
// empty).
|
|
326
|
+
export function readPullDocuments(buf) {
|
|
327
|
+
// `.uwx` ZIP — the local-file signature is "PK\x03\x04".
|
|
328
|
+
if (buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b) {
|
|
329
|
+
const docs = []
|
|
330
|
+
for (const [name, data] of readZip(buf)) {
|
|
331
|
+
if (name === 'manifest.json' || !name.endsWith('.json')) continue
|
|
332
|
+
try {
|
|
333
|
+
docs.push(JSON.parse(data.toString('utf8')))
|
|
334
|
+
} catch {
|
|
335
|
+
/* skip a non-document entry */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return docs
|
|
339
|
+
}
|
|
340
|
+
// JSON fallback — flatten any envelope splitRecordsPull understands into a
|
|
341
|
+
// flat `$`-document list (a raw doc, a list, `{entities}`/`{documents}`, or
|
|
342
|
+
// `{folder, records}`).
|
|
343
|
+
let payload
|
|
344
|
+
try {
|
|
345
|
+
payload = JSON.parse(buf.toString('utf8'))
|
|
346
|
+
} catch {
|
|
347
|
+
return []
|
|
348
|
+
}
|
|
349
|
+
if (Array.isArray(payload))
|
|
350
|
+
return payload.map(extractDocument).filter(Boolean)
|
|
351
|
+
if (payload?.folder)
|
|
352
|
+
return [payload.folder, ...(payload.records || [])].filter(Boolean)
|
|
353
|
+
const list = Array.isArray(payload?.entities)
|
|
354
|
+
? payload.entities
|
|
355
|
+
: Array.isArray(payload?.documents)
|
|
356
|
+
? payload.documents
|
|
357
|
+
: null
|
|
358
|
+
if (list) return list.map(extractDocument).filter(Boolean)
|
|
359
|
+
const single = extractDocument(payload)
|
|
360
|
+
return single ? [single] : []
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Read the per-entity optimistic-concurrency tokens out of a pull lane's manifest.
|
|
364
|
+
// The backend stamps each entity entry with a top-level `version` (opaque RFC3339);
|
|
365
|
+
// we cache it and echo it back as `base_version` on the next push, so a push
|
|
366
|
+
// against a base the backend has moved past is refused instead of silently
|
|
367
|
+
// overwriting. Returns a `{ <backend-uuid>: <version> }` map — empty for a JSON
|
|
368
|
+
// fallback body (no manifest ⇒ no tokens ⇒ that lane simply stays unconditional).
|
|
369
|
+
//
|
|
370
|
+
// TOP-LEVEL on the entry. The backend's `Entry::extra` is `#[serde(flatten)]`, so
|
|
371
|
+
// it never appears on the wire; reading `extra.version` finds nothing and silently
|
|
372
|
+
// leaves the gate disarmed (which is exactly what it did before 2026-07-26).
|
|
373
|
+
//
|
|
374
|
+
// Keyed by `entries[].uuid`, which on a PULL manifest is the real backend uuid.
|
|
375
|
+
// Note that is NOT symmetric with what we EMIT: on the push side that field is our
|
|
376
|
+
// `$id` handle, and the backend correlates our `base_version` via the body's
|
|
377
|
+
// `$uuid` instead. Same field name, different meaning by direction.
|
|
378
|
+
//
|
|
379
|
+
// This reads the SAME manifest.json that readPullDocuments deliberately skips:
|
|
380
|
+
// there the entity files are the payload, here the manifest is the index we were
|
|
381
|
+
// previously discarding.
|
|
382
|
+
export function readPullVersions(buf) {
|
|
383
|
+
return readManifestTokens(buf).entity
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Per-ITEM staleness tokens: `{ <record $uuid>: <opaque version> }`, from the
|
|
388
|
+
* entry's `item_versions`.
|
|
389
|
+
*
|
|
390
|
+
* The entity token can only say "something in this document moved"; these say
|
|
391
|
+
* which records did, which is what lets two people editing different sections
|
|
392
|
+
* both land instead of one being refused. Echoed back as `item_base_versions`.
|
|
393
|
+
*
|
|
394
|
+
* Keyed by record uuid rather than by our file path deliberately: the token then
|
|
395
|
+
* joins to the body by the same identity the backend's reconcile uses, and a file
|
|
396
|
+
* rename cannot silently re-target it. Absent for an entity with no items, and
|
|
397
|
+
* absent from an older backend — both mean "no per-item precondition", fall back
|
|
398
|
+
* to the entity grain.
|
|
399
|
+
*/
|
|
400
|
+
export function readPullItemVersions(buf) {
|
|
401
|
+
return readManifestTokens(buf).item
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// One manifest read for both grains — they arrive on the same entry and must not
|
|
405
|
+
// drift apart by being parsed in two places.
|
|
406
|
+
function readManifestTokens(buf) {
|
|
407
|
+
const out = { entity: {}, item: {} }
|
|
408
|
+
if (!(buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b)) return out
|
|
409
|
+
for (const [name, data] of readZip(buf)) {
|
|
410
|
+
if (name !== 'manifest.json') continue
|
|
411
|
+
try {
|
|
412
|
+
const manifest = JSON.parse(data.toString('utf8'))
|
|
413
|
+
for (const entry of manifest?.entries || []) {
|
|
414
|
+
if (entry?.uuid && typeof entry.version === 'string')
|
|
415
|
+
out.entity[entry.uuid] = entry.version
|
|
416
|
+
const items = entry?.item_versions
|
|
417
|
+
if (items && typeof items === 'object') {
|
|
418
|
+
for (const [uuid, v] of Object.entries(items)) {
|
|
419
|
+
if (typeof v === 'string') out.item[uuid] = v
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
/* an unreadable manifest just means no tokens this pull */
|
|
425
|
+
}
|
|
426
|
+
break
|
|
427
|
+
}
|
|
428
|
+
return out
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @param {string[]} args
|
|
433
|
+
* @param {object} [deps] - injectable seams for testing: `fetch` (default global
|
|
434
|
+
* fetch), `resolveSiteDir`, `getToken` (skip auth).
|
|
435
|
+
*/
|
|
436
|
+
// Every file under `roots` (site-relative paths, files or directories), site-relative.
|
|
437
|
+
function filesUnder(siteDir, roots) {
|
|
438
|
+
const out = []
|
|
439
|
+
const walk = (rel) => {
|
|
440
|
+
const abs = join(siteDir, rel)
|
|
441
|
+
let stat
|
|
442
|
+
try {
|
|
443
|
+
stat = statSync(abs)
|
|
444
|
+
} catch {
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
if (stat.isDirectory()) {
|
|
448
|
+
for (const name of readdirSync(abs)) walk(join(rel, name))
|
|
449
|
+
} else if (stat.isFile()) {
|
|
450
|
+
out.push(rel)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (const root of roots) walk(root)
|
|
454
|
+
return out
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Refuse a pull that would overwrite unsaved work. Returns a result object to
|
|
459
|
+
* return from `pull`, or null to proceed.
|
|
460
|
+
*
|
|
461
|
+
* With a repo: uncommitted work under pull's roots blocks, naming the files.
|
|
462
|
+
* Without one: there is nothing standing behind the tree, so ask — and in a
|
|
463
|
+
* non-interactive context refuse rather than assume consent.
|
|
464
|
+
*/
|
|
465
|
+
async function checkWorkingTree(siteDir, args) {
|
|
466
|
+
const roots = siteContentRoots(siteDir)
|
|
467
|
+
let dirty = uncommittedUnder(siteDir, roots)
|
|
468
|
+
|
|
469
|
+
if (dirty === null) {
|
|
470
|
+
// Not a git work tree. Nothing to fall back on if this goes wrong — unless nothing here is the
|
|
471
|
+
// author's: every file under pull's roots is one a pull, or the clone that made this site, wrote
|
|
472
|
+
// and nothing has changed since (`pull-written.js`). ⛔ Until 2026-09-26 a fresh clone's own pull,
|
|
473
|
+
// which runs with no terminal to ask, refused here, and `uniweb clone` could not finish.
|
|
474
|
+
const written = readWritten(siteDir)
|
|
475
|
+
if (filesUnder(siteDir, roots).every((f) => isPullOutput(siteDir, f, written))) return null
|
|
476
|
+
if (isNonInteractive(args)) {
|
|
477
|
+
error('Refusing to pull: this site is not in a git repository.')
|
|
478
|
+
note(
|
|
479
|
+
'Pull rewrites pages, sections and layout from the backend and deletes what it no longer has.'
|
|
480
|
+
)
|
|
481
|
+
note(
|
|
482
|
+
'Without version control there is no way back, and this session cannot ask.'
|
|
483
|
+
)
|
|
484
|
+
note(
|
|
485
|
+
'Re-run with --force to accept that, or put the site under git first.'
|
|
486
|
+
)
|
|
487
|
+
return { exitCode: 1 }
|
|
488
|
+
}
|
|
489
|
+
const ok = await confirm(
|
|
490
|
+
`Pull will overwrite ${roots.length} content location(s) and this site is not in git. Continue?`
|
|
491
|
+
)
|
|
492
|
+
if (!ok) {
|
|
493
|
+
info('Nothing pulled.')
|
|
494
|
+
return { exitCode: 0 }
|
|
495
|
+
}
|
|
496
|
+
return null
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const written = readWritten(siteDir)
|
|
500
|
+
dirty = dirty.filter((f) => !isPullOutput(siteDir, f, written))
|
|
501
|
+
if (!dirty.length) return null
|
|
502
|
+
|
|
503
|
+
error(
|
|
504
|
+
`Refusing to pull: ${dirty.length} uncommitted change(s) under the files pull rewrites.`
|
|
505
|
+
)
|
|
506
|
+
for (const f of dirty.slice(0, 12)) note(` ${f}`)
|
|
507
|
+
if (dirty.length > 12) note(` … and ${dirty.length - 12} more`)
|
|
508
|
+
note(
|
|
509
|
+
'Pull reconciles the working tree to the backend, so these would be overwritten or deleted.'
|
|
510
|
+
)
|
|
511
|
+
note(
|
|
512
|
+
'Commit or stash them first — then pull, and your work is still in git either way.'
|
|
513
|
+
)
|
|
514
|
+
note('To discard them and take the backend version, re-run with --force.')
|
|
515
|
+
return { exitCode: 1 }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The written-record helpers live in `utils/pull-written.js` so `clone` can share
|
|
519
|
+
// them: clone scaffolds site.yml/theme.yml and then delegates here, and this
|
|
520
|
+
// module cannot be imported from there — it statically imports `@uniweb/build`,
|
|
521
|
+
// which resolves from a project that does not exist yet when clone runs.
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* `--merge`: keep local work instead of refusing, by three-way merging it with what
|
|
525
|
+
* the backend sends.
|
|
526
|
+
*
|
|
527
|
+
* Without this the recovery from a same-section conflict is commit → pull (which
|
|
528
|
+
* overwrites your version) → re-apply by hand → push. Yet most such "conflicts"
|
|
529
|
+
* aren't: two people edited different paragraphs of one section, and a real merge
|
|
530
|
+
* takes both. Only genuine overlaps need a human.
|
|
531
|
+
*
|
|
532
|
+
* Works by capturing local content BEFORE the projection overwrites it, then
|
|
533
|
+
* merging afterwards — so the projector stays a plain writer and needs no hook, and
|
|
534
|
+
* nothing is lost in between because "mine" is already in memory.
|
|
535
|
+
*
|
|
536
|
+
* Requires a repo, and that is not a mandate creeping in: the ancestor is a
|
|
537
|
+
* committed version, and the merge is git's. There is nothing to merge against
|
|
538
|
+
* without one.
|
|
539
|
+
*
|
|
540
|
+
* ⭐ THE ANCESTOR IS THE VERSION THE LAST PULL WROTE — found in history by the hash
|
|
541
|
+
* the pull recorded (`findCommitted`), HEAD's only for a file no pull ever wrote.
|
|
542
|
+
* HEAD stops being that version the moment someone commits an edit on top of it,
|
|
543
|
+
* and a merge against it read their edit as the starting point: the backend's
|
|
544
|
+
* version won whole, reverting the committed edit (measured 2026-09-23 — commit,
|
|
545
|
+
* push refused, `uniweb refresh`, the edit gone from the working tree). The same
|
|
546
|
+
* reason brings in work that is COMMITTED but was never pushed: clean to git, so a
|
|
547
|
+
* dirty scan cannot see it, yet the projection would overwrite it.
|
|
548
|
+
*
|
|
549
|
+
* @returns {Map<string, {content: Buffer, base: Buffer|null}>|null} captured work,
|
|
550
|
+
* or null when merging isn't possible here (the caller reports and stops).
|
|
551
|
+
*/
|
|
552
|
+
function captureLocalWork(siteDir) {
|
|
553
|
+
const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
|
|
554
|
+
if (dirty === null) return null
|
|
555
|
+
const written = readWritten(siteDir)
|
|
556
|
+
const ancestorOf = (rel) => {
|
|
557
|
+
const recorded = written.files[rel]
|
|
558
|
+
return (recorded && findCommitted(siteDir, rel, recorded)) || showAtHead(siteDir, rel)
|
|
559
|
+
}
|
|
560
|
+
const out = new Map()
|
|
561
|
+
for (const rel of dirty) {
|
|
562
|
+
if (isPullOutput(siteDir, rel, written)) continue // pull's own output, not work
|
|
563
|
+
try {
|
|
564
|
+
out.set(rel, { content: readFileSync(join(siteDir, rel)), base: ancestorOf(rel) })
|
|
565
|
+
} catch {
|
|
566
|
+
// Locally deleted. Pull will restore the backend's copy, which is the
|
|
567
|
+
// sensible reading of "I removed this and then asked for their version".
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Committed work: a file a pull wrote, edited and committed since. Its ancestor is
|
|
571
|
+
// only ever the version the pull wrote — HEAD is the edit itself — and when that is
|
|
572
|
+
// not in history there is none, which the merge treats as such.
|
|
573
|
+
for (const [rel, recorded] of Object.entries(written.files)) {
|
|
574
|
+
if (out.has(rel) || dirty.includes(rel)) continue
|
|
575
|
+
let content
|
|
576
|
+
try {
|
|
577
|
+
content = readFileSync(join(siteDir, rel))
|
|
578
|
+
} catch {
|
|
579
|
+
continue
|
|
580
|
+
}
|
|
581
|
+
if (createHash('sha256').update(content).digest('hex') === recorded) continue // untouched
|
|
582
|
+
out.set(rel, { content, base: findCommitted(siteDir, rel, recorded) })
|
|
583
|
+
}
|
|
584
|
+
return out
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Merge captured work back over what the projection just wrote. Returns a report;
|
|
588
|
+
// `unavailable` lists files whose merge could not run at all — they did NOT take the
|
|
589
|
+
// backend's version, so the caller must not record this pull as taken.
|
|
590
|
+
function mergeLocalWork(siteDir, captured) {
|
|
591
|
+
const clean = []
|
|
592
|
+
const conflicted = []
|
|
593
|
+
const kept = []
|
|
594
|
+
const unavailable = []
|
|
595
|
+
for (const [rel, { content: mine, base }] of captured) {
|
|
596
|
+
const abs = join(siteDir, rel)
|
|
597
|
+
let theirs = null
|
|
598
|
+
try {
|
|
599
|
+
theirs = readFileSync(abs)
|
|
600
|
+
} catch {
|
|
601
|
+
/* pruned by the pull */
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (theirs === null) {
|
|
605
|
+
// The backend no longer has it, but we changed it. Restoring is the
|
|
606
|
+
// non-destructive reading; the alternative silently discards local work to
|
|
607
|
+
// honour a deletion the user never saw.
|
|
608
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
609
|
+
writeFileSync(abs, mine)
|
|
610
|
+
kept.push(`${rel} (removed upstream, kept yours)`)
|
|
611
|
+
continue
|
|
612
|
+
}
|
|
613
|
+
if (theirs.equals(mine)) continue // both sides agree already
|
|
614
|
+
|
|
615
|
+
// ⛔ NO ANCESTOR IS A TWO-WAY MERGE, NOT "KEEP OURS". Both sides hold the file and
|
|
616
|
+
// no common version is on record — added on both sides, or a pull's version that
|
|
617
|
+
// was never committed. Keeping ours hid the backend's version entirely, and the
|
|
618
|
+
// pull then recorded it as taken. An empty ancestor is git's add/add: both
|
|
619
|
+
// versions, under conflict markers, for a person to settle.
|
|
620
|
+
const dir = mkdtempSync(join(tmpdir(), 'uniweb-merge-'))
|
|
621
|
+
try {
|
|
622
|
+
const basePath = join(dir, 'base')
|
|
623
|
+
const theirsPath = join(dir, 'theirs')
|
|
624
|
+
writeFileSync(basePath, base || '')
|
|
625
|
+
writeFileSync(theirsPath, theirs)
|
|
626
|
+
writeFileSync(abs, mine) // merge-file rewrites this in place
|
|
627
|
+
const r = mergeFile(siteDir, abs, basePath, theirsPath)
|
|
628
|
+
if (!r.merged) {
|
|
629
|
+
writeFileSync(abs, mine) // merge couldn't run — never leave a half-state
|
|
630
|
+
kept.push(`${rel} (merge unavailable, kept yours)`)
|
|
631
|
+
unavailable.push(rel)
|
|
632
|
+
} else if (r.conflicted) {
|
|
633
|
+
conflicted.push(rel)
|
|
634
|
+
} else {
|
|
635
|
+
clean.push(rel)
|
|
636
|
+
}
|
|
637
|
+
} finally {
|
|
638
|
+
rmSync(dir, { recursive: true, force: true })
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return { clean, conflicted, kept, unavailable }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Minimal yes/no prompt; defaults to no, because the default must not destroy.
|
|
645
|
+
async function confirm(question) {
|
|
646
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
647
|
+
try {
|
|
648
|
+
const a = (await rl.question(`${question} [y/N] `)).trim().toLowerCase()
|
|
649
|
+
return a === 'y' || a === 'yes'
|
|
650
|
+
} finally {
|
|
651
|
+
rl.close()
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function pull(args = [], deps = {}) {
|
|
656
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a
|
|
657
|
+
// literal scan, so it silently keeps the default (production, for --backend).
|
|
658
|
+
const badFlag = checkFlags('pull', args)
|
|
659
|
+
if (badFlag) {
|
|
660
|
+
error(badFlag.message)
|
|
661
|
+
return { exitCode: 2 }
|
|
662
|
+
}
|
|
663
|
+
const resolveSiteDir = deps.resolveSiteDir || defaultResolveSiteDir
|
|
664
|
+
|
|
665
|
+
const dryRun = args.includes('--dry-run')
|
|
666
|
+
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
667
|
+
const noRecords =
|
|
668
|
+
args.includes('--no-records') || args.includes('--content-only')
|
|
669
|
+
const force = args.includes('--force')
|
|
670
|
+
const mergeMode = args.includes('--merge')
|
|
671
|
+
|
|
672
|
+
const siteDir = await resolveSiteDir(args, 'pull')
|
|
673
|
+
const fetchAssets = shouldFetchAssets(args, siteDir)
|
|
674
|
+
|
|
675
|
+
// Don't overwrite work that isn't saved anywhere. Pull reconciles the working
|
|
676
|
+
// tree to the backend — it rewrites section bodies from the fetched document and
|
|
677
|
+
// prunes what the backend doesn't have — so uncommitted work under the
|
|
678
|
+
// directories it owns is destroyed with no way back. That is the one place the
|
|
679
|
+
// CLI could still lose a user's work without them agreeing to it.
|
|
680
|
+
//
|
|
681
|
+
// Checked coarsely, by root, on purpose: pull rewrites essentially every file
|
|
682
|
+
// under those roots, so an exact per-file intersection would refuse the same
|
|
683
|
+
// cases while being able to miss one.
|
|
684
|
+
// `--merge` keeps local work instead of refusing it: capture what is dirty now,
|
|
685
|
+
// let the projection write the backend's version, then merge ours back over it.
|
|
686
|
+
let captured = null
|
|
687
|
+
let mergeReport = null
|
|
688
|
+
if (!dryRun && mergeMode) {
|
|
689
|
+
captured = captureLocalWork(siteDir)
|
|
690
|
+
if (captured === null) {
|
|
691
|
+
error('Cannot merge: this site is not in a git repository.')
|
|
692
|
+
note(
|
|
693
|
+
'The common ancestor a merge needs is the committed version of each file.'
|
|
694
|
+
)
|
|
695
|
+
note(
|
|
696
|
+
'Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.'
|
|
697
|
+
)
|
|
698
|
+
return { exitCode: 1 }
|
|
699
|
+
}
|
|
700
|
+
} else if (!dryRun && !force) {
|
|
701
|
+
const blocked = await checkWorkingTree(siteDir, args)
|
|
702
|
+
if (blocked) return blocked
|
|
703
|
+
}
|
|
704
|
+
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
705
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
|
|
706
|
+
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
707
|
+
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
708
|
+
// that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
|
|
709
|
+
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
710
|
+
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
711
|
+
// be reached: *"We do not allow any communication with backend if the user is not
|
|
712
|
+
// logged into a backend."*
|
|
713
|
+
const client = new BackendClient({
|
|
714
|
+
getToken: deps.getToken,
|
|
715
|
+
fetchImpl: deps.fetch,
|
|
716
|
+
args,
|
|
717
|
+
command: 'Pulling'
|
|
718
|
+
})
|
|
719
|
+
// One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
|
|
720
|
+
const readModel = makeModelResolver({ client, siteDir })
|
|
721
|
+
|
|
722
|
+
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
723
|
+
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
724
|
+
// wrong-backend pull cannot happen: it would find no uuid and stop below.
|
|
725
|
+
|
|
726
|
+
// One identity per site per backend. Both lanes (content + folder) are keyed by it —
|
|
727
|
+
// the backend resolves the site's `@uniweb/folder` from this uuid.
|
|
728
|
+
const siteYmlPath = join(siteDir, 'site.yml')
|
|
729
|
+
const identity = readYamlUuid(siteYmlPath)
|
|
730
|
+
|
|
731
|
+
// A file that will not parse is an error, not an absence — see readYamlUuid.
|
|
732
|
+
if (identity.unparsed) {
|
|
733
|
+
error(`site.yml did not parse: ${identity.unparsed}`)
|
|
734
|
+
note(siteYmlPath)
|
|
735
|
+
note(
|
|
736
|
+
'Nothing was read, so the $uuid in it (if any) was not seen. Fix the YAML and re-run.'
|
|
737
|
+
)
|
|
738
|
+
return { exitCode: 1 }
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ⛔ FROM sync.json, FOR THIS BACKEND. This read `identity.uuid` — site.yml's `$uuid`
|
|
742
|
+
// — and kept doing so after step 4 moved the key, so EVERY pull of a synced project
|
|
743
|
+
// answered "Nothing to pull — this project has no $uuid yet". site.yml is still
|
|
744
|
+
// parsed above, because a file that will not parse is its own error.
|
|
745
|
+
const siteContentUuid = readBackendState(siteDir, client.origin).site?.uuid || null
|
|
746
|
+
|
|
747
|
+
if (!siteContentUuid) {
|
|
748
|
+
// Following the login can land on a backend with no site for this project while it
|
|
749
|
+
// has one elsewhere — say where, rather than only "push first", which would create a
|
|
750
|
+
// second site. Not when UNIWEB_SERVER chose the backend: that is a decision.
|
|
751
|
+
const known = !process.env.UNIWEB_SERVER
|
|
752
|
+
? syncedElsewhere(siteDir, client.origin)
|
|
753
|
+
: null
|
|
754
|
+
if (known) {
|
|
755
|
+
info(`Nothing to pull — this project has no site on ${client.origin}.`)
|
|
756
|
+
note(
|
|
757
|
+
`Its ${known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`}. To pull from there: uniweb login --server <url>`
|
|
758
|
+
)
|
|
759
|
+
return { exitCode: 0 }
|
|
760
|
+
}
|
|
761
|
+
info(
|
|
762
|
+
`Nothing to pull — this project has not synced with ${client.origin} yet. Run \`uniweb push\` first.`
|
|
763
|
+
)
|
|
764
|
+
return { exitCode: 0 }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (dryRun) {
|
|
768
|
+
info(
|
|
769
|
+
`Dry run — would pull content from ${colors.dim}${client.origin}${colors.reset}`
|
|
770
|
+
)
|
|
771
|
+
if (!noRecords) info(`Dry run — would also pull records`)
|
|
772
|
+
return { exitCode: 0 }
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// The workspace this pull works in — the one chosen with the login, unless this
|
|
776
|
+
// command names another (`workspace.js`). Resolved at the first request, not before:
|
|
777
|
+
// a pull with nothing to pull never needs one. A site kept elsewhere stops it.
|
|
778
|
+
const ws = await resolveWorkspace({ client, args })
|
|
779
|
+
if (ws.refused) {
|
|
780
|
+
error('This pull works in one workspace, and none is chosen.')
|
|
781
|
+
note(ws.reason)
|
|
782
|
+
return { exitCode: 2 }
|
|
783
|
+
}
|
|
784
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
785
|
+
|
|
786
|
+
// GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
|
|
787
|
+
// body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
|
|
788
|
+
// conditional request whose ETag matches returns `{ notModified: true }` (304, empty
|
|
789
|
+
// body). `doRequest` is a thunk returning the client's Response promise. 404 / any
|
|
790
|
+
// failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
|
|
791
|
+
// `{ refused: true }`, which stops the pull: the other lane would only say it again.
|
|
792
|
+
const getDocs = async (label, doRequest) => {
|
|
793
|
+
info(
|
|
794
|
+
`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
|
|
795
|
+
)
|
|
796
|
+
let res
|
|
797
|
+
try {
|
|
798
|
+
res = await doRequest()
|
|
799
|
+
} catch (err) {
|
|
800
|
+
error(describeRequestError(err, client.origin))
|
|
801
|
+
if (err instanceof WorkspaceMismatchError) return { refused: true }
|
|
802
|
+
note('Is that the backend you meant? Switch with: uniweb login --server <url>')
|
|
803
|
+
return null
|
|
804
|
+
}
|
|
805
|
+
if (res.status === 404) {
|
|
806
|
+
note(`${label}: not found (404) — it was deleted, or you lack access.`)
|
|
807
|
+
return null
|
|
808
|
+
}
|
|
809
|
+
if (res.status === 304) {
|
|
810
|
+
note(`${label}: unchanged (304)`)
|
|
811
|
+
return { notModified: true }
|
|
812
|
+
}
|
|
813
|
+
if (!res.ok) {
|
|
814
|
+
error(`${label} pull failed: HTTP ${res.status} ${res.statusText}`)
|
|
815
|
+
const detail = await refusalDetail(res)
|
|
816
|
+
if (detail) note(detail)
|
|
817
|
+
if (res.status === 401 || res.status === 403)
|
|
818
|
+
note(
|
|
819
|
+
"Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
|
|
820
|
+
)
|
|
821
|
+
return null
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
const etag = res.headers?.get?.('etag') ?? null
|
|
825
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
826
|
+
const docs = readPullDocuments(buf)
|
|
827
|
+
// The staleness tokens this lane carried, so the next push is gated against
|
|
828
|
+
// the state we took. Banked by the caller once local work is merged back — a
|
|
829
|
+
// file the merge did not take must not have its token recorded as taken. A 304
|
|
830
|
+
// carries none, correctly: the tokens we hold are still current.
|
|
831
|
+
return {
|
|
832
|
+
docs,
|
|
833
|
+
etag,
|
|
834
|
+
versions: readPullVersions(buf),
|
|
835
|
+
itemVersions: readPullItemVersions(buf)
|
|
836
|
+
}
|
|
837
|
+
} catch (err) {
|
|
838
|
+
error(`Could not read the ${label} response: ${err.message}`)
|
|
839
|
+
return null
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Every file the projection writes, so the next pull can tell its own output
|
|
844
|
+
// apart from the user's work (see readWritten).
|
|
845
|
+
const wrote = []
|
|
846
|
+
const removed = []
|
|
847
|
+
let pages = 0
|
|
848
|
+
let sections = 0
|
|
849
|
+
let records = 0
|
|
850
|
+
let deleted = 0
|
|
851
|
+
// Set when the folder lane carried records this pull did not place — see the ⛔
|
|
852
|
+
// where it is set.
|
|
853
|
+
let recordsNotPlaced = null
|
|
854
|
+
|
|
855
|
+
// Conditional-pull cache: the last ETag seen per lane (opaque token — cached and
|
|
856
|
+
// echoed verbatim, never recomputed). Lives in the gitignored `.uniweb/`.
|
|
857
|
+
const cache = readPullCache(siteDir)
|
|
858
|
+
let etagContent = cache.content
|
|
859
|
+
let etagFolder = cache.folder
|
|
860
|
+
|
|
861
|
+
// ⭐ THE SCOPE THE PULLED RECORDS AND QUERIES WERE QUALIFIED WITH — the pushed site's
|
|
862
|
+
// FOUNDATION's: the scope of the pinned ref it carries (`info.foundation`), else the
|
|
863
|
+
// local foundation's name. A push qualifies `@/x` with it, so this is what undoes it,
|
|
864
|
+
// and a record lands back in `records/article/` rather than `records/acme/article/`.
|
|
865
|
+
// Resolved once, up front — it may load main.js — and handed to the synchronous
|
|
866
|
+
// projections, as Models are. ⛔ It was the site owner's org, from sync.json, until
|
|
867
|
+
// 2026-09-22; a foundation may be registered under any org its author belongs to.
|
|
868
|
+
let pullScope
|
|
869
|
+
const scopeFor = async (doc) => {
|
|
870
|
+
if (pullScope === undefined) {
|
|
871
|
+
const pinned = doc?.info?.foundation
|
|
872
|
+
pullScope = await siteSelfScope(siteDir, pinned !== undefined ? { foundation: pinned } : {})
|
|
873
|
+
}
|
|
874
|
+
return pullScope
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ⛔ `--force` IS UNCONDITIONAL. It means "take the backend's version over mine", and
|
|
878
|
+
// an ETag answers a different question — whether the BACKEND moved, never whether
|
|
879
|
+
// the files still hold what it sent. Echoed here, a `git checkout` followed by
|
|
880
|
+
// `pull --force` got a 304 and left the checked-out files in place (2026-09-23).
|
|
881
|
+
const conditional = !force
|
|
882
|
+
// Lane 1 — content → config + pages/** + layout/**. The .uwx carries a single
|
|
883
|
+
// entity (the site-content document). A 304 (unchanged) leaves local files as-is.
|
|
884
|
+
const content = await getDocs('content', () =>
|
|
885
|
+
client.pullSiteContent(siteContentUuid, { etag: conditional ? etagContent : undefined })
|
|
886
|
+
)
|
|
887
|
+
if (content?.refused) return { exitCode: 1 }
|
|
888
|
+
// The Models the pulled site's queries name, as the push qualified them — the re-bank
|
|
889
|
+
// below resolves with them too (see there).
|
|
890
|
+
let pulledQueryModels = []
|
|
891
|
+
// The versions this copy holds after the pull: every site-content item the pulled
|
|
892
|
+
// document carries (`pulledItemVersions`), banked below once the files have taken it.
|
|
893
|
+
let pulledVersions = null
|
|
894
|
+
if (content && !content.notModified) {
|
|
895
|
+
const siteDoc =
|
|
896
|
+
content.docs &&
|
|
897
|
+
(content.docs.find((d) => d?.info || documentSchema(d)) ||
|
|
898
|
+
content.docs[0] ||
|
|
899
|
+
null)
|
|
900
|
+
if (siteDoc) {
|
|
901
|
+
pulledQueryModels = (Array.isArray(siteDoc.queries) ? siteDoc.queries : [])
|
|
902
|
+
.map((q) => q?.schema)
|
|
903
|
+
.filter((s) => typeof s === 'string' && /^@[^/]+\//.test(s))
|
|
904
|
+
// Re-base the page attribution. The pulled document IS the backend's own
|
|
905
|
+
// representation, so it becomes the remote base directly. We deliberately
|
|
906
|
+
// CLEAR the local base rather than reuse it: our source files were just
|
|
907
|
+
// rewritten from this document, and what we would emit from them is not
|
|
908
|
+
// byte-identical to it, so the old local base no longer describes anything.
|
|
909
|
+
// The next push re-establishes it; until then our side reports as unknown,
|
|
910
|
+
// which is honest rather than wrong.
|
|
911
|
+
writeUnitBases(siteDir, client.origin, { remote: computeUnitHashes(siteDoc), local: {} })
|
|
912
|
+
pulledVersions = pulledItemVersions(siteDoc, content.itemVersions)
|
|
913
|
+
// Per-item identity for the next push. Without it the backend reads our
|
|
914
|
+
// records as new and re-mints every page and section row.
|
|
915
|
+
writeItemUuids(siteDir, client.origin, collectUnitUuids(siteDoc))
|
|
916
|
+
// The collections section's identity has no file to live in either — same
|
|
917
|
+
// reason, same remedy, keyed by name. A pull is the other route by which a
|
|
918
|
+
// copy can recover it (see readQueryUuids).
|
|
919
|
+
writeQueryUuids(siteDir, client.origin, collectQueryUuids(siteDoc))
|
|
920
|
+
// Bring the media down BEFORE projecting: a newly-landed asset gains a map
|
|
921
|
+
// entry, and the projection reads that map to put authored paths back. Run
|
|
922
|
+
// after, and this pull's new assets would project as URLs and only restore
|
|
923
|
+
// on the NEXT pull — a lag nobody would attribute to ordering.
|
|
924
|
+
//
|
|
925
|
+
// Declining is a project-level choice (`site.yml::assets.download: false`)
|
|
926
|
+
// with a per-run override (`--no-assets`), because "we do not want the
|
|
927
|
+
// bytes" is usually a property of the project and only sometimes of the
|
|
928
|
+
// invocation (CI). Either way the content keeps its URL and still renders.
|
|
929
|
+
if (fetchAssets) {
|
|
930
|
+
const dl = await downloadMissingAssets({
|
|
931
|
+
document: siteDoc,
|
|
932
|
+
siteDir,
|
|
933
|
+
origin: client.origin,
|
|
934
|
+
onProgress: (m) => note(` ${m}`),
|
|
935
|
+
warn: (m) => note(`! ${m}`)
|
|
936
|
+
})
|
|
937
|
+
if (dl.downloaded.length) {
|
|
938
|
+
note(`Assets : ${dl.downloaded.length} downloaded`)
|
|
939
|
+
}
|
|
940
|
+
if (dl.failed.length) {
|
|
941
|
+
note(
|
|
942
|
+
`Assets : ${dl.failed.length} could not be fetched — content keeps their URL`
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ⛔ Do NOT overwrite a workspace project's `site.yml::foundation`.
|
|
948
|
+
//
|
|
949
|
+
// Same principle as restoring authored asset paths above, and the same
|
|
950
|
+
// failure it prevents: a round trip must not mangle what the author wrote.
|
|
951
|
+
// `publish` stamps the RELEASED, version-pinned ref into `info.foundation`
|
|
952
|
+
// (delivery is version-pinned end to end), so projecting the stored value
|
|
953
|
+
// back turns `foundation: src` into `@org/x@1.2.3` — which the build then
|
|
954
|
+
// REFUSES to resolve, leaving the project unable to `build`, `dev` or
|
|
955
|
+
// `export`. It stays publishable throughout, so nothing surfaces it.
|
|
956
|
+
//
|
|
957
|
+
// ⚖️ Only suppressed when the AUTHORED value resolves to a local foundation.
|
|
958
|
+
// A project from `uniweb clone` has no local foundation on disk, and there
|
|
959
|
+
// the pinned ref is exactly what site.yml should say — so this is a
|
|
960
|
+
// question about which project shape we are writing into, not a blanket
|
|
961
|
+
// "never project it".
|
|
962
|
+
//
|
|
963
|
+
// Lazily imported: the resolver lives behind `@uniweb/build`'s root, which
|
|
964
|
+
// pulls the vite chain, and this file deliberately imports only the `uwx`
|
|
965
|
+
// leaf. Reused rather than reimplemented so "which foundation" cannot drift
|
|
966
|
+
// from what the build itself resolves.
|
|
967
|
+
let keepAuthoredFoundation = false
|
|
968
|
+
try {
|
|
969
|
+
const { resolveLocalFoundation } = await import(
|
|
970
|
+
'../backend/foundation-bring-along.js'
|
|
971
|
+
)
|
|
972
|
+
const authored = yaml.load(
|
|
973
|
+
readFileSync(join(siteDir, 'site.yml'), 'utf8')
|
|
974
|
+
)
|
|
975
|
+
keepAuthoredFoundation = Boolean(
|
|
976
|
+
resolveLocalFoundation(siteDir, authored)
|
|
977
|
+
)
|
|
978
|
+
} catch {
|
|
979
|
+
// No site.yml, unreadable, or nothing local — project the stored value,
|
|
980
|
+
// which is the pre-existing behaviour and right for a fresh clone.
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// A foundation named by catalog ref — a clone's: keep its registered version before the
|
|
984
|
+
// queries are written, since its section types are what type a query named for a data key.
|
|
985
|
+
if (!keepAuthoredFoundation) {
|
|
986
|
+
await ensureRegisteredFoundation({ client, siteDir, siteUuid: siteContentUuid, ref: siteDoc?.info?.foundation })
|
|
987
|
+
}
|
|
988
|
+
const report = siteContentDocumentToProject({
|
|
989
|
+
document: siteDoc,
|
|
990
|
+
siteRoot: siteDir,
|
|
991
|
+
// Which backend's asset ids to read back as the author's own paths. Without
|
|
992
|
+
// it a pull leaves every image pointing at a backend route.
|
|
993
|
+
backend: client.origin,
|
|
994
|
+
prune,
|
|
995
|
+
keepAuthoredFoundation,
|
|
996
|
+
// So a query's `@acme/member` comes back as the author's `@/member`.
|
|
997
|
+
scope: await scopeFor(siteDoc),
|
|
998
|
+
// A translated data block is read back by what its section's type declares it is, as the
|
|
999
|
+
// push translated it — after the registered foundation above, whose section types say it.
|
|
1000
|
+
dataModel: await dataBlockModels(siteDir)
|
|
1001
|
+
})
|
|
1002
|
+
wrote.push(...report.pages, ...report.sections, ...report.layout)
|
|
1003
|
+
removed.push(
|
|
1004
|
+
...(report.deleted || []),
|
|
1005
|
+
...(report.renamed || []).map((r) => r.from)
|
|
1006
|
+
)
|
|
1007
|
+
pages += report.pages.length
|
|
1008
|
+
sections += report.sections.length
|
|
1009
|
+
deleted += report.deleted.length
|
|
1010
|
+
}
|
|
1011
|
+
if (content.etag) etagContent = content.etag
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Lane 2 — folder → the folder + record files, keyed by the SAME site-content uuid
|
|
1015
|
+
// (the backend resolves the site's `@uniweb/folder` from it; the framework never
|
|
1016
|
+
// holds a folder uuid). Models are resolved by name (async) up front, so
|
|
1017
|
+
// recordsToProject keeps its synchronous contract. A 304 leaves files as-is.
|
|
1018
|
+
let folderLane = null
|
|
1019
|
+
// The record documents this pull took — the re-bank below pairs their list items with the
|
|
1020
|
+
// files just written.
|
|
1021
|
+
let pulledRecordDocs = []
|
|
1022
|
+
// The Models this pull read from the backend, by name — the re-bank below resolves with
|
|
1023
|
+
// them, since a clone's foundation is not in the project to resolve them offline.
|
|
1024
|
+
let pulledDeclarations = null
|
|
1025
|
+
if (!noRecords) {
|
|
1026
|
+
const folder = await getDocs('records', () =>
|
|
1027
|
+
client.pullFolder(siteContentUuid, { etag: conditional ? etagFolder : undefined })
|
|
1028
|
+
)
|
|
1029
|
+
folderLane = folder
|
|
1030
|
+
if (folder?.refused) return { exitCode: 1 }
|
|
1031
|
+
if (folder && !folder.notModified && folder.docs?.length) {
|
|
1032
|
+
const { folderDoc, recordDocs } = splitRecordsPull(folder.docs)
|
|
1033
|
+
pulledRecordDocs = recordDocs
|
|
1034
|
+
const resolveModel = readModel
|
|
1035
|
+
const declByModel = new Map()
|
|
1036
|
+
pulledDeclarations = declByModel
|
|
1037
|
+
for (const model of [
|
|
1038
|
+
...new Set(recordDocs.map((d) => documentSchema(d)).filter(Boolean))
|
|
1039
|
+
]) {
|
|
1040
|
+
try {
|
|
1041
|
+
declByModel.set(model, await resolveModel(model))
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
note(`! could not resolve model ${model}: ${err.message}`)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
// ⛔ NO QUERY CONFIG. A record's home is decided by what it IS — its data
|
|
1047
|
+
// schema (`documentSchema`) names the pool folder — not by any query that happens to select
|
|
1048
|
+
// it. Handed the foundation's scope (`scopeFor`), `recordsToProject` places a
|
|
1049
|
+
// `@/x` model the producer resolved to `@acme/x` back where the author wrote it.
|
|
1050
|
+
const report = recordsToProject({
|
|
1051
|
+
folderDoc,
|
|
1052
|
+
recordDocs,
|
|
1053
|
+
siteRoot: siteDir,
|
|
1054
|
+
opts: {
|
|
1055
|
+
// Whose record map to read and extend.
|
|
1056
|
+
backend: client.origin,
|
|
1057
|
+
resolveDeclaration: (name) => declByModel.get(name) || null,
|
|
1058
|
+
// The scope a model's `@/x` was qualified with, so `@acme/article` is placed
|
|
1059
|
+
// back in `records/article/` — see `scopeFor`.
|
|
1060
|
+
scope: await scopeFor(null)
|
|
1061
|
+
}
|
|
1062
|
+
})
|
|
1063
|
+
// ⭐ THE FOLDER'S PLACEMENT IDENTITY, from the folder this pull took — as a push banks
|
|
1064
|
+
// it from the folder it sent. ⛔ Only a push banked it until 2026-09-25, so a copy that
|
|
1065
|
+
// had never pushed (every clone) sent its first folder with its placements uuid-less,
|
|
1066
|
+
// and the backend refused that push (`identity_required`, section `contents`) —
|
|
1067
|
+
// measured on a template clone's first edit.
|
|
1068
|
+
if (folderDoc) writeFolderItemUuids(siteDir, client.origin, collectFolderItemUuids(folderDoc))
|
|
1069
|
+
// The folder's organization, `folder.yml` in the records directory — named as
|
|
1070
|
+
// the author would write it (`records/folder.yml`, or under `paths.records`).
|
|
1071
|
+
if (report.records === 'updated') {
|
|
1072
|
+
info(`Wrote ${report.recordsFile}`)
|
|
1073
|
+
wrote.push(resolve(siteDir, report.recordsFile))
|
|
1074
|
+
} else if (report.records === 'removed') {
|
|
1075
|
+
// The backend's folder has no sub-folders, and the file holds nothing else.
|
|
1076
|
+
info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
|
|
1077
|
+
removed.push(resolve(siteDir, report.recordsFile))
|
|
1078
|
+
}
|
|
1079
|
+
records += report.placed.length + report.updated.length
|
|
1080
|
+
// ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
|
|
1081
|
+
// places each one, as media are fetched. A file that did not arrive is a record not taken —
|
|
1082
|
+
// reported, and the pull fails below as it does for any, so the next one fetches it again.
|
|
1083
|
+
if (report.fileDownloads?.length) {
|
|
1084
|
+
const got = await downloadFileRecords({
|
|
1085
|
+
downloads: report.fileDownloads,
|
|
1086
|
+
siteDir,
|
|
1087
|
+
origin: client.origin,
|
|
1088
|
+
onProgress: (m) => note(` ${m}`),
|
|
1089
|
+
warn: (m) => note(`! ${m}`)
|
|
1090
|
+
})
|
|
1091
|
+
records += got.downloaded.length
|
|
1092
|
+
wrote.push(...got.downloaded)
|
|
1093
|
+
if (got.failed.length) {
|
|
1094
|
+
report.skipped.push(
|
|
1095
|
+
...got.failed.map((path) => ({ slug: relative(siteDir, path), reason: 'its file could not be fetched' }))
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// ⛔ A PULL THAT DID NOT PLACE A RECORD HAS NOT TAKEN THE BACKEND'S RECORDS.
|
|
1100
|
+
// Until 2026-09-23 each skip was a dim note, the pull said "✓ Pulled … 0
|
|
1101
|
+
// record(s)" and exited 0, and it banked the lane's ETag and tokens. So the next
|
|
1102
|
+
// pull would hear 304 and never retry, and a `refresh && push` would go on to
|
|
1103
|
+
// push — and a push sends the folder whole (`utils/records-guard.js`), so the
|
|
1104
|
+
// skipped records would leave it. It is an error now, the lane is not recorded
|
|
1105
|
+
// as taken (below), and the exit is non-zero (the end).
|
|
1106
|
+
if (report.skipped.length) {
|
|
1107
|
+
recordsNotPlaced = describeUnplacedRecords({
|
|
1108
|
+
docs: folder.docs,
|
|
1109
|
+
taken: report.placed.length + report.updated.length + report.unchanged.length,
|
|
1110
|
+
skipped: report.skipped
|
|
1111
|
+
})
|
|
1112
|
+
error(recordsNotPlaced.headline)
|
|
1113
|
+
for (const line of recordsNotPlaced.lines) note(` ${line}`)
|
|
1114
|
+
}
|
|
1115
|
+
for (const w of report.warnings) note(`! ${w}`)
|
|
1116
|
+
}
|
|
1117
|
+
if (folder?.etag) etagFolder = folder.etag
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ⛔ WHAT THIS PULL WROTE, AND THE HASHES A PUSH DIFFS AGAINST, DESCRIBE THE
|
|
1121
|
+
// BACKEND'S CONTENT — so both are taken over the projection, BEFORE local work is
|
|
1122
|
+
// merged back. Taken after it, as they were until 2026-09-23, they recorded the
|
|
1123
|
+
// merged-in local edits as pull output and as already pushed: the next push said
|
|
1124
|
+
// "Nothing to push", `status` said in sync, and the edit never left this machine —
|
|
1125
|
+
// while a later plain pull would have overwritten it as its own output.
|
|
1126
|
+
//
|
|
1127
|
+
// Config files are rewritten every pull whether or not they changed; include the
|
|
1128
|
+
// ones the projector owns so a pull-then-pull doesn't trip on them either.
|
|
1129
|
+
recordWritten(
|
|
1130
|
+
siteDir,
|
|
1131
|
+
[
|
|
1132
|
+
...wrote,
|
|
1133
|
+
// The records folder's `folder.yml` is in `wrote` when this pull wrote it.
|
|
1134
|
+
...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
|
|
1135
|
+
join(siteDir, f)
|
|
1136
|
+
)
|
|
1137
|
+
],
|
|
1138
|
+
removed
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
// ⛔ RE-BANK THE SEND-ONLY-CHANGED HASHES OVER WHAT WE JUST WROTE.
|
|
1142
|
+
//
|
|
1143
|
+
// The projection above is canonical, not byte-identical to what was on disk: it
|
|
1144
|
+
// moves section ordering out of filename prefixes (`1-hero.md` → `hero.md` plus an
|
|
1145
|
+
// explicit `sections:` list) and stamps each section's `id`. Lossless, and a
|
|
1146
|
+
// different document — which is exactly why the `local` unit base is cleared above.
|
|
1147
|
+
//
|
|
1148
|
+
// ⚠️ That same reasoning was never carried to the hashes, so they were left STALE
|
|
1149
|
+
// rather than unknown, and `uniweb status` reported unpushed content immediately
|
|
1150
|
+
// after a pull, permanently. Measured on matinee 2026-08-29: push → pull reported
|
|
1151
|
+
// 1 changed of 8, with nothing edited in between.
|
|
1152
|
+
//
|
|
1153
|
+
// Re-banking rather than clearing, because after a pull the on-disk state IS the
|
|
1154
|
+
// agreed state — it came from the backend, so a push with no edits should send
|
|
1155
|
+
// nothing. Clearing would make it send everything.
|
|
1156
|
+
//
|
|
1157
|
+
// Best-effort: a failure here costs an unnecessary re-send on the next push, never
|
|
1158
|
+
// wrong content, and must not fail a pull whose files are already written.
|
|
1159
|
+
// Nothing projected — both lanes unchanged (304) — leaves the banked state describing the
|
|
1160
|
+
// files as they are, so there is nothing to re-bank. Re-banking anyway only failed, noisily,
|
|
1161
|
+
// where the Models cannot be resolved offline (a clone's).
|
|
1162
|
+
const projected = Boolean(content && !content.notModified) || Boolean(folderLane && !folderLane.notModified)
|
|
1163
|
+
if (!dryRun && projected) {
|
|
1164
|
+
try {
|
|
1165
|
+
// ⭐ With the Models this pull read, and those its queries name — a query may name a
|
|
1166
|
+
// schema no record uses yet. The re-bank is offline, and a site whose foundation is not
|
|
1167
|
+
// in the project (a clone's) resolves none of them otherwise: every template clone
|
|
1168
|
+
// said "could not re-bank" until 2026-09-25, and banked nothing.
|
|
1169
|
+
const declarations = pulledDeclarations || new Map()
|
|
1170
|
+
for (const model of pulledQueryModels) {
|
|
1171
|
+
if (declarations.has(model)) continue
|
|
1172
|
+
try {
|
|
1173
|
+
declarations.set(model, await readModel(model))
|
|
1174
|
+
} catch {
|
|
1175
|
+
// Unreadable here: the re-bank says so if it needs it.
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
await rebankSyncHashes(siteDir, client.origin, {
|
|
1179
|
+
recordDocs: pulledRecordDocs,
|
|
1180
|
+
...(declarations.size ? { declarations } : {}),
|
|
1181
|
+
fields: await deploymentFields({ client, siteDir })
|
|
1182
|
+
})
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
note(`! could not re-bank the sync cache: ${err.message}`)
|
|
1185
|
+
note(' The next push will re-send content that is already current.')
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Merge captured local work back over what the projection just wrote. Runs after
|
|
1190
|
+
// BOTH lanes, so a file either lane produced is merged the same way.
|
|
1191
|
+
if (captured && captured.size) {
|
|
1192
|
+
mergeReport = mergeLocalWork(siteDir, captured)
|
|
1193
|
+
const r = mergeReport
|
|
1194
|
+
if (r.clean.length) {
|
|
1195
|
+
info(`Merged your changes into ${r.clean.length} file(s):`)
|
|
1196
|
+
for (const f of r.clean) note(` ${f}`)
|
|
1197
|
+
}
|
|
1198
|
+
for (const f of r.kept) note(`\u21b7 ${f}`)
|
|
1199
|
+
if (r.conflicted.length) {
|
|
1200
|
+
error(`${r.conflicted.length} file(s) have conflicts to resolve:`)
|
|
1201
|
+
for (const f of r.conflicted) note(` ${f}`)
|
|
1202
|
+
note('Conflict markers are in place. Resolve them, then commit and push.')
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ⛔ THE TOKENS AND ETAGS SAY "THIS COPY HOLDS THE BACKEND'S VERSION" — so they are
|
|
1207
|
+
// banked only once every file has taken it, merged or in conflict. A token banked
|
|
1208
|
+
// for a file that kept ours makes the next push of it read as "only you changed it"
|
|
1209
|
+
// and overwrite the backend's version; an ETag banked for it makes every later pull
|
|
1210
|
+
// answer 304, so the file never catches up. Measured 2026-09-23, both: a refused
|
|
1211
|
+
// push, `uniweb refresh`, and a push that quietly put back a heading another writer
|
|
1212
|
+
// had replaced. When a merge could not run, nothing of this pull is recorded as
|
|
1213
|
+
// taken, and the next pull fetches it again.
|
|
1214
|
+
const unavailable = mergeReport?.unavailable || []
|
|
1215
|
+
if (unavailable.length) {
|
|
1216
|
+
note("! Not recorded as taken — a merge could not run, so those files keep yours.")
|
|
1217
|
+
note(' Pushing them is refused until a pull merges them; fix git, then `uniweb pull --merge` again.')
|
|
1218
|
+
} else {
|
|
1219
|
+
for (const lane of [content, folderLane]) {
|
|
1220
|
+
if (!lane || lane.notModified || lane.refused) continue
|
|
1221
|
+
// Records this pull did not place were not taken, so neither is their lane.
|
|
1222
|
+
if (lane === folderLane && recordsNotPlaced) continue
|
|
1223
|
+
mergeBaseVersions(siteDir, client.origin, lane.versions)
|
|
1224
|
+
}
|
|
1225
|
+
// ⭐ The site-content items' versions REPLACE what this copy held: a pull is how it
|
|
1226
|
+
// sees the site, and a version kept for an item the site no longer has would be sent
|
|
1227
|
+
// again, asking the backend to delete it. The folder lane's are not kept — that lane
|
|
1228
|
+
// is gated by its entity version. (Both were merged into one map until 2026-10-07.)
|
|
1229
|
+
if (pulledVersions && content && !content.notModified && !content.refused) {
|
|
1230
|
+
writeItemBaseVersions(siteDir, client.origin, pulledVersions)
|
|
1231
|
+
}
|
|
1232
|
+
// Persist the ETags so the next pull is conditional (304 when unchanged). The
|
|
1233
|
+
// folder's is DROPPED when records were not placed, so the next pull fetches
|
|
1234
|
+
// them again rather than hearing 304 about a state it never took.
|
|
1235
|
+
writePullCache(siteDir, {
|
|
1236
|
+
content: etagContent,
|
|
1237
|
+
folder: recordsNotPlaced ? null : etagFolder
|
|
1238
|
+
})
|
|
1239
|
+
}
|
|
1240
|
+
if (recordsNotPlaced) {
|
|
1241
|
+
note('Records not recorded as taken — the next pull fetches them again.')
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const summary =
|
|
1245
|
+
`Pulled — ${pages} page(s), ${sections} section(s), ${records} record(s)` +
|
|
1246
|
+
(deleted ? `, ${deleted} deleted` : '')
|
|
1247
|
+
if (recordsNotPlaced) info(summary)
|
|
1248
|
+
else success(summary)
|
|
1249
|
+
// Unresolved conflicts are a non-zero exit, the way a conflicted `git merge` is.
|
|
1250
|
+
// The pull itself worked; there is work left for a human. This is what makes a
|
|
1251
|
+
// chained `uniweb pull --merge && uniweb push` safe by construction — without it,
|
|
1252
|
+
// the obvious one-liner pushes conflict markers into live content.
|
|
1253
|
+
// Records it could not place are the same: the pull ran, and what it was sent is not
|
|
1254
|
+
// all in the files — a chained push must not go on as if it were.
|
|
1255
|
+
const conflicts = mergeReport?.conflicted?.length ?? 0
|
|
1256
|
+
return { exitCode: conflicts || recordsNotPlaced ? 1 : 0, merge: mergeReport }
|
|
1257
|
+
}
|