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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "1.21.0",
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.2",
51
- "@uniweb/content-reader": "~1.5.7",
52
- "@uniweb/semantic-parser": "~1.13.0"
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": {
@@ -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; whether the site is live; 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.
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: (m) => say.warn(m) }) || []
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
- say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
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
- say.warn(
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
  /**
@@ -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
@@ -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
- const log = (...a) => console.log(...a)
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
- const fetchAssets = shouldFetchAssets(args, siteDir)
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
- if (repo && !dryRun) {
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
- const readModel = makeModelResolver({ client, siteDir })
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.
@@ -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 hash
20
- * (`backend/app-changes.js`). Its one write is the user-level npm cache `update` and
21
- * `--version` keep. A check it could not make is `unknown`, never `ok`.
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 { readBackendState } from '@uniweb/build/uwx'
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 ? readAppChanges({ client, siteDir, siteUuid: uuid }).catch((err) => ({ error: err })) : null,
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({ id: 'app.live', state: 'attention', summary: 'In the app, not live yet.' })
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: 'Live, with changes in the app that are not live yet.' })
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: 'Live, with the latest content.' })
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({ siteLabel, backend, checks, next }) {
1035
+ function render({ summary, backend, checks, next }) {
744
1036
  const out = ['']
745
- out.push(`${c.bold}Status of ${siteLabel}${c.reset}`)
1037
+ out.push(`${c.bold}${summary}${c.reset}`)
746
1038
  for (const [prefix, title] of GROUPS) {
747
- const group = checks.filter((k) => k.id.split('.')[0] === prefix && !(k.quiet && k.state === 'ok'))
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({ siteLabel: site === '.' ? 'this site' : `${site}/`, backend: app.backend, checks, next }))
1154
+ console.log(render({ summary, backend: app.backend, checks, next }))
849
1155
  return { exitCode: 0 }
850
1156
  }
851
1157
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-26T02:00:48.151Z",
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.2"
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, before you push, and after a command refuses. It checks:
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
- what is not pushed, whether the site is live, who has it open in the app,
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
@@ -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
@@ -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