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 +1 -1
- package/package.json +6 -6
- package/partials/agents.md +2 -2
- package/src/backend/client.js +22 -4
- package/src/backend/site-sync.js +131 -48
- package/src/commands/clone.js +68 -29
- package/src/commands/publish.js +23 -1
- package/src/commands/pull.js +476 -463
- package/src/commands/push.js +26 -0
- package/src/commands/refresh.js +17 -18
- package/src/framework-index.json +5 -5
- package/src/index.js +10 -15
- package/src/utils/flag-guard.js +20 -8
- package/src/utils/git.js +53 -226
- package/src/utils/pull-written.js +15 -28
- package/src/utils/sync-point.js +693 -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.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/
|
|
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/
|
|
50
|
+
"@uniweb/build": "~1.3.10",
|
|
51
51
|
"@uniweb/content-reader": "~1.4.0",
|
|
52
|
-
"@uniweb/
|
|
52
|
+
"@uniweb/semantic-parser": "~1.8.1"
|
|
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`; 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
|
|
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,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 {
|
|
18
|
-
|
|
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
|
|
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`).
|
|
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
|
-
//
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
|
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
|
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',
|
|
@@ -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
|
-
//
|
|
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 = []
|
|
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}`
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|