uniweb 1.7.4 → 1.8.0
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 +1 -1
- package/package.json +5 -5
- package/partials/agents.md +2 -2
- package/src/backend/client.js +22 -4
- package/src/backend/site-sync.js +70 -48
- package/src/commands/clone.js +55 -29
- package/src/commands/publish.js +11 -0
- package/src/commands/pull.js +423 -463
- package/src/commands/push.js +12 -0
- package/src/commands/refresh.js +16 -17
- package/src/index.js +5 -4
- package/src/utils/flag-guard.js +20 -8
- package/src/utils/git.js +20 -213
- package/src/utils/pull-written.js +15 -28
- package/src/utils/sync-point.js +512 -0
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.
|
|
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.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,14 +42,14 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "~1.2.2",
|
|
45
|
+
"@uniweb/semantic-parser": "~1.8.1",
|
|
45
46
|
"@uniweb/content-writer": "~1.1.2",
|
|
46
|
-
"@uniweb/schemas": "~1.3.1"
|
|
47
|
-
"@uniweb/semantic-parser": "~1.8.1"
|
|
47
|
+
"@uniweb/schemas": "~1.3.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@uniweb/semantic-parser": "~1.8.1",
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
51
|
+
"@uniweb/build": "~1.3.9",
|
|
52
|
+
"@uniweb/content-reader": "~1.4.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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`
|
|
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`. `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, `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
|
|
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
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
-
/**
|
|
792
|
-
|
|
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
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -14,8 +14,7 @@
|
|
|
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 {
|
|
18
|
-
import { recordWritten } from '../utils/pull-written.js'
|
|
17
|
+
import { repoOf, readSyncPoint, writeSyncPoint, isAncestor } from '../utils/sync-point.js'
|
|
19
18
|
import { humanBytes } from '../utils/bytes.js'
|
|
20
19
|
import { isAuthoredPreview } from '../utils/preview.js'
|
|
21
20
|
import {
|
|
@@ -301,10 +300,8 @@ export function pulledItemVersions(doc, itemVersions) {
|
|
|
301
300
|
// ⭐ A section unit's path names it by its id (`pages/about/about.md`), and the author's
|
|
302
301
|
// file may carry an ordering prefix or a child mark (`1-about.md`, `@about.md`), which a
|
|
303
302
|
// 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
|
|
305
|
-
//
|
|
306
|
-
// older version — a false conflict on a line only the other side had changed (measured
|
|
307
|
-
// on the starter's `1-welcome.md`).
|
|
303
|
+
// (`existingSectionFile`). ⛔ Until 2026-10-07 the id path was taken as the file, which
|
|
304
|
+
// names nothing for a numbered section (measured on the starter's `1-welcome.md`).
|
|
308
305
|
export function unitFilesOf(siteDir, unitPath) {
|
|
309
306
|
if (unitPath === 'site.yml') return ['site.yml', 'theme.yml', 'head.html']
|
|
310
307
|
let paths = {}
|
|
@@ -330,24 +327,6 @@ export function unitFilesOf(siteDir, unitPath) {
|
|
|
330
327
|
return []
|
|
331
328
|
}
|
|
332
329
|
|
|
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
330
|
// Pull the finalized entities out of the restore response. The backend returns
|
|
352
331
|
// `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
|
|
353
332
|
// carries its position in the SUBMITTED sequence (`index`, the correlation key — `$id`
|
|
@@ -919,6 +898,54 @@ export function mergeBaseVersions(siteDir, backend, versions) {
|
|
|
919
898
|
})
|
|
920
899
|
}
|
|
921
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Why a gated push must not go — or null when it may.
|
|
903
|
+
*
|
|
904
|
+
* ⛔ A PUSH CLAIMS A BASE ONLY WHILE ITS FILES HOLD IT. The versions it sends say "my content
|
|
905
|
+
* descends from the backend's at these versions". In a repository they describe one commit,
|
|
906
|
+
* the sync point this copy's last pull merged or its last push was made from
|
|
907
|
+
* (`utils/sync-point.js`), and they are true of the files only while HEAD's history holds
|
|
908
|
+
* it: a merge abandoned or a branch reset, and the push would put back what the backend's
|
|
909
|
+
* newer content replaced.
|
|
910
|
+
*
|
|
911
|
+
* ⛔ AND A SITE THE BACKEND HAS, PUSHED WITH NO VERSIONS AT ALL, GOES OVER WHATEVER CHANGED
|
|
912
|
+
* THERE — an entry without a token falls back to the `collision` policy, which the client
|
|
913
|
+
* sends as `force`. Until 2026-10-17 that was a fresh clone's first push, or any copy whose
|
|
914
|
+
* cache was gone.
|
|
915
|
+
*
|
|
916
|
+
* A push that creates the site has nothing to be stale against.
|
|
917
|
+
*
|
|
918
|
+
* @returns {null | { headline: string, lines: string[] }}
|
|
919
|
+
*/
|
|
920
|
+
export function untakenBase(siteDir, backend) {
|
|
921
|
+
if (!readBackendState(siteDir, backend).site?.uuid) return null
|
|
922
|
+
const held = Object.keys(readBaseVersions(siteDir, backend)).length > 0
|
|
923
|
+
const repo = repoOf(siteDir)
|
|
924
|
+
if (repo?.head) {
|
|
925
|
+
const point = readSyncPoint(siteDir, backend)
|
|
926
|
+
if (held && point && isAncestor(repo, point)) return null
|
|
927
|
+
return {
|
|
928
|
+
headline: "Not pushed — your branch does not hold the backend content this copy last took.",
|
|
929
|
+
lines: [
|
|
930
|
+
point && held
|
|
931
|
+
? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
|
|
932
|
+
: 'This copy has not taken the site from this backend yet.',
|
|
933
|
+
"Run `uniweb pull` to merge the backend's content with yours, then push.",
|
|
934
|
+
'To push over it regardless, re-run with --force (this discards changes made there).'
|
|
935
|
+
]
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (held) return null
|
|
939
|
+
return {
|
|
940
|
+
headline: 'Not pushed — this copy holds no record of the backend content it last took.',
|
|
941
|
+
lines: [
|
|
942
|
+
'A push now would go over whatever changed there.',
|
|
943
|
+
"`uniweb pull` takes the backend's version over your files here, outside git; under git a pull merges instead.",
|
|
944
|
+
'To push over it regardless, re-run with --force (this discards changes made there).'
|
|
945
|
+
]
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
922
949
|
/**
|
|
923
950
|
* Per-unit content hashes of the last synced state, kept in BOTH representations —
|
|
924
951
|
* the bases for the file-level attribution shown after a staleness refusal
|
|
@@ -1905,6 +1932,12 @@ export async function pushSyncPackages({
|
|
|
1905
1932
|
let finalizedTotal = 0
|
|
1906
1933
|
// Records sent as drafts that the backend stored enabled — reported at the end.
|
|
1907
1934
|
let draftsNotKept = []
|
|
1935
|
+
// ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
|
|
1936
|
+
// a pull, and kept here as this copy's sync point once a lane lands: what the backend
|
|
1937
|
+
// holds descends from it (`utils/sync-point.js`). ⚖️ Uncommitted edits ride on top of it,
|
|
1938
|
+
// and it stays a safe base for them — both sides hold them, so a merge takes them once.
|
|
1939
|
+
const repo = repoOf(siteDir)
|
|
1940
|
+
const source = repo?.head || null
|
|
1908
1941
|
|
|
1909
1942
|
// POST one lane via the client and parse the JSON response. `doRequest` is a thunk
|
|
1910
1943
|
// returning the client's Response promise (so the "Pushing …" line prints before the
|
|
@@ -2067,22 +2100,14 @@ export async function pushSyncPackages({
|
|
|
2067
2100
|
note(
|
|
2068
2101
|
'Nothing was written — the whole push was refused before any change.'
|
|
2069
2102
|
)
|
|
2070
|
-
// Say what will actually work from HERE. Anyone hitting this has local
|
|
2071
|
-
//
|
|
2072
|
-
//
|
|
2073
|
-
if (
|
|
2074
|
-
|
|
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
|
-
)
|
|
2103
|
+
// Say what will actually work from HERE. Anyone hitting this has local work — it is
|
|
2104
|
+
// why they pushed. In a repository a pull merges it with the backend's, committed or
|
|
2105
|
+
// not; outside one a pull takes the backend's version over the files.
|
|
2106
|
+
if (repo) {
|
|
2107
|
+
note("Run `uniweb pull` to merge the backend's changes with yours, then push again.")
|
|
2083
2108
|
} else {
|
|
2084
2109
|
note(
|
|
2085
|
-
|
|
2110
|
+
"Outside git, `uniweb pull` takes the backend's version over your files. Put the site under git and a pull merges instead."
|
|
2086
2111
|
)
|
|
2087
2112
|
}
|
|
2088
2113
|
note(
|
|
@@ -2226,10 +2251,12 @@ export async function pushSyncPackages({
|
|
|
2226
2251
|
if (f.uuid && f.version) newVersions[f.uuid] = f.version
|
|
2227
2252
|
}
|
|
2228
2253
|
}
|
|
2229
|
-
// Both grains land together, at every point the old code banked the entity one
|
|
2254
|
+
// Both grains land together, at every point the old code banked the entity one — and
|
|
2255
|
+
// with them the commit they describe.
|
|
2230
2256
|
const mergeHarvested = () => {
|
|
2231
2257
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
2232
2258
|
if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
|
|
2259
|
+
if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
|
|
2233
2260
|
}
|
|
2234
2261
|
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
2235
2262
|
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
@@ -2237,7 +2264,6 @@ export async function pushSyncPackages({
|
|
|
2237
2264
|
// collected here, to re-base attribution and to say so.
|
|
2238
2265
|
const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
|
|
2239
2266
|
const notHeld = { kept: [], foreign: [] }
|
|
2240
|
-
const heldUnits = []
|
|
2241
2267
|
const harvestSiteContent = (finalized) => {
|
|
2242
2268
|
for (const f of finalized || []) {
|
|
2243
2269
|
const held = heldTokens({
|
|
@@ -2249,7 +2275,6 @@ export async function pushSyncPackages({
|
|
|
2249
2275
|
})
|
|
2250
2276
|
harvest([{ ...f, version: held.version }])
|
|
2251
2277
|
if (held.itemVersions) heldItemVersions = held.itemVersions
|
|
2252
|
-
heldUnits.push(...held.held)
|
|
2253
2278
|
notHeld.kept.push(...held.kept)
|
|
2254
2279
|
notHeld.foreign.push(...held.foreign)
|
|
2255
2280
|
}
|
|
@@ -2331,16 +2356,13 @@ export async function pushSyncPackages({
|
|
|
2331
2356
|
'may refuse it. `uniweb pull` re-arms identity if that happens.'
|
|
2332
2357
|
)
|
|
2333
2358
|
}
|
|
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
2359
|
}
|
|
2338
2360
|
if (siteContent) {
|
|
2339
2361
|
if (siteContentUuid) {
|
|
2340
2362
|
const finalized = await pushLane(
|
|
2341
2363
|
'site-content',
|
|
2342
2364
|
() =>
|
|
2343
|
-
client.updateSiteContent(siteContentUuid, siteContent.buffer),
|
|
2365
|
+
client.updateSiteContent(siteContentUuid, siteContent.buffer, { source }),
|
|
2344
2366
|
() =>
|
|
2345
2367
|
explainStaleSiteContent({
|
|
2346
2368
|
client,
|
|
@@ -2361,7 +2383,7 @@ export async function pushSyncPackages({
|
|
|
2361
2383
|
} else {
|
|
2362
2384
|
const payload = await postLane(
|
|
2363
2385
|
'site-content',
|
|
2364
|
-
() => client.createSiteContent(siteContent.buffer),
|
|
2386
|
+
() => client.createSiteContent(siteContent.buffer, { source }),
|
|
2365
2387
|
undefined,
|
|
2366
2388
|
{ reportTemplate: true }
|
|
2367
2389
|
)
|
|
@@ -2408,7 +2430,7 @@ export async function pushSyncPackages({
|
|
|
2408
2430
|
}
|
|
2409
2431
|
const finalized = await pushLane(
|
|
2410
2432
|
'records',
|
|
2411
|
-
() => client.pushFolder(boundSiteUuid, records.buffer),
|
|
2433
|
+
() => client.pushFolder(boundSiteUuid, records.buffer, { source }),
|
|
2412
2434
|
undefined,
|
|
2413
2435
|
{ boundUuid: boundSiteUuid }
|
|
2414
2436
|
)
|
|
@@ -2498,7 +2520,7 @@ export async function pushSyncPackages({
|
|
|
2498
2520
|
...new Set([...notHeld.kept, ...notHeld.foreign].map((p) => (p === 'site.yml' ? p : unitFilesOf(siteDir, p)[0] || p)))
|
|
2499
2521
|
].sort()
|
|
2500
2522
|
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
|
|
2523
|
+
note('Take them with `uniweb refresh` (or `uniweb pull`). Until then, pushing leaves them as they are.')
|
|
2502
2524
|
}
|
|
2503
2525
|
// ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
|
|
2504
2526
|
// piece of sync state above is banked: the push itself happened, and the next one must
|
package/src/commands/clone.js
CHANGED
|
@@ -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',
|
|
@@ -169,6 +170,47 @@ function seedSyncJson(siteDir, origin, uuid) {
|
|
|
169
170
|
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Start the new project's history at a sync point — the site as the backend has it.
|
|
175
|
+
*
|
|
176
|
+
* ⭐ Like `uniweb create`, a clone puts its project under git unless it is already inside a
|
|
177
|
+
* repository; and its first commit holds the site as the backend has it, marked so a pull
|
|
178
|
+
* merges from it (`utils/sync-point.js`). Inside a repository the clone's pull already left
|
|
179
|
+
* one, in the commit that took the site.
|
|
180
|
+
*/
|
|
181
|
+
function startHistory({ projectDir, siteDir, origin, siteUuid }) {
|
|
182
|
+
const git = (args, input) =>
|
|
183
|
+
execFileSync('git', args, {
|
|
184
|
+
cwd: projectDir,
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
input,
|
|
187
|
+
stdio: [input == null ? 'ignore' : 'pipe', 'pipe', 'pipe']
|
|
188
|
+
})
|
|
189
|
+
try {
|
|
190
|
+
git(['--version'])
|
|
191
|
+
} catch {
|
|
192
|
+
return // no git here: the project works without one, and a pull checks out
|
|
193
|
+
}
|
|
194
|
+
if (repoOf(projectDir)) return
|
|
195
|
+
try {
|
|
196
|
+
git(['init', '-q'])
|
|
197
|
+
git(['add', '-A'])
|
|
198
|
+
const sitePath = relative(projectDir, siteDir).split(sep).join('/')
|
|
199
|
+
const siteTree = git(sitePath ? ['write-tree', `--prefix=${sitePath}/`] : ['write-tree']).trim()
|
|
200
|
+
git(
|
|
201
|
+
['commit', '-q', '-F', '-'],
|
|
202
|
+
`Clone the site from ${origin}\n\n${SYNC_TRAILER}: ${origin} ${siteUuid}\n${SITE_TRAILER}: ${sitePath || '.'}:${siteTree}\n`
|
|
203
|
+
)
|
|
204
|
+
writeSyncPoint(siteDir, origin, git(['rev-parse', 'HEAD']).trim())
|
|
205
|
+
success('Git repository initialized, its first commit the site as the backend has it')
|
|
206
|
+
} catch (err) {
|
|
207
|
+
note(
|
|
208
|
+
`! A git repository was started, but its first commit failed${err?.stderr ? `: ${String(err.stderr).trim().split('\n')[0]}` : ''}.`
|
|
209
|
+
)
|
|
210
|
+
note(' Commit the project before your first `uniweb pull`, so the pull has something to merge into.')
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
172
214
|
// Build the package-manager argv to run the project-local `uniweb pull`.
|
|
173
215
|
function pullExecArgv(pm, extra) {
|
|
174
216
|
// npm needs `exec --` to forward flags to the binary; pnpm/yarn don't.
|
|
@@ -411,32 +453,12 @@ export async function clone(args = [], deps = {}) {
|
|
|
411
453
|
}
|
|
412
454
|
}
|
|
413
455
|
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
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 = []
|
|
456
|
+
// ⭐ The delegated pull takes the backend's version whole (`--force`): every file under
|
|
457
|
+
// what it writes is one this clone scaffolded seconds ago — the site directory is new
|
|
458
|
+
// (refused above when it is not) — so there is nothing of the author's to protect.
|
|
459
|
+
// ⛔ Until 2026-10-17 clone recorded its scaffold as pull output instead, so the pull's
|
|
460
|
+
// guard let it through; a pull inside a repository merges now, and has no such guard.
|
|
461
|
+
const pullExtra = ['--force']
|
|
440
462
|
if (noRecords) pullExtra.push('--no-records')
|
|
441
463
|
// The workspace this clone was told to work in: the pull is another process, and asks the same
|
|
442
464
|
// question. ⛔ Until 2026-09-26 it was not told, so it used the login's — or, with none chosen,
|
|
@@ -463,6 +485,10 @@ export async function clone(args = [], deps = {}) {
|
|
|
463
485
|
}
|
|
464
486
|
}
|
|
465
487
|
|
|
488
|
+
if (!deps.skipGit && !args.includes('--no-git')) {
|
|
489
|
+
startHistory({ projectDir, siteDir, origin: client.origin, siteUuid })
|
|
490
|
+
}
|
|
491
|
+
|
|
466
492
|
log('')
|
|
467
493
|
success(
|
|
468
494
|
`Cloned site into ${colors.bright}${isNewWorkspace && !inPlace ? workspaceName : siteDir}${colors.reset}`
|
package/src/commands/publish.js
CHANGED
|
@@ -103,6 +103,7 @@ import {
|
|
|
103
103
|
ensureSiteExists,
|
|
104
104
|
refuseUnsendableRecords,
|
|
105
105
|
clearRemoteSyncStateIfUnbound,
|
|
106
|
+
untakenBase,
|
|
106
107
|
dropSiteBoundValues,
|
|
107
108
|
pushSyncPackages
|
|
108
109
|
} from '../backend/site-sync.js'
|
|
@@ -622,6 +623,16 @@ export async function publish(args = []) {
|
|
|
622
623
|
`Dropped the previous site's ${droppedValues.join(' and ')} from site.yml.`
|
|
623
624
|
)
|
|
624
625
|
}
|
|
626
|
+
// ⛔ The gated push publish rides claims a base only while the files hold it — said here,
|
|
627
|
+
// before anything is uploaded or created (`untakenBase`). `--force` drops the gate.
|
|
628
|
+
if (!args.includes('--force')) {
|
|
629
|
+
const untaken = untakenBase(siteDir, client.origin)
|
|
630
|
+
if (untaken) {
|
|
631
|
+
say.err(untaken.headline)
|
|
632
|
+
for (const line of untaken.lines) say.dim(line)
|
|
633
|
+
return { exitCode: 1 }
|
|
634
|
+
}
|
|
635
|
+
}
|
|
625
636
|
|
|
626
637
|
// 3b. Make sure the SITE EXISTS before a single byte is uploaded.
|
|
627
638
|
//
|