uniweb 1.21.5 → 1.21.7
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 +4 -4
- package/partials/agents.md +4 -2
- package/src/backend/site-sync.js +20 -0
- package/src/commands/doctor.js +39 -4
- package/src/commands/pull.js +117 -6
- package/src/commands/status.js +116 -11
- package/src/framework-index.json +5 -5
- package/src/index.js +3 -2
- 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.7",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"js-yaml": "^4.3.2",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.5.21",
|
|
44
|
+
"@uniweb/core": "~1.4.9",
|
|
44
45
|
"@uniweb/content-writer": "~1.3.1",
|
|
45
46
|
"@uniweb/schemas": "~1.6.5",
|
|
46
|
-
"@uniweb/semantic-parser": "~1.13.0"
|
|
47
|
-
"@uniweb/core": "~1.4.7"
|
|
47
|
+
"@uniweb/semantic-parser": "~1.13.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.10.
|
|
50
|
+
"@uniweb/build": "~1.10.6",
|
|
51
51
|
"@uniweb/semantic-parser": "~1.13.0",
|
|
52
52
|
"@uniweb/content-reader": "~1.5.7"
|
|
53
53
|
},
|
package/partials/agents.md
CHANGED
|
@@ -67,7 +67,7 @@ https://uniweb.io/llms.txt
|
|
|
67
67
|
|
|
68
68
|
It lists every documentation page with a one-line description and a fetchable link. Go there whenever your question isn't answered in this file — and *especially* when you don't know whether a feature exists at all. That's where guessing does the most damage: **a 404 on a path you invented tells you your guess was wrong, not that the feature is missing.** Concluding "the framework doesn't support X" from a failed fetch is a mistake worth actively guarding against; the index answers the existence question directly.
|
|
69
69
|
|
|
70
|
-
Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown
|
|
70
|
+
Then **follow the link the index gives you** rather than constructing one yourself. The index is the source of truth for where pages live, so it stays correct when the site's URL conventions change. Each link it gives is the page as Markdown, and a page's own address returns the same Markdown to a request sent with `Accept: text/markdown`.
|
|
71
71
|
|
|
72
72
|
**Clone the repo instead** when you expect several lookups, or may lose network access later:
|
|
73
73
|
|
|
@@ -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. 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.
|
|
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, and `uniweb status` says so — 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/doctor.js
CHANGED
|
@@ -1928,6 +1928,38 @@ async function runDoctor(args = []) {
|
|
|
1928
1928
|
try {
|
|
1929
1929
|
deployYml = await loadDeployYml(sitePath)
|
|
1930
1930
|
} catch (err) {
|
|
1931
|
+
// `saveDeploys:` holding a value of the retired `autoSave:` — the key renamed by hand, its
|
|
1932
|
+
// value kept (measured 2026-10-26 on a copied project) — is the retired key's to repair.
|
|
1933
|
+
let kept = null
|
|
1934
|
+
try {
|
|
1935
|
+
const v = yaml.load(readFileSync(deployYmlPath, 'utf8'))?.saveDeploys
|
|
1936
|
+
kept = v === 'lastDeploy' || v === 'off' ? v : null
|
|
1937
|
+
} catch {
|
|
1938
|
+
kept = null
|
|
1939
|
+
}
|
|
1940
|
+
if (kept) {
|
|
1941
|
+
const id = 'deploy-yml-retired-key'
|
|
1942
|
+
const issue = { id, type: 'error', site: siteName, message: `deploy.yml has \`saveDeploys: ${kept}\`, a value of the retired \`autoSave:\`` }
|
|
1943
|
+
issues.push(issue)
|
|
1944
|
+
error(`[${id}] ${siteName}: deploy.yml has \`saveDeploys: ${kept}\` — a value of the retired \`autoSave:\`, where \`saveDeploys\` is true or false.`)
|
|
1945
|
+
log(
|
|
1946
|
+
` ${colors.dim}${kept === 'off' ? '`autoSave: off` is `saveDeploys: false`.' : '`autoSave: lastDeploy` is what happens anyway — deploys are recorded — so the line goes.'}${colors.reset}`
|
|
1947
|
+
)
|
|
1948
|
+
if (shouldFix(id)) {
|
|
1949
|
+
try {
|
|
1950
|
+
const { retireDeployYmlKeys } = await import('@uniweb/build/site')
|
|
1951
|
+
if (await retireDeployYmlKeys(sitePath)) {
|
|
1952
|
+
issue.fixed = true
|
|
1953
|
+
fixed(`${relative(workspaceDir, deployYmlPath)}: ${kept === 'off' ? 'wrote `saveDeploys: false`' : 'removed `saveDeploys: lastDeploy`'}`)
|
|
1954
|
+
}
|
|
1955
|
+
} catch (fixErr) {
|
|
1956
|
+
warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${fixErr.message}`)
|
|
1957
|
+
}
|
|
1958
|
+
} else {
|
|
1959
|
+
log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to write what it meant, keeping your comments.${colors.reset}`)
|
|
1960
|
+
}
|
|
1961
|
+
continue
|
|
1962
|
+
}
|
|
1931
1963
|
issues.push({
|
|
1932
1964
|
id: 'deploy-yml-malformed',
|
|
1933
1965
|
type: 'error',
|
|
@@ -2013,10 +2045,13 @@ async function runDoctor(args = []) {
|
|
|
2013
2045
|
if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
|
|
2014
2046
|
}
|
|
2015
2047
|
if (String(deployYml.autoSave) === 'off') lost.push('`autoSave: off` is ignored, so deploys are recorded')
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2048
|
+
// What each one means now — said by value, not as a rename: "`autoSave:` is `saveDeploys:`"
|
|
2049
|
+
// read as one, and a copy came back holding `saveDeploys: lastDeploy` (2026-10-26).
|
|
2050
|
+
const means = {
|
|
2051
|
+
lastDeploy: '`lastDeploy:` is `deploys:` now',
|
|
2052
|
+
autoSave: '`autoSave:` goes — `autoSave: off` is `saveDeploys: false`, and `autoSave: lastDeploy` is what happens anyway'
|
|
2053
|
+
}
|
|
2054
|
+
log(` ${colors.dim}${retired.map((k) => means[k]).join('; ')}${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`)
|
|
2020
2055
|
if (shouldFix(id)) {
|
|
2021
2056
|
try {
|
|
2022
2057
|
const { retireDeployYmlKeys } = await import('@uniweb/build/site')
|
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
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { execFileSync } from 'node:child_process'
|
|
46
|
-
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
46
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
47
47
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
48
48
|
import yaml from 'js-yaml'
|
|
49
49
|
|
|
@@ -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)
|
|
@@ -712,7 +725,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
712
725
|
if (!moved) {
|
|
713
726
|
return [
|
|
714
727
|
unknown.length
|
|
715
|
-
? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
|
|
728
|
+
? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${[...new Set(unknown.map((l) => l.detail))].join('; ')}.` }
|
|
716
729
|
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
|
|
717
730
|
]
|
|
718
731
|
}
|
|
@@ -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`).
|
|
@@ -1215,18 +1257,34 @@ async function deployChecks(siteDir) {
|
|
|
1215
1257
|
host: t.host,
|
|
1216
1258
|
title,
|
|
1217
1259
|
default: name === defaultName,
|
|
1260
|
+
deployed: Boolean(ci || receipt || retired),
|
|
1218
1261
|
ci: ci ? { file: ci.file, branches: ci.branches } : null,
|
|
1219
1262
|
last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
|
|
1220
1263
|
behind,
|
|
1221
1264
|
...(retired ? { retired: true } : {})
|
|
1222
1265
|
})
|
|
1223
1266
|
}
|
|
1267
|
+
// ⭐ A TARGET IS A DESTINATION, NOT A DEPLOY. Only CI or a receipt says the site went there;
|
|
1268
|
+
// a target with neither is named as what it is. ⛔ Until 2026-10-26 every target was "Also
|
|
1269
|
+
// deployed to", and the status opened with it — on a project copied from a repository whose
|
|
1270
|
+
// CI deployed it, after the site had moved to Uniweb Cloud.
|
|
1271
|
+
const deployed = targets.filter((t) => t.deployed)
|
|
1272
|
+
const never = targets.filter((t) => !t.deployed)
|
|
1273
|
+
const parts = []
|
|
1274
|
+
if (deployed.length) parts.push(`Also deployed to ${deployed.map((t) => t.title).join(' and ')}.`)
|
|
1275
|
+
if (never.length) {
|
|
1276
|
+
parts.push(`deploy.yml names ${never.map((t) => t.title).join(' and ')}, never deployed from this copy.`)
|
|
1277
|
+
attention = true
|
|
1278
|
+
lines.push(
|
|
1279
|
+
'A target never deployed from here is a host the site has not gone to yet, or one it left — when it left, remove the target from deploy.yml, and `default:` when it names it.'
|
|
1280
|
+
)
|
|
1281
|
+
}
|
|
1224
1282
|
return {
|
|
1225
1283
|
checks: [
|
|
1226
1284
|
{
|
|
1227
1285
|
id: 'hosting.deploys',
|
|
1228
1286
|
state: attention ? 'attention' : 'ok',
|
|
1229
|
-
summary:
|
|
1287
|
+
summary: parts.join(' '),
|
|
1230
1288
|
lines,
|
|
1231
1289
|
detail: { targets }
|
|
1232
1290
|
}
|
|
@@ -1236,6 +1294,40 @@ async function deployChecks(siteDir) {
|
|
|
1236
1294
|
}
|
|
1237
1295
|
}
|
|
1238
1296
|
|
|
1297
|
+
/**
|
|
1298
|
+
* `hosting.workflows`: GitHub Actions workflows between this site and the top of its
|
|
1299
|
+
* repository — a project's own `.github/workflows` — which GitHub never runs: it runs the
|
|
1300
|
+
* repository's top-level workflows alone, so these are files, not CI.
|
|
1301
|
+
*
|
|
1302
|
+
* ⭐ A project copied into another repository brings its `.github/` along (`uniweb add ci`
|
|
1303
|
+
* writes one). Measured 2026-10-26 on a repository of client projects: a site that had moved to
|
|
1304
|
+
* Uniweb Cloud still carried the GitHub Pages workflow that used to deploy it.
|
|
1305
|
+
*/
|
|
1306
|
+
function workflowsCheck(siteDir) {
|
|
1307
|
+
const repo = repoOf(siteDir)
|
|
1308
|
+
if (!repo) return null
|
|
1309
|
+
const found = []
|
|
1310
|
+
for (let d = siteDir; d !== repo.top && d.startsWith(repo.top + sep); d = dirname(d)) {
|
|
1311
|
+
const dir = join(d, '.github', 'workflows')
|
|
1312
|
+
let names
|
|
1313
|
+
try {
|
|
1314
|
+
names = readdirSync(dir).filter((n) => /\.ya?ml$/.test(n))
|
|
1315
|
+
} catch {
|
|
1316
|
+
continue
|
|
1317
|
+
}
|
|
1318
|
+
for (const n of names.sort()) found.push(relative(siteDir, join(dir, n)))
|
|
1319
|
+
}
|
|
1320
|
+
if (!found.length) return { id: 'hosting.workflows', state: 'ok', summary: 'No workflow here that GitHub would not run.', quiet: true }
|
|
1321
|
+
const n = found.length
|
|
1322
|
+
return {
|
|
1323
|
+
id: 'hosting.workflows',
|
|
1324
|
+
state: 'attention',
|
|
1325
|
+
summary: `${count(n, 'workflow')} here never ${n === 1 ? 'runs' : 'run'}: GitHub runs workflows only from the top of the repository, ${relative(siteDir, join(repo.top, '.github', 'workflows'))}.`,
|
|
1326
|
+
files: found,
|
|
1327
|
+
lines: ['Most likely left from the repository this project came from. When the site no longer deploys that way, the folder can go — ask the person first.']
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1239
1331
|
const list = (names) => names.map((n) => `\`${n}\``).join(', ')
|
|
1240
1332
|
|
|
1241
1333
|
/**
|
|
@@ -1305,7 +1397,8 @@ function summaryLine({ name, synced, live, targets, origin }) {
|
|
|
1305
1397
|
const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
|
|
1306
1398
|
if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
|
|
1307
1399
|
else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
|
|
1308
|
-
|
|
1400
|
+
const deployed = targets.filter((t) => t.deployed)
|
|
1401
|
+
if (deployed.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${deployed.map((t) => t.title).join(' and ')}`)
|
|
1309
1402
|
else if (!synced.length) parts.push('not hosted anywhere yet')
|
|
1310
1403
|
return `${name} — ${parts.join(' · ')}`
|
|
1311
1404
|
}
|
|
@@ -1399,7 +1492,8 @@ async function workspaceOverview(root, { json }) {
|
|
|
1399
1492
|
})
|
|
1400
1493
|
const languages = normalizeLanguageList(yml.languages)
|
|
1401
1494
|
const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced']
|
|
1402
|
-
|
|
1495
|
+
const deployed = targets.filter((t) => t.deployed)
|
|
1496
|
+
if (deployed.length) parts.push(`deployed to ${deployed.map((t) => t.title).join(' and ')}`)
|
|
1403
1497
|
parts.push(
|
|
1404
1498
|
fnd.kind === 'local'
|
|
1405
1499
|
? `foundation ${fnd.name}`
|
|
@@ -1414,7 +1508,7 @@ async function workspaceOverview(root, { json }) {
|
|
|
1414
1508
|
name: s.name,
|
|
1415
1509
|
summary: `${s.path} — ${parts.join(' · ')}`,
|
|
1416
1510
|
synced,
|
|
1417
|
-
deploys: targets.map(({ name, host, title, default: isDefault, ci }) => ({ name, host, title, default: isDefault, ci })),
|
|
1511
|
+
deploys: targets.map(({ name, host, title, default: isDefault, deployed: was, ci }) => ({ name, host, title, default: isDefault, deployed: was, ci })),
|
|
1418
1512
|
foundation: { kind: fnd.kind, name: fnd.name ?? null, path: fnd.path ?? null },
|
|
1419
1513
|
mounts,
|
|
1420
1514
|
languages
|
|
@@ -1508,7 +1602,18 @@ export async function status(args = []) {
|
|
|
1508
1602
|
} finally {
|
|
1509
1603
|
Object.assign(console, saved)
|
|
1510
1604
|
}
|
|
1511
|
-
const
|
|
1605
|
+
const workflows = workflowsCheck(siteDir)
|
|
1606
|
+
const checks = [
|
|
1607
|
+
...project.checks,
|
|
1608
|
+
...git.checks,
|
|
1609
|
+
...app.checks,
|
|
1610
|
+
...deploys.checks,
|
|
1611
|
+
...(workflows ? [workflows] : []),
|
|
1612
|
+
...services.checks,
|
|
1613
|
+
...version.checks,
|
|
1614
|
+
...health.checks,
|
|
1615
|
+
dev
|
|
1616
|
+
]
|
|
1512
1617
|
const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
|
|
1513
1618
|
|
|
1514
1619
|
const root = findWorkspaceRoot(siteDir) || siteDir
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-26T22:50:14.357Z",
|
|
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.6"
|
|
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"
|
|
@@ -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/index.js
CHANGED
|
@@ -1789,8 +1789,9 @@ where, live where, deployed where — and checks:
|
|
|
1789
1789
|
app the session, what changed on the backend since this copy last synced —
|
|
1790
1790
|
the files a pull would change — what is not pushed, who has the site
|
|
1791
1791
|
open in the app, the foundation
|
|
1792
|
-
hosting where the site is live, the other hosts deploy.yml lists (by CI
|
|
1793
|
-
|
|
1792
|
+
hosting where the site is live, the other hosts deploy.yml lists (by CI, by hand
|
|
1793
|
+
and how far behind, or never from this copy), workflows GitHub never runs
|
|
1794
|
+
(a project's own .github/), and the services the live site has
|
|
1794
1795
|
uniweb whether a newer release is out
|
|
1795
1796
|
health doctor's findings
|
|
1796
1797
|
dev the dev server
|
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.
|