uniweb 1.12.0 → 1.13.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/package.json +6 -6
- package/partials/agents.md +2 -2
- package/src/backend/commit-before-sending.js +49 -0
- package/src/backend/site-sync.js +209 -13
- package/src/commands/publish.js +22 -1
- package/src/commands/pull.js +37 -9
- package/src/commands/push.js +27 -3
- package/src/commands/sync.js +21 -2
- package/src/framework-index.json +3 -3
- package/src/index.js +5 -0
- package/src/utils/flag-guard.js +5 -2
- package/src/utils/git.js +19 -0
- package/src/utils/sync-point.js +134 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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/semantic-parser": "~1.12.0",
|
|
45
|
-
"@uniweb/core": "~1.3.0",
|
|
46
44
|
"@uniweb/content-writer": "~1.3.0",
|
|
47
|
-
"@uniweb/schemas": "~1.5.0"
|
|
45
|
+
"@uniweb/schemas": "~1.5.0",
|
|
46
|
+
"@uniweb/semantic-parser": "~1.12.0",
|
|
47
|
+
"@uniweb/core": "~1.3.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
+
"@uniweb/content-reader": "~1.5.2",
|
|
50
51
|
"@uniweb/semantic-parser": "~1.12.0",
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/build": "~1.5.0"
|
|
52
|
+
"@uniweb/build": "~1.5.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2848,9 +2848,9 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2848
2848
|
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
2849
2849
|
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
2850
2850
|
|
|
2851
|
-
**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.
|
|
2851
|
+
**Nobody's work is overwritten without asking.** `uniweb push` is refused if the site changed on the backend since your last pull — usually an author editing in the app — and reports what changed; nothing is written. Edits to different sections never collide. **In a git repository `uniweb pull` merges, like `git pull`:** it commits the backend's content (*"The site's content on …"*) and merges that commit into your branch, against the last state of the site your history shares with the backend. Your work, committed or not, and a teammate's arriving through git are kept; a file the backend never had is never deleted; changes to different parts of a file combine silently; and only a genuine overlap leaves conflict markers and a non-zero exit — so `uniweb pull && uniweb push` never ships markers. Settle a conflict as in any merge, then `git commit`; where your uncommitted changes overlap what the pull brought, the markers are theirs — settle them and `git add` each file, then `git stash drop` (git keeps them in the stash too). A push or publish is refused while a file under the site is in conflict. **In a git repository a push sends what you have committed:** with the site's content not committed it is refused before anything is sent, naming the files — commit them, or run `uniweb push --commit -m "…"` (it commits the site's content only, and leaves anything else staged as it is); `publish` and `sync` take `--commit` too. That commit is where the next pull merges from, which is how the pull tells your changes from the backend's. What the push writes back into the files — the ids the backend assigned — it commits itself, as `uniweb`. When the backend has nothing new, a pull says so and lists the files where yours hold changes it lacks. `uniweb refresh` runs `git pull` first, then the same pull. A push is also refused when your branch does not hold what this copy last took from the backend — after `git merge --abort`, or in a fresh clone that has not pulled — so pull, then push. Outside git — which includes a folder the repository around it ignores, such as a scratch directory — `uniweb pull` takes the backend's version over your files, asking first. `--force` is the deliberate overwrite (on `push` it replaces the backend's changes, on `pull` it discards yours); don't use it to get past a refusal. `uniweb sync` is `refresh` then `push`; neither publishes.
|
|
2852
2852
|
|
|
2853
|
-
**`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`.
|
|
2853
|
+
**`sync.json` says which site this is, on each backend.** The first push to a backend records what that backend assigned — the site's id and owner, the ids of its records and uploaded files — in `sync.json` beside `site.yml`. In a git repository the push commits it; never edit it. **To make a new site from a copy of a project, run `uniweb forget --all` in the copy before its first push** — the copy carries the original's `sync.json`, so otherwise its push updates the original's site. When two projects in one workspace hold the same site, a push or publish from either is refused until that is done. `uniweb forget --server <url>` removes just one backend's records, such as a scratch server's. A record file's `$uuid` is its own id and stays in both cases. **`push`, `pull` and `publish` go to the backend you are logged in to** — the last `uniweb login --server <url>` — and never to one you are not logged in to. A bare `uniweb login` logs in to https://uniweb.app; for any other backend, name it — logging in is how you switch, and the backend commands take no `--server` of their own. When the project has no site on that backend but has one elsewhere, a push says so before creating a new one.
|
|
2854
2854
|
|
|
2855
2855
|
**`services:` in `site.yml` is where a site says which services it uses** — site search, form
|
|
2856
2856
|
handling, analytics, an assistant, accounts — one entry per service:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--commit` on the verbs that send a site — `push` and `publish` (and `sync`, through its
|
|
3
|
+
* push). In a repository they send what is committed (`uncommittedContent`,
|
|
4
|
+
* backend/site-sync.js); this is the stop where it is not, and the commit that makes it so.
|
|
5
|
+
*
|
|
6
|
+
* Its own module so the flags it reads stay out of the import graph of every verb that only
|
|
7
|
+
* shares site-sync.js — `pull`, `status`, `refresh` take no `--commit`
|
|
8
|
+
* (`test/flag-guard-coverage.test.js`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { uncommittedContent, commitSiteContent } from './site-sync.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The stop before a push or publish sends anything, where the site's content is not
|
|
15
|
+
* committed — or, with `--commit`, the commit that makes it so, which a dry run only says.
|
|
16
|
+
* Returns `{ exitCode }` to stop, or null to go on.
|
|
17
|
+
*/
|
|
18
|
+
export function commitBeforeSending(siteDir, args, { verb, dryRun, say }) {
|
|
19
|
+
const pending = uncommittedContent(siteDir, { verb })
|
|
20
|
+
if (!pending) return null
|
|
21
|
+
if (!args.includes('--commit') || pending.merge) {
|
|
22
|
+
say.error(pending.headline)
|
|
23
|
+
for (const line of pending.lines) say.note(line)
|
|
24
|
+
return { exitCode: 1 }
|
|
25
|
+
}
|
|
26
|
+
if (dryRun) {
|
|
27
|
+
say.info(`Dry run — would commit ${pending.paths.length} file(s) of the site first.`)
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
const made = commitSiteContent(siteDir, { message: commitMessage(args) })
|
|
31
|
+
if (made.error) {
|
|
32
|
+
say.error("Could not commit the site's content:")
|
|
33
|
+
for (const line of String(made.error).split('\n').slice(0, 8)) say.note(` ${line}`)
|
|
34
|
+
return { exitCode: 1 }
|
|
35
|
+
}
|
|
36
|
+
if (made.commit) say.success(`Committed ${made.paths.length} file(s) of the site — ${made.commit.slice(0, 7)}`)
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `-m` / `--message` for `--commit`, as `git commit` takes it. */
|
|
41
|
+
function commitMessage(args) {
|
|
42
|
+
for (const name of ['-m', '--message']) {
|
|
43
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
44
|
+
if (eq) return eq.slice(name.length + 1)
|
|
45
|
+
const i = args.indexOf(name)
|
|
46
|
+
if (i !== -1 && args[i + 1] != null) return args[i + 1]
|
|
47
|
+
}
|
|
48
|
+
return "Update the site's content"
|
|
49
|
+
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
writeSyncPoint,
|
|
21
21
|
isAncestor,
|
|
22
22
|
contentCommitted,
|
|
23
|
+
uncommittedPaths,
|
|
24
|
+
commitRoots,
|
|
25
|
+
gitError,
|
|
26
|
+
RECORDED_TRAILER,
|
|
23
27
|
unmergedPaths,
|
|
24
28
|
mergeInProgress
|
|
25
29
|
} from '../utils/sync-point.js'
|
|
@@ -64,7 +68,11 @@ import {
|
|
|
64
68
|
LINK_MODEL,
|
|
65
69
|
bankedFileUuids,
|
|
66
70
|
existingSectionFile,
|
|
67
|
-
layoutUnitFile
|
|
71
|
+
layoutUnitFile,
|
|
72
|
+
siteContentRemovals,
|
|
73
|
+
recordRemovals,
|
|
74
|
+
cleanedRecord,
|
|
75
|
+
SYNC_STORE_FILE
|
|
68
76
|
} from '@uniweb/build/uwx'
|
|
69
77
|
|
|
70
78
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -84,6 +92,26 @@ function entityDocFromUwx(buf) {
|
|
|
84
92
|
return null
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
// Every entity `$`-document of a `.uwx` in the order it was submitted — the manifest's, which is
|
|
96
|
+
// what a push's `finalized[].index` counts in. null where a listed file is missing or unreadable.
|
|
97
|
+
function submittedDocsFromUwx(buf) {
|
|
98
|
+
if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return []
|
|
99
|
+
const files = readZip(buf)
|
|
100
|
+
let entries
|
|
101
|
+
try {
|
|
102
|
+
entries = JSON.parse(files.get('manifest.json')?.toString('utf8') ?? '{}').entries
|
|
103
|
+
} catch {
|
|
104
|
+
return []
|
|
105
|
+
}
|
|
106
|
+
return (Array.isArray(entries) ? entries : []).map((entry) => {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(files.get(entry?.file).toString('utf8'))
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
87
115
|
// Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
|
|
88
116
|
function entityDocsFromUwx(buf) {
|
|
89
117
|
const docs = []
|
|
@@ -223,6 +251,10 @@ function writtenAsSent(sent, written) {
|
|
|
223
251
|
* (`siteItemsByKey`), and it replaces the one held before:
|
|
224
252
|
*
|
|
225
253
|
* - an item the backend holds as we sent it → its new version;
|
|
254
|
+
* - an item it holds as we sent it once cleaned to the rich-text vocabulary → its new version
|
|
255
|
+
* too: what a backend that cleans at write removed is not someone else's content
|
|
256
|
+
* (`cleanedRecord`). ⛔ Until 2026-10-18 it was, so a push with a link the store removed
|
|
257
|
+
* said the section had changed on the backend, and kept the version for what was sent;
|
|
226
258
|
* - an item we sent that it kept for someone else → the version we held, for OUR content;
|
|
227
259
|
* - an item it holds that we did not send → the version we held, if we had seen it, and
|
|
228
260
|
* none if we had not (a unit of it is `foreign`);
|
|
@@ -232,7 +264,7 @@ function writtenAsSent(sent, written) {
|
|
|
232
264
|
* the backend returned it — harmless only while none of them was ever sent.
|
|
233
265
|
*
|
|
234
266
|
* A service is compared by its switch and its whole `config` (`sameServiceRow`), every
|
|
235
|
-
* other item on the keys we sent (`writtenAsSent`).
|
|
267
|
+
* other item on the keys we sent (`writtenAsSent`), as sent or as cleaned.
|
|
236
268
|
*
|
|
237
269
|
* With either document missing there is nothing to compare, and with no versions
|
|
238
270
|
* returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
|
|
@@ -255,7 +287,8 @@ export function heldTokens({ sent, written, version = null, itemVersions = null,
|
|
|
255
287
|
const prior = before && typeof before === 'object' ? before : {}
|
|
256
288
|
const ours = siteItemsByKey(sent)
|
|
257
289
|
const theirs = siteItemsByKey(written)
|
|
258
|
-
const same = (key, a, b) =>
|
|
290
|
+
const same = (key, a, b) =>
|
|
291
|
+
key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b) || writtenAsSent(cleanedRecord(a), b)
|
|
259
292
|
const held = []
|
|
260
293
|
const kept = []
|
|
261
294
|
const foreign = []
|
|
@@ -371,7 +404,10 @@ export function extractFinalized(payload) {
|
|
|
371
404
|
d?.item_versions && typeof d.item_versions === 'object'
|
|
372
405
|
? d.item_versions
|
|
373
406
|
: null,
|
|
374
|
-
document: d?.document ?? null
|
|
407
|
+
document: d?.document ?? null,
|
|
408
|
+
// What a backend that cleans rich text at write removed from what was sent, each
|
|
409
|
+
// `{ $uuid, field, path, rule }` — the key absent when nothing was (`uwx/cleaned.js`).
|
|
410
|
+
...(Array.isArray(d?.cleaned) && d.cleaned.length ? { cleaned: d.cleaned } : {})
|
|
375
411
|
}))
|
|
376
412
|
.filter((e) => Number.isInteger(e.index) && e.uuid)
|
|
377
413
|
}
|
|
@@ -1002,11 +1038,136 @@ export function untakenBase(siteDir, backend) {
|
|
|
1002
1038
|
export function holdUnchangedPush(siteDir, backend) {
|
|
1003
1039
|
const repo = repoOf(siteDir)
|
|
1004
1040
|
if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
|
|
1005
|
-
if (!contentCommitted(repo,
|
|
1041
|
+
if (!contentCommitted(repo, sentRoots(siteDir))) return null
|
|
1006
1042
|
writeSyncPoint(siteDir, backend, repo.head)
|
|
1007
1043
|
return repo.head
|
|
1008
1044
|
}
|
|
1009
1045
|
|
|
1046
|
+
/** What a push sends from the files: the site's content, and its media (`public/`). */
|
|
1047
|
+
export const sentRoots = (siteDir) => [...siteContentRoots(siteDir), 'public']
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* ⛔ IN A REPOSITORY, A PUSH SENDS WHAT IS COMMITTED. The commit a push is made from is where
|
|
1051
|
+
* the next pull merges from — this copy's, and every copy the backend names it to — so what
|
|
1052
|
+
* the push sends must be what that commit holds. Returns the refusal, or null: outside a
|
|
1053
|
+
* repository nothing is kept to merge from.
|
|
1054
|
+
*
|
|
1055
|
+
* ⛔ Until 2026-10-18 a push sent uncommitted edits on top of HEAD and recorded HEAD. Measured
|
|
1056
|
+
* against a real backend: an edit pushed that way and undone before the next pull came back
|
|
1057
|
+
* with it — exit 0, no conflict, reported as the backend's change. Every such push also had
|
|
1058
|
+
* the next pull commit those edits as the backend's, and conflict with one changed again.
|
|
1059
|
+
*/
|
|
1060
|
+
export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
|
|
1061
|
+
const repo = repoOf(siteDir)
|
|
1062
|
+
if (!repo) return null
|
|
1063
|
+
const paths = uncommittedPaths(repo, sentRoots(siteDir))
|
|
1064
|
+
if (!paths?.length) return null
|
|
1065
|
+
const done = verb === 'publish' ? 'published' : 'pushed'
|
|
1066
|
+
// A merge waiting for its commit is finished with one, never by committing a part of it.
|
|
1067
|
+
if (mergeInProgress(repo)) {
|
|
1068
|
+
return {
|
|
1069
|
+
paths,
|
|
1070
|
+
merge: true,
|
|
1071
|
+
headline: `Not ${done} — a merge is in progress here.`,
|
|
1072
|
+
lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// ⭐ A repository with no commit yet is still the one that will hold the site's history —
|
|
1076
|
+
// and holds none of it, so every file is listed as not committed. Said as that, not as
|
|
1077
|
+
// edits. ⛔ Not taken for a folder outside git: its first push would keep nothing to merge
|
|
1078
|
+
// from, and the pull after its first commit would read every difference as a conflict.
|
|
1079
|
+
if (!repo.head) {
|
|
1080
|
+
return {
|
|
1081
|
+
paths,
|
|
1082
|
+
headline: `Not ${done} — this repository has no commit yet, so none of the site's ${paths.length} file(s) is committed.`,
|
|
1083
|
+
lines: [
|
|
1084
|
+
`In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
|
|
1085
|
+
`Commit the site, then ${verb} again — or re-run with --commit (and -m "…") to make that first commit.`
|
|
1086
|
+
]
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return {
|
|
1090
|
+
paths,
|
|
1091
|
+
headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
|
|
1092
|
+
lines: [
|
|
1093
|
+
...paths.slice(0, 12).map((p) => ` ${p}`),
|
|
1094
|
+
...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
|
|
1095
|
+
`In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
|
|
1096
|
+
`Commit them, then ${verb} again — or re-run with --commit (and -m "…") to commit them first.`
|
|
1097
|
+
]
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/** Says what `commitWriteBacks` committed, or that it could not. */
|
|
1102
|
+
export function reportWriteBacks(made, { note }) {
|
|
1103
|
+
if (!made) return
|
|
1104
|
+
if (made.error) {
|
|
1105
|
+
note('! Could not commit what the push wrote into your files — commit them yourself:')
|
|
1106
|
+
for (const line of String(made.error).split('\n').slice(0, 4)) note(` ${line}`)
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
note(`Committed what the backend gave the site — ${made.paths.length} file(s), ${made.commit.slice(0, 7)}.`)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* `--commit`: the site's content committed as the files hold it — the person's own commit,
|
|
1114
|
+
* of what a push sends and nothing else they have staged.
|
|
1115
|
+
*
|
|
1116
|
+
* @returns {{ commit: string|null, paths?: string[] } | { error: string }}
|
|
1117
|
+
*/
|
|
1118
|
+
export function commitSiteContent(siteDir, { message }) {
|
|
1119
|
+
const repo = repoOf(siteDir)
|
|
1120
|
+
if (!repo) return { commit: null }
|
|
1121
|
+
if (mergeInProgress(repo)) return { error: 'A merge is in progress here — finish it (`git commit`) first.' }
|
|
1122
|
+
try {
|
|
1123
|
+
return commitRoots(repo, sentRoots(siteDir), { message }) || { commit: null }
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
return { error: gitError(err) }
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* ⭐ A PUSH COMMITS WHAT IT WROTE. It writes what the backend gave it into the site's files —
|
|
1131
|
+
* the ids it minted, into each new record's file and `sync.json` — and in a repository it
|
|
1132
|
+
* commits those writes as uniweb's own, as a pull's merge is. The content was committed
|
|
1133
|
+
* before it was sent, so what changed under the site since is the push's. That commit holds
|
|
1134
|
+
* what the backend now holds, so once every lane landed it is this copy's sync point.
|
|
1135
|
+
*
|
|
1136
|
+
* ⛔ Until 2026-10-18 they were left to the person to commit, and a project pushed once and
|
|
1137
|
+
* not committed after had no `sync.json` in the history its clones got.
|
|
1138
|
+
*
|
|
1139
|
+
* @returns {{ commit: string, paths: string[] } | { error: string } | null}
|
|
1140
|
+
*/
|
|
1141
|
+
export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
|
|
1142
|
+
const repo = repoOf(siteDir)
|
|
1143
|
+
if (!repo?.head || mergeInProgress(repo)) return null
|
|
1144
|
+
let host = 'backend'
|
|
1145
|
+
try {
|
|
1146
|
+
host = new URL(backend).host
|
|
1147
|
+
} catch {
|
|
1148
|
+
/* the name only */
|
|
1149
|
+
}
|
|
1150
|
+
const identity = {
|
|
1151
|
+
GIT_AUTHOR_NAME: 'uniweb',
|
|
1152
|
+
GIT_AUTHOR_EMAIL: `sync@${host}`,
|
|
1153
|
+
GIT_COMMITTER_NAME: 'uniweb',
|
|
1154
|
+
GIT_COMMITTER_EMAIL: `sync@${host}`
|
|
1155
|
+
}
|
|
1156
|
+
try {
|
|
1157
|
+
// ⭐ Marked with the backend and the site, so a pull reads the commit a lane names through
|
|
1158
|
+
// it (`recordedAfter`, utils/sync-point.js): the ids written back are what the backend holds.
|
|
1159
|
+
const site = readBackendState(siteDir, backend).site?.uuid
|
|
1160
|
+
const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
|
|
1161
|
+
message: `Record what ${backend} gave the site${site ? `\n\n${RECORDED_TRAILER}: ${backend} ${site}` : ''}`,
|
|
1162
|
+
identity
|
|
1163
|
+
})
|
|
1164
|
+
if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
|
|
1165
|
+
return made
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
return { error: gitError(err) }
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1010
1171
|
/**
|
|
1011
1172
|
* Per-unit content hashes of the last synced state, kept in BOTH representations —
|
|
1012
1173
|
* the bases for the file-level attribution shown after a staleness refusal
|
|
@@ -1976,8 +2137,9 @@ const TEMPLATE_SAID = {
|
|
|
1976
2137
|
* @param {object} params.pkg - the `emitSyncPackages` result
|
|
1977
2138
|
* ({ siteContent, collections, siteContentUuid, hashes })
|
|
1978
2139
|
* @param {{info,note,error,dim?:Function}} params.report - injected logging
|
|
1979
|
-
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
|
|
2140
|
+
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], complete?: true }>}
|
|
1980
2141
|
* exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
|
|
2142
|
+
* `complete` once every lane with something to send has landed.
|
|
1981
2143
|
*/
|
|
1982
2144
|
export async function pushSyncPackages({
|
|
1983
2145
|
client,
|
|
@@ -1995,8 +2157,10 @@ export async function pushSyncPackages({
|
|
|
1995
2157
|
let draftsNotKept = []
|
|
1996
2158
|
// ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
|
|
1997
2159
|
// 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`).
|
|
1999
|
-
//
|
|
2160
|
+
// holds descends from it (`utils/sync-point.js`). In a repository it is exactly what is
|
|
2161
|
+
// sent: a push sends only committed content (`uncommittedContent`). ⛔ Until 2026-10-18
|
|
2162
|
+
// uncommitted edits rode on top of it, and this said it "stays a safe base for them" — but
|
|
2163
|
+
// one undone before the next pull read as no change against it, and the pull put it back.
|
|
2000
2164
|
const repo = repoOf(siteDir)
|
|
2001
2165
|
const source = repo?.head || null
|
|
2002
2166
|
|
|
@@ -2314,10 +2478,14 @@ export async function pushSyncPackages({
|
|
|
2314
2478
|
}
|
|
2315
2479
|
// Both grains land together, at every point the old code banked the entity one — and
|
|
2316
2480
|
// with them the commit they describe.
|
|
2317
|
-
|
|
2481
|
+
// ⛔ `point: false` where a lane was refused after another landed: the commit then describes
|
|
2482
|
+
// one lane and not the other, and as the base it would read what the refused lane lacks as
|
|
2483
|
+
// the backend's removal — measured, a pull after a push whose records were refused reverted
|
|
2484
|
+
// the record edits, exit 0. The versions of the lane that landed are still banked.
|
|
2485
|
+
const mergeHarvested = ({ point = true } = {}) => {
|
|
2318
2486
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
2319
2487
|
if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
|
|
2320
|
-
if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
|
|
2488
|
+
if (point && source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
|
|
2321
2489
|
}
|
|
2322
2490
|
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
2323
2491
|
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
@@ -2325,6 +2493,25 @@ export async function pushSyncPackages({
|
|
|
2325
2493
|
// collected here, to re-base attribution and to say so.
|
|
2326
2494
|
const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
|
|
2327
2495
|
const notHeld = { kept: [], foreign: [] }
|
|
2496
|
+
// ⭐ WHAT THE BACKEND REMOVED, beside the file it came from. A backend that cleans each
|
|
2497
|
+
// rich-text write to the vocabulary answers what it took out of each item
|
|
2498
|
+
// (`finalized[].cleaned`). The push never writes the returned document over the file, so the
|
|
2499
|
+
// file still holds it, and only this says so — on every push that sends it again.
|
|
2500
|
+
let toldRemoved = false
|
|
2501
|
+
const reportRemovals = (removals) => {
|
|
2502
|
+
for (const r of removals) note(`! ${r.where}${r.locale ? ` (${r.locale})` : ''}: ${r.says}`)
|
|
2503
|
+
if (removals.length && !toldRemoved) {
|
|
2504
|
+
note('Your files still hold what the backend removed; a pull would write them without it.')
|
|
2505
|
+
toldRemoved = true
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
const reportSiteContentRemovals = (finalized) =>
|
|
2509
|
+
reportRemovals(
|
|
2510
|
+
siteContentRemovals({ finalized, sent: sentSiteDoc }).map((r) => ({
|
|
2511
|
+
...r,
|
|
2512
|
+
where: r.unit ? unitFilesOf(siteDir, r.unit)[0] || r.unit : 'the site'
|
|
2513
|
+
}))
|
|
2514
|
+
)
|
|
2328
2515
|
const harvestSiteContent = (finalized) => {
|
|
2329
2516
|
for (const f of finalized || []) {
|
|
2330
2517
|
const held = heldTokens({
|
|
@@ -2438,6 +2625,7 @@ export async function pushSyncPackages({
|
|
|
2438
2625
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
2439
2626
|
}
|
|
2440
2627
|
harvestSiteContent(finalized)
|
|
2628
|
+
reportSiteContentRemovals(finalized)
|
|
2441
2629
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
2442
2630
|
finalizedTotal += finalized.length
|
|
2443
2631
|
settleSiteContent()
|
|
@@ -2472,6 +2660,7 @@ export async function pushSyncPackages({
|
|
|
2472
2660
|
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
2473
2661
|
const createdFinalized = extractFinalized(payload)
|
|
2474
2662
|
harvestSiteContent(createdFinalized)
|
|
2663
|
+
reportSiteContentRemovals(createdFinalized)
|
|
2475
2664
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
2476
2665
|
finalizedTotal += createdFinalized?.length ?? 1
|
|
2477
2666
|
settleSiteContent()
|
|
@@ -2496,10 +2685,17 @@ export async function pushSyncPackages({
|
|
|
2496
2685
|
{ boundUuid: boundSiteUuid }
|
|
2497
2686
|
)
|
|
2498
2687
|
if (!finalized) {
|
|
2499
|
-
mergeHarvested()
|
|
2688
|
+
mergeHarvested({ point: false })
|
|
2500
2689
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
2501
2690
|
}
|
|
2502
2691
|
harvest(finalized)
|
|
2692
|
+
reportRemovals(
|
|
2693
|
+
recordRemovals({ finalized, sent: submittedDocsFromUwx(records.buffer) }).map((r) => {
|
|
2694
|
+
const entry = records.index?.[r.index]
|
|
2695
|
+
const file = entry?.sourceFile
|
|
2696
|
+
return { ...r, where: file ? (isAbsolute(file) ? relative(siteDir, file) : file) : entry?.id || 'a record' }
|
|
2697
|
+
})
|
|
2698
|
+
)
|
|
2503
2699
|
const bf = backfillEntityUuids({ index: records.index, finalized })
|
|
2504
2700
|
// ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
|
|
2505
2701
|
// own id. The file keeps that own id; the next push to this backend sends the
|
|
@@ -2597,7 +2793,7 @@ export async function pushSyncPackages({
|
|
|
2597
2793
|
shown.join('\n ') +
|
|
2598
2794
|
'\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
|
|
2599
2795
|
)
|
|
2600
|
-
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
|
|
2796
|
+
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote, complete: true }
|
|
2601
2797
|
}
|
|
2602
|
-
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
2798
|
+
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote, complete: true }
|
|
2603
2799
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
* uniweb publish --dry-run Resolve everything; POST nothing
|
|
38
38
|
* uniweb publish --yes Skip confirmations (CI); never block on a prompt
|
|
39
39
|
* uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
|
|
40
|
+
* uniweb publish --commit [-m <message>]
|
|
41
|
+
* In a repository, commit the site's content first — a
|
|
42
|
+
* publish there sends only what is committed
|
|
40
43
|
* uniweb publish --org @org Work in @org for this publish, instead of the
|
|
41
44
|
* uniweb publish --personal workspace chosen at `uniweb login` (or your
|
|
42
45
|
* personal workspace). A site it creates is created
|
|
@@ -105,9 +108,13 @@ import {
|
|
|
105
108
|
clearRemoteSyncStateIfUnbound,
|
|
106
109
|
untakenBase,
|
|
107
110
|
unsettledFiles,
|
|
111
|
+
commitWriteBacks,
|
|
112
|
+
reportWriteBacks,
|
|
108
113
|
dropSiteBoundValues,
|
|
109
114
|
pushSyncPackages
|
|
110
115
|
} from '../backend/site-sync.js'
|
|
116
|
+
import { repoOf } from '../utils/sync-point.js'
|
|
117
|
+
import { commitBeforeSending } from '../backend/commit-before-sending.js'
|
|
111
118
|
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
112
119
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
113
120
|
import {
|
|
@@ -334,6 +341,16 @@ export async function publish(args = []) {
|
|
|
334
341
|
for (const line of unsettled.lines) say.dim(line)
|
|
335
342
|
return { exitCode: 1 }
|
|
336
343
|
}
|
|
344
|
+
// ⛔ In a repository a publish sends what is committed (`uncommittedContent`), whatever the
|
|
345
|
+
// flags. `--commit` commits it first; a dry run says it would.
|
|
346
|
+
{
|
|
347
|
+
const stop = commitBeforeSending(siteDir, args, {
|
|
348
|
+
verb: 'publish',
|
|
349
|
+
dryRun,
|
|
350
|
+
say: { success: say.ok, info: say.info, error: say.err, note: say.dim }
|
|
351
|
+
})
|
|
352
|
+
if (stop) return stop
|
|
353
|
+
}
|
|
337
354
|
if (!dryRun && !args.includes('--force')) {
|
|
338
355
|
const untaken = untakenBase(siteDir, client.origin)
|
|
339
356
|
if (untaken) {
|
|
@@ -711,7 +728,9 @@ export async function publish(args = []) {
|
|
|
711
728
|
const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
|
|
712
729
|
if (rec.written) {
|
|
713
730
|
say.dim(
|
|
714
|
-
`${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed
|
|
731
|
+
`${SYNC_STORE_FILE} : ${rec.added.length} asset(s) added, ${rec.changed.length} changed` +
|
|
732
|
+
// In a repository the publish commits it (`commitWriteBacks`).
|
|
733
|
+
(repoOf(siteDir) ? '' : ' — commit it')
|
|
715
734
|
)
|
|
716
735
|
}
|
|
717
736
|
if (ballAssets.length) ball = rewriteSchemalessDataAssets(ball, map)
|
|
@@ -955,6 +974,8 @@ export async function publish(args = []) {
|
|
|
955
974
|
pkg,
|
|
956
975
|
report
|
|
957
976
|
})
|
|
977
|
+
// What the push wrote into the files is committed, whatever became of the lanes.
|
|
978
|
+
reportWriteBacks(commitWriteBacks(siteDir, client.origin, { complete: pushResult.complete }), { note: say.dim })
|
|
958
979
|
if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
|
|
959
980
|
const siteUuid = pushResult.boundSiteUuid
|
|
960
981
|
if (!siteUuid) {
|
package/src/commands/pull.js
CHANGED
|
@@ -106,7 +106,7 @@ import {
|
|
|
106
106
|
writeFolderItemUuids
|
|
107
107
|
} from '../backend/site-sync.js'
|
|
108
108
|
import { resolveWorkspace } from '../backend/workspace.js'
|
|
109
|
-
import { siteContentRoots } from '../utils/git.js'
|
|
109
|
+
import { siteContentRoots, siteLaneRoots } from '../utils/git.js'
|
|
110
110
|
import {
|
|
111
111
|
repoOf,
|
|
112
112
|
mergeInProgress,
|
|
@@ -115,12 +115,13 @@ import {
|
|
|
115
115
|
isAncestor,
|
|
116
116
|
historySyncPoint,
|
|
117
117
|
madeOnto,
|
|
118
|
-
|
|
118
|
+
baseFor,
|
|
119
119
|
openStandIn,
|
|
120
120
|
mergeSyncPoint,
|
|
121
121
|
takeSyncPoint,
|
|
122
122
|
snapshotFiles,
|
|
123
123
|
changedInFiles,
|
|
124
|
+
filesDifferFrom,
|
|
124
125
|
readSyncPoint,
|
|
125
126
|
writeSyncPoint
|
|
126
127
|
} from '../utils/sync-point.js'
|
|
@@ -904,14 +905,25 @@ export async function pull(args = [], deps = {}) {
|
|
|
904
905
|
let onto = null
|
|
905
906
|
let standIn = null
|
|
906
907
|
if (repo && projected) {
|
|
907
|
-
|
|
908
|
+
// The bases this copy knows of itself — then what the backend names, per lane.
|
|
909
|
+
const known = [
|
|
908
910
|
historySyncPoint(repo, { origin: client.origin, siteUuid: siteContentUuid }),
|
|
909
911
|
held,
|
|
910
912
|
// What a sync point the branch no longer holds — a merge abandoned — was made onto.
|
|
911
|
-
...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid })
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
913
|
+
...madeOnto(repo, held, { origin: client.origin, siteUuid: siteContentUuid })
|
|
914
|
+
]
|
|
915
|
+
const laneRoots = siteLaneRoots(siteDir)
|
|
916
|
+
const taken = (lane) => lane && !lane.notModified && !lane.refused
|
|
917
|
+
base = baseFor(repo, {
|
|
918
|
+
known,
|
|
919
|
+
// ⛔ What each lane the backend answered names is a base only where every lane holds it (`baseFor`).
|
|
920
|
+
lanes: [
|
|
921
|
+
...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
|
|
922
|
+
...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
|
|
923
|
+
],
|
|
924
|
+
origin: client.origin,
|
|
925
|
+
siteUuid: siteContentUuid
|
|
926
|
+
})
|
|
915
927
|
onto = force || !base ? repo.head : base
|
|
916
928
|
try {
|
|
917
929
|
standIn = openStandIn(repo, { roots, rev: onto })
|
|
@@ -1136,6 +1148,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
1136
1148
|
siteUuid: siteContentUuid,
|
|
1137
1149
|
always: force || !base
|
|
1138
1150
|
})
|
|
1151
|
+
// The commit that holds the backend's content as the files would — the base itself when
|
|
1152
|
+
// they hold the same.
|
|
1153
|
+
merge.theirs = made.commit || base
|
|
1139
1154
|
if (!made.commit) {
|
|
1140
1155
|
// The backend holds what the base holds: nothing to merge.
|
|
1141
1156
|
writeSyncPoint(siteDir, client.origin, base)
|
|
@@ -1170,6 +1185,8 @@ export async function pull(args = [], deps = {}) {
|
|
|
1170
1185
|
} else {
|
|
1171
1186
|
merge.conflicted = result.conflicted || []
|
|
1172
1187
|
merge.stashKept = Boolean(result.stashKept)
|
|
1188
|
+
// Whether anything was set aside for the merge: the files as they were, against HEAD.
|
|
1189
|
+
merge.setAside = files !== repo.head
|
|
1173
1190
|
merge.noBase = !force && !base
|
|
1174
1191
|
merge.changed = changedInFiles(repo, {
|
|
1175
1192
|
from: repo.head,
|
|
@@ -1249,7 +1266,16 @@ export async function pull(args = [], deps = {}) {
|
|
|
1249
1266
|
if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
|
|
1250
1267
|
if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
|
|
1251
1268
|
} else if (!merge.conflicted.length) {
|
|
1252
|
-
|
|
1269
|
+
// ⛔ Nothing changed in the files is not the files holding what the backend has: what
|
|
1270
|
+
// this copy changed since the base, the backend lacks. Until 2026-10-18 it said they
|
|
1271
|
+
// held it either way — measured over a revert the backend did not have yet.
|
|
1272
|
+
const ahead = merge.theirs ? filesDifferFrom(repo, merge.theirs, roots) : []
|
|
1273
|
+
if (!ahead.length) note(`Your files already hold what ${client.origin} has.`)
|
|
1274
|
+
else {
|
|
1275
|
+
note(`Nothing new from ${client.origin}. ${ahead.length} file(s) here hold changes it does not have yet — \`uniweb push\` sends them:`)
|
|
1276
|
+
for (const p of ahead.slice(0, 12)) note(` ${p}`)
|
|
1277
|
+
if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
|
|
1278
|
+
}
|
|
1253
1279
|
}
|
|
1254
1280
|
if (merge.conflicted.length) {
|
|
1255
1281
|
error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
|
|
@@ -1264,7 +1290,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
1264
1290
|
note("your changes from the backend's: each file where they differ is a conflict, yours against the backend's.")
|
|
1265
1291
|
note('Once this merge is committed, later pulls merge from it.')
|
|
1266
1292
|
}
|
|
1267
|
-
note(
|
|
1293
|
+
note(
|
|
1294
|
+
`Settle them and \`git add\` each, then \`git commit\` to finish the merge${merge.setAside ? ' — your uncommitted changes come back with it' : ''}.`
|
|
1295
|
+
)
|
|
1268
1296
|
}
|
|
1269
1297
|
}
|
|
1270
1298
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
38
38
|
* uniweb push --all Send every record (bypass the changed-only cache)
|
|
39
39
|
* uniweb push --force Overwrite upstream changes (drop the staleness gate)
|
|
40
|
+
* uniweb push --commit [-m <message>] In a repository, commit the site's content first —
|
|
41
|
+
* a push there sends only what is committed
|
|
40
42
|
* uniweb push --no-release Send content against the foundation already
|
|
41
43
|
* released; release nothing
|
|
42
44
|
* uniweb push --bump Release above a newer registered version
|
|
@@ -78,6 +80,7 @@ import { confirm, isNonInteractive } from '../utils/interactive.js'
|
|
|
78
80
|
import { announceServices, foundationSupports } from '../backend/service-request.js'
|
|
79
81
|
import { guardEmptyRecords } from '../utils/records-guard.js'
|
|
80
82
|
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
83
|
+
import { repoOf } from '../utils/sync-point.js'
|
|
81
84
|
import {
|
|
82
85
|
makeModelResolver,
|
|
83
86
|
readSyncCache,
|
|
@@ -97,9 +100,12 @@ import {
|
|
|
97
100
|
dropSiteBoundValues,
|
|
98
101
|
untakenBase,
|
|
99
102
|
unsettledFiles,
|
|
103
|
+
commitWriteBacks,
|
|
104
|
+
reportWriteBacks,
|
|
100
105
|
holdUnchangedPush,
|
|
101
106
|
pushSyncPackages
|
|
102
107
|
} from '../backend/site-sync.js'
|
|
108
|
+
import { commitBeforeSending } from '../backend/commit-before-sending.js'
|
|
103
109
|
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
104
110
|
|
|
105
111
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
@@ -223,6 +229,12 @@ export async function push(args = [], deps = {}) {
|
|
|
223
229
|
for (const line of unsettled.lines) note(line)
|
|
224
230
|
return { exitCode: 1 }
|
|
225
231
|
}
|
|
232
|
+
// ⛔ In a repository a push sends what is committed (`uncommittedContent`), whatever the
|
|
233
|
+
// flags but `-o`, which sends nothing. `--commit` commits it first; a dry run says it would.
|
|
234
|
+
if (!output) {
|
|
235
|
+
const stop = commitBeforeSending(siteDir, args, { verb: 'push', dryRun, say: { success, info, error, note } })
|
|
236
|
+
if (stop) return stop
|
|
237
|
+
}
|
|
226
238
|
if (!output && !force) {
|
|
227
239
|
const untaken = untakenBase(siteDir, client.origin)
|
|
228
240
|
if (untaken) {
|
|
@@ -514,8 +526,10 @@ export async function push(args = [], deps = {}) {
|
|
|
514
526
|
// carries only the refs its content touched.
|
|
515
527
|
const rec = updateBackendMap(siteDir, client.origin, 'assets', ids, carryServed)
|
|
516
528
|
if (rec.written) {
|
|
529
|
+
// In a repository the push commits it (`commitWriteBacks`).
|
|
517
530
|
note(
|
|
518
|
-
`${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed
|
|
531
|
+
`${SYNC_STORE_FILE}: ${rec.added.length} asset(s) added, ${rec.changed.length} changed` +
|
|
532
|
+
(repoOf(siteDir) ? '' : ' — commit it')
|
|
519
533
|
)
|
|
520
534
|
}
|
|
521
535
|
} catch (err) {
|
|
@@ -626,7 +640,11 @@ export async function push(args = [], deps = {}) {
|
|
|
626
640
|
)
|
|
627
641
|
// ⭐ And so the backend holds what this commit sends: it is where the next pull merges
|
|
628
642
|
// from (`holdUnchangedPush`). Not for a preview, which keeps nothing.
|
|
629
|
-
if (!output && !dryRun)
|
|
643
|
+
if (!output && !dryRun) {
|
|
644
|
+
holdUnchangedPush(siteDir, client.origin)
|
|
645
|
+
// What it recorded on the way — a media map, an identity recovered — is committed too.
|
|
646
|
+
reportWriteBacks(commitWriteBacks(siteDir, client.origin), { note })
|
|
647
|
+
}
|
|
630
648
|
return { exitCode: 0 }
|
|
631
649
|
}
|
|
632
650
|
if (siteContent)
|
|
@@ -683,10 +701,16 @@ export async function push(args = [], deps = {}) {
|
|
|
683
701
|
dim: (s) => `${colors.dim}${s}${colors.reset}`
|
|
684
702
|
}
|
|
685
703
|
})
|
|
686
|
-
|
|
704
|
+
// What the push wrote into the files is committed, whatever became of the lanes.
|
|
705
|
+
const written = commitWriteBacks(siteDir, client.origin, { complete: result.complete })
|
|
706
|
+
if (result.exitCode !== 0) {
|
|
707
|
+
reportWriteBacks(written, { note })
|
|
708
|
+
return { exitCode: result.exitCode }
|
|
709
|
+
}
|
|
687
710
|
success(
|
|
688
711
|
`Pushed ${result.finalizedTotal} entit${result.finalizedTotal === 1 ? 'y' : 'ies'}` +
|
|
689
712
|
(result.wrote.length ? ` — ${result.wrote.join(', ')}` : '')
|
|
690
713
|
)
|
|
714
|
+
reportWriteBacks(written, { note })
|
|
691
715
|
return { exitCode: 0 }
|
|
692
716
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* uniweb sync refresh, then push
|
|
36
36
|
* uniweb sync --no-git skip the git remote half of the refresh
|
|
37
37
|
* uniweb sync --force forwarded to push (overwrite upstream changes)
|
|
38
|
+
* uniweb sync --commit [-m <msg>]
|
|
39
|
+
* forwarded to push (commit the site's content first —
|
|
40
|
+
* in a repository a push sends only what is committed)
|
|
38
41
|
*/
|
|
39
42
|
|
|
40
43
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
@@ -73,8 +76,9 @@ export async function sync(args = [], deps = {}) {
|
|
|
73
76
|
|
|
74
77
|
// `--force` means "overwrite upstream" and belongs to the push half only. Passing
|
|
75
78
|
// it to the refresh would ask pull to DISCARD the local work this command exists
|
|
76
|
-
// to send — the same word meaning opposite things on the two halves.
|
|
77
|
-
|
|
79
|
+
// to send — the same word meaning opposite things on the two halves. `--commit` and
|
|
80
|
+
// its message are the push's too: the refresh merges with the work as it is.
|
|
81
|
+
const refreshArgs = pushOnly(args)
|
|
78
82
|
|
|
79
83
|
const r = await refresh(refreshArgs, { skipFlagCheck: true })
|
|
80
84
|
if (r?.exitCode) {
|
|
@@ -90,4 +94,19 @@ export async function sync(args = [], deps = {}) {
|
|
|
90
94
|
return { exitCode: p?.exitCode ?? 0 }
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
/** The argv without the push half's own flags — `--force`, `--commit`, `-m <message>`. */
|
|
98
|
+
function pushOnly(args) {
|
|
99
|
+
const out = []
|
|
100
|
+
for (let i = 0; i < args.length; i++) {
|
|
101
|
+
const a = args[i]
|
|
102
|
+
if (a === '--force' || a === '--commit' || a.startsWith('-m=') || a.startsWith('--message=')) continue
|
|
103
|
+
if (a === '-m' || a === '--message') {
|
|
104
|
+
i++ // its value
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
out.push(a)
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
export default sync
|
package/src/framework-index.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-19T02:42:59.650Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "1.5.2"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.5.
|
|
9
|
+
"version": "1.5.1"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
|
-
"version": "1.5.
|
|
12
|
+
"version": "1.5.2"
|
|
13
13
|
},
|
|
14
14
|
"@uniweb/content-writer": {
|
|
15
15
|
"version": "1.3.0"
|
package/src/index.js
CHANGED
|
@@ -1470,6 +1470,8 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1470
1470
|
bind to, and the app cannot open such a site.
|
|
1471
1471
|
--bump When the registry holds a newer version of the foundation
|
|
1472
1472
|
than yours, release yours above it instead of stopping
|
|
1473
|
+
--commit In a git repository, commit the site's content first
|
|
1474
|
+
(-m <message>) — a publish there sends only what is committed
|
|
1473
1475
|
--no-save Do not record this deploy in deploy.yml
|
|
1474
1476
|
--no-validate Skip the content-conformance check, which stops a publish
|
|
1475
1477
|
whose content does not conform to its data schemas
|
|
@@ -2105,6 +2107,8 @@ ${colors.bright}Push Options:${colors.reset}
|
|
|
2105
2107
|
--no-release Send content against the already-released code; release nothing
|
|
2106
2108
|
--bump Release above a newer registered foundation version
|
|
2107
2109
|
--force Overwrite upstream changes (drop the staleness gate)
|
|
2110
|
+
--commit In a git repository, commit the site's content first (-m <message>);
|
|
2111
|
+
a push there sends only what is committed
|
|
2108
2112
|
--all Send every record (bypass the changed-only cache)
|
|
2109
2113
|
--org @org Work in @org for this push, not your login's workspace
|
|
2110
2114
|
--personal Work in your personal workspace for this push
|
|
@@ -2115,6 +2119,7 @@ ${colors.bright}Publish Options:${colors.reset}
|
|
|
2115
2119
|
--yes Skip confirmations (CI); never block on a prompt
|
|
2116
2120
|
--no-release Ship content against the already-released code; release nothing
|
|
2117
2121
|
--bump Release above a newer registered foundation version
|
|
2122
|
+
--commit In a git repository, commit the site's content first (-m <message>)
|
|
2118
2123
|
--org @org Work in @org for this publish, not your login's workspace
|
|
2119
2124
|
--personal Work in your personal workspace for this publish
|
|
2120
2125
|
--no-save Do not record this deploy in deploy.yml
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -67,14 +67,17 @@ const VERBS = {
|
|
|
67
67
|
'--no-verify',
|
|
68
68
|
// ship content against the already-released code, releasing nothing — or
|
|
69
69
|
// release above a newer registered version instead of stopping (both bring-along)
|
|
70
|
-
'--no-release', '--bump',
|
|
70
|
+
'--no-release', '--bump',
|
|
71
|
+
// commit the site's content first, with this message (backend/site-sync.js)
|
|
72
|
+
'--commit', '-m', '--message', ...VIA_DEPLOY
|
|
71
73
|
],
|
|
72
74
|
publish: [
|
|
73
75
|
'--org', '--dry-run', '--force', '--foundation',
|
|
74
76
|
'--personal',
|
|
75
77
|
// read in utils/conformance.js, backend/site-sync.js, and
|
|
76
78
|
// backend/foundation-bring-along.js — none appear in publish.js
|
|
77
|
-
'--no-validate', '--yes', '--no-verify', '--no-release', '--bump',
|
|
79
|
+
'--no-validate', '--yes', '--no-verify', '--no-release', '--bump',
|
|
80
|
+
'--commit', '-m', '--message', ...VIA_DEPLOY
|
|
78
81
|
],
|
|
79
82
|
pull: [
|
|
80
83
|
'--content-only', '--dry-run', '--force',
|
package/src/utils/git.js
CHANGED
|
@@ -66,6 +66,25 @@ export function siteContentRoots(siteDir) {
|
|
|
66
66
|
return [...roots]
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* What each lane of a push carries, as roots: the folder lane the records — their files,
|
|
71
|
+
* and their translations — and the content lane everything else a pull writes, media
|
|
72
|
+
* (`public/`) included. ⚠️ `locales` stays whole on the content side, record translations
|
|
73
|
+
* with it: a root too many there costs a less precise base, never a wrong one.
|
|
74
|
+
*/
|
|
75
|
+
export function siteLaneRoots(siteDir) {
|
|
76
|
+
let records = 'records'
|
|
77
|
+
try {
|
|
78
|
+
records = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths?.records || 'records'
|
|
79
|
+
} catch {
|
|
80
|
+
/* no or unreadable site.yml — the default is right */
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
content: [...siteContentRoots(siteDir), 'public'].filter((r) => r !== records),
|
|
84
|
+
records: [records, 'locales/records']
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
function git(args, cwd) {
|
|
70
89
|
return execFileSync('git', args, {
|
|
71
90
|
cwd,
|
package/src/utils/sync-point.js
CHANGED
|
@@ -53,6 +53,8 @@ import { join, dirname } from 'node:path'
|
|
|
53
53
|
|
|
54
54
|
export const SYNC_TRAILER = 'Uniweb-Sync'
|
|
55
55
|
export const SITE_TRAILER = 'Uniweb-Site'
|
|
56
|
+
/** Marks the commit a push makes of what it wrote back: `Uniweb-Recorded: <origin> <site>`. */
|
|
57
|
+
export const RECORDED_TRAILER = 'Uniweb-Recorded'
|
|
56
58
|
|
|
57
59
|
const MAX_BUFFER = 1024 * 1024 * 1024
|
|
58
60
|
|
|
@@ -292,6 +294,70 @@ export function newestBase(repo, candidates) {
|
|
|
292
294
|
}
|
|
293
295
|
}
|
|
294
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The base a pull merges from, given the bases this copy knows of itself and what the backend
|
|
299
|
+
* names for each lane it answered (`x-uniweb-source`).
|
|
300
|
+
*
|
|
301
|
+
* ⭐ THIS COPY'S OWN BASES ARE EXACT BY CONSTRUCTION — a pull's commit, the commit a push was
|
|
302
|
+
* made from once every lane landed, one a push found nothing to send from — and are never
|
|
303
|
+
* second-guessed here.
|
|
304
|
+
*
|
|
305
|
+
* ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and what the backend names is exact for its own
|
|
306
|
+
* lane only. A commit one lane names that is newer than another lane's, with that lane's files
|
|
307
|
+
* different, would read what that lane lacks — a push of it the backend refused while it took the
|
|
308
|
+
* other — as the backend's removal, and the merge would drop it. So it is not a base. A lane is
|
|
309
|
+
* held to the commit it names, read through the commit its push made of what it wrote back
|
|
310
|
+
* (`recordedAfter`), and a lane that names none to the newest base this copy knows.
|
|
311
|
+
*
|
|
312
|
+
* ⛔ Until 2026-10-19 (cli 713f62d, uniweb 1.13.0) every candidate was held to that, this copy's
|
|
313
|
+
* own included, and a lane to the commit it names alone. A first push writes each new record's
|
|
314
|
+
* `$uuid` back, so the records differed from the commit the folder lane names in every commit after
|
|
315
|
+
* it, and each was dropped: a pull merged from the site's first push, measured by flows against a
|
|
316
|
+
* real backend — a reverted change came back (A18), a re-wrap and six pushed edits conflicted.
|
|
317
|
+
*/
|
|
318
|
+
export function baseFor(repo, { known = [], lanes = [], origin, siteUuid }) {
|
|
319
|
+
const held = (s) => Boolean(s) && isCommit(repo, s) && isAncestor(repo, s)
|
|
320
|
+
const fallback = newestBase(repo, known)
|
|
321
|
+
const refs = lanes.map(({ source, roots }) => ({
|
|
322
|
+
roots,
|
|
323
|
+
ref: held(source) ? recordedAfter(repo, source, { origin, siteUuid }) || source : fallback
|
|
324
|
+
}))
|
|
325
|
+
const named = lanes.flatMap(({ source }) => (held(source) ? [source, recordedAfter(repo, source, { origin, siteUuid })] : []))
|
|
326
|
+
const taken = named.filter(Boolean).filter((s) =>
|
|
327
|
+
refs.every(({ ref, roots }) => Boolean(ref) && (s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)))
|
|
328
|
+
)
|
|
329
|
+
return newestBase(repo, [...known, ...taken])
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The commit a push made of what it wrote back, after being made from `sha` — its first parent,
|
|
334
|
+
* marked with the push's backend and site — when HEAD's history holds it; else null.
|
|
335
|
+
*/
|
|
336
|
+
export function recordedAfter(repo, sha, { origin, siteUuid }) {
|
|
337
|
+
if (!sha || !origin || !siteUuid) return null
|
|
338
|
+
let out
|
|
339
|
+
try {
|
|
340
|
+
out = gitText(repo.top, ['log', '--fixed-strings', `--grep=${RECORDED_TRAILER}: ${origin} ${siteUuid}`, '--format=%H %P', 'HEAD'])
|
|
341
|
+
} catch {
|
|
342
|
+
return null
|
|
343
|
+
}
|
|
344
|
+
for (const line of out.split('\n')) {
|
|
345
|
+
const [commit, first] = line.trim().split(' ')
|
|
346
|
+
if (commit && first && (first === sha || first.startsWith(sha))) return commit
|
|
347
|
+
}
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
|
|
352
|
+
function differsIn(repo, a, b, roots) {
|
|
353
|
+
try {
|
|
354
|
+
git(repo.top, ['diff', '--quiet', a, b, '--', ...roots.map((r) => `${repo.prefix}${r}`)])
|
|
355
|
+
return false
|
|
356
|
+
} catch {
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
295
361
|
// ── the stand-in ────────────────────────────────────────────────────────────
|
|
296
362
|
|
|
297
363
|
/**
|
|
@@ -652,14 +718,79 @@ export function snapshotFiles(repo) {
|
|
|
652
718
|
* under the roots? (What the repository ignores is no change.)
|
|
653
719
|
*/
|
|
654
720
|
export function contentCommitted(repo, roots) {
|
|
721
|
+
return uncommittedPaths(repo, roots)?.length === 0
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The files under the roots that HEAD does not hold as they are — staged, changed, deleted
|
|
726
|
+
* or untracked, and both sides of a rename — relative to the site; null when git cannot
|
|
727
|
+
* say. What the repository ignores is no change.
|
|
728
|
+
*/
|
|
729
|
+
export function uncommittedPaths(repo, roots) {
|
|
730
|
+
let parts
|
|
655
731
|
try {
|
|
656
|
-
|
|
657
|
-
return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
|
|
732
|
+
parts = zlist(git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...roots.map((r) => `${repo.prefix}${r}`)]))
|
|
658
733
|
} catch {
|
|
659
|
-
return
|
|
734
|
+
return null
|
|
735
|
+
}
|
|
736
|
+
const out = []
|
|
737
|
+
for (let i = 0; i < parts.length; i++) {
|
|
738
|
+
out.push(parts[i].slice(3))
|
|
739
|
+
if (/[RC]/.test(parts[i].slice(0, 2))) out.push(parts[++i]) // a rename's source follows it
|
|
660
740
|
}
|
|
741
|
+
return [...new Set(out.map((p) => siteRelative(repo, p)))]
|
|
661
742
|
}
|
|
662
743
|
|
|
744
|
+
/**
|
|
745
|
+
* The files under the roots that differ from what `rev` holds there — changed, deleted, or
|
|
746
|
+
* new and not ignored — relative to the site.
|
|
747
|
+
*/
|
|
748
|
+
export function filesDifferFrom(repo, rev, roots) {
|
|
749
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
750
|
+
try {
|
|
751
|
+
const changed = zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', rev, '--', ...full]))
|
|
752
|
+
const added = zlist(git(repo.top, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...full]))
|
|
753
|
+
return [...new Set([...changed, ...added].map((p) => siteRelative(repo, p)))]
|
|
754
|
+
} catch {
|
|
755
|
+
return []
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Commit what the files hold under the roots — added, changed, deleted or renamed — and
|
|
761
|
+
* nothing else that is staged. With `identity` it is uniweb's commit, made as a pull's merge
|
|
762
|
+
* is: no hooks, unsigned. Without it, the person's own, as their `git commit` makes it.
|
|
763
|
+
*
|
|
764
|
+
* ⛔ Staged by ROOT, committed by NAME: `git add` refuses a path that is on neither the disk
|
|
765
|
+
* nor the index — a rename's source — and the commit takes exactly what was staged.
|
|
766
|
+
*
|
|
767
|
+
* @returns {{ commit: string, paths: string[] } | null} null when there was nothing to commit
|
|
768
|
+
*/
|
|
769
|
+
export function commitRoots(repo, roots, { message, identity = null }) {
|
|
770
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
771
|
+
const present = full.filter((p) => existsSync(join(repo.top, p)) || zlist(git(repo.top, ['ls-files', '-z', '--', p])).length)
|
|
772
|
+
let addable = present
|
|
773
|
+
if (present.length) {
|
|
774
|
+
try {
|
|
775
|
+
const ignored = zlist(git(repo.top, ['check-ignore', '-z', '--stdin'], { input: `${present.join('\0')}\0` }))
|
|
776
|
+
addable = present.filter((p) => !ignored.includes(p))
|
|
777
|
+
} catch (err) {
|
|
778
|
+
if (err?.status !== 1) throw err // 1: none of them is ignored
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (addable.length) git(repo.top, ['--literal-pathspecs', 'add', '-A', '--', ...addable])
|
|
782
|
+
const staged = zlist(git(repo.top, ['diff', '--cached', '--name-only', '-z', '--no-renames', '--', ...full]))
|
|
783
|
+
if (!staged.length) return null
|
|
784
|
+
git(
|
|
785
|
+
repo.top,
|
|
786
|
+
['--literal-pathspecs', 'commit', '-q', ...(identity ? ['--no-verify', '--no-gpg-sign'] : []), '-m', message, '--pathspec-from-file=-', '--pathspec-file-nul'],
|
|
787
|
+
{ input: `${staged.join('\0')}\0`, ...(identity ? { env: identity } : {}) }
|
|
788
|
+
)
|
|
789
|
+
return { commit: gitText(repo.top, ['rev-parse', 'HEAD']).trim(), paths: staged.map((p) => siteRelative(repo, p)) }
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const siteRelative = (repo, p) => (repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p)
|
|
793
|
+
|
|
663
794
|
// ── this copy's own sync point ──────────────────────────────────────────────
|
|
664
795
|
|
|
665
796
|
const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
|