uniweb 1.21.6 → 1.21.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.21.6",
3
+ "version": "1.21.8",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.3.2",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.5.21",
44
- "@uniweb/core": "~1.4.8",
45
- "@uniweb/schemas": "~1.6.5",
44
+ "@uniweb/content-writer": "~1.3.1",
45
+ "@uniweb/core": "~1.4.9",
46
46
  "@uniweb/semantic-parser": "~1.13.0",
47
- "@uniweb/content-writer": "~1.3.1"
47
+ "@uniweb/schemas": "~1.6.5"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "~1.10.6",
51
- "@uniweb/semantic-parser": "~1.13.0",
52
- "@uniweb/content-reader": "~1.5.7"
50
+ "@uniweb/build": "~1.10.7",
51
+ "@uniweb/content-reader": "~1.5.7",
52
+ "@uniweb/semantic-parser": "~1.13.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2862,7 +2862,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
2862
2862
  | Any static host at all | `uniweb export` | full control; no Uniweb account needed |
2863
2863
  | Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
2864
2864
 
2865
- `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs, and `uniweb status` says so — as it says when a target in `deploy.yml` was never deployed from this copy. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2865
+ `uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs — `uniweb status` says so, and `uniweb add ci` refuses to write one there — as it says when a target in `deploy.yml` was never deployed from this copy. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
2866
2866
 
2867
2867
  Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
2868
2868
 
@@ -2916,6 +2916,8 @@ A site can be in more than one place — usually while it moves from one host to
2916
2916
 
2917
2917
  **Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
2918
2918
 
2919
+ **Moving a synced project to another repository.** Where the copy last met the backend is in its git history, and in `.uniweb/`, which git never holds — both stay behind; `sync.json` comes along, so the copy still knows its site. Move one this way: `uniweb sync` in the old copy, then copy the project in without its `.git/` and commit it, then sign in to the site's workspace and `uniweb pull` once — it records where the copy and the site meet, with nothing to settle when the old copy had synced. When it had not, `uniweb status` says the copy shares no history with the site's content and lists the files that say something else; the first pull makes each a conflict. Settle each by what you know: an edit made in the app since the last push is the backend's side, a change made in the copy and never pushed is yours — ask the person when you cannot tell. Never `--force` it in either direction: one side's edits would be gone from every file.
2920
+
2919
2921
  **`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. In a git repository the push commits it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
2920
2922
 
2921
2923
  **`services:` in `site.yml` is where a site says which services it uses** — site search, form
@@ -1012,6 +1012,8 @@ export function untakenBase(siteDir, backend) {
1012
1012
  }
1013
1013
  return {
1014
1014
  headline: "Not pushed — your branch does not hold the backend content this copy last took.",
1015
+ // Never taken here — a fresh copy, or one brought from another repository — not lost.
1016
+ ...(point && held ? {} : { never: true }),
1015
1017
  lines: [
1016
1018
  point && held
1017
1019
  ? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
@@ -1980,6 +1982,24 @@ async function comparisonEmit(
1980
1982
  })
1981
1983
  }
1982
1984
 
1985
+ /**
1986
+ * What a push would send from the files as they stand, every entity hashed — the same emit
1987
+ * the banked hashes are compared with (`comparisonEmit`) — or null when the files do not emit
1988
+ * (a key the build refuses, say). Two folders whose hashes agree say the same thing to the
1989
+ * backend, however their files are written: a pull with no common history reads that to leave
1990
+ * a file alone (`commands/pull.js`).
1991
+ *
1992
+ * @returns {Promise<Record<string,string>|null>}
1993
+ */
1994
+ export async function entityHashes(siteDir, backend = null, { declarations, fields = null } = {}) {
1995
+ try {
1996
+ const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
1997
+ return pkg.hashes || {}
1998
+ } catch {
1999
+ return null
2000
+ }
2001
+ }
2002
+
1983
2003
  /**
1984
2004
  * Re-bank the send-only-changed hashes over the files as they NOW stand.
1985
2005
  *
@@ -44,6 +44,7 @@ import {
44
44
  resolveUniqueName
45
45
  } from '../utils/names.js'
46
46
  import { findWorkspaceRoot } from '../utils/workspace.js'
47
+ import { repoOf } from '../utils/sync-point.js'
47
48
  import {
48
49
  normalizeFoundationName,
49
50
  ensureFoundationName,
@@ -1479,6 +1480,20 @@ export default function ${name}({ content, params }) {
1479
1480
  * and can plug in later.
1480
1481
  */
1481
1482
  async function addCi(rootDir, opts, pm = 'pnpm') {
1483
+ // ⛔ A PROJECT THAT IS A FOLDER OF A LARGER REPOSITORY gets no workflow: GitHub runs workflows
1484
+ // only from a repository's top, and this writes into the project's own `.github/`, where it
1485
+ // would never run. Refused rather than written, for now [Diego, 2026-10-26: "refuse for now"] —
1486
+ // measured on a repository of client projects, one of which carried a GitHub Pages workflow
1487
+ // from its old repository that had not run since it moved.
1488
+ const repo = repoOf(rootDir)
1489
+ if (repo?.prefix) {
1490
+ error(`Not added: this project is the folder ${repo.path} of the repository at ${repo.top}.`)
1491
+ log(` GitHub runs workflows only from the top of a repository (${relative(rootDir, join(repo.top, '.github', 'workflows'))}/),`)
1492
+ log(` so one written in this project's own .github/ would never run.`)
1493
+ log(` Deploy from a repository of its own, or by hand from here: uniweb deploy --host=<adapter>.`)
1494
+ process.exit(1)
1495
+ }
1496
+
1482
1497
  // Lazy-load the host registry so the CLI doesn't pay this import on
1483
1498
  // every add invocation.
1484
1499
  let getAdapter, listAdapters
@@ -97,6 +97,7 @@ import {
97
97
  makeModelResolver,
98
98
  ensureRegisteredFoundation,
99
99
  deploymentFields,
100
+ entityHashes,
100
101
  rebankSyncHashes,
101
102
  writeQueryUuids,
102
103
  mergeBaseVersions,
@@ -118,6 +119,7 @@ import {
118
119
  madeOnto,
119
120
  baseFor,
120
121
  openStandIn,
122
+ fileAt,
121
123
  mergeSyncPoint,
122
124
  takeSyncPoint,
123
125
  snapshotFiles,
@@ -565,6 +567,100 @@ function restore(path, bytes) {
565
567
  else writeFileSync(path, bytes)
566
568
  }
567
569
 
570
+ /**
571
+ * ⭐ WITH NO COMMON HISTORY, A FILE THAT SAYS WHAT THE BACKEND HOLDS IS LEFT AS IT IS.
572
+ *
573
+ * A project copied into another repository leaves its sync points in the old history, so its
574
+ * first pull merges two histories that share nothing: every file the projection rewrites is a
575
+ * conflict, this copy's against the backend's. The writer already keeps an author's text where it
576
+ * would write the same document (`project-writer.js`, "an unchanged body keeps the author's own
577
+ * text"); what it rewrites, this tries once more, two ways. Every hash a push would send, with the
578
+ * backend's file put in among this copy's (`entityHashes`): where none moves, the two say the
579
+ * same. And the same asset written two ways, which a push sends as different data
580
+ * (`sameButAssetForms`). Either way this copy's text goes into the backend's commit, and the merge
581
+ * has nothing to settle there. A backend file that does not emit at all — a key the build
582
+ * refuses — says something else, and stays the backend's.
583
+ *
584
+ * Measured 2026-10-26 on two sites copied out of their own repositories: six conflicts, three of
585
+ * them content — a logo added in the app, a key the copy had renamed on both sites — and three
586
+ * gallery records naming their images by URL where the backend's files name them by path.
587
+ *
588
+ * @returns {Promise<string[]>} the files left as this copy writes them
589
+ */
590
+ async function keepWhatMeansTheSame({ standIn, repo, origin, declarations, fields }) {
591
+ const dir = standIn.siteDir
592
+ const ours = new Map()
593
+ const theirs = new Map()
594
+ for (const [status, path] of standIn.changes()) {
595
+ if (status !== 'M') continue
596
+ const head = fileAt(repo, 'HEAD', path)
597
+ const now = bytesOf(join(dir, path))
598
+ if (head && now) {
599
+ ours.set(path, head)
600
+ theirs.set(path, now)
601
+ }
602
+ }
603
+ if (!ours.size) return []
604
+ const opts = { ...(declarations?.size ? { declarations } : {}), fields }
605
+ const kept = []
606
+ // What the emits print is a comparison's, not this pull's to say.
607
+ const said = { warn: console.warn, log: console.log, info: console.info }
608
+ console.warn = console.log = console.info = () => {}
609
+ try {
610
+ // This copy's text everywhere: the files each backend one is tried among.
611
+ for (const [path, bytes] of ours) writeFileSync(join(dir, path), bytes)
612
+ const mine = await entityHashes(dir, origin, opts)
613
+ if (mine) {
614
+ for (const [path, bytes] of theirs) {
615
+ writeFileSync(join(dir, path), bytes)
616
+ const tried = await entityHashes(dir, origin, opts)
617
+ if ((tried && sameHashes(tried, mine)) || sameButAssetForms(path, ours.get(path), bytes)) kept.push(path)
618
+ writeFileSync(join(dir, path), ours.get(path))
619
+ }
620
+ }
621
+ } finally {
622
+ Object.assign(console, said)
623
+ // The backend's text back wherever it says something else.
624
+ for (const [path, bytes] of theirs) if (!kept.includes(path)) writeFileSync(join(dir, path), bytes)
625
+ }
626
+ return kept
627
+ }
628
+
629
+ function sameHashes(a, b) {
630
+ const keys = Object.keys(a)
631
+ return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
632
+ }
633
+
634
+ /**
635
+ * Two versions of a file that differ only in how they name the same assets: a URL to the
636
+ * backend's copy (`https://…/assets/<sha256>/base.jpeg`) where the other has the site's own path
637
+ * (`/assets/<sha256>.jpeg`). A push sends different data from each — a URL, an asset reference —
638
+ * so the hashes differ, but the asset is one, by its content hash. This copy's text is kept: the
639
+ * banked hash is its own, so a push leaves the file alone, and the next pull from this base writes
640
+ * the backend's way (ours unchanged, theirs different). Measured 2026-10-26: three gallery records
641
+ * an older pull had written with URLs.
642
+ */
643
+ export function sameButAssetForms(path, a, b) {
644
+ const asset = (s) => s.replace(/(?:https?:\/\/[^\s"'()]+)?\/assets\/([0-9a-f]{64})(?:\/[^\s"'()]*?)?(?:\.[A-Za-z0-9]+)?(?=[\s"'()]|$)/g, 'asset:$1')
645
+ const read = (bytes) => {
646
+ const text = bytes.toString('utf8')
647
+ if (!/\.ya?ml$/.test(path)) return asset(text)
648
+ try {
649
+ return JSON.stringify(sortedDeep(yaml.load(text)), (_, v) => (typeof v === 'string' ? asset(v) : v))
650
+ } catch {
651
+ return asset(text)
652
+ }
653
+ }
654
+ const [x, y] = [read(a), read(b)]
655
+ return x === y && a.toString('utf8') !== b.toString('utf8')
656
+ }
657
+
658
+ function sortedDeep(v) {
659
+ if (Array.isArray(v)) return v.map(sortedDeep)
660
+ if (v && typeof v === 'object' && !(v instanceof Date)) return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortedDeep(v[k])]))
661
+ return v
662
+ }
663
+
568
664
  // The sync point's subject line, and the merge's.
569
665
  const pulledFrom = (origin) => `The site's content on ${origin}`
570
666
  const mergedFrom = (origin) => `Merge the site's content from ${origin}`
@@ -999,9 +1095,9 @@ export async function pull(args = [], deps = {}) {
999
1095
  origin: client.origin,
1000
1096
  siteUuid: siteContentUuid
1001
1097
  })
1002
- // A probe with no base has no way to tell the backend's changes from this copy's: onto
1003
- // HEAD, every difference would read as the backend's.
1004
- if (probe && !base) return { exitCode: 0, probe: null }
1098
+ // A probe with no base cannot tell the backend's changes from this copy's: onto HEAD, it says
1099
+ // what differs, and which way is the person's to read (`noBase`, below). ⛔ Until 2026-10-26
1100
+ // it said nothing at all, and status could not size a copied project's first pull.
1005
1101
  onto = force || !base ? repo.head : base
1006
1102
  try {
1007
1103
  standIn = openStandIn(repo, { roots, rev: onto })
@@ -1158,10 +1254,20 @@ export async function pull(args = [], deps = {}) {
1158
1254
  for (const w of report.warnings) note(`! ${w}`)
1159
1255
  }
1160
1256
 
1257
+ // ⭐ With no common history, each file that says what the backend holds is left as this copy
1258
+ // writes it (`keepWhatMeansTheSame`): only a difference in content is a conflict.
1259
+ if (standIn && !base && !force) {
1260
+ merge.keptAsWritten = await keepWhatMeansTheSame({ standIn, repo, origin: client.origin, declarations, fields })
1261
+ }
1262
+
1161
1263
  // ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
1162
- // from the base is what the backend changed. Nothing is banked; `finally` closes it.
1264
+ // from the base is what the backend changed — or, with no base, what differs at all, which
1265
+ // way unknown (`noBase`). Nothing is banked; `finally` closes it.
1163
1266
  if (probe) {
1164
- return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
1267
+ return {
1268
+ exitCode: 0,
1269
+ probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced), ...(base ? {} : { noBase: true }) }
1270
+ }
1165
1271
  }
1166
1272
 
1167
1273
  // Outside git: what this pull wrote, so the next one can tell its own output from the
@@ -1369,6 +1475,11 @@ export async function pull(args = [], deps = {}) {
1369
1475
  if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
1370
1476
  }
1371
1477
  }
1478
+ if (merge.keptAsWritten?.length) {
1479
+ note(`${merge.keptAsWritten.length} file(s) differ from ${client.origin}'s only in how they are written, and are left as they are:`)
1480
+ for (const p of merge.keptAsWritten.slice(0, 12)) note(` ${repo.prefix}${p}`)
1481
+ if (merge.keptAsWritten.length > 12) note(` … and ${merge.keptAsWritten.length - 12} more`)
1482
+ }
1372
1483
  if (merge.conflicted.length) {
1373
1484
  error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
1374
1485
  for (const f of merge.conflicted) note(` ${f}`)
@@ -1379,7 +1490,7 @@ export async function pull(args = [], deps = {}) {
1379
1490
  } else {
1380
1491
  if (merge.noBase) {
1381
1492
  note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
1382
- note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
1493
+ note("your changes from the backend's: each file that says something else is a conflict, yours against the backend's.")
1383
1494
  note('Once this merge is committed, later pulls merge from it.')
1384
1495
  }
1385
1496
  note(
@@ -467,7 +467,11 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
467
467
  checks.push({
468
468
  id: 'app.base',
469
469
  state: 'blocking',
470
- summary: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
470
+ // ⛔ Not "does not hold what this copy last took" for a copy that took nothing here — a fresh
471
+ // copy, or a project brought from another repository, whose sync points stayed there.
472
+ summary: base.never
473
+ ? 'This copy has not taken the site from the backend yet: a push waits until a pull has merged its content.'
474
+ : base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
471
475
  blocks: ['push', 'publish']
472
476
  })
473
477
  proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
@@ -502,6 +506,14 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
502
506
  }
503
507
  : { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
504
508
  )
509
+ } else if (base?.never && git.repo) {
510
+ // ⛔ A copy that took nothing here has no record of what it last sent, so every entity would
511
+ // count as unsent — "holds changes" is a guess, and a push step before the pull it needs.
512
+ checks.push({
513
+ id: 'app.unpushed',
514
+ state: 'unknown',
515
+ summary: "What this copy holds that the backend lacks is known once a pull has merged the site's content here."
516
+ })
505
517
  } else {
506
518
  try {
507
519
  const probe = await probeUnpushed(siteDir, { backend: origin })
@@ -575,8 +587,9 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
575
587
 
576
588
  // ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
577
589
  // stopped before it takes anything (`probePull`): the files it would change, translations
578
- // and records included. Where it cannot answer — outside git, no sync point to merge from —
579
- // the backend's units against this copy's last sync (`backend/app-changes.js`).
590
+ // and records included — with no sync point in this history, the files that differ, which way
591
+ // unknown (`noBaseChanges`). Outside git, the backend's units against this copy's last sync
592
+ // (`backend/app-changes.js`).
580
593
  const readChanges = async () => {
581
594
  if (git.repo) {
582
595
  const org = readOrgFlag(args)
@@ -746,7 +759,8 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
746
759
  * `app.changes` from the pull's own answer (`probePull`): the files it would change, each
747
760
  * as the pull would write it.
748
761
  */
749
- function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
762
+ function probedChanges({ files: list, recordsNotPlaced, noBase }, unpushedFiles, proposals) {
763
+ if (noBase) return noBaseChanges(list, recordsNotPlaced)
750
764
  if (!list.length) {
751
765
  return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
752
766
  }
@@ -776,6 +790,34 @@ function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposa
776
790
  ]
777
791
  }
778
792
 
793
+ /**
794
+ * `app.changes` for a copy whose history shares no commit with the site's content — a project
795
+ * brought into another repository, whose sync points stayed in the old one. What differs is
796
+ * said, which way unknown: the first pull makes each such file a conflict, yours against the
797
+ * backend's, and records where the two meet; one that says what the backend holds, written
798
+ * another way, it leaves alone, and it is not listed (`keepWhatMeansTheSame`, `commands/pull.js`).
799
+ * The pull itself is `app.base`'s step.
800
+ */
801
+ function noBaseChanges(list, recordsNotPlaced) {
802
+ const differ = list.filter(([s]) => s === 'M').map(([, p]) => p)
803
+ const added = list.filter(([s]) => s === 'A').map(([, p]) => p)
804
+ const lines = []
805
+ if (added.length) lines.push(`${count(added.length, 'file')} only the backend has — the pull adds ${added.length === 1 ? 'it' : 'them'}.`)
806
+ if (recordsNotPlaced) lines.push('Some records could not be placed in files — `npx uniweb pull` says which, and why.')
807
+ return [
808
+ {
809
+ id: 'app.changes',
810
+ state: 'attention',
811
+ summary: differ.length
812
+ ? `This copy shares no history with the site's content on the backend — brought from another repository, most likely. ${count(differ.length, 'file')} here ${differ.length === 1 ? 'says' : 'say'} something else, and the first pull makes ${differ.length === 1 ? 'it a conflict' : 'each a conflict'} to settle, yours against the backend's.`
813
+ : "This copy shares no history with the site's content on the backend, and its files say what the site holds: the first pull records where they meet, with nothing to settle.",
814
+ files: differ,
815
+ lines,
816
+ detail: { source: 'pull', noBase: true, files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })) }
817
+ }
818
+ ]
819
+ }
820
+
779
821
  /**
780
822
  * `app.foundation`: the local foundation against its registered version — what the next
781
823
  * push or publish does with it (`backend/foundation-bring-along.js`).
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T21:28:38.450Z",
3
+ "generatedAt": "2026-10-26T23:21:20.295Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "1.6.4"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.10.6"
9
+ "version": "1.10.7"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.5.7"
@@ -15,7 +15,7 @@
15
15
  "version": "1.3.1"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "1.4.8"
18
+ "version": "1.4.9"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "1.0.2"
@@ -24,7 +24,7 @@
24
24
  "version": "1.0.6"
25
25
  },
26
26
  "@uniweb/kit": {
27
- "version": "1.6.6"
27
+ "version": "1.6.7"
28
28
  },
29
29
  "@uniweb/loom": {
30
30
  "version": "1.1.2"
@@ -33,10 +33,10 @@
33
33
  "version": "1.1.4"
34
34
  },
35
35
  "@uniweb/projections": {
36
- "version": "1.2.2"
36
+ "version": "1.2.3"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "1.2.4"
39
+ "version": "1.2.5"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "1.0.1"
@@ -759,6 +759,15 @@ export function uncommittedPaths(repo, roots) {
759
759
  return [...new Set(out.map((p) => siteRelative(repo, p)))]
760
760
  }
761
761
 
762
+ /** A site file's bytes as `rev` holds it — `path` relative to the site — or null when it holds none. */
763
+ export function fileAt(repo, rev, path) {
764
+ try {
765
+ return git(repo.top, ['cat-file', 'blob', `${rev}:${repo.prefix}${path}`])
766
+ } catch {
767
+ return null
768
+ }
769
+ }
770
+
762
771
  /**
763
772
  * The files under the roots that differ from what `rev` holds there — changed, deleted, or
764
773
  * new and not ignored — relative to the site.