uniweb 1.21.6 → 1.21.8
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 +3 -1
- package/src/backend/site-sync.js +20 -0
- package/src/commands/add.js +15 -0
- package/src/commands/pull.js +117 -6
- package/src/commands/status.js +46 -4
- package/src/framework-index.json +6 -6
- package/src/utils/sync-point.js +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.8",
|
|
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.3.2",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.5.21",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/content-writer": "~1.3.1",
|
|
45
|
+
"@uniweb/core": "~1.4.9",
|
|
46
46
|
"@uniweb/semantic-parser": "~1.13.0",
|
|
47
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/schemas": "~1.6.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.10.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "~1.10.7",
|
|
51
|
+
"@uniweb/content-reader": "~1.5.7",
|
|
52
|
+
"@uniweb/semantic-parser": "~1.13.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -2862,7 +2862,7 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
|
|
|
2862
2862
|
| Any static host at all | `uniweb export` | full control; no Uniweb account needed |
|
|
2863
2863
|
| Uniweb Cloud | `uniweb publish` | teams with non-technical content authors, or client work |
|
|
2864
2864
|
|
|
2865
|
-
`uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs
|
|
2865
|
+
`uniweb deploy` never assumes a host: with nothing configured it opens a picker listing only destinations it can act on, and records the choice in `deploy.yml` so later runs go straight there. On Cloudflare Pages / Netlify / Vercel, `add ci` also adds per-PR previews that comment the URL. GitHub runs workflows only from the top of the repository: in a project that is a folder of a larger repository, the project's own `.github/` never runs — `uniweb status` says so, and `uniweb add ci` refuses to write one there — as it says when a target in `deploy.yml` was never deployed from this copy. Adapters: `github-pages`, `cloudflare-pages`, `netlify`, `vercel`, plus `s3-cloudfront` for `deploy`. That record is `deploy.yml`, beside `site.yml` — **the CLI writes it, you don't create it**: the first successful deploy scaffolds the whole file, and later deploys rewrite only its `deploys:` block. Edit `targets:` when you want to change where a site ships. Host credentials come from the environment, never from that committed file.
|
|
2866
2866
|
|
|
2867
2867
|
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
2868
2868
|
|
|
@@ -2916,6 +2916,8 @@ A site can be in more than one place — usually while it moves from one host to
|
|
|
2916
2916
|
|
|
2917
2917
|
**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.
|
|
2918
2918
|
|
|
2919
|
+
**Moving a synced project to another repository.** Where the copy last met the backend is in its git history, and in `.uniweb/`, which git never holds — both stay behind; `sync.json` comes along, so the copy still knows its site. Move one this way: `uniweb sync` in the old copy, then copy the project in without its `.git/` and commit it, then sign in to the site's workspace and `uniweb pull` once — it records where the copy and the site meet, with nothing to settle when the old copy had synced. When it had not, `uniweb status` says the copy shares no history with the site's content and lists the files that say something else; the first pull makes each a conflict. Settle each by what you know: an edit made in the app since the last push is the backend's side, a change made in the copy and never pushed is yours — ask the person when you cannot tell. Never `--force` it in either direction: one side's edits would be gone from every file.
|
|
2920
|
+
|
|
2919
2921
|
**`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.
|
|
2920
2922
|
|
|
2921
2923
|
**`services:` in `site.yml` is where a site says which services it uses** — site search, form
|
package/src/backend/site-sync.js
CHANGED
|
@@ -1012,6 +1012,8 @@ export function untakenBase(siteDir, backend) {
|
|
|
1012
1012
|
}
|
|
1013
1013
|
return {
|
|
1014
1014
|
headline: "Not pushed — your branch does not hold the backend content this copy last took.",
|
|
1015
|
+
// Never taken here — a fresh copy, or one brought from another repository — not lost.
|
|
1016
|
+
...(point && held ? {} : { never: true }),
|
|
1015
1017
|
lines: [
|
|
1016
1018
|
point && held
|
|
1017
1019
|
? 'The pull or push it came from is not in your history — a merge abandoned, or the branch moved.'
|
|
@@ -1980,6 +1982,24 @@ async function comparisonEmit(
|
|
|
1980
1982
|
})
|
|
1981
1983
|
}
|
|
1982
1984
|
|
|
1985
|
+
/**
|
|
1986
|
+
* What a push would send from the files as they stand, every entity hashed — the same emit
|
|
1987
|
+
* the banked hashes are compared with (`comparisonEmit`) — or null when the files do not emit
|
|
1988
|
+
* (a key the build refuses, say). Two folders whose hashes agree say the same thing to the
|
|
1989
|
+
* backend, however their files are written: a pull with no common history reads that to leave
|
|
1990
|
+
* a file alone (`commands/pull.js`).
|
|
1991
|
+
*
|
|
1992
|
+
* @returns {Promise<Record<string,string>|null>}
|
|
1993
|
+
*/
|
|
1994
|
+
export async function entityHashes(siteDir, backend = null, { declarations, fields = null } = {}) {
|
|
1995
|
+
try {
|
|
1996
|
+
const pkg = await comparisonEmit(siteDir, { backend, sendAll: true, declarations, fields })
|
|
1997
|
+
return pkg.hashes || {}
|
|
1998
|
+
} catch {
|
|
1999
|
+
return null
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1983
2003
|
/**
|
|
1984
2004
|
* Re-bank the send-only-changed hashes over the files as they NOW stand.
|
|
1985
2005
|
*
|
package/src/commands/add.js
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
resolveUniqueName
|
|
45
45
|
} from '../utils/names.js'
|
|
46
46
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
47
|
+
import { repoOf } from '../utils/sync-point.js'
|
|
47
48
|
import {
|
|
48
49
|
normalizeFoundationName,
|
|
49
50
|
ensureFoundationName,
|
|
@@ -1479,6 +1480,20 @@ export default function ${name}({ content, params }) {
|
|
|
1479
1480
|
* and can plug in later.
|
|
1480
1481
|
*/
|
|
1481
1482
|
async function addCi(rootDir, opts, pm = 'pnpm') {
|
|
1483
|
+
// ⛔ A PROJECT THAT IS A FOLDER OF A LARGER REPOSITORY gets no workflow: GitHub runs workflows
|
|
1484
|
+
// only from a repository's top, and this writes into the project's own `.github/`, where it
|
|
1485
|
+
// would never run. Refused rather than written, for now [Diego, 2026-10-26: "refuse for now"] —
|
|
1486
|
+
// measured on a repository of client projects, one of which carried a GitHub Pages workflow
|
|
1487
|
+
// from its old repository that had not run since it moved.
|
|
1488
|
+
const repo = repoOf(rootDir)
|
|
1489
|
+
if (repo?.prefix) {
|
|
1490
|
+
error(`Not added: this project is the folder ${repo.path} of the repository at ${repo.top}.`)
|
|
1491
|
+
log(` GitHub runs workflows only from the top of a repository (${relative(rootDir, join(repo.top, '.github', 'workflows'))}/),`)
|
|
1492
|
+
log(` so one written in this project's own .github/ would never run.`)
|
|
1493
|
+
log(` Deploy from a repository of its own, or by hand from here: uniweb deploy --host=<adapter>.`)
|
|
1494
|
+
process.exit(1)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1482
1497
|
// Lazy-load the host registry so the CLI doesn't pay this import on
|
|
1483
1498
|
// every add invocation.
|
|
1484
1499
|
let getAdapter, listAdapters
|
package/src/commands/pull.js
CHANGED
|
@@ -97,6 +97,7 @@ import {
|
|
|
97
97
|
makeModelResolver,
|
|
98
98
|
ensureRegisteredFoundation,
|
|
99
99
|
deploymentFields,
|
|
100
|
+
entityHashes,
|
|
100
101
|
rebankSyncHashes,
|
|
101
102
|
writeQueryUuids,
|
|
102
103
|
mergeBaseVersions,
|
|
@@ -118,6 +119,7 @@ import {
|
|
|
118
119
|
madeOnto,
|
|
119
120
|
baseFor,
|
|
120
121
|
openStandIn,
|
|
122
|
+
fileAt,
|
|
121
123
|
mergeSyncPoint,
|
|
122
124
|
takeSyncPoint,
|
|
123
125
|
snapshotFiles,
|
|
@@ -565,6 +567,100 @@ function restore(path, bytes) {
|
|
|
565
567
|
else writeFileSync(path, bytes)
|
|
566
568
|
}
|
|
567
569
|
|
|
570
|
+
/**
|
|
571
|
+
* ⭐ WITH NO COMMON HISTORY, A FILE THAT SAYS WHAT THE BACKEND HOLDS IS LEFT AS IT IS.
|
|
572
|
+
*
|
|
573
|
+
* A project copied into another repository leaves its sync points in the old history, so its
|
|
574
|
+
* first pull merges two histories that share nothing: every file the projection rewrites is a
|
|
575
|
+
* conflict, this copy's against the backend's. The writer already keeps an author's text where it
|
|
576
|
+
* would write the same document (`project-writer.js`, "an unchanged body keeps the author's own
|
|
577
|
+
* text"); what it rewrites, this tries once more, two ways. Every hash a push would send, with the
|
|
578
|
+
* backend's file put in among this copy's (`entityHashes`): where none moves, the two say the
|
|
579
|
+
* same. And the same asset written two ways, which a push sends as different data
|
|
580
|
+
* (`sameButAssetForms`). Either way this copy's text goes into the backend's commit, and the merge
|
|
581
|
+
* has nothing to settle there. A backend file that does not emit at all — a key the build
|
|
582
|
+
* refuses — says something else, and stays the backend's.
|
|
583
|
+
*
|
|
584
|
+
* Measured 2026-10-26 on two sites copied out of their own repositories: six conflicts, three of
|
|
585
|
+
* them content — a logo added in the app, a key the copy had renamed on both sites — and three
|
|
586
|
+
* gallery records naming their images by URL where the backend's files name them by path.
|
|
587
|
+
*
|
|
588
|
+
* @returns {Promise<string[]>} the files left as this copy writes them
|
|
589
|
+
*/
|
|
590
|
+
async function keepWhatMeansTheSame({ standIn, repo, origin, declarations, fields }) {
|
|
591
|
+
const dir = standIn.siteDir
|
|
592
|
+
const ours = new Map()
|
|
593
|
+
const theirs = new Map()
|
|
594
|
+
for (const [status, path] of standIn.changes()) {
|
|
595
|
+
if (status !== 'M') continue
|
|
596
|
+
const head = fileAt(repo, 'HEAD', path)
|
|
597
|
+
const now = bytesOf(join(dir, path))
|
|
598
|
+
if (head && now) {
|
|
599
|
+
ours.set(path, head)
|
|
600
|
+
theirs.set(path, now)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!ours.size) return []
|
|
604
|
+
const opts = { ...(declarations?.size ? { declarations } : {}), fields }
|
|
605
|
+
const kept = []
|
|
606
|
+
// What the emits print is a comparison's, not this pull's to say.
|
|
607
|
+
const said = { warn: console.warn, log: console.log, info: console.info }
|
|
608
|
+
console.warn = console.log = console.info = () => {}
|
|
609
|
+
try {
|
|
610
|
+
// This copy's text everywhere: the files each backend one is tried among.
|
|
611
|
+
for (const [path, bytes] of ours) writeFileSync(join(dir, path), bytes)
|
|
612
|
+
const mine = await entityHashes(dir, origin, opts)
|
|
613
|
+
if (mine) {
|
|
614
|
+
for (const [path, bytes] of theirs) {
|
|
615
|
+
writeFileSync(join(dir, path), bytes)
|
|
616
|
+
const tried = await entityHashes(dir, origin, opts)
|
|
617
|
+
if ((tried && sameHashes(tried, mine)) || sameButAssetForms(path, ours.get(path), bytes)) kept.push(path)
|
|
618
|
+
writeFileSync(join(dir, path), ours.get(path))
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
} finally {
|
|
622
|
+
Object.assign(console, said)
|
|
623
|
+
// The backend's text back wherever it says something else.
|
|
624
|
+
for (const [path, bytes] of theirs) if (!kept.includes(path)) writeFileSync(join(dir, path), bytes)
|
|
625
|
+
}
|
|
626
|
+
return kept
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function sameHashes(a, b) {
|
|
630
|
+
const keys = Object.keys(a)
|
|
631
|
+
return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k])
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Two versions of a file that differ only in how they name the same assets: a URL to the
|
|
636
|
+
* backend's copy (`https://…/assets/<sha256>/base.jpeg`) where the other has the site's own path
|
|
637
|
+
* (`/assets/<sha256>.jpeg`). A push sends different data from each — a URL, an asset reference —
|
|
638
|
+
* so the hashes differ, but the asset is one, by its content hash. This copy's text is kept: the
|
|
639
|
+
* banked hash is its own, so a push leaves the file alone, and the next pull from this base writes
|
|
640
|
+
* the backend's way (ours unchanged, theirs different). Measured 2026-10-26: three gallery records
|
|
641
|
+
* an older pull had written with URLs.
|
|
642
|
+
*/
|
|
643
|
+
export function sameButAssetForms(path, a, b) {
|
|
644
|
+
const asset = (s) => s.replace(/(?:https?:\/\/[^\s"'()]+)?\/assets\/([0-9a-f]{64})(?:\/[^\s"'()]*?)?(?:\.[A-Za-z0-9]+)?(?=[\s"'()]|$)/g, 'asset:$1')
|
|
645
|
+
const read = (bytes) => {
|
|
646
|
+
const text = bytes.toString('utf8')
|
|
647
|
+
if (!/\.ya?ml$/.test(path)) return asset(text)
|
|
648
|
+
try {
|
|
649
|
+
return JSON.stringify(sortedDeep(yaml.load(text)), (_, v) => (typeof v === 'string' ? asset(v) : v))
|
|
650
|
+
} catch {
|
|
651
|
+
return asset(text)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const [x, y] = [read(a), read(b)]
|
|
655
|
+
return x === y && a.toString('utf8') !== b.toString('utf8')
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function sortedDeep(v) {
|
|
659
|
+
if (Array.isArray(v)) return v.map(sortedDeep)
|
|
660
|
+
if (v && typeof v === 'object' && !(v instanceof Date)) return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortedDeep(v[k])]))
|
|
661
|
+
return v
|
|
662
|
+
}
|
|
663
|
+
|
|
568
664
|
// The sync point's subject line, and the merge's.
|
|
569
665
|
const pulledFrom = (origin) => `The site's content on ${origin}`
|
|
570
666
|
const mergedFrom = (origin) => `Merge the site's content from ${origin}`
|
|
@@ -999,9 +1095,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
999
1095
|
origin: client.origin,
|
|
1000
1096
|
siteUuid: siteContentUuid
|
|
1001
1097
|
})
|
|
1002
|
-
// A probe with no base
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1098
|
+
// A probe with no base cannot tell the backend's changes from this copy's: onto HEAD, it says
|
|
1099
|
+
// what differs, and which way is the person's to read (`noBase`, below). ⛔ Until 2026-10-26
|
|
1100
|
+
// it said nothing at all, and status could not size a copied project's first pull.
|
|
1005
1101
|
onto = force || !base ? repo.head : base
|
|
1006
1102
|
try {
|
|
1007
1103
|
standIn = openStandIn(repo, { roots, rev: onto })
|
|
@@ -1158,10 +1254,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
1158
1254
|
for (const w of report.warnings) note(`! ${w}`)
|
|
1159
1255
|
}
|
|
1160
1256
|
|
|
1257
|
+
// ⭐ With no common history, each file that says what the backend holds is left as this copy
|
|
1258
|
+
// writes it (`keepWhatMeansTheSame`): only a difference in content is a conflict.
|
|
1259
|
+
if (standIn && !base && !force) {
|
|
1260
|
+
merge.keptAsWritten = await keepWhatMeansTheSame({ standIn, repo, origin: client.origin, declarations, fields })
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1161
1263
|
// ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
|
|
1162
|
-
// from the base is what the backend changed
|
|
1264
|
+
// from the base is what the backend changed — or, with no base, what differs at all, which
|
|
1265
|
+
// way unknown (`noBase`). Nothing is banked; `finally` closes it.
|
|
1163
1266
|
if (probe) {
|
|
1164
|
-
return {
|
|
1267
|
+
return {
|
|
1268
|
+
exitCode: 0,
|
|
1269
|
+
probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced), ...(base ? {} : { noBase: true }) }
|
|
1270
|
+
}
|
|
1165
1271
|
}
|
|
1166
1272
|
|
|
1167
1273
|
// Outside git: what this pull wrote, so the next one can tell its own output from the
|
|
@@ -1369,6 +1475,11 @@ export async function pull(args = [], deps = {}) {
|
|
|
1369
1475
|
if (ahead.length > 12) note(` … and ${ahead.length - 12} more`)
|
|
1370
1476
|
}
|
|
1371
1477
|
}
|
|
1478
|
+
if (merge.keptAsWritten?.length) {
|
|
1479
|
+
note(`${merge.keptAsWritten.length} file(s) differ from ${client.origin}'s only in how they are written, and are left as they are:`)
|
|
1480
|
+
for (const p of merge.keptAsWritten.slice(0, 12)) note(` ${repo.prefix}${p}`)
|
|
1481
|
+
if (merge.keptAsWritten.length > 12) note(` … and ${merge.keptAsWritten.length - 12} more`)
|
|
1482
|
+
}
|
|
1372
1483
|
if (merge.conflicted.length) {
|
|
1373
1484
|
error(`${merge.conflicted.length} file(s) have conflicts to resolve:`)
|
|
1374
1485
|
for (const f of merge.conflicted) note(` ${f}`)
|
|
@@ -1379,7 +1490,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
1379
1490
|
} else {
|
|
1380
1491
|
if (merge.noBase) {
|
|
1381
1492
|
note(`No commit in this history is one ${client.origin}'s content came from, so nothing tells`)
|
|
1382
|
-
note("your changes from the backend's: each file
|
|
1493
|
+
note("your changes from the backend's: each file that says something else is a conflict, yours against the backend's.")
|
|
1383
1494
|
note('Once this merge is committed, later pulls merge from it.')
|
|
1384
1495
|
}
|
|
1385
1496
|
note(
|
package/src/commands/status.js
CHANGED
|
@@ -467,7 +467,11 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
467
467
|
checks.push({
|
|
468
468
|
id: 'app.base',
|
|
469
469
|
state: 'blocking',
|
|
470
|
-
|
|
470
|
+
// ⛔ Not "does not hold what this copy last took" for a copy that took nothing here — a fresh
|
|
471
|
+
// copy, or a project brought from another repository, whose sync points stayed there.
|
|
472
|
+
summary: base.never
|
|
473
|
+
? 'This copy has not taken the site from the backend yet: a push waits until a pull has merged its content.'
|
|
474
|
+
: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
|
|
471
475
|
blocks: ['push', 'publish']
|
|
472
476
|
})
|
|
473
477
|
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
|
|
@@ -502,6 +506,14 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
502
506
|
}
|
|
503
507
|
: { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
|
|
504
508
|
)
|
|
509
|
+
} else if (base?.never && git.repo) {
|
|
510
|
+
// ⛔ A copy that took nothing here has no record of what it last sent, so every entity would
|
|
511
|
+
// count as unsent — "holds changes" is a guess, and a push step before the pull it needs.
|
|
512
|
+
checks.push({
|
|
513
|
+
id: 'app.unpushed',
|
|
514
|
+
state: 'unknown',
|
|
515
|
+
summary: "What this copy holds that the backend lacks is known once a pull has merged the site's content here."
|
|
516
|
+
})
|
|
505
517
|
} else {
|
|
506
518
|
try {
|
|
507
519
|
const probe = await probeUnpushed(siteDir, { backend: origin })
|
|
@@ -575,8 +587,9 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
575
587
|
|
|
576
588
|
// ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
|
|
577
589
|
// stopped before it takes anything (`probePull`): the files it would change, translations
|
|
578
|
-
// and records included
|
|
579
|
-
// the backend's units against this copy's last sync
|
|
590
|
+
// and records included — with no sync point in this history, the files that differ, which way
|
|
591
|
+
// unknown (`noBaseChanges`). Outside git, the backend's units against this copy's last sync
|
|
592
|
+
// (`backend/app-changes.js`).
|
|
580
593
|
const readChanges = async () => {
|
|
581
594
|
if (git.repo) {
|
|
582
595
|
const org = readOrgFlag(args)
|
|
@@ -746,7 +759,8 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
746
759
|
* `app.changes` from the pull's own answer (`probePull`): the files it would change, each
|
|
747
760
|
* as the pull would write it.
|
|
748
761
|
*/
|
|
749
|
-
function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
|
|
762
|
+
function probedChanges({ files: list, recordsNotPlaced, noBase }, unpushedFiles, proposals) {
|
|
763
|
+
if (noBase) return noBaseChanges(list, recordsNotPlaced)
|
|
750
764
|
if (!list.length) {
|
|
751
765
|
return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
|
|
752
766
|
}
|
|
@@ -776,6 +790,34 @@ function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposa
|
|
|
776
790
|
]
|
|
777
791
|
}
|
|
778
792
|
|
|
793
|
+
/**
|
|
794
|
+
* `app.changes` for a copy whose history shares no commit with the site's content — a project
|
|
795
|
+
* brought into another repository, whose sync points stayed in the old one. What differs is
|
|
796
|
+
* said, which way unknown: the first pull makes each such file a conflict, yours against the
|
|
797
|
+
* backend's, and records where the two meet; one that says what the backend holds, written
|
|
798
|
+
* another way, it leaves alone, and it is not listed (`keepWhatMeansTheSame`, `commands/pull.js`).
|
|
799
|
+
* The pull itself is `app.base`'s step.
|
|
800
|
+
*/
|
|
801
|
+
function noBaseChanges(list, recordsNotPlaced) {
|
|
802
|
+
const differ = list.filter(([s]) => s === 'M').map(([, p]) => p)
|
|
803
|
+
const added = list.filter(([s]) => s === 'A').map(([, p]) => p)
|
|
804
|
+
const lines = []
|
|
805
|
+
if (added.length) lines.push(`${count(added.length, 'file')} only the backend has — the pull adds ${added.length === 1 ? 'it' : 'them'}.`)
|
|
806
|
+
if (recordsNotPlaced) lines.push('Some records could not be placed in files — `npx uniweb pull` says which, and why.')
|
|
807
|
+
return [
|
|
808
|
+
{
|
|
809
|
+
id: 'app.changes',
|
|
810
|
+
state: 'attention',
|
|
811
|
+
summary: differ.length
|
|
812
|
+
? `This copy shares no history with the site's content on the backend — brought from another repository, most likely. ${count(differ.length, 'file')} here ${differ.length === 1 ? 'says' : 'say'} something else, and the first pull makes ${differ.length === 1 ? 'it a conflict' : 'each a conflict'} to settle, yours against the backend's.`
|
|
813
|
+
: "This copy shares no history with the site's content on the backend, and its files say what the site holds: the first pull records where they meet, with nothing to settle.",
|
|
814
|
+
files: differ,
|
|
815
|
+
lines,
|
|
816
|
+
detail: { source: 'pull', noBase: true, files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })) }
|
|
817
|
+
}
|
|
818
|
+
]
|
|
819
|
+
}
|
|
820
|
+
|
|
779
821
|
/**
|
|
780
822
|
* `app.foundation`: the local foundation against its registered version — what the next
|
|
781
823
|
* push or publish does with it (`backend/foundation-bring-along.js`).
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-26T23:21:20.295Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "1.6.4"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.10.
|
|
9
|
+
"version": "1.10.7"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
12
|
"version": "1.5.7"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"version": "1.3.1"
|
|
16
16
|
},
|
|
17
17
|
"@uniweb/core": {
|
|
18
|
-
"version": "1.4.
|
|
18
|
+
"version": "1.4.9"
|
|
19
19
|
},
|
|
20
20
|
"@uniweb/frame-bridge": {
|
|
21
21
|
"version": "1.0.2"
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"version": "1.0.6"
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/kit": {
|
|
27
|
-
"version": "1.6.
|
|
27
|
+
"version": "1.6.7"
|
|
28
28
|
},
|
|
29
29
|
"@uniweb/loom": {
|
|
30
30
|
"version": "1.1.2"
|
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
"version": "1.1.4"
|
|
34
34
|
},
|
|
35
35
|
"@uniweb/projections": {
|
|
36
|
-
"version": "1.2.
|
|
36
|
+
"version": "1.2.3"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.2.
|
|
39
|
+
"version": "1.2.5"
|
|
40
40
|
},
|
|
41
41
|
"@uniweb/scene": {
|
|
42
42
|
"version": "1.0.1"
|
package/src/utils/sync-point.js
CHANGED
|
@@ -759,6 +759,15 @@ export function uncommittedPaths(repo, roots) {
|
|
|
759
759
|
return [...new Set(out.map((p) => siteRelative(repo, p)))]
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
+
/** A site file's bytes as `rev` holds it — `path` relative to the site — or null when it holds none. */
|
|
763
|
+
export function fileAt(repo, rev, path) {
|
|
764
|
+
try {
|
|
765
|
+
return git(repo.top, ['cat-file', 'blob', `${rev}:${repo.prefix}${path}`])
|
|
766
|
+
} catch {
|
|
767
|
+
return null
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
762
771
|
/**
|
|
763
772
|
* The files under the roots that differ from what `rev` holds there — changed, deleted, or
|
|
764
773
|
* new and not ignored — relative to the site.
|