uniweb 1.21.0 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/partials/agents.md +10 -2
- 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 +327 -21
- package/src/framework-index.json +2 -2
- package/src/index.js +8 -4
- 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.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,14 +42,14 @@
|
|
|
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.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "~1.10.3",
|
|
51
|
+
"@uniweb/semantic-parser": "~1.13.0",
|
|
52
|
+
"@uniweb/content-reader": "~1.5.7"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
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
|
|
|
@@ -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.
|
package/src/commands/status.js
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
* dev the dev server
|
|
17
17
|
*
|
|
18
18
|
* ⛔ READ-ONLY, AND IT NEVER PROMPTS. It signs nobody in — with no session it says how — and
|
|
19
|
-
* writes nothing a later command reads as taken: no ETag, version or
|
|
20
|
-
* (`backend/app-changes.js`).
|
|
21
|
-
* `--version` keep
|
|
19
|
+
* writes nothing a later command reads as taken: no ETag, version, hash or sync point
|
|
20
|
+
* (`probePull`, `backend/app-changes.js`). It writes the user-level npm cache `update` and
|
|
21
|
+
* `--version` keep, and — for a foundation named by catalog ref — the registered version's
|
|
22
|
+
* section types a pull keeps, which never change. A check it could not make is `unknown`,
|
|
23
|
+
* never `ok`.
|
|
22
24
|
*
|
|
23
25
|
* ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
|
|
24
26
|
* from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
* (`utils/flag-guard.js`).
|
|
40
42
|
*/
|
|
41
43
|
|
|
44
|
+
import { execFileSync } from 'node:child_process'
|
|
42
45
|
import { existsSync, readFileSync } from 'node:fs'
|
|
43
46
|
import { join, relative, resolve } from 'node:path'
|
|
44
47
|
import yaml from 'js-yaml'
|
|
@@ -56,7 +59,9 @@ import {
|
|
|
56
59
|
import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
57
60
|
import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
|
|
58
61
|
import { readAppChanges } from '../backend/app-changes.js'
|
|
59
|
-
import {
|
|
62
|
+
import { probePull } from './pull.js'
|
|
63
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
64
|
+
import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
|
|
60
65
|
import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
|
|
61
66
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
62
67
|
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
@@ -248,7 +253,7 @@ async function gitChecks(siteDir, { offline, origin }) {
|
|
|
248
253
|
? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
|
|
249
254
|
: 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
|
|
250
255
|
})
|
|
251
|
-
return { checks, proposals, localFiles: null }
|
|
256
|
+
return { checks, proposals, localFiles: null, repo: false }
|
|
252
257
|
}
|
|
253
258
|
checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
|
|
254
259
|
|
|
@@ -334,7 +339,7 @@ async function gitChecks(siteDir, { offline, origin }) {
|
|
|
334
339
|
if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
|
|
335
340
|
if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
|
|
336
341
|
}
|
|
337
|
-
return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging }
|
|
342
|
+
return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging, repo: Boolean(repo.head) }
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
/**
|
|
@@ -488,9 +493,24 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
488
493
|
summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
|
|
489
494
|
})
|
|
490
495
|
|
|
496
|
+
// ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
|
|
497
|
+
// stopped before it takes anything (`probePull`): the files it would change, translations
|
|
498
|
+
// and records included. Where it cannot answer — outside git, no sync point to merge from —
|
|
499
|
+
// the backend's units against this copy's last sync (`backend/app-changes.js`).
|
|
500
|
+
const readChanges = async () => {
|
|
501
|
+
if (git.repo) {
|
|
502
|
+
const org = readOrgFlag(args)
|
|
503
|
+
const probed = await probePull([...(org ? ['--org', org] : []), ...(args.includes('--personal') ? ['--personal'] : [])], {
|
|
504
|
+
resolveSiteDir: async () => siteDir,
|
|
505
|
+
getToken: async () => session.token
|
|
506
|
+
})
|
|
507
|
+
if (probed) return { probe: probed }
|
|
508
|
+
}
|
|
509
|
+
return readAppChanges({ client, siteDir, siteUuid: uuid })
|
|
510
|
+
}
|
|
491
511
|
const [state, changes, foundation] = await Promise.all([
|
|
492
512
|
uuid ? client.siteState(uuid) : null,
|
|
493
|
-
uuid ?
|
|
513
|
+
uuid ? readChanges().catch((err) => ({ error: err })) : null,
|
|
494
514
|
foundationCheck(siteDir, siteYml, client).catch(() => null)
|
|
495
515
|
])
|
|
496
516
|
|
|
@@ -532,14 +552,35 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
532
552
|
|
|
533
553
|
const site = state?.site
|
|
534
554
|
if (site) {
|
|
555
|
+
// ⭐ WHERE THE SITE IS LIVE IS THE BACKEND'S TO SAY, never deploy.yml's: a receipt is
|
|
556
|
+
// what this copy published, and a publish from the app or another copy does not move it.
|
|
557
|
+
// ⛔ `last_published_url` outlives an unpublish (`client.siteStatus`), so it is said as
|
|
558
|
+
// where the site WAS live when it is not live now — never as where it is.
|
|
559
|
+
const url = typeof site.last_published_url === 'string' && site.last_published_url ? site.last_published_url : null
|
|
560
|
+
const when = site.last_published_at ? sinceLabel(site.last_published_at, Date.now()) : null
|
|
561
|
+
const at = `${url ? ` at ${url}` : ''}${when ? `, published ${when}` : ''}`
|
|
562
|
+
const detail = {
|
|
563
|
+
published: Boolean(site.published),
|
|
564
|
+
url,
|
|
565
|
+
published_at: site.last_published_at ?? null,
|
|
566
|
+
draft_dirty: Boolean(site.draft_dirty),
|
|
567
|
+
foundation: site.last_published_foundation ?? null,
|
|
568
|
+
runtime: site.last_published_runtime ?? null
|
|
569
|
+
}
|
|
535
570
|
if (!site.published) {
|
|
536
|
-
checks.push({
|
|
571
|
+
checks.push({
|
|
572
|
+
id: 'app.live',
|
|
573
|
+
state: 'attention',
|
|
574
|
+
summary: `On ${cloudName(origin)}, not live yet.`,
|
|
575
|
+
lines: url ? [`It was live at ${url} before.`] : [],
|
|
576
|
+
detail
|
|
577
|
+
})
|
|
537
578
|
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'take the site live — the person decides, and it may ask for payment', ask: true })
|
|
538
579
|
} else if (site.draft_dirty) {
|
|
539
|
-
checks.push({ id: 'app.live', state: 'attention', summary:
|
|
580
|
+
checks.push({ id: 'app.live', state: 'attention', summary: `Live on ${cloudName(origin)}${at} — the app holds changes that are not live yet.`, detail })
|
|
540
581
|
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
|
|
541
582
|
} else {
|
|
542
|
-
checks.push({ id: 'app.live', state: 'ok', summary:
|
|
583
|
+
checks.push({ id: 'app.live', state: 'ok', summary: `Live on ${cloudName(origin)}${at}, with the latest content.`, detail })
|
|
543
584
|
}
|
|
544
585
|
// An array is the backend's answer, `[]` included; no key is a backend that does not
|
|
545
586
|
// say, and then neither do we.
|
|
@@ -561,14 +602,20 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
561
602
|
}
|
|
562
603
|
}
|
|
563
604
|
if (foundation) checks.push(foundation)
|
|
564
|
-
return { checks, proposals, backend }
|
|
605
|
+
return { checks, proposals, backend, site: site ?? null }
|
|
565
606
|
}
|
|
566
607
|
|
|
608
|
+
/** The name a backend's hosting goes by: Uniweb Cloud on the default backend, its origin elsewhere. */
|
|
609
|
+
const cloudName = (origin) => (origin === DEFAULT_BACKEND_ORIGIN ? 'Uniweb Cloud' : origin)
|
|
610
|
+
|
|
611
|
+
const CHANGE = { A: 'added', M: 'changed', D: 'removed' }
|
|
612
|
+
|
|
567
613
|
/** `app.changes`: what moved on the backend, and where it meets what moved here. */
|
|
568
614
|
function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
569
615
|
if (changes.error) {
|
|
570
616
|
return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
|
|
571
617
|
}
|
|
618
|
+
if (changes.probe) return probedChanges(changes.probe, unpushedFiles, proposals)
|
|
572
619
|
const { content, records } = changes
|
|
573
620
|
const unknown = [content, records].filter((l) => l.state === 'unknown')
|
|
574
621
|
const files = [
|
|
@@ -586,7 +633,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
586
633
|
return [
|
|
587
634
|
unknown.length
|
|
588
635
|
? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
|
|
589
|
-
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.' }
|
|
636
|
+
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
|
|
590
637
|
]
|
|
591
638
|
}
|
|
592
639
|
// Where the backend's changes meet this copy's: a push of those is refused until a pull
|
|
@@ -605,6 +652,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
605
652
|
lines,
|
|
606
653
|
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
607
654
|
detail: {
|
|
655
|
+
source: 'units',
|
|
608
656
|
content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
|
|
609
657
|
records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
|
|
610
658
|
...(overlap.length ? { overlap } : {}),
|
|
@@ -614,6 +662,40 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
614
662
|
]
|
|
615
663
|
}
|
|
616
664
|
|
|
665
|
+
/**
|
|
666
|
+
* `app.changes` from the pull's own answer (`probePull`): the files it would change, each
|
|
667
|
+
* as the pull would write it.
|
|
668
|
+
*/
|
|
669
|
+
function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
|
|
670
|
+
if (!list.length) {
|
|
671
|
+
return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
|
|
672
|
+
}
|
|
673
|
+
const paths = list.map(([, p]) => p)
|
|
674
|
+
const files = list.map(([s, p]) => (s === 'A' ? `${p} (new)` : s === 'D' ? `${p} (removed)` : p))
|
|
675
|
+
// Where the backend's changes meet this copy's: the pull merges them, and a push of those
|
|
676
|
+
// before it is refused.
|
|
677
|
+
const overlap = unpushedFiles?.length ? paths.filter((p) => unpushedFiles.includes(p)) : []
|
|
678
|
+
const n = list.length
|
|
679
|
+
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'file')} changed on the backend` })
|
|
680
|
+
return [
|
|
681
|
+
{
|
|
682
|
+
id: 'app.changes',
|
|
683
|
+
state: overlap.length ? 'blocking' : 'attention',
|
|
684
|
+
summary:
|
|
685
|
+
`${count(n, 'file')} changed on the backend since this copy last synced — in the app, or pushed from another copy.` +
|
|
686
|
+
(overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
|
|
687
|
+
files,
|
|
688
|
+
lines: recordsNotPlaced ? ['Some records could not be placed in files — `npx uniweb pull` says which, and why.'] : [],
|
|
689
|
+
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
690
|
+
detail: {
|
|
691
|
+
source: 'pull',
|
|
692
|
+
files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })),
|
|
693
|
+
...(overlap.length ? { overlap } : {})
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
]
|
|
697
|
+
}
|
|
698
|
+
|
|
617
699
|
/**
|
|
618
700
|
* `app.foundation`: the local foundation against its registered version — what the next
|
|
619
701
|
* push or publish does with it (`backend/foundation-bring-along.js`).
|
|
@@ -722,16 +804,226 @@ async function devCheck(siteDir, { offline }) {
|
|
|
722
804
|
}
|
|
723
805
|
}
|
|
724
806
|
|
|
807
|
+
// ─── where the site is hosted ───────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
/** A host adapter's name as people know it (`S3 + CloudFront`), or its id. */
|
|
810
|
+
async function hostTitle(host) {
|
|
811
|
+
try {
|
|
812
|
+
const { getAdapter } = await import('@uniweb/build/hosts')
|
|
813
|
+
return getAdapter(host)?.display?.title || host
|
|
814
|
+
} catch {
|
|
815
|
+
return host
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The CI workflow `uniweb add ci` writes for `host`, when it deploys this site: its file,
|
|
821
|
+
* and the branches whose pushes run it. A workspace with several sites deploys one per
|
|
822
|
+
* workflow, which names that site's build output.
|
|
823
|
+
*/
|
|
824
|
+
function ciWorkflow(repo, host) {
|
|
825
|
+
const rel = `.github/workflows/deploy-${host}.yml`
|
|
826
|
+
let text
|
|
827
|
+
try {
|
|
828
|
+
text = readFileSync(join(repo.top, rel), 'utf8')
|
|
829
|
+
} catch {
|
|
830
|
+
return null
|
|
831
|
+
}
|
|
832
|
+
if (repo.path !== '.' && !text.includes(`${repo.path}/dist`)) return null
|
|
833
|
+
let branches = []
|
|
834
|
+
try {
|
|
835
|
+
const doc = yaml.load(text)
|
|
836
|
+
branches = (doc?.on ?? doc?.[true])?.push?.branches ?? []
|
|
837
|
+
} catch {
|
|
838
|
+
// unreadable: the branches are not known
|
|
839
|
+
}
|
|
840
|
+
return { file: rel, branches: Array.isArray(branches) && branches.length ? branches.map(String) : [] }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The commits since `sha` that changed the site's files — null when this branch does not hold `sha`. */
|
|
844
|
+
function siteCommitsSince(repo, sha, siteDir) {
|
|
845
|
+
try {
|
|
846
|
+
if (!isAncestor(repo, sha)) return null
|
|
847
|
+
const roots = sentRoots(siteDir).map((r) => `${repo.prefix}${r}`)
|
|
848
|
+
const out = execFileSync('git', ['rev-list', '--count', `${sha}..HEAD`, '--', ...roots], {
|
|
849
|
+
cwd: repo.top,
|
|
850
|
+
encoding: 'utf8',
|
|
851
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
852
|
+
})
|
|
853
|
+
return Number(out.trim())
|
|
854
|
+
} catch {
|
|
855
|
+
return null
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* `hosting.deploys`: where else the site is deployed — `deploy.yml`'s targets on hosts that
|
|
861
|
+
* are not a backend — each by CI on every push, or by hand, and how far behind a
|
|
862
|
+
* hand-deployed one is.
|
|
863
|
+
*
|
|
864
|
+
* ⛔ FROM THIS COPY'S FILES ONLY. A host's own state is not asked, and a receipt
|
|
865
|
+
* (`deploys:`) says what this copy deployed — never what another copy, or a CI run, did.
|
|
866
|
+
*/
|
|
867
|
+
async function deployChecks(siteDir) {
|
|
868
|
+
let deployYml
|
|
869
|
+
try {
|
|
870
|
+
const { loadDeployYml } = await import('@uniweb/build/site')
|
|
871
|
+
deployYml = await loadDeployYml(siteDir)
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return { checks: [{ id: 'hosting.deploys', state: 'unknown', summary: `deploy.yml could not be read: ${err.message}` }], proposals: [], targets: [] }
|
|
874
|
+
}
|
|
875
|
+
const all = deployYml?.targets && typeof deployYml.targets === 'object' ? deployYml.targets : {}
|
|
876
|
+
const names = Object.keys(all)
|
|
877
|
+
const defaultName = deployYml?.default ?? (names.length === 1 ? names[0] : null)
|
|
878
|
+
const elsewhere = Object.entries(all).filter(([, t]) => t && typeof t === 'object' && t.host && t.host !== 'uniweb')
|
|
879
|
+
if (!elsewhere.length) {
|
|
880
|
+
return { checks: [{ id: 'hosting.deploys', state: 'skipped', summary: 'Deployed to no host but a backend.', quiet: true }], proposals: [], targets: [] }
|
|
881
|
+
}
|
|
882
|
+
const repo = repoOf(siteDir)
|
|
883
|
+
const proposals = []
|
|
884
|
+
const targets = []
|
|
885
|
+
const lines = []
|
|
886
|
+
let attention = false
|
|
887
|
+
for (const [name, t] of elsewhere) {
|
|
888
|
+
const title = await hostTitle(t.host)
|
|
889
|
+
const ci = repo?.head ? ciWorkflow(repo, t.host) : null
|
|
890
|
+
const receipt = deployYml.deploys?.[name] || null
|
|
891
|
+
const retired = !receipt && deployYml.lastDeploy?.[name] ? deployYml.lastDeploy[name] : null
|
|
892
|
+
const behind = receipt?.git?.sha && repo?.head ? siteCommitsSince(repo, receipt.git.sha, siteDir) : null
|
|
893
|
+
const when = receipt?.at ? sinceLabel(receipt.at, Date.now()) : null
|
|
894
|
+
let how
|
|
895
|
+
if (ci) {
|
|
896
|
+
how = `deployed by CI on every push${ci.branches.length ? ` to ${ci.branches.join(' or ')}` : ''} (${ci.file})`
|
|
897
|
+
} else if (receipt) {
|
|
898
|
+
how =
|
|
899
|
+
`deployed by hand, last ${when ?? 'at an unknown time'}${receipt.url ? ` to ${receipt.url}` : ''}` +
|
|
900
|
+
(behind > 0 ? ` — the site changed in ${count(behind, 'commit')} since` : behind === 0 ? ', and the site has not changed since' : '')
|
|
901
|
+
if (behind > 0) {
|
|
902
|
+
attention = true
|
|
903
|
+
proposals.push({ step: 'deploy', command: `npx uniweb deploy --target ${name}`, why: `update ${title}, ${count(behind, 'commit')} behind`, ask: true })
|
|
904
|
+
}
|
|
905
|
+
} else if (retired) {
|
|
906
|
+
attention = true
|
|
907
|
+
how = `its last deploy (${String(retired.at ?? '').slice(0, 10) || 'undated'}) is under \`lastDeploy:\`, which nothing reads — \`npx uniweb doctor --fix deploy-yml-retired-key\` moves it`
|
|
908
|
+
} else {
|
|
909
|
+
how = 'never deployed from this copy'
|
|
910
|
+
}
|
|
911
|
+
lines.push(`${title} — target \`${name}\`${name === defaultName ? ', the one a bare `uniweb deploy` uses' : ''}: ${how}`)
|
|
912
|
+
targets.push({
|
|
913
|
+
name,
|
|
914
|
+
host: t.host,
|
|
915
|
+
title,
|
|
916
|
+
default: name === defaultName,
|
|
917
|
+
ci: ci ? { file: ci.file, branches: ci.branches } : null,
|
|
918
|
+
last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
|
|
919
|
+
behind,
|
|
920
|
+
...(retired ? { retired: true } : {})
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
checks: [
|
|
925
|
+
{
|
|
926
|
+
id: 'hosting.deploys',
|
|
927
|
+
state: attention ? 'attention' : 'ok',
|
|
928
|
+
summary: `Also deployed to ${targets.map((t) => t.title).join(' and ')}.`,
|
|
929
|
+
lines,
|
|
930
|
+
detail: { targets }
|
|
931
|
+
}
|
|
932
|
+
],
|
|
933
|
+
proposals,
|
|
934
|
+
targets
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const list = (names) => names.map((n) => `\`${n}\``).join(', ')
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* `app.services`: what `site.yml` asks for, beside what the backend holds — the request it
|
|
942
|
+
* stored, and what the last publish delivered (`last_published_services`). Comparing the
|
|
943
|
+
* file with those is how to tell it is stale, by the backend's own contract.
|
|
944
|
+
*
|
|
945
|
+
* ⛔ A request is never said as what the site has: what is live is the backend's to say.
|
|
946
|
+
*/
|
|
947
|
+
async function servicesCheck({ siteDir, siteYml, site }) {
|
|
948
|
+
const { readServicesRequest } = await import('@uniweb/build/uwx')
|
|
949
|
+
const { foundationSupports, serviceNotes } = await import('../backend/service-request.js')
|
|
950
|
+
// ⚠️ Two shapes, as the backend sends them (measured 2026-10-26 on uniweb.app): the stored
|
|
951
|
+
// request is rows (`{ name, enabled? }`), what the last publish delivered is names. A reader
|
|
952
|
+
// of rows alone reads every live service as none.
|
|
953
|
+
const on = (entries) =>
|
|
954
|
+
(Array.isArray(entries) ? entries : [])
|
|
955
|
+
.map((e) => (typeof e === 'string' ? e : e && typeof e.name === 'string' && e.enabled !== false ? e.name : null))
|
|
956
|
+
.filter(Boolean)
|
|
957
|
+
const asks = readServicesRequest(siteYml.services) || []
|
|
958
|
+
const asked = on(asks)
|
|
959
|
+
const lines = serviceNotes({ siteYml, supports: await foundationSupports(siteDir, siteYml) })
|
|
960
|
+
const proposals = []
|
|
961
|
+
if (!site) {
|
|
962
|
+
return {
|
|
963
|
+
checks: [
|
|
964
|
+
{
|
|
965
|
+
id: 'app.services',
|
|
966
|
+
state: lines.length ? 'attention' : 'ok',
|
|
967
|
+
summary: asked.length ? `site.yml asks for ${list(asked)}.` : 'site.yml asks for no services.',
|
|
968
|
+
lines,
|
|
969
|
+
detail: { asked, requested: null, live: null }
|
|
970
|
+
}
|
|
971
|
+
],
|
|
972
|
+
proposals
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const live = on(site.last_published_services)
|
|
976
|
+
const requested = on(site.services)
|
|
977
|
+
const waiting = requested.filter((n) => !live.includes(n))
|
|
978
|
+
if (waiting.length && site.published) {
|
|
979
|
+
lines.push(`Requested, not live yet: ${list(waiting)} — publishing makes ${waiting.length === 1 ? 'it' : 'them'} live, and may ask for payment.`)
|
|
980
|
+
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
|
|
981
|
+
}
|
|
982
|
+
const unsent = asked.filter((n) => !requested.includes(n))
|
|
983
|
+
if (unsent.length) lines.push(`site.yml asks for ${list(unsent)}, which the backend does not hold yet — the next push sends the request.`)
|
|
984
|
+
const offHere = asks.filter((a) => a.enabled === false && live.includes(a.name)).map((a) => a.name)
|
|
985
|
+
if (offHere.length) {
|
|
986
|
+
lines.push(`site.yml turns off ${list(offHere)}, which the live site has — a request the owner settles; a paid service stays on until its subscription changes.`)
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
checks: [
|
|
990
|
+
{
|
|
991
|
+
id: 'app.services',
|
|
992
|
+
state: lines.length ? 'attention' : 'ok',
|
|
993
|
+
summary: live.length ? `Services on the live site: ${list(live)}.` : site.published ? 'No services on the live site.' : 'No services live — the site is not.',
|
|
994
|
+
lines,
|
|
995
|
+
detail: { asked, requested, live }
|
|
996
|
+
}
|
|
997
|
+
],
|
|
998
|
+
proposals
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** What the site is, in one line: where it is synced, and where it is live. */
|
|
1003
|
+
function summaryLine({ name, synced, live, targets, origin }) {
|
|
1004
|
+
const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
|
|
1005
|
+
if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
|
|
1006
|
+
else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
|
|
1007
|
+
if (targets.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${targets.map((t) => t.title).join(' and ')}`)
|
|
1008
|
+
else if (!synced.length) parts.push('not hosted anywhere yet')
|
|
1009
|
+
return `${name} — ${parts.join(' · ')}`
|
|
1010
|
+
}
|
|
1011
|
+
|
|
725
1012
|
// ─── the command ────────────────────────────────────────────────────────────
|
|
726
1013
|
|
|
727
1014
|
const GROUPS = [
|
|
728
1015
|
['git', 'Git'],
|
|
729
1016
|
['app', 'App'],
|
|
1017
|
+
['hosting', 'Hosting'],
|
|
730
1018
|
['uniweb', 'Uniweb'],
|
|
731
1019
|
['health', 'Health'],
|
|
732
1020
|
['dev', 'Dev server']
|
|
733
1021
|
]
|
|
734
1022
|
|
|
1023
|
+
// Where a check is shown: where the site is live, and what it serves, are its hosting —
|
|
1024
|
+
// `app.live` and `app.services` kept their ids, which the document had before the group.
|
|
1025
|
+
const groupOf = (id) => (id === 'app.live' || id === 'app.services' || id.startsWith('hosting.') ? 'hosting' : id.split('.')[0])
|
|
1026
|
+
|
|
735
1027
|
const ICON = {
|
|
736
1028
|
ok: `${c.green}✓${c.reset}`,
|
|
737
1029
|
attention: `${c.cyan}→${c.reset}`,
|
|
@@ -740,11 +1032,11 @@ const ICON = {
|
|
|
740
1032
|
skipped: `${c.dim}·${c.reset}`
|
|
741
1033
|
}
|
|
742
1034
|
|
|
743
|
-
function render({
|
|
1035
|
+
function render({ summary, backend, checks, next }) {
|
|
744
1036
|
const out = ['']
|
|
745
|
-
out.push(`${c.bold}
|
|
1037
|
+
out.push(`${c.bold}${summary}${c.reset}`)
|
|
746
1038
|
for (const [prefix, title] of GROUPS) {
|
|
747
|
-
const group = checks.filter((k) => k.id
|
|
1039
|
+
const group = checks.filter((k) => groupOf(k.id) === prefix && !(k.quiet && (k.state === 'ok' || k.state === 'skipped')))
|
|
748
1040
|
if (!group.length) continue
|
|
749
1041
|
out.push('')
|
|
750
1042
|
out.push(prefix === 'app' ? `${c.bold}${title}${c.reset} ${c.dim}— ${backend.origin}${c.reset}` : `${c.bold}${title}${c.reset}`)
|
|
@@ -813,31 +1105,45 @@ export async function status(args = []) {
|
|
|
813
1105
|
// helpers say on the way goes to stderr.
|
|
814
1106
|
const saved = { log: console.log, info: console.info, warn: console.warn }
|
|
815
1107
|
if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
|
|
816
|
-
let git, app, version, health, dev
|
|
1108
|
+
let git, app, version, health, dev, deploys, services
|
|
817
1109
|
try {
|
|
818
1110
|
git = await gitChecks(siteDir, { offline, origin })
|
|
819
|
-
;[app, version, health, dev] = await Promise.all([
|
|
1111
|
+
;[app, version, health, dev, deploys] = await Promise.all([
|
|
820
1112
|
appChecks(siteDir, siteYml, { offline, args, git }),
|
|
821
1113
|
versionCheck(siteDir, { offline }),
|
|
822
1114
|
doctorCheck(siteDir),
|
|
823
|
-
devCheck(siteDir, { offline })
|
|
1115
|
+
devCheck(siteDir, { offline }),
|
|
1116
|
+
deployChecks(siteDir)
|
|
824
1117
|
])
|
|
1118
|
+
services = await servicesCheck({ siteDir, siteYml, site: app.site })
|
|
825
1119
|
} finally {
|
|
826
1120
|
Object.assign(console, saved)
|
|
827
1121
|
}
|
|
828
|
-
const checks = [...git.checks, ...app.checks, ...version.checks, ...health.checks, dev]
|
|
829
|
-
const next = planNext([...git.proposals, ...app.proposals, ...version.proposals, ...health.proposals])
|
|
1122
|
+
const checks = [...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
|
|
1123
|
+
const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
|
|
830
1124
|
|
|
831
1125
|
const root = findWorkspaceRoot(siteDir) || siteDir
|
|
832
1126
|
const site = relative(root, siteDir) || '.'
|
|
1127
|
+
// What the site is, before what to do about it: the backends it is synced with — every one
|
|
1128
|
+
// this copy holds a site on, custom servers included — and where it is live.
|
|
1129
|
+
const synced = listSyncedBackends(siteDir).filter((o) => readBackendState(siteDir, o).site?.uuid)
|
|
1130
|
+
const summary = summaryLine({
|
|
1131
|
+
name: typeof siteYml.name === 'string' && siteYml.name ? siteYml.name : site === '.' ? 'This site' : site,
|
|
1132
|
+
synced,
|
|
1133
|
+
live: checks.find((k) => k.id === 'app.live')?.detail ?? null,
|
|
1134
|
+
targets: deploys.targets,
|
|
1135
|
+
origin: app.backend.origin
|
|
1136
|
+
})
|
|
833
1137
|
if (json) {
|
|
834
1138
|
console.log(
|
|
835
1139
|
JSON.stringify({
|
|
836
1140
|
schema: STATUS_SCHEMA,
|
|
837
1141
|
uniweb: getCliVersion(),
|
|
1142
|
+
summary,
|
|
838
1143
|
site,
|
|
839
1144
|
backend: app.backend.origin,
|
|
840
1145
|
workspace: app.backend.workspace,
|
|
1146
|
+
synced,
|
|
841
1147
|
offline,
|
|
842
1148
|
checks: checks.map(({ quiet, id, state, summary, blocks = [], ...rest }) => ({ id, state, summary, blocks, ...rest })),
|
|
843
1149
|
next
|
|
@@ -845,7 +1151,7 @@ export async function status(args = []) {
|
|
|
845
1151
|
)
|
|
846
1152
|
return { exitCode: 0 }
|
|
847
1153
|
}
|
|
848
|
-
console.log(render({
|
|
1154
|
+
console.log(render({ summary, backend: app.backend, checks, next }))
|
|
849
1155
|
return { exitCode: 0 }
|
|
850
1156
|
}
|
|
851
1157
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-26T17:19:54.265Z",
|
|
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.3"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
12
|
"version": "1.5.7"
|
package/src/index.js
CHANGED
|
@@ -1777,12 +1777,16 @@ ${colors.cyan}${colors.bright}uniweb status${colors.reset} ${colors.dim}— What
|
|
|
1777
1777
|
${colors.bright}Usage:${colors.reset}
|
|
1778
1778
|
uniweb status [options]
|
|
1779
1779
|
|
|
1780
|
-
Run it when you begin,
|
|
1780
|
+
Run it when you begin, when you come back to the project, before you push, publish
|
|
1781
|
+
or deploy, and after a command refuses. It opens with what the site is — synced
|
|
1782
|
+
where, live where, deployed where — and checks:
|
|
1781
1783
|
|
|
1782
1784
|
git conflicts, a merge waiting, the site's files not committed, the git remote
|
|
1783
|
-
app the session, what changed on the backend since this copy last synced
|
|
1784
|
-
|
|
1785
|
-
the foundation
|
|
1785
|
+
app the session, what changed on the backend since this copy last synced —
|
|
1786
|
+
the files a pull would change — what is not pushed, who has the site
|
|
1787
|
+
open in the app, the foundation
|
|
1788
|
+
hosting where the site is live, the other hosts deploy.yml lists (by CI or by
|
|
1789
|
+
hand, and how far behind), and the services the live site has
|
|
1786
1790
|
uniweb whether a newer release is out
|
|
1787
1791
|
health doctor's findings
|
|
1788
1792
|
dev the dev server
|
package/src/utils/status-next.js
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
* then sends the reader back up. So the order is one table, not each check's own idea:
|
|
8
8
|
*
|
|
9
9
|
* sign in → settle conflicts → finish a merge → take what others did (refresh, or pull)
|
|
10
|
-
* → push → push to git → go live → update Uniweb → read doctor's findings
|
|
10
|
+
* → push → push to git → go live → deploy to another host → update Uniweb → read doctor's findings
|
|
11
11
|
*
|
|
12
12
|
* Each check proposes steps by name (`step`); this sorts them, folds the ones another step
|
|
13
13
|
* already does, and drops duplicates.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
const ORDER = ['login', 'conflicts', 'merge', 'refresh', 'pull', 'push', 'gitpush', 'publish', 'update', 'doctor']
|
|
16
|
+
const ORDER = ['login', 'conflicts', 'merge', 'refresh', 'pull', 'push', 'gitpush', 'publish', 'deploy', 'update', 'doctor']
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* @param {Array<{ step: string, command: string|null, why: string, ask?: boolean }>} proposals
|
package/src/utils/sync-point.js
CHANGED
|
@@ -382,7 +382,7 @@ function differsIn(repo, a, b, roots) {
|
|
|
382
382
|
* @param {string[]} opts.roots - the site's content roots, relative to the site
|
|
383
383
|
* @param {string|null} opts.rev - the commit whose content the roots are filled from;
|
|
384
384
|
* null for none
|
|
385
|
-
* @returns {{ siteDir: string, commit: Function, placeIgnored: Function, close: Function }}
|
|
385
|
+
* @returns {{ siteDir: string, commit: Function, changes: Function, placeIgnored: Function, close: Function }}
|
|
386
386
|
*/
|
|
387
387
|
export function openStandIn(repo, { roots, rev }) {
|
|
388
388
|
for (const r of roots) {
|
|
@@ -543,6 +543,24 @@ export function openStandIn(repo, { roots, rev }) {
|
|
|
543
543
|
return placed
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
/**
|
|
547
|
+
* What is written under the roots that `rev` does not hold so — each `[status, path]`,
|
|
548
|
+
* status `A`, `M` or `D`, the path relative to the site. Asked of the stand-in's own
|
|
549
|
+
* index, which holds `rev`: nothing is staged, committed or written to the repository.
|
|
550
|
+
* What the repository ignores is no change, as for `commit`.
|
|
551
|
+
* @returns {Array<[string, string]>}
|
|
552
|
+
*/
|
|
553
|
+
const changes = () => {
|
|
554
|
+
const roots = full.filter((f) => present.includes(f) || existsSync(join(root, f)))
|
|
555
|
+
if (!roots.length) return []
|
|
556
|
+
const site = (p) => (sitePrefix && p.startsWith(sitePrefix) ? p.slice(sitePrefix.length) : p)
|
|
557
|
+
const out = []
|
|
558
|
+
const tracked = zlist(at(['diff', '--name-status', '-z', '--no-renames', '--', ...roots]))
|
|
559
|
+
for (let i = 0; i + 1 < tracked.length; i += 2) out.push([tracked[i], site(tracked[i + 1])])
|
|
560
|
+
for (const p of zlist(at(['ls-files', '--others', '--exclude-standard', '-z', '--', ...roots]))) out.push(['A', site(p)])
|
|
561
|
+
return out.sort((a, b) => (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
|
|
562
|
+
}
|
|
563
|
+
|
|
546
564
|
// The roots git can hold here. ⛔ One the repository ignores whole is not named to
|
|
547
565
|
// `git add`, which refuses an ignored path; what is written under it is placed instead.
|
|
548
566
|
const held = (paths) => {
|
|
@@ -556,7 +574,7 @@ export function openStandIn(repo, { roots, rev }) {
|
|
|
556
574
|
return paths.filter((p) => !ignored.includes(p))
|
|
557
575
|
}
|
|
558
576
|
|
|
559
|
-
return { siteDir, commit, placeIgnored, close }
|
|
577
|
+
return { siteDir, commit, changes, placeIgnored, close }
|
|
560
578
|
} catch (err) {
|
|
561
579
|
close()
|
|
562
580
|
throw err
|