uniweb 1.21.0 → 1.21.2
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 +3 -3
- package/partials/agents.md +11 -3
- package/src/backend/app-changes.js +6 -0
- package/src/backend/service-request.js +21 -5
- package/src/commands/doctor.js +48 -0
- package/src/commands/pull.js +65 -7
- package/src/commands/status.js +654 -38
- package/src/framework-index.json +2 -2
- package/src/index.js +13 -5
- package/src/utils/status-next.js +2 -2
- package/src/utils/sync-point.js +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.2",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.5.21",
|
|
44
44
|
"@uniweb/content-writer": "~1.3.1",
|
|
45
|
-
"@uniweb/core": "~1.4.7",
|
|
46
45
|
"@uniweb/schemas": "~1.6.5",
|
|
46
|
+
"@uniweb/core": "~1.4.7",
|
|
47
47
|
"@uniweb/semantic-parser": "~1.13.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.10.
|
|
50
|
+
"@uniweb/build": "~1.10.4",
|
|
51
51
|
"@uniweb/content-reader": "~1.5.7",
|
|
52
52
|
"@uniweb/semantic-parser": "~1.13.0"
|
|
53
53
|
},
|
package/partials/agents.md
CHANGED
|
@@ -44,7 +44,7 @@ uniweb doctor # diagnose project configuration (--fix to repair)
|
|
|
44
44
|
|
|
45
45
|
`uniweb inspect` is the important one for content. Every parse rule in Part 3 is faster to *confirm* than to re-derive, and re-deriving from memory is where agents go wrong. Don't predict the shape — print it.
|
|
46
46
|
|
|
47
|
-
`uniweb status` is the one for a project other people edit. A site in the Uniweb app can change there at any time, teammates push to git, and Uniweb releases often. Run it when you begin, before you push, and after a command refuses: it changes nothing, and ends with the commands to run, in order (`--json` for a script).
|
|
47
|
+
`uniweb status` is the one for a project other people edit. A site in the Uniweb app can change there at any time, teammates push to git, and Uniweb releases often. Run it when you begin, when you come back to the project, before you push, publish or deploy, and after a command refuses: it changes nothing, opens with what the site is — synced where, live where — and ends with the commands to run, in order (`--json` for a script).
|
|
48
48
|
|
|
49
49
|
### The four silent failures
|
|
50
50
|
|
|
@@ -192,7 +192,7 @@ The rest of this guide explains how Uniweb works. This part is what to do *first
|
|
|
192
192
|
|
|
193
193
|
### 1. Orient — what kind of workspace is this?
|
|
194
194
|
|
|
195
|
-
**Run `uniweb status` first.** It says
|
|
195
|
+
**Run `uniweb status` first.** It says where the site sits and what it is made of — the workspace's sites and foundations, the foundation this site is built with and which other sites a change to it reaches, pages it takes from outside its folder (another repository, sometimes), the languages it is written in — then whether it changed in the app since this copy last synced, whether git or Uniweb has something newer, and what to run about each, before you edit something someone else just changed. Run at the root of a workspace with several sites, it says what each one is. The rest of this step is how to read the same wiring by hand.
|
|
196
196
|
|
|
197
197
|
**There is no single layout. Read `pnpm-workspace.yaml` first** — its `packages:` globs name the shape in one glance:
|
|
198
198
|
|
|
@@ -2864,6 +2864,14 @@ uniweb <command> --help # Per-command flags — no side effects. Prefe
|
|
|
2864
2864
|
|
|
2865
2865
|
Foundations have their own free path too: `uniweb add ci --target foundation` publishes to permanent versioned URLs on GitHub Pages.
|
|
2866
2866
|
|
|
2867
|
+
**Three facts about a site, and they are independent — `uniweb status` opens with all three:**
|
|
2868
|
+
|
|
2869
|
+
- **Synced** — the project has a site on a backend, so authors can edit it in the Uniweb app. `sync.json` names it on each backend: `https://uniweb.app` by default, or a server of your own (`uniweb login --server <url>`).
|
|
2870
|
+
- **Published** — that backend serves it live: Uniweb Cloud, on `https://uniweb.app`. A published site is a synced site; a synced site need not be published — it can sit in the app as a draft.
|
|
2871
|
+
- **Deployed** — a build of it is on another host, such as GitHub Pages or S3: by CI on every push (`uniweb add ci`), or by hand (`uniweb deploy`). `deploy.yml` lists those hosts, and a bare `uniweb deploy` goes to its default target.
|
|
2872
|
+
|
|
2873
|
+
A site can be in more than one place — usually while it moves from one host to another. Before you ship, read where it is: `publish` makes a change live on Uniweb Cloud, `git push` on a CI-deployed host, and `uniweb deploy --target <name>` on one deployed by hand — and each of these is the person's to decide. `uniweb status` also lists the services the live site has (`services:` in `site.yml` is the request, never proof of what the site has).
|
|
2874
|
+
|
|
2867
2875
|
### Uniweb Cloud and the two-sided workflow
|
|
2868
2876
|
|
|
2869
2877
|
`uniweb publish` ships to Uniweb Cloud, and it's worth understanding what that buys, because it isn't only hosting — it's how a team of technical and non-technical people works on one site.
|
|
@@ -2902,7 +2910,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2902
2910
|
- **Git is the reviewed, durable record** — you `pull` content back, read it with `git diff`, and commit.
|
|
2903
2911
|
- **Authors never push or pull.** For them, content simply updates, whether the change came from another author or from a developer's CLI.
|
|
2904
2912
|
|
|
2905
|
-
**Working on a site others edit — run `uniweb status` when you begin, before you push, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file; what you hold that it lacks;
|
|
2913
|
+
**Working on a site others edit — run `uniweb status` when you begin, when you come back to it, before you push, publish or deploy, and after any refusal.** It reads the backend without taking anything: what changed there since this copy last synced, file by file — as `uniweb pull` would write it, translations and records included; what you hold that it lacks; where the site is live, and what services it has; who has it open in the app right now, and on which file. Then it lists the commands to run, in an order that works from the top — `uniweb pull` before the push it would otherwise refuse. Three habits go with it: take what changed (`pull`, or `refresh`) before you edit; never `--force` past a refusal; and when someone has a page open in the app, tell the person before you change that page. A step marked *ask the person first* — going live, which may cost money — is theirs to decide.
|
|
2906
2914
|
|
|
2907
2915
|
**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.
|
|
2908
2916
|
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What changed on the backend since this copy last synced — READ, never taken.
|
|
3
3
|
*
|
|
4
|
+
* ⚠️ THE SECOND ANSWER. In a repository `uniweb status` asks the pull itself
|
|
5
|
+
* (`commands/pull.js::probePull`), which names every file a pull would change. This answers
|
|
6
|
+
* where that cannot — outside git, or with no sync point to merge from — by units and
|
|
7
|
+
* records, not files: a section's translations, which a pull writes to `locales/*.json`,
|
|
8
|
+
* travel inside the section here (measured 2026-10-26: 8 units named, 11 files pulled).
|
|
9
|
+
*
|
|
4
10
|
* `uniweb pull` takes the backend's content; `uniweb status` asks what there is to take.
|
|
5
11
|
* The two must agree on what "changed" means, so this reads the backend as a pull does —
|
|
6
12
|
* the same two lanes, the same conditional GET (`etagsHold`) — and compares the answer
|
|
@@ -164,27 +164,43 @@ const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
|
164
164
|
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
165
165
|
*/
|
|
166
166
|
export function announceServices({ siteYml, say, supports = null }) {
|
|
167
|
+
for (const note of serviceNotes({ siteYml, supports, warn: (m) => say.warn(m) })) say.warn(note)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where `site.yml::services` and the foundation disagree — the notes `announceServices`
|
|
172
|
+
* says, as lines, for a caller that reports them (`uniweb status`).
|
|
173
|
+
*
|
|
174
|
+
* @param {object} p
|
|
175
|
+
* @param {object} p.siteYml - parsed
|
|
176
|
+
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
177
|
+
* @param {(m: string) => void} [p.warn] - told what the file asks that will not be sent as written
|
|
178
|
+
* @returns {string[]}
|
|
179
|
+
*/
|
|
180
|
+
export function serviceNotes({ siteYml, supports = null, warn = () => {} }) {
|
|
167
181
|
// An entry the push cannot read stops the package build next, which says what to write
|
|
168
182
|
// (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
|
|
169
183
|
// past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
|
|
170
|
-
if (unreadableServices(siteYml?.services).length) return
|
|
171
|
-
const asks = readServicesRequest(siteYml?.services, { warn
|
|
172
|
-
if (!Array.isArray(supports)) return
|
|
184
|
+
if (unreadableServices(siteYml?.services).length) return []
|
|
185
|
+
const asks = readServicesRequest(siteYml?.services, { warn }) || []
|
|
186
|
+
if (!Array.isArray(supports)) return []
|
|
187
|
+
const notes = []
|
|
173
188
|
const ignored = new Set(['tracking', 'records'])
|
|
174
189
|
const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
|
|
175
190
|
const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
|
|
176
191
|
if (unrendered.length) {
|
|
177
|
-
|
|
192
|
+
notes.push(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
|
|
178
193
|
}
|
|
179
194
|
const mentioned = new Set(asks.map((a) => a.name))
|
|
180
195
|
const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
|
|
181
196
|
if (unasked.length) {
|
|
182
|
-
|
|
197
|
+
notes.push(
|
|
183
198
|
`Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
|
|
184
199
|
`unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
|
|
185
200
|
`${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
|
|
186
201
|
)
|
|
187
202
|
}
|
|
203
|
+
return notes
|
|
188
204
|
}
|
|
189
205
|
|
|
190
206
|
/**
|
package/src/commands/doctor.js
CHANGED
|
@@ -1993,6 +1993,54 @@ async function runDoctor(args = []) {
|
|
|
1993
1993
|
}
|
|
1994
1994
|
}
|
|
1995
1995
|
|
|
1996
|
+
// ⛔ The keys retired on 2026-09-20 read as nothing, without a word: a deploy recorded
|
|
1997
|
+
// under `lastDeploy:` is one `uniweb status` cannot see, and `autoSave: off` is ignored,
|
|
1998
|
+
// so deploys are recorded after all. A renamed key returns empty; it does not fail.
|
|
1999
|
+
const retired = ['lastDeploy', 'autoSave'].filter((k) => deployYml[k] !== undefined)
|
|
2000
|
+
if (retired.length) {
|
|
2001
|
+
const id = 'deploy-yml-retired-key'
|
|
2002
|
+
const issue = {
|
|
2003
|
+
id,
|
|
2004
|
+
type: 'warn',
|
|
2005
|
+
site: siteName,
|
|
2006
|
+
message: `deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads`
|
|
2007
|
+
}
|
|
2008
|
+
issues.push(issue)
|
|
2009
|
+
warn(`[${id}] ${siteName}: deploy.yml uses ${retired.map((k) => `\`${k}:\``).join(' and ')}, which the CLI no longer reads.`)
|
|
2010
|
+
const lost = []
|
|
2011
|
+
if (deployYml.lastDeploy && typeof deployYml.lastDeploy === 'object') {
|
|
2012
|
+
const names = Object.keys(deployYml.lastDeploy).filter((n) => !deployYml.deploys?.[n])
|
|
2013
|
+
if (names.length) lost.push(`the last deploy of ${names.map((n) => `\`${n}\``).join(', ')} is recorded only there, where nothing sees it`)
|
|
2014
|
+
}
|
|
2015
|
+
if (String(deployYml.autoSave) === 'off') lost.push('`autoSave: off` is ignored, so deploys are recorded')
|
|
2016
|
+
const renamed = { lastDeploy: 'deploys', autoSave: 'saveDeploys' }
|
|
2017
|
+
log(
|
|
2018
|
+
` ${colors.dim}${retired.map((k) => `\`${k}:\` is \`${renamed[k]}:\``).join(' and ')} now${lost.length ? ` — ${lost.join('; ')}` : ''}.${colors.reset}`
|
|
2019
|
+
)
|
|
2020
|
+
if (shouldFix(id)) {
|
|
2021
|
+
try {
|
|
2022
|
+
const { retireDeployYmlKeys } = await import('@uniweb/build/site')
|
|
2023
|
+
const done = await retireDeployYmlKeys(sitePath)
|
|
2024
|
+
if (done) {
|
|
2025
|
+
issue.fixed = true
|
|
2026
|
+
fixed(
|
|
2027
|
+
`${relative(workspaceDir, deployYmlPath)}: ${[
|
|
2028
|
+
done.moved.length && `moved ${done.moved.join(', ')} to \`deploys:\``,
|
|
2029
|
+
done.saveDeploys === false && 'wrote `saveDeploys: false`',
|
|
2030
|
+
'removed the old keys'
|
|
2031
|
+
]
|
|
2032
|
+
.filter(Boolean)
|
|
2033
|
+
.join(', ')}`
|
|
2034
|
+
)
|
|
2035
|
+
}
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
warn(`[${id}] could not rewrite ${relative(workspaceDir, deployYmlPath)}: ${err.message}`)
|
|
2038
|
+
}
|
|
2039
|
+
} else {
|
|
2040
|
+
log(` ${colors.dim}Run \`uniweb doctor --fix ${id}\` to move them, keeping your comments.${colors.reset}`)
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
1996
2044
|
// github-pages-specific: if a target has `domain`, `site/public/CNAME`
|
|
1997
2045
|
// should exist with that value. Vite copies public/ into dist/, and
|
|
1998
2046
|
// GH Pages reads dist/CNAME on each deploy to keep the custom domain
|
package/src/commands/pull.js
CHANGED
|
@@ -150,10 +150,12 @@ const colors = {
|
|
|
150
150
|
green: '\x1b[32m',
|
|
151
151
|
blue: '\x1b[36m'
|
|
152
152
|
}
|
|
153
|
-
// Looked up at each call, so a caller that captures the console captures these too
|
|
154
|
-
|
|
153
|
+
// Looked up at each call, so a caller that captures the console captures these too — and
|
|
154
|
+
// silent while a probe runs (`probePull`), which answers by what it returns.
|
|
155
|
+
let quiet = false
|
|
156
|
+
const log = (...a) => quiet || console.log(...a)
|
|
155
157
|
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
156
|
-
const error = (m) => console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
158
|
+
const error = (m) => quiet || console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
157
159
|
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
158
160
|
const note = (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
159
161
|
|
|
@@ -568,6 +570,34 @@ const pulledFrom = (origin) => `The site's content on ${origin}`
|
|
|
568
570
|
const mergedFrom = (origin) => `Merge the site's content from ${origin}`
|
|
569
571
|
const takenFrom = (origin) => `Take the site's content from ${origin}`
|
|
570
572
|
|
|
573
|
+
/**
|
|
574
|
+
* What a pull would change in the files, and nothing more — for `uniweb status`.
|
|
575
|
+
*
|
|
576
|
+
* ⭐ THE PULL ITSELF, STOPPED BEFORE IT TAKES ANYTHING. The same requests, the same base,
|
|
577
|
+
* the same projection onto it in a stand-in — so status and pull cannot disagree about
|
|
578
|
+
* which files the backend changed: a section's translations (`locales/*.json`) and a
|
|
579
|
+
* record's file are named as the pull would write them. It downloads no media, merges
|
|
580
|
+
* nothing, commits nothing and banks nothing — no ETag, version, hash or sync point —
|
|
581
|
+
* and prints nothing.
|
|
582
|
+
*
|
|
583
|
+
* Null when a probe cannot answer: outside git, where a pull writes into the files; in a
|
|
584
|
+
* repository with no commit or no sync point to merge from; when a lane failed or the
|
|
585
|
+
* workspace was refused. The caller then says what it can by other means.
|
|
586
|
+
*
|
|
587
|
+
* @param {string[]} [args] - the workspace flags only (`--org`, `--personal`)
|
|
588
|
+
* @param {object} [deps] - as for `pull`; `getToken` keeps it from ever asking for a login
|
|
589
|
+
* @returns {Promise<{ files: Array<[string, string]>, recordsNotPlaced: boolean } | null>}
|
|
590
|
+
* `files` — `[status, path]`, status `A`, `M` or `D`, the path relative to the site
|
|
591
|
+
*/
|
|
592
|
+
export async function probePull(args = [], deps = {}) {
|
|
593
|
+
quiet = true
|
|
594
|
+
try {
|
|
595
|
+
return (await pull(args, { ...deps, probe: true }))?.probe ?? null
|
|
596
|
+
} finally {
|
|
597
|
+
quiet = false
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
571
601
|
export async function pull(args = [], deps = {}) {
|
|
572
602
|
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
|
|
573
603
|
// it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
|
|
@@ -587,13 +617,20 @@ export async function pull(args = [], deps = {}) {
|
|
|
587
617
|
const force = args.includes('--force')
|
|
588
618
|
|
|
589
619
|
const siteDir = await resolveSiteDir(args, 'pull')
|
|
590
|
-
|
|
620
|
+
// ⛔ A probe (`probePull`) fetches no media: it says what changes, and takes nothing.
|
|
621
|
+
const probe = Boolean(deps.probe)
|
|
622
|
+
const fetchAssets = !probe && shouldFetchAssets(args, siteDir)
|
|
591
623
|
|
|
592
624
|
// ⭐ IN A REPOSITORY A PULL MERGES; OUTSIDE ONE IT CHECKS OUT. A merge needs the state both
|
|
593
625
|
// sides started from, and only a history holds one (`utils/sync-point.js`). Outside git
|
|
594
626
|
// a pull rewrites the files, so work there that a pull did not write stops it.
|
|
595
627
|
const repo = repoOf(siteDir)
|
|
596
|
-
|
|
628
|
+
// A probe writes onto a commit in a stand-in, never into the files: with no repository,
|
|
629
|
+
// or no commit to write onto, it has nothing to say.
|
|
630
|
+
if (probe && !repo?.head) return { exitCode: 0, probe: null }
|
|
631
|
+
// …and touching nothing here, it is not stopped by what a merge would be: a conflict or a
|
|
632
|
+
// merge waiting is the caller's to report.
|
|
633
|
+
if (repo && !dryRun && !probe) {
|
|
597
634
|
if (!repo.head) {
|
|
598
635
|
error('This repository has no commits yet, so there is nothing for a pull to merge into.')
|
|
599
636
|
note('Commit the project, then pull.')
|
|
@@ -631,7 +668,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
631
668
|
command: 'Pulling'
|
|
632
669
|
})
|
|
633
670
|
// One reader of Models for the whole pull, so a Model is read once whichever lane asks first.
|
|
634
|
-
|
|
671
|
+
// ⛔ A probe keeps none: a Model kept here is what a later offline emit resolves with, and a
|
|
672
|
+
// probe takes nothing (`probePull`).
|
|
673
|
+
const readModel = makeModelResolver({ client, siteDir: probe ? null : siteDir })
|
|
635
674
|
|
|
636
675
|
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
637
676
|
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
@@ -703,6 +742,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
703
742
|
// body). `doRequest` is a thunk returning the client's Response promise. 404 / any
|
|
704
743
|
// failure → null (the lane is skipped, not fatal) — except a workspace mismatch,
|
|
705
744
|
// `{ refused: true }`, which stops the pull: the other lane would only say it again.
|
|
745
|
+
// A lane that answered 404 is named in `missing`, so a probe can tell a site with no
|
|
746
|
+
// records lane from a lane that did not answer.
|
|
747
|
+
const missing = new Set()
|
|
706
748
|
const getDocs = async (label, doRequest) => {
|
|
707
749
|
info(
|
|
708
750
|
`Pulling ${colors.bright}${label}${colors.reset} from ${colors.dim}${client.origin}${colors.reset} …`
|
|
@@ -718,6 +760,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
718
760
|
}
|
|
719
761
|
if (res.status === 404) {
|
|
720
762
|
note(`${label}: not found (404) — it was deleted, or you lack access.`)
|
|
763
|
+
missing.add(label)
|
|
721
764
|
return null
|
|
722
765
|
}
|
|
723
766
|
if (res.status === 304) {
|
|
@@ -820,6 +863,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
820
863
|
? splitRecordsPull(folderLane.docs)
|
|
821
864
|
: null
|
|
822
865
|
const projected = Boolean(siteDoc) || Boolean(folderTaken)
|
|
866
|
+
if (probe) {
|
|
867
|
+
// A lane that did not answer is not knowing — a records lane the backend has none of
|
|
868
|
+
// (404) is no records; both unchanged is nothing to take.
|
|
869
|
+
if (!content || (!noRecords && !folderLane && !missing.has('records'))) return { exitCode: 1, probe: null }
|
|
870
|
+
if (!projected) return { exitCode: 0, probe: { files: [], recordsNotPlaced: false } }
|
|
871
|
+
}
|
|
823
872
|
|
|
824
873
|
// ── What the project itself says, read before anything is written ──────────
|
|
825
874
|
// The foundation, the Models and the scope come from the project as it is: in a
|
|
@@ -950,6 +999,9 @@ export async function pull(args = [], deps = {}) {
|
|
|
950
999
|
origin: client.origin,
|
|
951
1000
|
siteUuid: siteContentUuid
|
|
952
1001
|
})
|
|
1002
|
+
// A probe with no base has no way to tell the backend's changes from this copy's: onto
|
|
1003
|
+
// HEAD, every difference would read as the backend's.
|
|
1004
|
+
if (probe && !base) return { exitCode: 0, probe: null }
|
|
953
1005
|
onto = force || !base ? repo.head : base
|
|
954
1006
|
try {
|
|
955
1007
|
standIn = openStandIn(repo, { roots, rev: onto })
|
|
@@ -1071,7 +1123,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
1071
1123
|
// ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
|
|
1072
1124
|
// places each one, as media are fetched. A file that did not arrive is a record not taken —
|
|
1073
1125
|
// reported, and the pull fails below as it does for any, so the next one fetches it again.
|
|
1074
|
-
if (report.fileDownloads?.length) {
|
|
1126
|
+
if (report.fileDownloads?.length && !probe) {
|
|
1075
1127
|
const got = await downloadFileRecords({
|
|
1076
1128
|
downloads: report.fileDownloads,
|
|
1077
1129
|
siteDir: projDir,
|
|
@@ -1106,6 +1158,12 @@ export async function pull(args = [], deps = {}) {
|
|
|
1106
1158
|
for (const w of report.warnings) note(`! ${w}`)
|
|
1107
1159
|
}
|
|
1108
1160
|
|
|
1161
|
+
// ⭐ A PROBE STOPS HERE, the stand-in holding what this pull would take: what differs
|
|
1162
|
+
// from the base is what the backend changed. Nothing is banked; `finally` closes it.
|
|
1163
|
+
if (probe) {
|
|
1164
|
+
return { exitCode: 0, probe: { files: standIn.changes(), recordsNotPlaced: Boolean(recordsNotPlaced) } }
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1109
1167
|
// Outside git: what this pull wrote, so the next one can tell its own output from the
|
|
1110
1168
|
// author's work. Config files are rewritten every pull whether or not they changed;
|
|
1111
1169
|
// include the ones the projector owns so a pull-then-pull doesn't trip on them either.
|