uniweb 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +2 -2
- package/src/backend/commit-before-sending.js +49 -0
- package/src/backend/site-sync.js +191 -13
- package/src/commands/publish.js +22 -1
- package/src/commands/pull.js +29 -7
- package/src/commands/push.js +27 -3
- package/src/commands/sync.js +21 -2
- package/src/framework-index.json +11 -11
- 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 +96 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
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/content-writer": "~1.3.0",
|
|
45
|
-
"@uniweb/core": "~1.2.5",
|
|
46
44
|
"@uniweb/schemas": "~1.5.0",
|
|
47
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/content-writer": "~1.3.0",
|
|
46
|
+
"@uniweb/core": "~1.3.0",
|
|
47
|
+
"@uniweb/semantic-parser": "~1.12.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/semantic-parser": "~1.
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/content-reader": "~1.5.2",
|
|
51
|
+
"@uniweb/semantic-parser": "~1.12.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,9 @@ import {
|
|
|
20
20
|
writeSyncPoint,
|
|
21
21
|
isAncestor,
|
|
22
22
|
contentCommitted,
|
|
23
|
+
uncommittedPaths,
|
|
24
|
+
commitRoots,
|
|
25
|
+
gitError,
|
|
23
26
|
unmergedPaths,
|
|
24
27
|
mergeInProgress
|
|
25
28
|
} from '../utils/sync-point.js'
|
|
@@ -64,7 +67,11 @@ import {
|
|
|
64
67
|
LINK_MODEL,
|
|
65
68
|
bankedFileUuids,
|
|
66
69
|
existingSectionFile,
|
|
67
|
-
layoutUnitFile
|
|
70
|
+
layoutUnitFile,
|
|
71
|
+
siteContentRemovals,
|
|
72
|
+
recordRemovals,
|
|
73
|
+
cleanedRecord,
|
|
74
|
+
SYNC_STORE_FILE
|
|
68
75
|
} from '@uniweb/build/uwx'
|
|
69
76
|
|
|
70
77
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -84,6 +91,26 @@ function entityDocFromUwx(buf) {
|
|
|
84
91
|
return null
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
// Every entity `$`-document of a `.uwx` in the order it was submitted — the manifest's, which is
|
|
95
|
+
// what a push's `finalized[].index` counts in. null where a listed file is missing or unreadable.
|
|
96
|
+
function submittedDocsFromUwx(buf) {
|
|
97
|
+
if (!buf || buf.length < 2 || buf[0] !== 0x50 || buf[1] !== 0x4b) return []
|
|
98
|
+
const files = readZip(buf)
|
|
99
|
+
let entries
|
|
100
|
+
try {
|
|
101
|
+
entries = JSON.parse(files.get('manifest.json')?.toString('utf8') ?? '{}').entries
|
|
102
|
+
} catch {
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
return (Array.isArray(entries) ? entries : []).map((entry) => {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(files.get(entry?.file).toString('utf8'))
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
87
114
|
// Every entity `$`-document of a `.uwx` — the folder lane holds the folder and its records.
|
|
88
115
|
function entityDocsFromUwx(buf) {
|
|
89
116
|
const docs = []
|
|
@@ -223,6 +250,10 @@ function writtenAsSent(sent, written) {
|
|
|
223
250
|
* (`siteItemsByKey`), and it replaces the one held before:
|
|
224
251
|
*
|
|
225
252
|
* - an item the backend holds as we sent it → its new version;
|
|
253
|
+
* - an item it holds as we sent it once cleaned to the rich-text vocabulary → its new version
|
|
254
|
+
* too: what a backend that cleans at write removed is not someone else's content
|
|
255
|
+
* (`cleanedRecord`). ⛔ Until 2026-10-18 it was, so a push with a link the store removed
|
|
256
|
+
* said the section had changed on the backend, and kept the version for what was sent;
|
|
226
257
|
* - an item we sent that it kept for someone else → the version we held, for OUR content;
|
|
227
258
|
* - an item it holds that we did not send → the version we held, if we had seen it, and
|
|
228
259
|
* none if we had not (a unit of it is `foreign`);
|
|
@@ -232,7 +263,7 @@ function writtenAsSent(sent, written) {
|
|
|
232
263
|
* the backend returned it — harmless only while none of them was ever sent.
|
|
233
264
|
*
|
|
234
265
|
* A service is compared by its switch and its whole `config` (`sameServiceRow`), every
|
|
235
|
-
* other item on the keys we sent (`writtenAsSent`).
|
|
266
|
+
* other item on the keys we sent (`writtenAsSent`), as sent or as cleaned.
|
|
236
267
|
*
|
|
237
268
|
* With either document missing there is nothing to compare, and with no versions
|
|
238
269
|
* returned (an older backend) nothing to bank: `itemVersions` is null, and the versions
|
|
@@ -255,7 +286,8 @@ export function heldTokens({ sent, written, version = null, itemVersions = null,
|
|
|
255
286
|
const prior = before && typeof before === 'object' ? before : {}
|
|
256
287
|
const ours = siteItemsByKey(sent)
|
|
257
288
|
const theirs = siteItemsByKey(written)
|
|
258
|
-
const same = (key, a, b) =>
|
|
289
|
+
const same = (key, a, b) =>
|
|
290
|
+
key.startsWith('services:') ? sameServiceRow(a, b) : writtenAsSent(a, b) || writtenAsSent(cleanedRecord(a), b)
|
|
259
291
|
const held = []
|
|
260
292
|
const kept = []
|
|
261
293
|
const foreign = []
|
|
@@ -371,7 +403,10 @@ export function extractFinalized(payload) {
|
|
|
371
403
|
d?.item_versions && typeof d.item_versions === 'object'
|
|
372
404
|
? d.item_versions
|
|
373
405
|
: null,
|
|
374
|
-
document: d?.document ?? null
|
|
406
|
+
document: d?.document ?? null,
|
|
407
|
+
// What a backend that cleans rich text at write removed from what was sent, each
|
|
408
|
+
// `{ $uuid, field, path, rule }` — the key absent when nothing was (`uwx/cleaned.js`).
|
|
409
|
+
...(Array.isArray(d?.cleaned) && d.cleaned.length ? { cleaned: d.cleaned } : {})
|
|
375
410
|
}))
|
|
376
411
|
.filter((e) => Number.isInteger(e.index) && e.uuid)
|
|
377
412
|
}
|
|
@@ -1002,11 +1037,119 @@ export function untakenBase(siteDir, backend) {
|
|
|
1002
1037
|
export function holdUnchangedPush(siteDir, backend) {
|
|
1003
1038
|
const repo = repoOf(siteDir)
|
|
1004
1039
|
if (!repo?.head || !readBackendState(siteDir, backend).site?.uuid) return null
|
|
1005
|
-
if (!contentCommitted(repo,
|
|
1040
|
+
if (!contentCommitted(repo, sentRoots(siteDir))) return null
|
|
1006
1041
|
writeSyncPoint(siteDir, backend, repo.head)
|
|
1007
1042
|
return repo.head
|
|
1008
1043
|
}
|
|
1009
1044
|
|
|
1045
|
+
/** What a push sends from the files: the site's content, and its media (`public/`). */
|
|
1046
|
+
export const sentRoots = (siteDir) => [...siteContentRoots(siteDir), 'public']
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* ⛔ IN A REPOSITORY, A PUSH SENDS WHAT IS COMMITTED. The commit a push is made from is where
|
|
1050
|
+
* the next pull merges from — this copy's, and every copy the backend names it to — so what
|
|
1051
|
+
* the push sends must be what that commit holds. Returns the refusal, or null: outside a
|
|
1052
|
+
* repository nothing is kept to merge from.
|
|
1053
|
+
*
|
|
1054
|
+
* ⛔ Until 2026-10-18 a push sent uncommitted edits on top of HEAD and recorded HEAD. Measured
|
|
1055
|
+
* against a real backend: an edit pushed that way and undone before the next pull came back
|
|
1056
|
+
* with it — exit 0, no conflict, reported as the backend's change. Every such push also had
|
|
1057
|
+
* the next pull commit those edits as the backend's, and conflict with one changed again.
|
|
1058
|
+
*/
|
|
1059
|
+
export function uncommittedContent(siteDir, { verb = 'push' } = {}) {
|
|
1060
|
+
const repo = repoOf(siteDir)
|
|
1061
|
+
if (!repo) return null
|
|
1062
|
+
const paths = uncommittedPaths(repo, sentRoots(siteDir))
|
|
1063
|
+
if (!paths?.length) return null
|
|
1064
|
+
const done = verb === 'publish' ? 'published' : 'pushed'
|
|
1065
|
+
// A merge waiting for its commit is finished with one, never by committing a part of it.
|
|
1066
|
+
if (mergeInProgress(repo)) {
|
|
1067
|
+
return {
|
|
1068
|
+
paths,
|
|
1069
|
+
merge: true,
|
|
1070
|
+
headline: `Not ${done} — a merge is in progress here.`,
|
|
1071
|
+
lines: [`Finish it (\`git commit\`), then ${verb} — or abandon it (\`git merge --abort\`) and pull again.`]
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return {
|
|
1075
|
+
paths,
|
|
1076
|
+
headline: `Not ${done} — ${paths.length} file(s) of the site are not committed:`,
|
|
1077
|
+
lines: [
|
|
1078
|
+
...paths.slice(0, 12).map((p) => ` ${p}`),
|
|
1079
|
+
...(paths.length > 12 ? [` … and ${paths.length - 12} more`] : []),
|
|
1080
|
+
`In a repository a ${verb} sends what you have committed, so a later pull can tell your changes from the backend's.`,
|
|
1081
|
+
`Commit them, then ${verb} again — or re-run with --commit (and -m "…") to commit them first.`
|
|
1082
|
+
]
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/** Says what `commitWriteBacks` committed, or that it could not. */
|
|
1087
|
+
export function reportWriteBacks(made, { note }) {
|
|
1088
|
+
if (!made) return
|
|
1089
|
+
if (made.error) {
|
|
1090
|
+
note('! Could not commit what the push wrote into your files — commit them yourself:')
|
|
1091
|
+
for (const line of String(made.error).split('\n').slice(0, 4)) note(` ${line}`)
|
|
1092
|
+
return
|
|
1093
|
+
}
|
|
1094
|
+
note(`Committed what the backend gave the site — ${made.paths.length} file(s), ${made.commit.slice(0, 7)}.`)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* `--commit`: the site's content committed as the files hold it — the person's own commit,
|
|
1099
|
+
* of what a push sends and nothing else they have staged.
|
|
1100
|
+
*
|
|
1101
|
+
* @returns {{ commit: string|null, paths?: string[] } | { error: string }}
|
|
1102
|
+
*/
|
|
1103
|
+
export function commitSiteContent(siteDir, { message }) {
|
|
1104
|
+
const repo = repoOf(siteDir)
|
|
1105
|
+
if (!repo) return { commit: null }
|
|
1106
|
+
if (mergeInProgress(repo)) return { error: 'A merge is in progress here — finish it (`git commit`) first.' }
|
|
1107
|
+
try {
|
|
1108
|
+
return commitRoots(repo, sentRoots(siteDir), { message }) || { commit: null }
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
return { error: gitError(err) }
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* ⭐ A PUSH COMMITS WHAT IT WROTE. It writes what the backend gave it into the site's files —
|
|
1116
|
+
* the ids it minted, into each new record's file and `sync.json` — and in a repository it
|
|
1117
|
+
* commits those writes as uniweb's own, as a pull's merge is. The content was committed
|
|
1118
|
+
* before it was sent, so what changed under the site since is the push's. That commit holds
|
|
1119
|
+
* what the backend now holds, so once every lane landed it is this copy's sync point.
|
|
1120
|
+
*
|
|
1121
|
+
* ⛔ Until 2026-10-18 they were left to the person to commit, and a project pushed once and
|
|
1122
|
+
* not committed after had no `sync.json` in the history its clones got.
|
|
1123
|
+
*
|
|
1124
|
+
* @returns {{ commit: string, paths: string[] } | { error: string } | null}
|
|
1125
|
+
*/
|
|
1126
|
+
export function commitWriteBacks(siteDir, backend, { complete = false } = {}) {
|
|
1127
|
+
const repo = repoOf(siteDir)
|
|
1128
|
+
if (!repo?.head || mergeInProgress(repo)) return null
|
|
1129
|
+
let host = 'backend'
|
|
1130
|
+
try {
|
|
1131
|
+
host = new URL(backend).host
|
|
1132
|
+
} catch {
|
|
1133
|
+
/* the name only */
|
|
1134
|
+
}
|
|
1135
|
+
const identity = {
|
|
1136
|
+
GIT_AUTHOR_NAME: 'uniweb',
|
|
1137
|
+
GIT_AUTHOR_EMAIL: `sync@${host}`,
|
|
1138
|
+
GIT_COMMITTER_NAME: 'uniweb',
|
|
1139
|
+
GIT_COMMITTER_EMAIL: `sync@${host}`
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
const made = commitRoots(repo, [...sentRoots(siteDir), SYNC_STORE_FILE], {
|
|
1143
|
+
message: `Record what ${backend} gave the site`,
|
|
1144
|
+
identity
|
|
1145
|
+
})
|
|
1146
|
+
if (made && complete) writeSyncPoint(siteDir, backend, made.commit)
|
|
1147
|
+
return made
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
return { error: gitError(err) }
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1010
1153
|
/**
|
|
1011
1154
|
* Per-unit content hashes of the last synced state, kept in BOTH representations —
|
|
1012
1155
|
* the bases for the file-level attribution shown after a staleness refusal
|
|
@@ -1976,8 +2119,9 @@ const TEMPLATE_SAID = {
|
|
|
1976
2119
|
* @param {object} params.pkg - the `emitSyncPackages` result
|
|
1977
2120
|
* ({ siteContent, collections, siteContentUuid, hashes })
|
|
1978
2121
|
* @param {{info,note,error,dim?:Function}} params.report - injected logging
|
|
1979
|
-
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[] }>}
|
|
2122
|
+
* @returns {Promise<{ exitCode: number, boundSiteUuid?: string, finalizedTotal: number, wrote: string[], complete?: true }>}
|
|
1980
2123
|
* exitCode 1 on any lane failure (already reported, cache NOT persisted); 0 on success.
|
|
2124
|
+
* `complete` once every lane with something to send has landed.
|
|
1981
2125
|
*/
|
|
1982
2126
|
export async function pushSyncPackages({
|
|
1983
2127
|
client,
|
|
@@ -1995,8 +2139,10 @@ export async function pushSyncPackages({
|
|
|
1995
2139
|
let draftsNotKept = []
|
|
1996
2140
|
// ⭐ THE COMMIT THIS PUSH IS MADE FROM — sent with each lane, so the backend can name it on
|
|
1997
2141
|
// 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
|
-
//
|
|
2142
|
+
// holds descends from it (`utils/sync-point.js`). In a repository it is exactly what is
|
|
2143
|
+
// sent: a push sends only committed content (`uncommittedContent`). ⛔ Until 2026-10-18
|
|
2144
|
+
// uncommitted edits rode on top of it, and this said it "stays a safe base for them" — but
|
|
2145
|
+
// one undone before the next pull read as no change against it, and the pull put it back.
|
|
2000
2146
|
const repo = repoOf(siteDir)
|
|
2001
2147
|
const source = repo?.head || null
|
|
2002
2148
|
|
|
@@ -2314,10 +2460,14 @@ export async function pushSyncPackages({
|
|
|
2314
2460
|
}
|
|
2315
2461
|
// Both grains land together, at every point the old code banked the entity one — and
|
|
2316
2462
|
// with them the commit they describe.
|
|
2317
|
-
|
|
2463
|
+
// ⛔ `point: false` where a lane was refused after another landed: the commit then describes
|
|
2464
|
+
// one lane and not the other, and as the base it would read what the refused lane lacks as
|
|
2465
|
+
// the backend's removal — measured, a pull after a push whose records were refused reverted
|
|
2466
|
+
// the record edits, exit 0. The versions of the lane that landed are still banked.
|
|
2467
|
+
const mergeHarvested = ({ point = true } = {}) => {
|
|
2318
2468
|
mergeBaseVersions(siteDir, client.origin, newVersions)
|
|
2319
2469
|
if (heldItemVersions) writeItemBaseVersions(siteDir, client.origin, heldItemVersions)
|
|
2320
|
-
if (source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
|
|
2470
|
+
if (point && source && (Object.keys(newVersions).length || heldItemVersions)) writeSyncPoint(siteDir, client.origin, source)
|
|
2321
2471
|
}
|
|
2322
2472
|
// ⛔ The site-content lane banks only the tokens for content this copy holds — the
|
|
2323
2473
|
// document we sent against the one the backend stored (`heldTokens`, and the two
|
|
@@ -2325,6 +2475,25 @@ export async function pushSyncPackages({
|
|
|
2325
2475
|
// collected here, to re-base attribution and to say so.
|
|
2326
2476
|
const sentSiteDoc = siteContent ? entityDocFromUwx(siteContent.buffer) : null
|
|
2327
2477
|
const notHeld = { kept: [], foreign: [] }
|
|
2478
|
+
// ⭐ WHAT THE BACKEND REMOVED, beside the file it came from. A backend that cleans each
|
|
2479
|
+
// rich-text write to the vocabulary answers what it took out of each item
|
|
2480
|
+
// (`finalized[].cleaned`). The push never writes the returned document over the file, so the
|
|
2481
|
+
// file still holds it, and only this says so — on every push that sends it again.
|
|
2482
|
+
let toldRemoved = false
|
|
2483
|
+
const reportRemovals = (removals) => {
|
|
2484
|
+
for (const r of removals) note(`! ${r.where}${r.locale ? ` (${r.locale})` : ''}: ${r.says}`)
|
|
2485
|
+
if (removals.length && !toldRemoved) {
|
|
2486
|
+
note('Your files still hold what the backend removed; a pull would write them without it.')
|
|
2487
|
+
toldRemoved = true
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
const reportSiteContentRemovals = (finalized) =>
|
|
2491
|
+
reportRemovals(
|
|
2492
|
+
siteContentRemovals({ finalized, sent: sentSiteDoc }).map((r) => ({
|
|
2493
|
+
...r,
|
|
2494
|
+
where: r.unit ? unitFilesOf(siteDir, r.unit)[0] || r.unit : 'the site'
|
|
2495
|
+
}))
|
|
2496
|
+
)
|
|
2328
2497
|
const harvestSiteContent = (finalized) => {
|
|
2329
2498
|
for (const f of finalized || []) {
|
|
2330
2499
|
const held = heldTokens({
|
|
@@ -2438,6 +2607,7 @@ export async function pushSyncPackages({
|
|
|
2438
2607
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
2439
2608
|
}
|
|
2440
2609
|
harvestSiteContent(finalized)
|
|
2610
|
+
reportSiteContentRemovals(finalized)
|
|
2441
2611
|
siteFinalizedDoc = finalized[0]?.document || null
|
|
2442
2612
|
finalizedTotal += finalized.length
|
|
2443
2613
|
settleSiteContent()
|
|
@@ -2472,6 +2642,7 @@ export async function pushSyncPackages({
|
|
|
2472
2642
|
wrote.push(`recorded its owner (${createdOrg}) in sync.json`)
|
|
2473
2643
|
const createdFinalized = extractFinalized(payload)
|
|
2474
2644
|
harvestSiteContent(createdFinalized)
|
|
2645
|
+
reportSiteContentRemovals(createdFinalized)
|
|
2475
2646
|
siteFinalizedDoc = createdFinalized?.[0]?.document || null
|
|
2476
2647
|
finalizedTotal += createdFinalized?.length ?? 1
|
|
2477
2648
|
settleSiteContent()
|
|
@@ -2496,10 +2667,17 @@ export async function pushSyncPackages({
|
|
|
2496
2667
|
{ boundUuid: boundSiteUuid }
|
|
2497
2668
|
)
|
|
2498
2669
|
if (!finalized) {
|
|
2499
|
-
mergeHarvested()
|
|
2670
|
+
mergeHarvested({ point: false })
|
|
2500
2671
|
return { exitCode: 1, finalizedTotal, wrote }
|
|
2501
2672
|
}
|
|
2502
2673
|
harvest(finalized)
|
|
2674
|
+
reportRemovals(
|
|
2675
|
+
recordRemovals({ finalized, sent: submittedDocsFromUwx(records.buffer) }).map((r) => {
|
|
2676
|
+
const entry = records.index?.[r.index]
|
|
2677
|
+
const file = entry?.sourceFile
|
|
2678
|
+
return { ...r, where: file ? (isAbsolute(file) ? relative(siteDir, file) : file) : entry?.id || 'a record' }
|
|
2679
|
+
})
|
|
2680
|
+
)
|
|
2503
2681
|
const bf = backfillEntityUuids({ index: records.index, finalized })
|
|
2504
2682
|
// ⭐ Record which uuid THIS backend minted for each record, keyed by the record's
|
|
2505
2683
|
// own id. The file keeps that own id; the next push to this backend sends the
|
|
@@ -2597,7 +2775,7 @@ export async function pushSyncPackages({
|
|
|
2597
2775
|
shown.join('\n ') +
|
|
2598
2776
|
'\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
|
|
2599
2777
|
)
|
|
2600
|
-
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
|
|
2778
|
+
return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote, complete: true }
|
|
2601
2779
|
}
|
|
2602
|
-
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
2780
|
+
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote, complete: true }
|
|
2603
2781
|
}
|
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,
|
|
@@ -121,6 +121,7 @@ import {
|
|
|
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,23 @@ 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 = newestBase(repo, [...known, content?.source, folderLane?.source], {
|
|
918
|
+
known,
|
|
919
|
+
// ⛔ Each lane the backend answered holds every base to what it names (`newestBase`).
|
|
920
|
+
lanes: [
|
|
921
|
+
...(taken(content) ? [{ source: content.source, roots: laneRoots.content }] : []),
|
|
922
|
+
...(taken(folderLane) ? [{ source: folderLane.source, roots: laneRoots.records }] : [])
|
|
923
|
+
]
|
|
924
|
+
})
|
|
915
925
|
onto = force || !base ? repo.head : base
|
|
916
926
|
try {
|
|
917
927
|
standIn = openStandIn(repo, { roots, rev: onto })
|
|
@@ -1136,6 +1146,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
1136
1146
|
siteUuid: siteContentUuid,
|
|
1137
1147
|
always: force || !base
|
|
1138
1148
|
})
|
|
1149
|
+
// The commit that holds the backend's content as the files would — the base itself when
|
|
1150
|
+
// they hold the same.
|
|
1151
|
+
merge.theirs = made.commit || base
|
|
1139
1152
|
if (!made.commit) {
|
|
1140
1153
|
// The backend holds what the base holds: nothing to merge.
|
|
1141
1154
|
writeSyncPoint(siteDir, client.origin, base)
|
|
@@ -1249,7 +1262,16 @@ export async function pull(args = [], deps = {}) {
|
|
|
1249
1262
|
if (merge.changed.length > 12) note(` … and ${merge.changed.length - 12} more`)
|
|
1250
1263
|
if (force) note('What they held is in the commit before this one (`git diff HEAD^1 HEAD`), unless it was never committed.')
|
|
1251
1264
|
} else if (!merge.conflicted.length) {
|
|
1252
|
-
|
|
1265
|
+
// ⛔ Nothing changed in the files is not the files holding what the backend has: what
|
|
1266
|
+
// this copy changed since the base, the backend lacks. Until 2026-10-18 it said they
|
|
1267
|
+
// held it either way — measured over a revert the backend did not have yet.
|
|
1268
|
+
const ahead = merge.theirs ? filesDifferFrom(repo, merge.theirs, roots) : []
|
|
1269
|
+
if (!ahead.length) note(`Your files already hold what ${client.origin} has.`)
|
|
1270
|
+
else {
|
|
1271
|
+
note(`Nothing new from ${client.origin}. ${ahead.length} file(s) here hold changes it does not have yet — \`uniweb push\` sends them:`)
|
|
1272
|
+
for (const p of ahead.slice(0, 12)) note(` ${p}`)
|
|
1273
|
+
if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
|
|
1274
|
+
}
|
|
1253
1275
|
}
|
|
1254
1276
|
if (merge.conflicted.length) {
|
|
1255
1277
|
error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
|
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,30 +1,30 @@
|
|
|
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
|
-
"version": "1.5.
|
|
6
|
+
"version": "1.5.2"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.
|
|
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"
|
|
16
16
|
},
|
|
17
17
|
"@uniweb/core": {
|
|
18
|
-
"version": "1.
|
|
18
|
+
"version": "1.3.0"
|
|
19
19
|
},
|
|
20
20
|
"@uniweb/frame-bridge": {
|
|
21
21
|
"version": "1.0.2"
|
|
22
22
|
},
|
|
23
23
|
"@uniweb/icons": {
|
|
24
|
-
"version": "1.0.
|
|
24
|
+
"version": "1.0.4"
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/kit": {
|
|
27
|
-
"version": "1.
|
|
27
|
+
"version": "1.4.0"
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/loom": {
|
|
30
30
|
"version": "1.1.0"
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"version": "1.1.2"
|
|
34
34
|
},
|
|
35
35
|
"@uniweb/projections": {
|
|
36
|
-
"version": "1.1.
|
|
36
|
+
"version": "1.1.4"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.1.
|
|
39
|
+
"version": "1.1.8"
|
|
40
40
|
},
|
|
41
41
|
"@uniweb/scene": {
|
|
42
42
|
"version": "1.0.1"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"version": "1.1.0"
|
|
49
49
|
},
|
|
50
50
|
"@uniweb/semantic-parser": {
|
|
51
|
-
"version": "1.
|
|
51
|
+
"version": "1.12.0"
|
|
52
52
|
},
|
|
53
53
|
"@uniweb/snapshot": {
|
|
54
54
|
"version": "1.1.0"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"version": "1.2.0"
|
|
61
61
|
},
|
|
62
62
|
"@uniweb/unipress": {
|
|
63
|
-
"version": "1.1.
|
|
63
|
+
"version": "1.1.9"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"templates": {
|
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
|
@@ -280,8 +280,24 @@ export function madeOnto(repo, sha, { origin, siteUuid }) {
|
|
|
280
280
|
* reads the old text, so the change-back looks like no change at all. ⛔ Until
|
|
281
281
|
* 2026-10-17 this said an older base "costs precision at worst".
|
|
282
282
|
*/
|
|
283
|
-
export function newestBase(repo, candidates) {
|
|
284
|
-
|
|
283
|
+
export function newestBase(repo, candidates, { lanes = null, known = [] } = {}) {
|
|
284
|
+
let shas = [...new Set(candidates.filter(Boolean))].filter((s) => isCommit(repo, s) && isAncestor(repo, s))
|
|
285
|
+
// ⛔ ONE MERGE TAKES ONE BASE FOR EVERY LANE, and a lane's own source says what that lane
|
|
286
|
+
// holds. A commit newer than it whose content differs there reads what the lane lacks — a
|
|
287
|
+
// push of it the backend refused while it took the other lane — as the backend's removal,
|
|
288
|
+
// and the merge drops it. A lane that names no source is held to the newest of the bases
|
|
289
|
+
// this copy knows of itself; with none, only those can be the base.
|
|
290
|
+
if (lanes?.length) {
|
|
291
|
+
const own = new Set(known.filter(Boolean))
|
|
292
|
+
const fallback = newestBase(repo, known)
|
|
293
|
+
shas = shas.filter((s) =>
|
|
294
|
+
lanes.every(({ source, roots }) => {
|
|
295
|
+
const ref = source && isCommit(repo, source) && isAncestor(repo, source) ? source : fallback
|
|
296
|
+
if (!ref) return own.has(s)
|
|
297
|
+
return s === ref || isAncestor(repo, s, ref) || !differsIn(repo, ref, s, roots)
|
|
298
|
+
})
|
|
299
|
+
)
|
|
300
|
+
}
|
|
285
301
|
if (shas.length <= 1) return shas[0] || null
|
|
286
302
|
const newest = shas.find((s) => shas.every((o) => o === s || isAncestor(repo, o, s)))
|
|
287
303
|
if (newest) return newest
|
|
@@ -292,6 +308,16 @@ export function newestBase(repo, candidates) {
|
|
|
292
308
|
}
|
|
293
309
|
}
|
|
294
310
|
|
|
311
|
+
/** Does the content under `roots` differ between two commits? Anything but "no" counts as yes. */
|
|
312
|
+
function differsIn(repo, a, b, roots) {
|
|
313
|
+
try {
|
|
314
|
+
git(repo.top, ['diff', '--quiet', a, b, '--', ...roots.map((r) => `${repo.prefix}${r}`)])
|
|
315
|
+
return false
|
|
316
|
+
} catch {
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
295
321
|
// ── the stand-in ────────────────────────────────────────────────────────────
|
|
296
322
|
|
|
297
323
|
/**
|
|
@@ -652,14 +678,79 @@ export function snapshotFiles(repo) {
|
|
|
652
678
|
* under the roots? (What the repository ignores is no change.)
|
|
653
679
|
*/
|
|
654
680
|
export function contentCommitted(repo, roots) {
|
|
681
|
+
return uncommittedPaths(repo, roots)?.length === 0
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The files under the roots that HEAD does not hold as they are — staged, changed, deleted
|
|
686
|
+
* or untracked, and both sides of a rename — relative to the site; null when git cannot
|
|
687
|
+
* say. What the repository ignores is no change.
|
|
688
|
+
*/
|
|
689
|
+
export function uncommittedPaths(repo, roots) {
|
|
690
|
+
let parts
|
|
655
691
|
try {
|
|
656
|
-
|
|
657
|
-
return git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...full]).length === 0
|
|
692
|
+
parts = zlist(git(repo.top, ['status', '--porcelain', '-z', '--untracked-files=all', '--', ...roots.map((r) => `${repo.prefix}${r}`)]))
|
|
658
693
|
} catch {
|
|
659
|
-
return
|
|
694
|
+
return null
|
|
695
|
+
}
|
|
696
|
+
const out = []
|
|
697
|
+
for (let i = 0; i < parts.length; i++) {
|
|
698
|
+
out.push(parts[i].slice(3))
|
|
699
|
+
if (/[RC]/.test(parts[i].slice(0, 2))) out.push(parts[++i]) // a rename's source follows it
|
|
660
700
|
}
|
|
701
|
+
return [...new Set(out.map((p) => siteRelative(repo, p)))]
|
|
661
702
|
}
|
|
662
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The files under the roots that differ from what `rev` holds there — changed, deleted, or
|
|
706
|
+
* new and not ignored — relative to the site.
|
|
707
|
+
*/
|
|
708
|
+
export function filesDifferFrom(repo, rev, roots) {
|
|
709
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
710
|
+
try {
|
|
711
|
+
const changed = zlist(git(repo.top, ['diff', '--name-only', '-z', '--no-renames', rev, '--', ...full]))
|
|
712
|
+
const added = zlist(git(repo.top, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...full]))
|
|
713
|
+
return [...new Set([...changed, ...added].map((p) => siteRelative(repo, p)))]
|
|
714
|
+
} catch {
|
|
715
|
+
return []
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Commit what the files hold under the roots — added, changed, deleted or renamed — and
|
|
721
|
+
* nothing else that is staged. With `identity` it is uniweb's commit, made as a pull's merge
|
|
722
|
+
* is: no hooks, unsigned. Without it, the person's own, as their `git commit` makes it.
|
|
723
|
+
*
|
|
724
|
+
* ⛔ Staged by ROOT, committed by NAME: `git add` refuses a path that is on neither the disk
|
|
725
|
+
* nor the index — a rename's source — and the commit takes exactly what was staged.
|
|
726
|
+
*
|
|
727
|
+
* @returns {{ commit: string, paths: string[] } | null} null when there was nothing to commit
|
|
728
|
+
*/
|
|
729
|
+
export function commitRoots(repo, roots, { message, identity = null }) {
|
|
730
|
+
const full = roots.map((r) => `${repo.prefix}${r}`)
|
|
731
|
+
const present = full.filter((p) => existsSync(join(repo.top, p)) || zlist(git(repo.top, ['ls-files', '-z', '--', p])).length)
|
|
732
|
+
let addable = present
|
|
733
|
+
if (present.length) {
|
|
734
|
+
try {
|
|
735
|
+
const ignored = zlist(git(repo.top, ['check-ignore', '-z', '--stdin'], { input: `${present.join('\0')}\0` }))
|
|
736
|
+
addable = present.filter((p) => !ignored.includes(p))
|
|
737
|
+
} catch (err) {
|
|
738
|
+
if (err?.status !== 1) throw err // 1: none of them is ignored
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (addable.length) git(repo.top, ['--literal-pathspecs', 'add', '-A', '--', ...addable])
|
|
742
|
+
const staged = zlist(git(repo.top, ['diff', '--cached', '--name-only', '-z', '--no-renames', '--', ...full]))
|
|
743
|
+
if (!staged.length) return null
|
|
744
|
+
git(
|
|
745
|
+
repo.top,
|
|
746
|
+
['--literal-pathspecs', 'commit', '-q', ...(identity ? ['--no-verify', '--no-gpg-sign'] : []), '-m', message, '--pathspec-from-file=-', '--pathspec-file-nul'],
|
|
747
|
+
{ input: `${staged.join('\0')}\0`, ...(identity ? { env: identity } : {}) }
|
|
748
|
+
)
|
|
749
|
+
return { commit: gitText(repo.top, ['rev-parse', 'HEAD']).trim(), paths: staged.map((p) => siteRelative(repo, p)) }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const siteRelative = (repo, p) => (repo.prefix && p.startsWith(repo.prefix) ? p.slice(repo.prefix.length) : p)
|
|
753
|
+
|
|
663
754
|
// ── this copy's own sync point ──────────────────────────────────────────────
|
|
664
755
|
|
|
665
756
|
const cachePath = (siteDir) => join(siteDir, '.uniweb', 'backend-cache.json')
|