uniweb 1.20.4 → 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.
@@ -1,81 +1,101 @@
1
1
  /**
2
- * uniweb status — show how a site's local files compare to the Uniweb backend:
3
- * its sync identity, unpushed content changes, and the foundation it references.
2
+ * uniweb status — what to know before working on a site, and what to run about it.
4
3
  *
5
- * LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
6
- * resolver and diffs them against the send-only-changed cache (the same diff
7
- * `uniweb push` runs) — no auth, no backend round-trip.
4
+ * ⭐ THE ONE QUESTION AN AGENT ASKS IN A PROJECT OTHERS EDIT. Authors change a synced site in
5
+ * the app at any time, teammates push to git, and Uniweb releases often — and nothing else
6
+ * in the CLI says so before a command is refused. So status checks each of them, and ends
7
+ * with the steps to take, in an order that works when followed from the top
8
+ * (`utils/status-next.js`):
8
9
  *
9
- * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
- * - whether the synced draft differs from what's live (publish needed),
11
- * - whether a newer foundation version is registered than the site pins, and
12
- * - who has the site open in the app right now, and on what (`describeEditors`).
13
- * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
14
- * the backend exposes them, `--remote` degrades silently to the local view.
10
+ * git conflicts, a merge waiting, the site's files not committed, the git remote
11
+ * app the session, the site, what changed on the backend since this copy last
12
+ * synced, what is not pushed, whether the draft is live, who has the site open,
13
+ * the foundation
14
+ * uniweb a newer release
15
+ * health doctor's findings
16
+ * dev the dev server
17
+ *
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, 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`.
24
+ *
25
+ * ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
26
+ * from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
27
+ * so the two cannot drift.
28
+ *
29
+ * ⭐ AN UPDATE IS NOT AN ALARM [Diego, 2026-10-26]: "most 'breaking' changes don't break a
30
+ * project, so we don't want to worry agents unnecessarily." A newer release is named with
31
+ * the one way to take it, the same for every release; none is called breaking.
15
32
  *
16
33
  * Usage:
17
- * uniweb status Sync identity + unpushed content + foundation ref (local)
18
- * uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
19
- * a newer-registered-foundation check, and who has it open in
20
- * the app
21
- * uniweb status --json One JSON line (adds a `remote` object under --remote, whose
22
- * `site_state` is live / gone / unknown — `gone` only on the
23
- * backend's own word that it holds no such site)
34
+ * uniweb status every check
35
+ * uniweb status --offline only what this machine knows — no request at all
36
+ * uniweb status --json one JSON document (`schema: 1`)
37
+ * --org @x / --personal the workspace to ask the backend in, for this command only
24
38
  *
25
- * Run from a site, or a workspace with one site.
39
+ * ⛔ Until 2026-10-26 a bare status was offline, `--remote` asked the backend, and `--json`
40
+ * was `{ synced, uuid, foundation, changed, unchanged, remote }`. `--remote` is refused now
41
+ * (`utils/flag-guard.js`).
26
42
  */
27
43
 
44
+ import { execFileSync } from 'node:child_process'
28
45
  import { existsSync, readFileSync } from 'node:fs'
29
- import { join } from 'node:path'
46
+ import { join, relative, resolve } from 'node:path'
30
47
  import yaml from 'js-yaml'
31
48
 
32
- import { resolveSiteDir } from './deploy.js'
33
- import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
34
- import { resolveWorkspace } from '../backend/workspace.js'
35
49
  import {
36
- BackendClient,
37
- resolveBackendOrigin,
38
- WorkspaceMismatchError
39
- } from '../backend/client.js'
40
- import { readBackendState } from '@uniweb/build/uwx'
41
- import {
42
- resolveLocalFoundation,
43
- foundationScopedName
44
- } from '../backend/foundation-bring-along.js'
50
+ probeUnpushed,
51
+ readItemUuids,
52
+ readSiteWorkspace,
53
+ sentRoots,
54
+ uncommittedContent,
55
+ unitFilesOf,
56
+ unsettledFiles,
57
+ untakenBase
58
+ } from '../backend/site-sync.js'
59
+ import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
60
+ import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
61
+ import { readAppChanges } from '../backend/app-changes.js'
62
+ import { probePull } from './pull.js'
63
+ import { readOrgFlag } from '../utils/args.js'
64
+ import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
65
+ import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
45
66
  import { computeFoundationDigest } from '../utils/code-upload.js'
46
67
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
47
68
  import { checkFlags } from '../utils/flag-guard.js'
69
+ import { isExpired, readRegistryAuth } from '../utils/registry-auth.js'
70
+ import {
71
+ filesDifferFrom,
72
+ ignoringRepo,
73
+ isAncestor,
74
+ mergeInProgress,
75
+ readSyncPoint,
76
+ repoOf,
77
+ unmergedPaths
78
+ } from '../utils/sync-point.js'
79
+ import { aheadBehind, remoteMoved, upstreamOf } from '../utils/git.js'
80
+ import { getLatestVersion, isBehind } from '../utils/update-check.js'
81
+ import { syncedElsewhere } from '../utils/site-identity.js'
82
+ import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
83
+ import { classifyPackage, findSites, findWorkspaceRoot } from '../utils/workspace.js'
84
+ import { getCliVersion } from '../versions.js'
85
+ import { planNext } from '../utils/status-next.js'
48
86
 
49
87
  const c = {
50
88
  reset: '\x1b[0m',
51
89
  bold: '\x1b[1m',
52
90
  dim: '\x1b[2m',
91
+ red: '\x1b[31m',
53
92
  cyan: '\x1b[36m',
54
93
  green: '\x1b[32m',
55
94
  yellow: '\x1b[33m'
56
95
  }
57
- const say = {
58
- ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
59
- info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
60
- warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
61
- dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
62
- }
63
96
 
64
- function readSiteYml(siteDir) {
65
- const p = join(siteDir, 'site.yml')
66
- if (!existsSync(p)) return {}
67
- try {
68
- return yaml.load(readFileSync(p, 'utf8')) || {}
69
- } catch {
70
- return {}
71
- }
72
- }
73
-
74
- function foundationRef(siteYml) {
75
- const f = siteYml.foundation
76
- if (!f) return null
77
- return typeof f === 'string' ? f : f.ref || null
78
- }
97
+ /** What the document's version says: a reader keyed on it never reads another shape. */
98
+ export const STATUS_SCHEMA = 1
79
99
 
80
100
  // What an item that this copy has not synced is called — by its Section on the site.
81
101
  const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
@@ -150,244 +170,988 @@ export function fileByItemUuid(siteDir, backend) {
150
170
  }
151
171
  }
152
172
 
153
- // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
154
- // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
173
+ // ─── reading the project ────────────────────────────────────────────────────
174
+
175
+ function readSiteYml(siteDir) {
176
+ const p = join(siteDir, 'site.yml')
177
+ if (!existsSync(p)) return {}
178
+ try {
179
+ return yaml.load(readFileSync(p, 'utf8')) || {}
180
+ } catch {
181
+ return {}
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The site this status is about — never asked for: a status prompts for nothing. A site
187
+ * folder is its own; a workspace with one site, that one; with several, none, and the
188
+ * caller names them.
189
+ */
190
+ async function findSite(cwd) {
191
+ if ((await classifyPackage(cwd)) === 'site') return { siteDir: cwd }
192
+ const root = findWorkspaceRoot(cwd)
193
+ if (!root) return { none: true }
194
+ const sites = await findSites(root)
195
+ if (sites.length === 1) return { siteDir: resolve(root, sites[0]) }
196
+ return sites.length ? { several: sites, root } : { none: true, root }
197
+ }
198
+
199
+ /**
200
+ * The `uniweb` this project runs: the copy installed in it, else the version its
201
+ * `package.json` names, else this CLI's own.
202
+ */
203
+ function projectUniweb(siteDir) {
204
+ const root = findWorkspaceRoot(siteDir) || siteDir
205
+ try {
206
+ const v = JSON.parse(readFileSync(join(root, 'node_modules', 'uniweb', 'package.json'), 'utf8')).version
207
+ if (typeof v === 'string') return v
208
+ } catch {
209
+ // not installed
210
+ }
211
+ try {
212
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
213
+ const spec = pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
214
+ const m = typeof spec === 'string' ? spec.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/) : null
215
+ if (m) return m[0]
216
+ } catch {
217
+ // no package.json
218
+ }
219
+ return getCliVersion()
220
+ }
221
+
222
+ function foundationRef(siteYml) {
223
+ const f = siteYml.foundation
224
+ if (!f) return null
225
+ return typeof f === 'string' ? f : f.ref || null
226
+ }
227
+
228
+ // A versioned registry ref `@org/name@1.2.3` → its scoped name and pinned version.
155
229
  function splitFoundationRef(fnd) {
156
230
  if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
157
231
  const at = fnd.lastIndexOf('@')
158
- return at > 0
159
- ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) }
160
- : { scope: null, version: null }
232
+ return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
161
233
  }
162
234
 
163
- export async function status(args = []) {
164
- // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
165
- // Straight to stderr: this is a usage error, so it must not be mistaken for the
166
- // status document even under --json.
167
- const badFlag = checkFlags('status', args)
168
- if (badFlag) {
169
- console.error(`\x1b[31m✗\x1b[0m ${badFlag.message}`)
170
- return { exitCode: 2 }
235
+ const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
236
+
237
+ // ─── the checks ─────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Git: conflicts, a merge waiting, what is not committed, and the git remote.
241
+ * @returns {Promise<{ checks: object[], proposals: object[], localFiles: string[]|null }>}
242
+ */
243
+ async function gitChecks(siteDir, { offline, origin }) {
244
+ const checks = []
245
+ const proposals = []
246
+ const repo = repoOf(siteDir)
247
+ if (!repo) {
248
+ const ignored = Boolean(ignoringRepo(siteDir))
249
+ checks.push({
250
+ id: 'git.repo',
251
+ state: 'skipped',
252
+ summary: ignored
253
+ ? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
254
+ : 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
255
+ })
256
+ return { checks, proposals, localFiles: null, repo: false }
171
257
  }
172
- const jsonMode = args.includes('--json')
173
- const remote = args.includes('--remote')
174
- const siteDir = await resolveSiteDir(args, 'status')
175
- const siteYml = readSiteYml(siteDir)
176
- const fnd = foundationRef(siteYml)
177
- const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
258
+ checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
178
259
 
179
- // Local content diff — builds the sync packages, never authenticates.
180
- //
181
- // ⭐ The ORIGIN is resolved here rather than taken from the client below, because
182
- // this probe is deliberately offline and the client may never be built. It decides
183
- // whose asset ids the comparison reads: against the wrong backend every media ref
184
- // reads as changed.
185
- const probeBackend = resolveBackendOrigin()
186
- // ⭐ Identity is this backend's, from sync.json. It read `site.yml::$uuid`, so after
187
- // step 4 moved the key every project reported itself as never synced.
188
- const uuid = readBackendState(siteDir, probeBackend).site?.uuid || null
189
- let probe = null
190
- let probeErr = null
191
- try {
192
- probe = await probeUnpushed(siteDir, { backend: probeBackend })
193
- } catch (err) {
194
- probeErr = err.message
195
- }
196
-
197
- // Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
198
- // on 404 / any failure, so a backend without the endpoints just shows local.
199
- let site = null
200
- // Whether the backend holds the site this binding names — live / gone / unknown, never
201
- // folded (`client.siteState`). Null when there is no binding to ask about.
202
- let siteState = null
203
- let fdnLatest = null
204
- let foundationFresh = null // true/false when both digests are known; else null
205
- let localFoundationVersion = null
206
- let remoteError = null // a refusal about the workspace — the one remote failure worth saying
207
- if (remote) {
208
- try {
209
- const client = new BackendClient({
210
- args,
211
- command: 'Status'
212
- })
213
- // The workspace this status works in — the login's, unless this command names
214
- // another (`workspace.js`). None chosen is reported, like a site outside it.
215
- const ws = await resolveWorkspace({ client, args })
216
- if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
217
- client.setWorkspace(ws.workspace, { source: ws.source })
218
- if (uuid) {
219
- siteState = await client.siteState(uuid)
220
- if (siteState.state === 'live') {
221
- site = siteState.site || null
222
- // In another workspace: it exists, and the refusal says where — report it.
223
- if (siteState.status === 409) remoteError = siteState.detail
224
- } else if (siteState.refused) {
225
- // The workspace this status works in, refused: say so — it is not a fact about the site.
226
- remoteError = siteState.detail
260
+ if (unsettledFiles(siteDir)) {
261
+ const files = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
262
+ checks.push({
263
+ id: 'git.conflicts',
264
+ state: 'blocking',
265
+ summary: `${count(files.length, 'file')} here ${files.length === 1 ? 'is' : 'are'} in conflict.`,
266
+ files,
267
+ blocks: ['push', 'publish', 'pull', 'refresh']
268
+ })
269
+ proposals.push({ step: 'conflicts', command: null, why: `settle the conflict markers in ${count(files.length, 'file')}, then \`git add\` each` })
270
+ } else {
271
+ checks.push({ id: 'git.conflicts', state: 'ok', summary: 'Nothing in conflict.', quiet: true })
272
+ }
273
+
274
+ const merging = mergeInProgress(repo)
275
+ checks.push(
276
+ merging
277
+ ? { id: 'git.merge', state: 'blocking', summary: 'A merge is waiting for its commit.', blocks: ['push', 'publish', 'pull', 'refresh'] }
278
+ : { id: 'git.merge', state: 'ok', summary: 'No merge in progress.', quiet: true }
279
+ )
280
+ if (merging) proposals.push({ step: 'merge', command: 'git commit', why: 'finish the merge in progress' })
281
+
282
+ const uncommitted = merging ? null : uncommittedContent(siteDir, { verb: 'push' })
283
+ const n = uncommitted?.paths.length || 0
284
+ checks.push(
285
+ uncommitted
286
+ ? {
287
+ id: 'git.uncommitted',
288
+ state: 'attention',
289
+ summary: repo.head
290
+ ? `${n} of the site's files ${n === 1 ? 'is' : 'are'} not committed — in git a push sends what is committed.`
291
+ : 'This repository has no commit yet — in git a push sends what is committed.',
292
+ files: uncommitted.paths,
293
+ blocks: ['push', 'publish']
227
294
  }
295
+ : { id: 'git.uncommitted', state: 'ok', summary: "The site's files are committed." }
296
+ )
297
+
298
+ // What this copy changed since its last sync, file by file — while its branch holds the
299
+ // sync point that sync recorded.
300
+ let localFiles = null
301
+ const point = readSyncPoint(siteDir, origin)
302
+ if (repo.head && point && isAncestor(repo, point)) localFiles = filesDifferFrom(repo, point, sentRoots(siteDir))
303
+
304
+ const upstream = upstreamOf(siteDir)
305
+ if (!upstream) {
306
+ checks.push({ id: 'git.remote', state: 'skipped', summary: 'No upstream branch to compare with.' })
307
+ } else {
308
+ const counts = aheadBehind(siteDir) || { ahead: 0, behind: 0 }
309
+ let moved = null
310
+ let asked = false
311
+ if (offline) {
312
+ moved = counts.behind > 0
313
+ } else {
314
+ const answer = await remoteMoved(siteDir)
315
+ if (answer) {
316
+ moved = answer.moved
317
+ asked = true
228
318
  }
229
- // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
230
- // local-foundation site can be checked too); fall back to a scoped
231
- // site.yml ref. The digest compare is read-only — it never builds, so it
232
- // only fires when the local foundation is already built (dist present).
233
- const local = resolveLocalFoundation(siteDir, siteYml)
234
- localFoundationVersion = local?.version ?? null
235
- const lookupName =
236
- (local && (await foundationScopedName(local.dir))) || fndScope
237
- if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
238
- if (local?.dir && fdnLatest?.digest) {
239
- const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
240
- if (localDigest) foundationFresh = localDigest === fdnLatest.digest
241
- }
319
+ }
320
+ const parts = []
321
+ if (moved) parts.push(`${upstream.remote} has commits this branch does not${asked ? '' : ' (as of the last fetch)'}.`)
322
+ if (counts.ahead) parts.push(`${count(counts.ahead, 'commit')} here ${counts.ahead === 1 ? 'is' : 'are'} not on ${upstream.tracking}.`)
323
+ if (moved === null) {
324
+ checks.push({
325
+ id: 'git.remote',
326
+ state: 'unknown',
327
+ summary: `Could not ask ${upstream.remote} for new commits — no network, or it needs a sign-in.`,
328
+ lines: parts,
329
+ detail: { ...counts, upstream: upstream.tracking }
330
+ })
331
+ } else {
332
+ checks.push({
333
+ id: 'git.remote',
334
+ state: parts.length ? 'attention' : 'ok',
335
+ summary: parts.length ? parts.join(' ') : `Up to date with ${upstream.tracking}${asked ? '' : ' (as of the last fetch)'}.`,
336
+ detail: { ...counts, upstream: upstream.tracking }
337
+ })
338
+ }
339
+ if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
340
+ if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
341
+ }
342
+ return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging, repo: Boolean(repo.head) }
343
+ }
344
+
345
+ /**
346
+ * The session for `origin`, read and never asked for: UNIWEB_TOKEN, else the stored one.
347
+ * @returns {Promise<{ token: string, handle: string|null } | { expired: true } | null>}
348
+ */
349
+ async function sessionFor(origin) {
350
+ if (process.env.UNIWEB_TOKEN) return { token: process.env.UNIWEB_TOKEN, handle: null }
351
+ const stored = await readRegistryAuth(origin)
352
+ if (!stored?.token) return null
353
+ if (isExpired(stored)) return { expired: true }
354
+ return { token: stored.token, handle: stored.handle || stored.username || null }
355
+ }
356
+
357
+ /**
358
+ * The sign-in an agent runs: on this backend — a bare login goes to the default one — and in
359
+ * the workspace the site was created in, when this copy knows it.
360
+ */
361
+ function loginCommand(siteDir, origin, uuid) {
362
+ const server = origin === DEFAULT_BACKEND_ORIGIN ? '' : ` --server ${origin}`
363
+ if (!uuid) return `npx uniweb login --browser${server}`
364
+ const ws = readSiteWorkspace(siteDir, origin)
365
+ return `npx uniweb login --browser${server} ${ws ? `--org ${ws}` : '--personal'}`
366
+ }
367
+
368
+ /** Unit paths → the files the author keeps them in, as a pull writes them back. */
369
+ function filesOfUnits(siteDir, units) {
370
+ return units.map((u) => (u === 'site.yml' ? 'site.yml' : unitFilesOf(siteDir, u)[0] ?? u))
371
+ }
372
+
373
+ /**
374
+ * The app: what the backend holds of this site, against this copy.
375
+ * @returns {Promise<{ checks: object[], proposals: object[], backend: object }>}
376
+ */
377
+ async function appChecks(siteDir, siteYml, { offline, args, git }) {
378
+ const checks = []
379
+ const proposals = []
380
+ const origin = resolveBackendOrigin()
381
+ const uuid = readBackendState(siteDir, origin).site?.uuid || null
382
+ const backend = { origin, workspace: null }
383
+
384
+ // ── What this machine knows ──
385
+ const base = git.merging ? null : untakenBase(siteDir, origin)
386
+ if (base) {
387
+ checks.push({
388
+ id: 'app.base',
389
+ state: 'blocking',
390
+ summary: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
391
+ blocks: ['push', 'publish']
392
+ })
393
+ proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
394
+ }
395
+
396
+ // What this copy holds that the backend lacks: by file, from the sync point, in git; else
397
+ // the send-only-changed probe a push runs, which counts entities.
398
+ let unpushedFiles = null
399
+ let unpushed = false
400
+ if (!uuid) {
401
+ const elsewhere = process.env.UNIWEB_SERVER ? null : syncedElsewhere(siteDir, origin)
402
+ checks.push(
403
+ elsewhere
404
+ ? {
405
+ id: 'app.site',
406
+ state: 'attention',
407
+ summary: `This project's site is on ${elsewhere.join(', ')}, not on ${origin}, where you are signed in.`,
408
+ lines: [`To work with it there: npx uniweb login --server ${elsewhere[0]}`]
409
+ }
410
+ : { id: 'app.unpushed', state: 'skipped', summary: `Not in the app on ${origin} yet — \`npx uniweb push\` puts it there.` }
411
+ )
412
+ } else if (git.localFiles) {
413
+ unpushedFiles = git.localFiles
414
+ unpushed = unpushedFiles.length > 0
415
+ checks.push(
416
+ unpushed
417
+ ? {
418
+ id: 'app.unpushed',
419
+ state: 'attention',
420
+ summary: `${count(unpushedFiles.length, 'file')} here ${unpushedFiles.length === 1 ? 'holds' : 'hold'} changes the backend does not have yet.`,
421
+ files: unpushedFiles
422
+ }
423
+ : { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
424
+ )
425
+ } else {
426
+ try {
427
+ const probe = await probeUnpushed(siteDir, { backend: origin })
428
+ unpushed = probe.changed > 0
429
+ checks.push({
430
+ id: 'app.unpushed',
431
+ state: unpushed ? 'attention' : 'ok',
432
+ summary: unpushed ? 'This copy holds changes the backend does not have yet.' : 'Nothing waiting to be pushed.',
433
+ detail: { entities: probe.changed, unchanged: probe.unchanged }
434
+ })
242
435
  } catch (err) {
243
- // Degrade silently — except a refusal about the workspace: `--org` named one the
244
- // backend does not work on this site from, or the deployment has another. Saying
245
- // nothing would read as "fine".
246
- if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
247
- // Not asked, or not answered: that is not knowing, never `gone`.
248
- if (uuid && !siteState) siteState = { state: 'unknown', status: null, detail: err?.message || null }
436
+ checks.push({ id: 'app.unpushed', state: 'unknown', summary: `Could not tell what is not pushed: ${err.message}` })
249
437
  }
250
438
  }
439
+ if (uuid && (unpushed || git.uncommitted)) {
440
+ proposals.push({
441
+ step: 'push',
442
+ command: git.uncommitted ? 'npx uniweb push --commit -m "<what you changed>"' : 'npx uniweb push',
443
+ why: git.uncommitted ? 'commit your changes and send them to the app' : 'send your changes to the app'
444
+ })
445
+ }
251
446
 
252
- if (jsonMode) {
253
- console.log(
254
- JSON.stringify({
255
- synced: Boolean(uuid),
256
- uuid,
257
- foundation: fnd,
258
- changed: probe ? probe.changed : null,
259
- unchanged: probe ? probe.unchanged : null,
260
- ...(probeErr ? { error: probeErr } : {}),
261
- ...(remote
262
- ? {
263
- remote: {
264
- // ⭐ live · gone · unknown — `gone` only on the backend's own word about
265
- // this site; null when this directory names no site on this backend.
266
- site_state: siteState ? siteState.state : null,
267
- site_state_detail: siteState?.detail ?? null,
268
- site,
269
- foundation_latest: fdnLatest?.latest_version ?? null,
270
- foundation_fresh: foundationFresh,
271
- ...(remoteError ? { error: remoteError } : {})
272
- }
273
- }
274
- : {})
447
+ if (offline) {
448
+ checks.push({ id: 'app.session', state: 'skipped', summary: 'The backend was not asked (--offline).' })
449
+ return { checks, proposals, backend }
450
+ }
451
+
452
+ // ── What the backend says ──
453
+ const session = await sessionFor(origin)
454
+ if (!session || session.expired) {
455
+ if (!uuid) {
456
+ checks.push({ id: 'app.session', state: 'skipped', summary: `Not signed in to ${origin} — needed to bring the site into the app.` })
457
+ } else {
458
+ checks.push({
459
+ id: 'app.session',
460
+ state: 'unknown',
461
+ summary: session?.expired
462
+ ? `The session on ${origin} has expired, so nothing there was checked.`
463
+ : `Not signed in to ${origin}, so nothing there was checked.`
275
464
  })
276
- )
277
- return { exitCode: 0 }
465
+ proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: `sign in, so the backend can be checked` })
466
+ }
467
+ return { checks, proposals, backend }
278
468
  }
279
469
 
280
- console.log('')
470
+ const client = new BackendClient({ token: session.token, args, command: 'Status' })
471
+ const ws = await resolveWorkspace({ client, args }).catch((err) => ({ refused: true, reason: err.message }))
472
+ if (ws.refused) {
473
+ checks.push({
474
+ id: 'app.session',
475
+ state: 'blocking',
476
+ summary: `Signed in to ${origin}, with no workspace chosen.`,
477
+ lines: String(ws.reason || '').split('\n').map((l) => l.trim()).filter(Boolean),
478
+ blocks: ['push', 'publish', 'pull']
479
+ })
480
+ proposals.push({
481
+ step: 'login',
482
+ command: uuid && readSiteWorkspace(siteDir, origin) ? `npx uniweb login --org ${readSiteWorkspace(siteDir, origin)}` : null,
483
+ why: 'choose the workspace to work in — the person decides which',
484
+ ask: true
485
+ })
486
+ return { checks, proposals, backend }
487
+ }
488
+ client.setWorkspace(ws.workspace, { source: ws.source })
489
+ backend.workspace = ws.workspace
490
+ checks.push({
491
+ id: 'app.session',
492
+ state: 'ok',
493
+ summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
494
+ })
281
495
 
282
- // Sync identity
283
- if (uuid) {
284
- say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
285
- } else {
286
- say.warn('Not synced — this site has never been pushed to a backend.')
287
- say.dim(
288
- 'Run `uniweb push` to create it, or `uniweb publish` to sync and go live in one step.'
289
- )
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 })
290
510
  }
511
+ const [state, changes, foundation] = await Promise.all([
512
+ uuid ? client.siteState(uuid) : null,
513
+ uuid ? readChanges().catch((err) => ({ error: err })) : null,
514
+ foundationCheck(siteDir, siteYml, client).catch(() => null)
515
+ ])
291
516
 
292
- // Content
293
- if (probeErr) {
294
- say.warn(`Couldn't compute content changes: ${probeErr}`)
295
- say.dim(
296
- 'A build error or an unresolved data Model can block the offline diff.'
297
- )
298
- } else if (!uuid) {
299
- const n = probe.changed
300
- say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
301
- } else if (probe.changed === 0) {
302
- say.ok('Content is in sync with the last push.')
303
- } else {
304
- const n = probe.changed
305
- say.info(
306
- `${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
307
- (probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
308
- '.'
309
- )
310
- say.dim(
311
- 'Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).'
312
- )
517
+ if (state) {
518
+ // ⭐ `site_state` is the backend's word on this site — `live`, `gone` or `unknown` —
519
+ // for a script that decides on it: `gone` only when the backend says it holds no such
520
+ // site, never for a failure to reach it (`client.siteState`).
521
+ const detail = { site_state: state.state }
522
+ if (state.status === 401) {
523
+ checks.push({ id: 'app.site', state: 'unknown', summary: `${origin} did not accept the session — sign in again.`, detail })
524
+ proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: 'sign in again' })
525
+ } else if (state.state === 'gone') {
526
+ checks.push({
527
+ id: 'app.site',
528
+ state: 'blocking',
529
+ summary: `${origin} has no site ${uuid} — it was deleted there, or the backend was rebuilt.`,
530
+ lines: [`To push this as a new site: npx uniweb forget --server ${origin}, then npx uniweb push.`],
531
+ blocks: ['push', 'publish', 'pull'],
532
+ detail
533
+ })
534
+ } else if (state.state === 'live' && state.status === 409) {
535
+ checks.push({
536
+ id: 'app.site',
537
+ state: 'blocking',
538
+ summary: 'The site is kept in another workspace.',
539
+ lines: state.detail ? [state.detail] : [],
540
+ blocks: ['push', 'publish', 'pull'],
541
+ detail: { ...detail, workspace: state.workspace ?? null }
542
+ })
543
+ if (state.workspace) proposals.push({ step: 'login', command: `npx uniweb login --org ${state.workspace}`, why: `work in ${state.workspace}, where the site is`, ask: true })
544
+ } else if (state.state === 'live') {
545
+ checks.push({ id: 'app.site', state: 'ok', summary: `The site is in the app on ${origin}.`, quiet: true, detail })
546
+ } else {
547
+ checks.push({ id: 'app.site', state: 'unknown', summary: `Could not tell whether ${origin} holds this site${state.detail ? ` (${state.detail})` : ''}.`, detail })
548
+ }
313
549
  }
314
550
 
315
- // Foundation
316
- if (fnd) say.dim(`Foundation: ${fnd}`)
551
+ if (changes) checks.push(...changeChecks(siteDir, changes, unpushedFiles, proposals))
317
552
 
318
- // Remote signals
319
- if (remote) {
320
- if (remoteError) say.warn(remoteError)
321
- if (siteState?.state === 'gone') {
322
- say.warn(`The backend has no site ${uuid} — it was deleted there, or the backend was rebuilt.`)
323
- say.dim(
324
- `To push this as a new site: uniweb forget --server ${probeBackend}, then uniweb push.`
325
- )
326
- } else if (siteState?.state === 'unknown') {
327
- // A refused workspace is already said above, as itself.
328
- const detail = siteState.refused ? null : siteState.detail
329
- say.dim(`Could not tell whether the backend holds this site${detail ? ` (${detail})` : ''}.`)
553
+ const site = state?.site
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
330
569
  }
331
- if (site) {
332
- if (site.draft_dirty) {
333
- say.info(
334
- 'Synced draft has changes not yet live — run `uniweb publish` to go live.'
335
- )
336
- } else if (site.published) {
337
- say.ok('Live with the latest synced content.')
338
- } else {
339
- say.info(
340
- 'Synced but not published yet — run `uniweb publish` to go live.'
341
- )
342
- }
570
+ if (!site.published) {
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
+ })
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 })
579
+ } else if (site.draft_dirty) {
580
+ checks.push({ id: 'app.live', state: 'attention', summary: `Live on ${cloudName(origin)}${at} — the app holds changes that are not live yet.`, detail })
581
+ proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
582
+ } else {
583
+ checks.push({ id: 'app.live', state: 'ok', summary: `Live on ${cloudName(origin)}${at}, with the latest content.`, detail })
343
584
  }
344
585
  // An array is the backend's answer, `[]` included; no key is a backend that does not
345
586
  // say, and then neither do we.
346
- if (Array.isArray(site?.editors)) {
347
- if (!site.editors.length) {
348
- say.dim('Nobody has this site open in the app.')
349
- } else {
350
- say.info('Open in the app now:')
351
- const fileOf = fileByItemUuid(siteDir, probeBackend)
352
- for (const { person, tabs } of describeEditors(site.editors, { fileOf })) {
353
- console.log(` ${person}${tabs.length ? '' : ', with nothing open'}`)
354
- for (const tab of tabs) console.log(` ${c.dim}${tab}${c.reset}`)
355
- }
587
+ if (Array.isArray(site.editors)) {
588
+ const people = describeEditors(site.editors, { fileOf: fileByItemUuid(siteDir, origin) })
589
+ // One person is named once, in the summary; several, on each of their tabs.
590
+ const who = (p) => (people.length === 1 ? '' : `${p.person}: `)
591
+ checks.push(
592
+ people.length
593
+ ? {
594
+ id: 'app.editors',
595
+ state: 'attention',
596
+ summary: `Open in the app now: ${people.map((p) => p.person).join(', ')}.`,
597
+ lines: people.flatMap((p) => (p.tabs.length ? p.tabs.map((t) => `${who(p)}${t}`) : [`${who(p)}nothing open`])),
598
+ detail: { editors: site.editors }
599
+ }
600
+ : { id: 'app.editors', state: 'ok', summary: 'Nobody has the site open in the app.', detail: { editors: [] } }
601
+ )
602
+ }
603
+ }
604
+ if (foundation) checks.push(foundation)
605
+ return { checks, proposals, backend, site: site ?? null }
606
+ }
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
+
613
+ /** `app.changes`: what moved on the backend, and where it meets what moved here. */
614
+ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
615
+ if (changes.error) {
616
+ return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
617
+ }
618
+ if (changes.probe) return probedChanges(changes.probe, unpushedFiles, proposals)
619
+ const { content, records } = changes
620
+ const unknown = [content, records].filter((l) => l.state === 'unknown')
621
+ const files = [
622
+ ...filesOfUnits(siteDir, [...content.changed, ...content.removed]),
623
+ ...content.added.map((u) => `${u} (new)`)
624
+ ]
625
+ const lines = []
626
+ if (content.outsideUnits) lines.push("the site's settings — its services, languages or queries")
627
+ if (records.changed.length) lines.push(`records changed: ${records.changed.join(', ')}`)
628
+ if (records.added.length) lines.push(`records added: ${records.added.join(', ')}`)
629
+ if (records.removed) lines.push(`${count(records.removed, 'record')} removed`)
630
+ if (records.folder) lines.push('the records\' sub-folders (folder.yml)')
631
+ const moved = files.length + lines.length
632
+ if (!moved) {
633
+ return [
634
+ unknown.length
635
+ ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
636
+ : { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
637
+ ]
638
+ }
639
+ // Where the backend's changes meet this copy's: a push of those is refused until a pull
640
+ // merges them — `diffSiteUnits`' "changed on both sides".
641
+ const overlap = unpushedFiles?.length ? files.filter((f) => unpushedFiles.includes(f)) : []
642
+ const n = files.length + content.outsideUnits + records.changed.length + records.added.length + records.removed + records.folder
643
+ proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'change')} made on the backend` })
644
+ return [
645
+ {
646
+ id: 'app.changes',
647
+ state: overlap.length ? 'blocking' : 'attention',
648
+ summary:
649
+ `${count(n, 'change')} on the backend since this copy last synced — in the app, or pushed from another copy.` +
650
+ (overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
651
+ files,
652
+ lines,
653
+ ...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
654
+ detail: {
655
+ source: 'units',
656
+ content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
657
+ records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
658
+ ...(overlap.length ? { overlap } : {}),
659
+ ...(unknown.length ? { unknown: unknown.map((l) => l.detail) } : {})
660
+ }
661
+ }
662
+ ]
663
+ }
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 } : {})
356
694
  }
357
695
  }
358
- if (
359
- fdnLatest?.latest_version &&
360
- fndVersion &&
361
- fdnLatest.latest_version !== fndVersion
362
- ) {
363
- say.info(
364
- `A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`
696
+ ]
697
+ }
698
+
699
+ /**
700
+ * `app.foundation`: the local foundation against its registered version — what the next
701
+ * push or publish does with it (`backend/foundation-bring-along.js`).
702
+ */
703
+ async function foundationCheck(siteDir, siteYml, client) {
704
+ const fnd = foundationRef(siteYml)
705
+ const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
706
+ const local = resolveLocalFoundation(siteDir, siteYml)
707
+ const name = (local && (await foundationScopedName(local.dir))) || fndScope
708
+ if (!name) return null
709
+ const latest = await client.readFoundationLatest(name)
710
+ if (!latest?.latest_version) return null
711
+ if (local?.dir) {
712
+ const digest = latest.digest ? computeFoundationDigest(join(local.dir, 'dist')) : null
713
+ if (!digest) return null // not built, or the registry keeps no digest: nothing to compare
714
+ if (digest === latest.digest) return { id: 'app.foundation', state: 'ok', summary: 'The foundation matches its registered version.' }
715
+ if (compareSemverPrecedence(local.version, latest.latest_version) === -1) {
716
+ return {
717
+ id: 'app.foundation',
718
+ state: 'blocking',
719
+ summary: `The registry holds foundation ${latest.latest_version}, newer than your ${local.version} — a push or publish stops until you pull that change, or pass --bump to release yours above it.`,
720
+ blocks: ['push', 'publish']
721
+ }
722
+ }
723
+ return { id: 'app.foundation', state: 'attention', summary: "The foundation's code changed since it was registered — the next push or publish releases it as the next version." }
724
+ }
725
+ if (fndVersion && latest.latest_version !== fndVersion) {
726
+ return { id: 'app.foundation', state: 'attention', summary: `A newer version of the foundation (${latest.latest_version}) is registered than the site pins (${fndVersion}).` }
727
+ }
728
+ return { id: 'app.foundation', state: 'ok', summary: `The site pins the latest version of its foundation (${fndVersion}).` }
729
+ }
730
+
731
+ /** `uniweb.version`: a newer Uniweb, named the same way for every release. */
732
+ async function versionCheck(siteDir, { offline }) {
733
+ const runs = projectUniweb(siteDir)
734
+ if (offline) return { checks: [{ id: 'uniweb.version', state: 'skipped', summary: `This project runs Uniweb ${runs}; npm was not asked (--offline).` }], proposals: [] }
735
+ const latest = await getLatestVersion({ allowNetwork: true, timeoutMs: 2000, maxAgeMs: 60 * 60 * 1000 })
736
+ if (!latest) return { checks: [{ id: 'uniweb.version', state: 'unknown', summary: `This project runs Uniweb ${runs}; npm did not answer, so a newer release is not known.` }], proposals: [] }
737
+ if (!isBehind(runs, latest)) {
738
+ return { checks: [{ id: 'uniweb.version', state: 'ok', summary: `Uniweb ${runs}, the latest.`, detail: { runs, latest } }], proposals: [] }
739
+ }
740
+ return {
741
+ checks: [{ id: 'uniweb.version', state: 'attention', summary: `Uniweb ${latest} is out; this project runs ${runs}.`, detail: { runs, latest } }],
742
+ proposals: [{ step: 'update', latest: true, command: 'npx uniweb@latest update', why: 'bring the project and its AGENTS.md up to date — then build, and look at the pages' }]
743
+ }
744
+ }
745
+
746
+ // Doctor's findings `uniweb update` answers: the project's dependencies, or its AGENTS.md,
747
+ // behind the CLI it runs.
748
+ const UPDATE_FINDINGS = new Set(['deps-behind-cli', 'agents-md-missing', 'agents-md-unstamped', 'agents-md-outdated'])
749
+
750
+ /**
751
+ * `health.doctor`: doctor's findings, read without printing them.
752
+ *
753
+ * ⚠️ doctor measures the project against the CLI that RUNS it. Run by a newer CLI than the
754
+ * project's (`npx uniweb@latest status`), its dependency and AGENTS.md findings are against
755
+ * that newer one — which the project's own `uniweb update` does not know — so the step that
756
+ * answers them is the newer CLI's.
757
+ */
758
+ async function doctorCheck(siteDir) {
759
+ try {
760
+ const { doctor } = await import('./doctor.js')
761
+ const res = await doctor([], { quiet: true })
762
+ const open = (res.issues || []).filter((i) => !i.fixed && (i.type === 'error' || i.type === 'warn'))
763
+ if (!open.length) return { checks: [{ id: 'health.doctor', state: 'ok', summary: 'doctor: every check passes.' }], proposals: [] }
764
+ const ids = [...new Set(open.map((i) => i.id).filter(Boolean))]
765
+ const parts = [res.errors && count(res.errors, 'error'), res.warnings && count(res.warnings, 'warning')].filter(Boolean)
766
+ const proposals = []
767
+ if (open.some((i) => UPDATE_FINDINGS.has(i.id))) {
768
+ const newer = isBehind(projectUniweb(siteDir), getCliVersion())
769
+ proposals.push(
770
+ newer
771
+ ? { step: 'update', latest: true, command: 'npx uniweb@latest update', why: 'bring the project and its AGENTS.md up to date — then build, and look at the pages' }
772
+ : { step: 'update', command: 'npx uniweb update', why: "align the dependencies and AGENTS.md with this project's Uniweb" }
365
773
  )
366
774
  }
367
- // What the next push or publish does with it (`backend/foundation-bring-along.js`):
368
- // release changed code — under the next version when its own is taken — unless the
369
- // registry holds a NEWER version, which stops them. ⛔ This pointed at `uniweb
370
- // register` until 2026-09-24 — which cannot release a change under a version
371
- // already registered, since a registered version is immutable.
372
- if (foundationFresh === false) {
373
- if (compareSemverPrecedence(localFoundationVersion, fdnLatest.latest_version) === -1) {
374
- say.info(
375
- `The registry holds foundation ${fdnLatest.latest_version}, newer than your ${localFoundationVersion} — a push or publish stops until you pull that change, or pass \`--bump\` to release yours above it.`
376
- )
377
- } else {
378
- say.info(
379
- 'Local foundation differs from the registered version — the next `uniweb push` or `uniweb publish` releases it.'
380
- )
775
+ const rest = open.filter((i) => !UPDATE_FINDINGS.has(i.id))
776
+ if (rest.length) proposals.push({ step: 'doctor', command: 'npx uniweb doctor', why: `read its ${count(rest.length, 'finding')} — \`--fix\` repairs what it can` })
777
+ return {
778
+ checks: [
779
+ {
780
+ id: 'health.doctor',
781
+ state: 'attention',
782
+ summary: `doctor: ${parts.join(', ')}${ids.length ? ` (${ids.join(', ')})` : ''}.`,
783
+ detail: { errors: res.errors, warnings: res.warnings, issues: open.map(({ id, type, message }) => ({ id: id ?? null, type, message })) }
784
+ }
785
+ ],
786
+ proposals
787
+ }
788
+ } catch (err) {
789
+ return { checks: [{ id: 'health.doctor', state: 'unknown', summary: `doctor did not finish: ${err.message}` }], proposals: [] }
790
+ }
791
+ }
792
+
793
+ /** `dev.server`: the site's dev server, confirmed by asking it. */
794
+ async function devCheck(siteDir, { offline }) {
795
+ if (offline) return { id: 'dev.server', state: 'skipped', summary: 'The dev server was not asked (--offline).' }
796
+ try {
797
+ const { runningDevServer } = await import('@uniweb/build/site')
798
+ const running = await runningDevServer(siteDir, { timeout: 1000 })
799
+ return running
800
+ ? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url } }
801
+ : { id: 'dev.server', state: 'skipped', summary: 'The dev server is not running — `npx uniweb dev` starts it.' }
802
+ } catch (err) {
803
+ return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}` }
804
+ }
805
+ }
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 })
381
904
  }
382
- } else if (foundationFresh === true) {
383
- say.ok('Local foundation matches the registered version.')
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'
384
910
  }
385
- if (!site && !fdnLatest) {
386
- say.dim('(No remote signals — the backend may not expose them yet.)')
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
387
973
  }
388
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
+ }
389
1011
 
390
- console.log('')
1012
+ // ─── the command ────────────────────────────────────────────────────────────
1013
+
1014
+ const GROUPS = [
1015
+ ['git', 'Git'],
1016
+ ['app', 'App'],
1017
+ ['hosting', 'Hosting'],
1018
+ ['uniweb', 'Uniweb'],
1019
+ ['health', 'Health'],
1020
+ ['dev', 'Dev server']
1021
+ ]
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
+
1027
+ const ICON = {
1028
+ ok: `${c.green}✓${c.reset}`,
1029
+ attention: `${c.cyan}→${c.reset}`,
1030
+ blocking: `${c.red}✗${c.reset}`,
1031
+ unknown: `${c.yellow}?${c.reset}`,
1032
+ skipped: `${c.dim}·${c.reset}`
1033
+ }
1034
+
1035
+ function render({ summary, backend, checks, next }) {
1036
+ const out = ['']
1037
+ out.push(`${c.bold}${summary}${c.reset}`)
1038
+ for (const [prefix, title] of GROUPS) {
1039
+ const group = checks.filter((k) => groupOf(k.id) === prefix && !(k.quiet && (k.state === 'ok' || k.state === 'skipped')))
1040
+ if (!group.length) continue
1041
+ out.push('')
1042
+ out.push(prefix === 'app' ? `${c.bold}${title}${c.reset} ${c.dim}— ${backend.origin}${c.reset}` : `${c.bold}${title}${c.reset}`)
1043
+ for (const k of group) {
1044
+ out.push(` ${ICON[k.state]} ${k.state === 'skipped' ? `${c.dim}${k.summary}${c.reset}` : k.summary}`)
1045
+ const files = k.files || []
1046
+ for (const f of files.slice(0, 12)) out.push(` ${c.dim}${f}${c.reset}`)
1047
+ if (files.length > 12) out.push(` ${c.dim}… and ${files.length - 12} more${c.reset}`)
1048
+ for (const l of k.lines || []) out.push(` ${c.dim}${l}${c.reset}`)
1049
+ }
1050
+ }
1051
+ out.push('')
1052
+ if (!next.length) {
1053
+ out.push(`${ICON.ok} ${c.bold}Nothing to do.${c.reset}`)
1054
+ } else {
1055
+ out.push(`${c.bold}Next${c.reset}`)
1056
+ next.forEach((s, i) => {
1057
+ const ask = s.ask ? `${c.yellow}ask the person first —${c.reset} ` : ''
1058
+ if (s.command) {
1059
+ out.push(` ${i + 1}. ${c.cyan}${s.command}${c.reset}`)
1060
+ out.push(` ${ask}${c.dim}${s.why}${c.reset}`)
1061
+ } else {
1062
+ out.push(` ${i + 1}. ${ask}${s.why}`)
1063
+ }
1064
+ })
1065
+ }
1066
+ const unknown = checks.filter((k) => k.state === 'unknown').map((k) => k.id)
1067
+ if (unknown.length) {
1068
+ out.push('')
1069
+ out.push(`${c.dim}Not known: ${unknown.join(', ')} — see above.${c.reset}`)
1070
+ }
1071
+ out.push('')
1072
+ return out.join('\n')
1073
+ }
1074
+
1075
+ export async function status(args = []) {
1076
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
1077
+ // Straight to stderr: this is a usage error, so it must not be mistaken for the
1078
+ // status document even under --json.
1079
+ const badFlag = checkFlags('status', args)
1080
+ if (badFlag) {
1081
+ console.error(`${c.red}✗${c.reset} ${badFlag.message}`)
1082
+ return { exitCode: 2 }
1083
+ }
1084
+ const json = args.includes('--json')
1085
+ const offline = args.includes('--offline')
1086
+
1087
+ const found = await findSite(process.cwd())
1088
+ if (!found.siteDir) {
1089
+ const sites = found.several || []
1090
+ if (json) {
1091
+ console.log(JSON.stringify({ schema: STATUS_SCHEMA, error: sites.length ? 'several-sites' : 'no-site', sites }))
1092
+ } else if (sites.length) {
1093
+ console.error(`${c.red}✗${c.reset} Several sites here — run status from the one you mean:`)
1094
+ for (const s of sites) console.error(` ${c.cyan}cd ${s} && npx uniweb status${c.reset}`)
1095
+ } else {
1096
+ console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
1097
+ }
1098
+ return { exitCode: 2 }
1099
+ }
1100
+ const siteDir = found.siteDir
1101
+ const siteYml = readSiteYml(siteDir)
1102
+ const origin = resolveBackendOrigin()
1103
+
1104
+ // ⭐ Under --json, stdout carries the document and nothing else: whatever a check's
1105
+ // helpers say on the way goes to stderr.
1106
+ const saved = { log: console.log, info: console.info, warn: console.warn }
1107
+ if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
1108
+ let git, app, version, health, dev, deploys, services
1109
+ try {
1110
+ git = await gitChecks(siteDir, { offline, origin })
1111
+ ;[app, version, health, dev, deploys] = await Promise.all([
1112
+ appChecks(siteDir, siteYml, { offline, args, git }),
1113
+ versionCheck(siteDir, { offline }),
1114
+ doctorCheck(siteDir),
1115
+ devCheck(siteDir, { offline }),
1116
+ deployChecks(siteDir)
1117
+ ])
1118
+ services = await servicesCheck({ siteDir, siteYml, site: app.site })
1119
+ } finally {
1120
+ Object.assign(console, saved)
1121
+ }
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])
1124
+
1125
+ const root = findWorkspaceRoot(siteDir) || siteDir
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
+ })
1137
+ if (json) {
1138
+ console.log(
1139
+ JSON.stringify({
1140
+ schema: STATUS_SCHEMA,
1141
+ uniweb: getCliVersion(),
1142
+ summary,
1143
+ site,
1144
+ backend: app.backend.origin,
1145
+ workspace: app.backend.workspace,
1146
+ synced,
1147
+ offline,
1148
+ checks: checks.map(({ quiet, id, state, summary, blocks = [], ...rest }) => ({ id, state, summary, blocks, ...rest })),
1149
+ next
1150
+ })
1151
+ )
1152
+ return { exitCode: 0 }
1153
+ }
1154
+ console.log(render({ summary, backend: app.backend, checks, next }))
391
1155
  return { exitCode: 0 }
392
1156
  }
393
1157