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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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
+ }