uniweb 1.7.4 → 1.8.1

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/README.md CHANGED
@@ -340,7 +340,7 @@ This is the best path when site content has a life independent of the foundation
340
340
 
341
341
  ### Hybrid
342
342
 
343
- Markdown in a git repo and content in the Uniweb apps can share the same site. Directional sync is available today: `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back to local files, and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. The advanced two-way-merge experience — conflict resolution, branch isolation, and review flows when both surfaces change the same content concurrently — is still evolving.
343
+ Markdown in a git repo and content in the Uniweb apps can share the same site. `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back — in a git repository by merging it into your branch, like `git pull`, so edits made on both surfaces at once are combined and only a genuine overlap is a conflict to settle — and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. Review flows and branch isolation across the two are still evolving.
344
344
 
345
345
  ### Commands at a glance
346
346
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.7.4",
3
+ "version": "1.8.1",
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.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "~1.2.2",
45
44
  "@uniweb/content-writer": "~1.1.2",
46
- "@uniweb/schemas": "~1.3.1",
47
- "@uniweb/semantic-parser": "~1.8.1"
45
+ "@uniweb/core": "~1.2.2",
46
+ "@uniweb/semantic-parser": "~1.8.1",
47
+ "@uniweb/schemas": "~1.4.0"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/semantic-parser": "~1.8.1",
50
+ "@uniweb/build": "~1.3.10",
51
51
  "@uniweb/content-reader": "~1.4.0",
52
- "@uniweb/build": "~1.3.9"
52
+ "@uniweb/semantic-parser": "~1.8.1"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2850,7 +2850,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2850
2850
  - **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
2851
2851
  - **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
2852
2852
 
2853
- **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. `uniweb pull` overwrites rather than merges, so it refuses while you have uncommitted changes. The recovery for both is `uniweb pull --merge` (or `uniweb refresh`, which runs `git pull` first): changes to different parts of a file combine silently, and a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull --merge && uniweb push` never ships markers. `--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.
2853
+ **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. `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.
2854
2854
 
2855
2855
  **`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`. Commit 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.
2856
2856
 
@@ -2878,7 +2878,7 @@ says everything about its service: a setting you remove from it is removed on th
2878
2878
  removing the entry switches the service off. `uniweb push` and `uniweb publish` send each service
2879
2879
  `site.yml` lists; a service the site has that this copy has never seen (turned on in the app since
2880
2880
  your last pull) is left as it is, and one changed both in the file and on the site since your last
2881
- pull stops the push, naming it — `uniweb pull --merge`, then push. `uniweb pull` writes what the site
2881
+ pull stops the push, naming it — `uniweb pull`, then push. `uniweb pull` writes what the site
2882
2882
  has into `services:` (an off service with no settings only where the file already names it) — pull
2883
2883
  before you edit. A change that needs payment is settled in the app: `publish` opens it. **Everything in an entry but a
2884
2884
  credential is public** — it is built into the site, except `backend`'s settings, which only your host
@@ -121,6 +121,18 @@ export const DISCOVERY_DEFAULTS = {}
121
121
  */
122
122
  export const WORKSPACE_HEADER = 'x-uniweb-workspace'
123
123
 
124
+ /**
125
+ * The header naming where a push's content came from — the commit it was made from — and
126
+ * where a pull's content last came from, as the backend recorded it.
127
+ *
128
+ * ⭐ Opaque to the backend: it stores the value a push sends, with the version that push
129
+ * produced, and returns it on every pull of that entity. A copy whose history holds that
130
+ * commit merges from it (`utils/sync-point.js`) — the precise base, for a teammate or a
131
+ * fresh clone that never saw the push. Absent on a pull, there is simply no such base.
132
+ */
133
+ export const SOURCE_HEADER = 'x-uniweb-source'
134
+ const sourceHeaders = (source) => (source ? { [SOURCE_HEADER]: source } : {})
135
+
124
136
  /**
125
137
  * Whether a request works IN a workspace — a site route, `/dev/site…`.
126
138
  *
@@ -788,29 +800,35 @@ export class BackendClient {
788
800
  })
789
801
  }
790
802
 
791
- /** POST /dev/site/content — CREATE a site from its content lane (.uwx zip). */
792
- async createSiteContent(buffer) {
803
+ /**
804
+ * POST /dev/site/content — CREATE a site from its content lane (.uwx zip). `source`: the
805
+ * commit the content came from (`SOURCE_HEADER`).
806
+ */
807
+ async createSiteContent(buffer, { source } = {}) {
793
808
  return this.request('/dev/site/content', {
794
809
  method: 'POST',
795
810
  body: buffer,
811
+ headers: sourceHeaders(source),
796
812
  query: PUSH_QUERY
797
813
  })
798
814
  }
799
815
 
800
816
  /** POST /dev/site/content/push/{uuid} — UPDATE the content lane by site uuid (.uwx zip). */
801
- async updateSiteContent(uuid, buffer) {
817
+ async updateSiteContent(uuid, buffer, { source } = {}) {
802
818
  return this.request(`/dev/site/content/push/${encodeURIComponent(uuid)}`, {
803
819
  method: 'POST',
804
820
  body: buffer,
821
+ headers: sourceHeaders(source),
805
822
  query: PUSH_QUERY
806
823
  })
807
824
  }
808
825
 
809
826
  /** POST /dev/site/folder/push/{uuid} — push the folder lane, keyed by the site uuid (.uwx zip). */
810
- async pushFolder(uuid, buffer) {
827
+ async pushFolder(uuid, buffer, { source } = {}) {
811
828
  return this.request(`/dev/site/folder/push/${encodeURIComponent(uuid)}`, {
812
829
  method: 'POST',
813
830
  body: buffer,
831
+ headers: sourceHeaders(source),
814
832
  query: PUSH_QUERY
815
833
  })
816
834
  }
@@ -14,8 +14,16 @@
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
15
  import { join, dirname, relative, isAbsolute, basename } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
- import { hasUncommittedContent } from '../utils/git.js'
18
- import { recordWritten } from '../utils/pull-written.js'
17
+ import {
18
+ repoOf,
19
+ readSyncPoint,
20
+ writeSyncPoint,
21
+ isAncestor,
22
+ contentCommitted,
23
+ unmergedPaths,
24
+ mergeInProgress
25
+ } from '../utils/sync-point.js'
26
+ import { siteContentRoots } from '../utils/git.js'
19
27
  import { humanBytes } from '../utils/bytes.js'
20
28
  import { isAuthoredPreview } from '../utils/preview.js'
21
29
  import {
@@ -301,10 +309,8 @@ export function pulledItemVersions(doc, itemVersions) {
301
309
  // ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
302
310
  // file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
303
311
  // pull writes back into in place. So the unit is looked up as a pull finds it
304
- // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file: a push
305
- // recorded no file for a numbered section, and the next `pull --merge` merged against an
306
- // older version — a false conflict on a line only the other side had changed (measured
307
- // on the starter's `1-welcome.md`).
312
+ // (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file, which
313
+ // names nothing for a numbered section (measured on the starter's `1-welcome.md`).
308
314
  export function unitFilesOf(siteDir, unitPath) {
309
315
  if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
310
316
  let paths = {}
@@ -330,24 +336,6 @@ export function unitFilesOf(siteDir, unitPath) {
330
336
  return []
331
337
  }
332
338
 
333
- /**
334
- * Record, as synced, the files of the units a push left on the backend as we sent them.
335
- *
336
- * ⭐ WHAT A PUSH DELIVERED IS THE NEXT MERGE'S ANCESTOR. `pull --merge` finds the
337
- * version a file last synced at by the hash in `pull-written.json`. A pull records what
338
- * it wrote; this records what a push delivered — the units `heldTokens` says the
339
- * backend holds as ours, never one it kept for someone else. Without it, after a merge
340
- * and a push the ancestor was the pull's raw output, which nobody commits: the next
341
- * merge found no common version and put both whole files under conflict markers, for
342
- * edits on different lines (measured 2026-09-23).
343
- */
344
- function recordPushedUnits(siteDir, unitPaths) {
345
- const files = new Set()
346
- for (const p of unitPaths) for (const f of unitFilesOf(siteDir, p)) files.add(f)
347
- const present = [...files].map((f) => join(siteDir, f)).filter((abs) => existsSync(abs))
348
- if (present.length) recordWritten(siteDir, present)
349
- }
350
-
351
339
  // Pull the finalized entities out of the restore response. The backend returns
352
340
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
353
341
  // carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
@@ -919,6 +907,106 @@ export function mergeBaseVersions(siteDir, backend, versions) {
919
907
  })
920
908
  }
921
909
 
910
+ /**
911
+ * ⛔ A PUSH NEVER SENDS A FILE IN CONFLICT. Where git marks a file under the site unmerged — a
912
+ * merge stopped on it, or uncommitted work a merge could not put back — the file holds markers,
913
+ * and a push hands them to every author editing the site, then to its visitors once published.
914
+ * Refused whatever the flags: `--force` overwrites the backend's changes, it never sends
915
+ * markers. Returns the refusal, or null.
916
+ *
917
+ * ⛔ Until 2026-10-17 nothing checked it: a pull whose stash could not be put back exited 0,
918
+ * and the push after it sent the markers [flows, measured against a real backend, 1.8.0].
919
+ */
920
+ export function unsettledFiles(siteDir) {
921
+ const repo = repoOf(siteDir)
922
+ if (!repo) return null
923
+ const paths = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
924
+ if (!paths.length) return null
925
+ return {
926
+ headline: `Not pushed — ${paths.length} file(s) here are in conflict:`,
927
+ lines: [
928
+ ...paths.slice(0, 12).map((p) => ` ${p}`),
929
+ ...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
930
+ 'Settle each one and `git add` it, then push.'
931
+ ]
932
+ }
933
+ }
934
+
935
+ /**
936
+ * Why a gated push must not go — or null when it may.
937
+ *
938
+ * ⛔ A PUSH CLAIMS A BASE ONLY WHILE ITS FILES HOLD IT. The versions it sends say "my content
939
+ * descends from the backend's at these versions". In a repository they describe one commit,
940
+ * the sync point this copy's last pull merged or its last push was made from
941
+ * (`utils/sync-point.js`), and they are true of the files only while HEAD's history holds
942
+ * it: a merge abandoned or a branch reset, and the push would put back what the backend's
943
+ * newer content replaced.
944
+ *
945
+ * ⛔ AND A SITE THE BACKEND HAS, PUSHED WITH NO VERSIONS AT ALL, GOES OVER WHATEVER CHANGED
946
+ * THERE — an entry without a token falls back to the `collision` policy, which the client
947
+ * sends as `force`. Until 2026-10-17 that was a fresh clone's first push, or any copy whose
948
+ * cache was gone.
949
+ *
950
+ * A push that creates the site has nothing to be stale against.
951
+ *
952
+ * @returns {null | { headline: string, lines: string[] }}
953
+ */
954
+ export function untakenBase(siteDir, backend) {
955
+ if (!readBackendState(siteDir, backend).site?.uuid) return null
956
+ const held = Object.keys(readBaseVersions(siteDir, backend)).length > 0
957
+ const repo = repoOf(siteDir)
958
+ if (repo?.head) {
959
+ const point = readSyncPoint(siteDir, backend)
960
+ if (held && point && isAncestor(repo, point)) return null
961
+ // A pull's merge waiting for its commit is not one abandoned: say which.
962
+ if (held && point && mergeInProgress(repo)) {
963
+ return {
964
+ headline: 'Not pushed — a merge is in progress here.',
965
+ lines: ['Finish it (`git commit`), then push — or abandon it (`git merge --abort`) and pull again.']
966
+ }
967
+ }
968
+ return {
969
+ headline: "Not pushed — your branch does not hold the backend content this copy last took.",
970
+ lines: [
971
+ point && held
972
+ ? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
973
+ : 'This copy has not taken the site from this backend yet.',
974
+ "Run `uniweb pull` to merge the backend's content with yours, then push.",
975
+ 'To push over it regardless, re-run with --force (this discards changes made there).'
976
+ ]
977
+ }
978
+ }
979
+ if (held) return null
980
+ return {
981
+ headline: 'Not pushed — this copy holds no record of the backend content it last took.',
982
+ lines: [
983
+ 'A push now would go over whatever changed there.',
984
+ "`uniweb pull` takes the backend's version over your files here, outside git; under git a pull merges instead.",
985
+ 'To push over it regardless, re-run with --force (this discards changes made there).'
986
+ ]
987
+ }
988
+ }
989
+
990
+ /**
991
+ * A push that finds nothing to send has learned that the backend holds what this commit
992
+ * sends — so the commit is a sync point, as the one a push is made from is
993
+ * (`pushSyncPackages`). Kept only where HEAD holds the site's content as the files do: with
994
+ * a change under the roots not committed, HEAD is not what was compared, and a base the
995
+ * backend's content does not descend from would read a committed change the backend lacks
996
+ * as its removal. Returns the commit kept, or null.
997
+ *
998
+ * ⛔ Until 2026-10-17 it kept nothing, so a paragraph re-wrapped and committed — which a push
999
+ * calls "nothing to push", a plain newline being a space — was merged on the next pull from
1000
+ * the commit before it, and conflicted with an author's words typed into it since.
1001
+ */
1002
+ export function holdUnchangedPush(siteDir, backend) {
1003
+ const repo = repoOf(siteDir)
1004
+ if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
1005
+ if (!contentCommitted(repo, [...siteContentRoots(siteDir), 'public'])) return null
1006
+ writeSyncPoint(siteDir, backend, repo.head)
1007
+ return repo.head
1008
+ }
1009
+
922
1010
  /**
923
1011
  * Per-unit content hashes of the last synced state, kept in BOTH representations —
924
1012
  * the bases for the file-level attribution shown after a staleness refusal
@@ -1905,6 +1993,12 @@ export async function pushSyncPackages({
1905
1993
  let finalizedTotal = 0
1906
1994
  // Records sent as drafts that the backend stored enabled — reported at the end.
1907
1995
  let draftsNotKept = []
1996
+ // ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
1997
+ // a pull, and kept here as this copy's sync point once a lane lands: what the backend
1998
+ // holds descends from it (`utils/sync-point.js`). ⚖️ Uncommitted edits ride on top of it,
1999
+ // and it stays a safe base for them — both sides hold them, so a merge takes them once.
2000
+ const repo = repoOf(siteDir)
2001
+ const source = repo?.head || null
1908
2002
 
1909
2003
  // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1910
2004
  // returning the client's Response promise (so the "Pushing …" line prints before the
@@ -2067,22 +2161,14 @@ export async function pushSyncPackages({
2067
2161
  note(
2068
2162
  'Nothing was written — the whole push was refused before any change.'
2069
2163
  )
2070
- // Say what will actually work from HERE. Anyone hitting this has local
2071
- // work — it is why they pushed — and if it is uncommitted then `pull`
2072
- // refuses too, so bare "run pull" advice walks them into a dead end.
2073
- if (hasUncommittedContent(siteDir)) {
2074
- // `--merge` is the recovery that keeps both sides: most of these are two
2075
- // people editing different parts of one section, which merges silently.
2076
- // Offer it first, and keep the take-theirs route for anyone who wants it.
2077
- note(
2078
- "Commit your changes, then `uniweb pull --merge` to combine them with the backend's."
2079
- )
2080
- note(
2081
- '(Plain `uniweb pull` declines while the work is unsaved — it overwrites rather than merges.)'
2082
- )
2164
+ // Say what will actually work from HERE. Anyone hitting this has local work — it is
2165
+ // why they pushed. In a repository a pull merges it with the backend's, committed or
2166
+ // not; outside one a pull takes the backend's version over the files.
2167
+ if (repo) {
2168
+ note("Run `uniweb pull` to merge the backend's changes with yours, then push again.")
2083
2169
  } else {
2084
2170
  note(
2085
- 'Run `uniweb pull --merge` to combine the changes, or `uniweb pull` to take the backend version.'
2171
+ "Outside git, `uniweb pull` takes the backend's version over your files. Put the site under git and a pull merges instead."
2086
2172
  )
2087
2173
  }
2088
2174
  note(
@@ -2226,10 +2312,12 @@ export async function pushSyncPackages({
2226
2312
  if (f.uuid && f.version) newVersions[f.uuid] = f.version
2227
2313
  }
2228
2314
  }
2229
- // Both grains land together, at every point the old code banked the entity one.
2315
+ // Both grains land together, at every point the old code banked the entity one — and
2316
+ // with them the commit they describe.
2230
2317
  const mergeHarvested = () => {
2231
2318
  mergeBaseVersions(siteDir, client.origin, newVersions)
2232
2319
  if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
2320
+ if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
2233
2321
  }
2234
2322
  // ⛔ The site-content lane banks only the tokens for content this copy holds — the
2235
2323
  // document we sent against the one the backend stored (`heldTokens`, and the two
@@ -2237,7 +2325,6 @@ export async function pushSyncPackages({
2237
2325
  // collected here, to re-base attribution and to say so.
2238
2326
  const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
2239
2327
  const notHeld = { kept: [], foreign: [] }
2240
- const heldUnits = []
2241
2328
  const harvestSiteContent = (finalized) => {
2242
2329
  for (const f of finalized || []) {
2243
2330
  const held = heldTokens({
@@ -2249,7 +2336,6 @@ export async function pushSyncPackages({
2249
2336
  })
2250
2337
  harvest([{ ...f, version: held.version }])
2251
2338
  if (held.itemVersions) heldItemVersions = held.itemVersions
2252
- heldUnits.push(...held.held)
2253
2339
  notHeld.kept.push(...held.kept)
2254
2340
  notHeld.foreign.push(...held.foreign)
2255
2341
  }
@@ -2331,16 +2417,13 @@ export async function pushSyncPackages({
2331
2417
  'may refuse it. `uniweb pull` re-arms identity if that happens.'
2332
2418
  )
2333
2419
  }
2334
- // The files of the units the backend now holds as ours are the synced version — the
2335
- // ancestor the next `pull --merge` merges from.
2336
- if (heldUnits.length) recordPushedUnits(siteDir, heldUnits)
2337
2420
  }
2338
2421
  if (siteContent) {
2339
2422
  if (siteContentUuid) {
2340
2423
  const finalized = await pushLane(
2341
2424
  'site-content',
2342
2425
  () =>
2343
- client.updateSiteContent(siteContentUuid, siteContent.buffer),
2426
+ client.updateSiteContent(siteContentUuid, siteContent.buffer, { source }),
2344
2427
  () =>
2345
2428
  explainStaleSiteContent({
2346
2429
  client,
@@ -2361,7 +2444,7 @@ export async function pushSyncPackages({
2361
2444
  } else {
2362
2445
  const payload = await postLane(
2363
2446
  'site-content',
2364
- () => client.createSiteContent(siteContent.buffer),
2447
+ () => client.createSiteContent(siteContent.buffer, { source }),
2365
2448
  undefined,
2366
2449
  { reportTemplate: true }
2367
2450
  )
@@ -2408,7 +2491,7 @@ export async function pushSyncPackages({
2408
2491
  }
2409
2492
  const finalized = await pushLane(
2410
2493
  'records',
2411
- () => client.pushFolder(boundSiteUuid, records.buffer),
2494
+ () => client.pushFolder(boundSiteUuid, records.buffer, { source }),
2412
2495
  undefined,
2413
2496
  { boundUuid: boundSiteUuid }
2414
2497
  )
@@ -2498,7 +2581,7 @@ export async function pushSyncPackages({
2498
2581
  ...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
2499
2582
  ].sort()
2500
2583
  note(`Changed on the backend by someone else, not in your files yet: ${paths.join(', ')}`)
2501
- note('Take them with `uniweb refresh` (or `uniweb pull --merge`). Until then, pushing leaves them as they are.')
2584
+ note('Take them with `uniweb refresh` (or `uniweb pull`). Until then, pushing leaves them as they are.')
2502
2585
  }
2503
2586
  // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
2504
2587
  // piece of sync state above is banked: the push itself happened, and the next one must
@@ -39,6 +39,7 @@
39
39
  * uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
40
40
  * uniweb clone <uuid> --project docs Co-located docs/site
41
41
  * uniweb clone <uuid> --no-records Pull pages only; skip records
42
+ * uniweb clone <uuid> --no-git Do not start a git repository for the new project
42
43
  * uniweb clone <uuid> --org @org Work in @org for this clone, instead of the
43
44
  * workspace chosen at `uniweb login`. A site in
44
45
  * another workspace stops it.
@@ -51,8 +52,8 @@
51
52
  */
52
53
 
53
54
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
54
- import { join, resolve, basename, dirname } from 'node:path'
55
- import { spawnSync } from 'node:child_process'
55
+ import { join, resolve, basename, dirname, relative, sep } from 'node:path'
56
+ import { spawnSync, execFileSync } from 'node:child_process'
56
57
  import { scaffoldWorkspace, scaffoldSite } from '../utils/scaffold.js'
57
58
  import { resolvePlacement, projectConflictLines, SITE_KIND } from '../utils/placement.js'
58
59
  import { findWorkspaceRoot } from '../utils/workspace.js'
@@ -63,8 +64,8 @@ import { resolveWorkspace } from '../backend/workspace.js'
63
64
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
64
65
  import { extractFoundationRef } from '../utils/site-content-refs.js'
65
66
  import { readUwxDocuments } from '../utils/uwx-read.js'
66
- import { recordWritten } from '../utils/pull-written.js'
67
67
  import { checkFlags } from '../utils/flag-guard.js'
68
+ import { repoOf, writeSyncPoint, SYNC_TRAILER, SITE_TRAILER } from '../utils/sync-point.js'
68
69
 
69
70
  const colors = {
70
71
  reset: '\x1b[0m',
@@ -98,6 +99,9 @@ function slugify(s) {
98
99
  .replace(/^-+|-+$/g, '')
99
100
  }
100
101
 
102
+ // The data schema a site's own document names (`$schema`, as the push writes it).
103
+ const SITE_MODEL = '@uniweb/site-content'
104
+
101
105
  // Tolerant single-entity document extraction (mirrors pull.js; duplicated rather
102
106
  // than imported because pull.js statically imports @uniweb/build).
103
107
  export function extractDocument(payload) {
@@ -169,6 +173,48 @@ function seedSyncJson(siteDir, origin, uuid) {
169
173
  writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
170
174
  }
171
175
 
176
+ /**
177
+ * Start the new project's history at a sync point — the site as the backend has it.
178
+ *
179
+ * ⭐ Like `uniweb create`, a clone puts its project under git unless a repository already
180
+ * holds it — one it is inside, in a folder that repository does not ignore (`repoOf`); and
181
+ * its first commit holds the site as the backend has it, marked so a pull merges from it
182
+ * (`utils/sync-point.js`). Inside a repository the clone's pull already left one, in the
183
+ * commit that took the site.
184
+ */
185
+ function startHistory({ projectDir, siteDir, origin, siteUuid }) {
186
+ const git = (args, input) =>
187
+ execFileSync('git', args, {
188
+ cwd: projectDir,
189
+ encoding: 'utf8',
190
+ input,
191
+ stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
192
+ })
193
+ try {
194
+ git(['--version'])
195
+ } catch {
196
+ return // no git here: the project works without one, and a pull checks out
197
+ }
198
+ if (repoOf(projectDir)) return
199
+ try {
200
+ git(['init', '-q'])
201
+ git(['add', '-A'])
202
+ const sitePath = relative(projectDir, siteDir).split(sep).join('/')
203
+ const siteTree = git(sitePath ? ['write-tree', `--prefix=${sitePath}/`] : ['write-tree']).trim()
204
+ git(
205
+ ['commit', '-q', '-F', '-'],
206
+ `Clone the site from ${origin}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${sitePath || '.'}:${siteTree}\n`
207
+ )
208
+ writeSyncPoint(siteDir, origin, git(['rev-parse', 'HEAD']).trim())
209
+ success('Git repository initialized, its first commit the site as the backend has it')
210
+ } catch (err) {
211
+ note(
212
+ `! A git repository was started, but its first commit failed${err?.stderr ? `: ${String(err.stderr).trim().split('\n')[0]}` : ''}.`
213
+ )
214
+ note(' Commit the project before your first `uniweb pull`, so the pull has something to merge into.')
215
+ }
216
+ }
217
+
172
218
  // Build the package-manager argv to run the project-local `uniweb pull`.
173
219
  function pullExecArgv(pm, extra) {
174
220
  // npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
@@ -265,6 +311,15 @@ export async function clone(args = [], deps = {}) {
265
311
  return { exitCode: 1 }
266
312
  }
267
313
  const { foundationRef, name: siteDisplayName } = extractCloneSeeds(document)
314
+ // ⛔ An id that names another entity — a record's, say — comes back as that entity. Say
315
+ // so, rather than ask for a foundation and a name it could never have: only where it
316
+ // names another data schema AND carries neither, so a site is never turned away.
317
+ const named = typeof document.$schema === 'string' ? document.$schema : null
318
+ if (named && named !== SITE_MODEL && !foundationRef && !siteDisplayName) {
319
+ error(`${siteUuid} is not a site on ${client.origin} — it is a ${named} entity.`)
320
+ note('`uniweb clone` takes a site\'s id: `uniweb site list` shows each site with its id.')
321
+ return { exitCode: 1 }
322
+ }
268
323
  if (!foundationRef) {
269
324
  note(
270
325
  '! The pulled site declares no foundation ref — set `foundation:` in site.yml after clone.'
@@ -411,32 +466,12 @@ export async function clone(args = [], deps = {}) {
411
466
  }
412
467
  }
413
468
 
414
- // Tell the delegated pull which files WE wrote, or it refuses on them.
415
- //
416
- // Pull guards against overwriting uncommitted work under the paths it rewrites,
417
- // and `site.yml` / `theme.yml` are exactly those paths — uncommitted here
418
- // because clone scaffolded them seconds ago. Without this, clone reliably ends
419
- // in "Refusing to pull: 2 uncommitted change(s)" on a project it just created,
420
- // where there is no user work to protect. The advice it prints (`--force`) is
421
- // both unreachable — clone does not forward that flag — and wrong, since it
422
- // means "discard my changes" and the changes are ours.
423
- //
424
- // ⭐ This exempts them for the RIGHT reason rather than overriding the guard.
425
- // `recordWritten` stores a content hash, so a file the user edits between
426
- // scaffold and pull no longer matches and the refusal correctly stands. The
427
- // guard keeps its teeth; it just stops firing on machine output, which is what
428
- // it already does for pull's own writes.
429
- //
430
- // Recorded AFTER the uuid seeding above — the hash has to be of the final
431
- // bytes on disk, not of what the scaffolder first wrote.
432
- recordWritten(
433
- siteDir,
434
- ['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
435
- .map((f) => join(siteDir, f))
436
- .filter((p) => existsSync(p))
437
- )
438
-
439
- const pullExtra = []
469
+ // ⭐ The delegated pull takes the backend's version whole (`--force`): every file under
470
+ // what it writes is one this clone scaffolded seconds ago — the site directory is new
471
+ // (refused above when it is not) — so there is nothing of the author's to protect.
472
+ // ⛔ Until 2026-10-17 clone recorded its scaffold as pull output instead, so the pull's
473
+ // guard let it through; a pull inside a repository merges now, and has no such guard.
474
+ const pullExtra = ['--force']
440
475
  if (noRecords) pullExtra.push('--no-records')
441
476
  // The workspace this clone was told to work in: the pull is another process, and asks the same
442
477
  // question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
@@ -463,6 +498,10 @@ export async function clone(args = [], deps = {}) {
463
498
  }
464
499
  }
465
500
 
501
+ if (!deps.skipGit && !args.includes('--no-git')) {
502
+ startHistory({ projectDir, siteDir, origin: client.origin, siteUuid })
503
+ }
504
+
466
505
  log('')
467
506
  success(
468
507
  `Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`
@@ -103,6 +103,8 @@ import {
103
103
  ensureSiteExists,
104
104
  refuseUnsendableRecords,
105
105
  clearRemoteSyncStateIfUnbound,
106
+ untakenBase,
107
+ unsettledFiles,
106
108
  dropSiteBoundValues,
107
109
  pushSyncPackages
108
110
  } from '../backend/site-sync.js'
@@ -320,6 +322,27 @@ export async function publish(args = []) {
320
322
  }
321
323
  }
322
324
 
325
+ // ⛔ The gated push publish rides claims a base only while the files hold it
326
+ // (`untakenBase`) — said before anything reaches the backend, the foundation's release
327
+ // included. `--force` drops the gate; a dry run does not preview it. ⛔ *Until 2026-10-17
328
+ // it ran after the bring-along and the build, so a refused publish could release a local
329
+ // foundation first.*
330
+ // ⛔ Never a file in conflict — whatever the flags (`unsettledFiles`).
331
+ const unsettled = unsettledFiles(siteDir)
332
+ if (unsettled) {
333
+ say.err(unsettled.headline)
334
+ for (const line of unsettled.lines) say.dim(line)
335
+ return { exitCode: 1 }
336
+ }
337
+ if (!dryRun && !args.includes('--force')) {
338
+ const untaken = untakenBase(siteDir, client.origin)
339
+ if (untaken) {
340
+ say.err(untaken.headline)
341
+ for (const line of untaken.lines) say.dim(line)
342
+ return { exitCode: 1 }
343
+ }
344
+ }
345
+
323
346
  // ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
324
347
  // command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
325
348
  // creates the site in it, and a site the backend keeps elsewhere is refused.
@@ -622,7 +645,6 @@ export async function publish(args = []) {
622
645
  `Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
623
646
  )
624
647
  }
625
-
626
648
  // 3b. Make sure the SITE EXISTS before a single byte is uploaded.
627
649
  //
628
650
  // Uploaded bytes are metered against an owning entity and reclaimed by