uniweb 1.20.3 → 1.21.0

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,96 @@
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 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`.
22
+ *
23
+ * ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
24
+ * from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
25
+ * so the two cannot drift.
26
+ *
27
+ * ⭐ AN UPDATE IS NOT AN ALARM [Diego, 2026-10-26]: "most 'breaking' changes don't break a
28
+ * project, so we don't want to worry agents unnecessarily." A newer release is named with
29
+ * the one way to take it, the same for every release; none is called breaking.
15
30
  *
16
31
  * 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)
32
+ * uniweb status every check
33
+ * uniweb status --offline only what this machine knows — no request at all
34
+ * uniweb status --json one JSON document (`schema: 1`)
35
+ * --org @x / --personal the workspace to ask the backend in, for this command only
24
36
  *
25
- * Run from a site, or a workspace with one site.
37
+ * ⛔ Until 2026-10-26 a bare status was offline, `--remote` asked the backend, and `--json`
38
+ * was `{ synced, uuid, foundation, changed, unchanged, remote }`. `--remote` is refused now
39
+ * (`utils/flag-guard.js`).
26
40
  */
27
41
 
28
42
  import { existsSync, readFileSync } from 'node:fs'
29
- import { join } from 'node:path'
43
+ import { join, relative, resolve } from 'node:path'
30
44
  import yaml from 'js-yaml'
31
45
 
32
- import { resolveSiteDir } from './deploy.js'
33
- import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
34
- import { resolveWorkspace } from '../backend/workspace.js'
35
46
  import {
36
- BackendClient,
37
- resolveBackendOrigin,
38
- WorkspaceMismatchError
39
- } from '../backend/client.js'
47
+ probeUnpushed,
48
+ readItemUuids,
49
+ readSiteWorkspace,
50
+ sentRoots,
51
+ uncommittedContent,
52
+ unitFilesOf,
53
+ unsettledFiles,
54
+ untakenBase
55
+ } from '../backend/site-sync.js'
56
+ import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
57
+ import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
58
+ import { readAppChanges } from '../backend/app-changes.js'
40
59
  import { readBackendState } from '@uniweb/build/uwx'
41
- import {
42
- resolveLocalFoundation,
43
- foundationScopedName
44
- } from '../backend/foundation-bring-along.js'
60
+ import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
45
61
  import { computeFoundationDigest } from '../utils/code-upload.js'
46
62
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
47
63
  import { checkFlags } from '../utils/flag-guard.js'
64
+ import { isExpired, readRegistryAuth } from '../utils/registry-auth.js'
65
+ import {
66
+ filesDifferFrom,
67
+ ignoringRepo,
68
+ isAncestor,
69
+ mergeInProgress,
70
+ readSyncPoint,
71
+ repoOf,
72
+ unmergedPaths
73
+ } from '../utils/sync-point.js'
74
+ import { aheadBehind, remoteMoved, upstreamOf } from '../utils/git.js'
75
+ import { getLatestVersion, isBehind } from '../utils/update-check.js'
76
+ import { syncedElsewhere } from '../utils/site-identity.js'
77
+ import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
78
+ import { classifyPackage, findSites, findWorkspaceRoot } from '../utils/workspace.js'
79
+ import { getCliVersion } from '../versions.js'
80
+ import { planNext } from '../utils/status-next.js'
48
81
 
49
82
  const c = {
50
83
  reset: '\x1b[0m',
51
84
  bold: '\x1b[1m',
52
85
  dim: '\x1b[2m',
86
+ red: '\x1b[31m',
53
87
  cyan: '\x1b[36m',
54
88
  green: '\x1b[32m',
55
89
  yellow: '\x1b[33m'
56
90
  }
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
-
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
91
 
74
- function foundationRef(siteYml) {
75
- const f = siteYml.foundation
76
- if (!f) return null
77
- return typeof f === 'string' ? f : f.ref || null
78
- }
92
+ /** What the document's version says: a reader keyed on it never reads another shape. */
93
+ export const STATUS_SCHEMA = 1
79
94
 
80
95
  // What an item that this copy has not synced is called — by its Section on the site.
81
96
  const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
@@ -150,244 +165,687 @@ export function fileByItemUuid(siteDir, backend) {
150
165
  }
151
166
  }
152
167
 
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.
168
+ // ─── reading the project ────────────────────────────────────────────────────
169
+
170
+ function readSiteYml(siteDir) {
171
+ const p = join(siteDir, 'site.yml')
172
+ if (!existsSync(p)) return {}
173
+ try {
174
+ return yaml.load(readFileSync(p, 'utf8')) || {}
175
+ } catch {
176
+ return {}
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The site this status is about — never asked for: a status prompts for nothing. A site
182
+ * folder is its own; a workspace with one site, that one; with several, none, and the
183
+ * caller names them.
184
+ */
185
+ async function findSite(cwd) {
186
+ if ((await classifyPackage(cwd)) === 'site') return { siteDir: cwd }
187
+ const root = findWorkspaceRoot(cwd)
188
+ if (!root) return { none: true }
189
+ const sites = await findSites(root)
190
+ if (sites.length === 1) return { siteDir: resolve(root, sites[0]) }
191
+ return sites.length ? { several: sites, root } : { none: true, root }
192
+ }
193
+
194
+ /**
195
+ * The `uniweb` this project runs: the copy installed in it, else the version its
196
+ * `package.json` names, else this CLI's own.
197
+ */
198
+ function projectUniweb(siteDir) {
199
+ const root = findWorkspaceRoot(siteDir) || siteDir
200
+ try {
201
+ const v = JSON.parse(readFileSync(join(root, 'node_modules', 'uniweb', 'package.json'), 'utf8')).version
202
+ if (typeof v === 'string') return v
203
+ } catch {
204
+ // not installed
205
+ }
206
+ try {
207
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
208
+ const spec = pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
209
+ const m = typeof spec === 'string' ? spec.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/) : null
210
+ if (m) return m[0]
211
+ } catch {
212
+ // no package.json
213
+ }
214
+ return getCliVersion()
215
+ }
216
+
217
+ function foundationRef(siteYml) {
218
+ const f = siteYml.foundation
219
+ if (!f) return null
220
+ return typeof f === 'string' ? f : f.ref || null
221
+ }
222
+
223
+ // A versioned registry ref `@org/name@1.2.3` → its scoped name and pinned version.
155
224
  function splitFoundationRef(fnd) {
156
225
  if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
157
226
  const at = fnd.lastIndexOf('@')
158
- return at > 0
159
- ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) }
160
- : { scope: null, version: null }
227
+ return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
161
228
  }
162
229
 
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 }
230
+ const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
231
+
232
+ // ─── the checks ─────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Git: conflicts, a merge waiting, what is not committed, and the git remote.
236
+ * @returns {Promise<{ checks: object[], proposals: object[], localFiles: string[]|null }>}
237
+ */
238
+ async function gitChecks(siteDir, { offline, origin }) {
239
+ const checks = []
240
+ const proposals = []
241
+ const repo = repoOf(siteDir)
242
+ if (!repo) {
243
+ const ignored = Boolean(ignoringRepo(siteDir))
244
+ checks.push({
245
+ id: 'git.repo',
246
+ state: 'skipped',
247
+ summary: ignored
248
+ ? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
249
+ : 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
250
+ })
251
+ return { checks, proposals, localFiles: null }
171
252
  }
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)
253
+ checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
178
254
 
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
255
+ if (unsettledFiles(siteDir)) {
256
+ const files = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
257
+ checks.push({
258
+ id: 'git.conflicts',
259
+ state: 'blocking',
260
+ summary: `${count(files.length, 'file')} here ${files.length === 1 ? 'is' : 'are'} in conflict.`,
261
+ files,
262
+ blocks: ['push', 'publish', 'pull', 'refresh']
263
+ })
264
+ proposals.push({ step: 'conflicts', command: null, why: `settle the conflict markers in ${count(files.length, 'file')}, then \`git add\` each` })
265
+ } else {
266
+ checks.push({ id: 'git.conflicts', state: 'ok', summary: 'Nothing in conflict.', quiet: true })
195
267
  }
196
268
 
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
269
+ const merging = mergeInProgress(repo)
270
+ checks.push(
271
+ merging
272
+ ? { id: 'git.merge', state: 'blocking', summary: 'A merge is waiting for its commit.', blocks: ['push', 'publish', 'pull', 'refresh'] }
273
+ : { id: 'git.merge', state: 'ok', summary: 'No merge in progress.', quiet: true }
274
+ )
275
+ if (merging) proposals.push({ step: 'merge', command: 'git commit', why: 'finish the merge in progress' })
276
+
277
+ const uncommitted = merging ? null : uncommittedContent(siteDir, { verb: 'push' })
278
+ const n = uncommitted?.paths.length || 0
279
+ checks.push(
280
+ uncommitted
281
+ ? {
282
+ id: 'git.uncommitted',
283
+ state: 'attention',
284
+ summary: repo.head
285
+ ? `${n} of the site's files ${n === 1 ? 'is' : 'are'} not committed — in git a push sends what is committed.`
286
+ : 'This repository has no commit yet — in git a push sends what is committed.',
287
+ files: uncommitted.paths,
288
+ blocks: ['push', 'publish']
227
289
  }
290
+ : { id: 'git.uncommitted', state: 'ok', summary: "The site's files are committed." }
291
+ )
292
+
293
+ // What this copy changed since its last sync, file by file — while its branch holds the
294
+ // sync point that sync recorded.
295
+ let localFiles = null
296
+ const point = readSyncPoint(siteDir, origin)
297
+ if (repo.head && point && isAncestor(repo, point)) localFiles = filesDifferFrom(repo, point, sentRoots(siteDir))
298
+
299
+ const upstream = upstreamOf(siteDir)
300
+ if (!upstream) {
301
+ checks.push({ id: 'git.remote', state: 'skipped', summary: 'No upstream branch to compare with.' })
302
+ } else {
303
+ const counts = aheadBehind(siteDir) || { ahead: 0, behind: 0 }
304
+ let moved = null
305
+ let asked = false
306
+ if (offline) {
307
+ moved = counts.behind > 0
308
+ } else {
309
+ const answer = await remoteMoved(siteDir)
310
+ if (answer) {
311
+ moved = answer.moved
312
+ asked = true
228
313
  }
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
- }
242
- } 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 }
249
314
  }
250
- }
251
-
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
- : {})
315
+ const parts = []
316
+ if (moved) parts.push(`${upstream.remote} has commits this branch does not${asked ? '' : ' (as of the last fetch)'}.`)
317
+ if (counts.ahead) parts.push(`${count(counts.ahead, 'commit')} here ${counts.ahead === 1 ? 'is' : 'are'} not on ${upstream.tracking}.`)
318
+ if (moved === null) {
319
+ checks.push({
320
+ id: 'git.remote',
321
+ state: 'unknown',
322
+ summary: `Could not ask ${upstream.remote} for new commits — no network, or it needs a sign-in.`,
323
+ lines: parts,
324
+ detail: { ...counts, upstream: upstream.tracking }
275
325
  })
276
- )
277
- return { exitCode: 0 }
326
+ } else {
327
+ checks.push({
328
+ id: 'git.remote',
329
+ state: parts.length ? 'attention' : 'ok',
330
+ summary: parts.length ? parts.join(' ') : `Up to date with ${upstream.tracking}${asked ? '' : ' (as of the last fetch)'}.`,
331
+ detail: { ...counts, upstream: upstream.tracking }
332
+ })
333
+ }
334
+ if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
335
+ if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
278
336
  }
337
+ return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging }
338
+ }
279
339
 
280
- console.log('')
340
+ /**
341
+ * The session for `origin`, read and never asked for: UNIWEB_TOKEN, else the stored one.
342
+ * @returns {Promise<{ token: string, handle: string|null } | { expired: true } | null>}
343
+ */
344
+ async function sessionFor(origin) {
345
+ if (process.env.UNIWEB_TOKEN) return { token: process.env.UNIWEB_TOKEN, handle: null }
346
+ const stored = await readRegistryAuth(origin)
347
+ if (!stored?.token) return null
348
+ if (isExpired(stored)) return { expired: true }
349
+ return { token: stored.token, handle: stored.handle || stored.username || null }
350
+ }
281
351
 
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
- )
352
+ /**
353
+ * The sign-in an agent runs: on this backend — a bare login goes to the default one — and in
354
+ * the workspace the site was created in, when this copy knows it.
355
+ */
356
+ function loginCommand(siteDir, origin, uuid) {
357
+ const server = origin === DEFAULT_BACKEND_ORIGIN ? '' : ` --server ${origin}`
358
+ if (!uuid) return `npx uniweb login --browser${server}`
359
+ const ws = readSiteWorkspace(siteDir, origin)
360
+ return `npx uniweb login --browser${server} ${ws ? `--org ${ws}` : '--personal'}`
361
+ }
362
+
363
+ /** Unit paths → the files the author keeps them in, as a pull writes them back. */
364
+ function filesOfUnits(siteDir, units) {
365
+ return units.map((u) => (u === 'site.yml' ? 'site.yml' : unitFilesOf(siteDir, u)[0] ?? u))
366
+ }
367
+
368
+ /**
369
+ * The app: what the backend holds of this site, against this copy.
370
+ * @returns {Promise<{ checks: object[], proposals: object[], backend: object }>}
371
+ */
372
+ async function appChecks(siteDir, siteYml, { offline, args, git }) {
373
+ const checks = []
374
+ const proposals = []
375
+ const origin = resolveBackendOrigin()
376
+ const uuid = readBackendState(siteDir, origin).site?.uuid || null
377
+ const backend = { origin, workspace: null }
378
+
379
+ // ── What this machine knows ──
380
+ const base = git.merging ? null : untakenBase(siteDir, origin)
381
+ if (base) {
382
+ checks.push({
383
+ id: 'app.base',
384
+ state: 'blocking',
385
+ summary: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
386
+ blocks: ['push', 'publish']
387
+ })
388
+ proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
290
389
  }
291
390
 
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
- '.'
391
+ // What this copy holds that the backend lacks: by file, from the sync point, in git; else
392
+ // the send-only-changed probe a push runs, which counts entities.
393
+ let unpushedFiles = null
394
+ let unpushed = false
395
+ if (!uuid) {
396
+ const elsewhere = process.env.UNIWEB_SERVER ? null : syncedElsewhere(siteDir, origin)
397
+ checks.push(
398
+ elsewhere
399
+ ? {
400
+ id: 'app.site',
401
+ state: 'attention',
402
+ summary: `This project's site is on ${elsewhere.join(', ')}, not on ${origin}, where you are signed in.`,
403
+ lines: [`To work with it there: npx uniweb login --server ${elsewhere[0]}`]
404
+ }
405
+ : { id: 'app.unpushed', state: 'skipped', summary: `Not in the app on ${origin} yet — \`npx uniweb push\` puts it there.` }
309
406
  )
310
- say.dim(
311
- 'Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).'
407
+ } else if (git.localFiles) {
408
+ unpushedFiles = git.localFiles
409
+ unpushed = unpushedFiles.length > 0
410
+ checks.push(
411
+ unpushed
412
+ ? {
413
+ id: 'app.unpushed',
414
+ state: 'attention',
415
+ summary: `${count(unpushedFiles.length, 'file')} here ${unpushedFiles.length === 1 ? 'holds' : 'hold'} changes the backend does not have yet.`,
416
+ files: unpushedFiles
417
+ }
418
+ : { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
312
419
  )
420
+ } else {
421
+ try {
422
+ const probe = await probeUnpushed(siteDir, { backend: origin })
423
+ unpushed = probe.changed > 0
424
+ checks.push({
425
+ id: 'app.unpushed',
426
+ state: unpushed ? 'attention' : 'ok',
427
+ summary: unpushed ? 'This copy holds changes the backend does not have yet.' : 'Nothing waiting to be pushed.',
428
+ detail: { entities: probe.changed, unchanged: probe.unchanged }
429
+ })
430
+ } catch (err) {
431
+ checks.push({ id: 'app.unpushed', state: 'unknown', summary: `Could not tell what is not pushed: ${err.message}` })
432
+ }
433
+ }
434
+ if (uuid && (unpushed || git.uncommitted)) {
435
+ proposals.push({
436
+ step: 'push',
437
+ command: git.uncommitted ? 'npx uniweb push --commit -m "<what you changed>"' : 'npx uniweb push',
438
+ why: git.uncommitted ? 'commit your changes and send them to the app' : 'send your changes to the app'
439
+ })
313
440
  }
314
441
 
315
- // Foundation
316
- if (fnd) say.dim(`Foundation: ${fnd}`)
442
+ if (offline) {
443
+ checks.push({ id: 'app.session', state: 'skipped', summary: 'The backend was not asked (--offline).' })
444
+ return { checks, proposals, backend }
445
+ }
317
446
 
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})` : ''}.`)
447
+ // ── What the backend says ──
448
+ const session = await sessionFor(origin)
449
+ if (!session || session.expired) {
450
+ if (!uuid) {
451
+ checks.push({ id: 'app.session', state: 'skipped', summary: `Not signed in to ${origin} — needed to bring the site into the app.` })
452
+ } else {
453
+ checks.push({
454
+ id: 'app.session',
455
+ state: 'unknown',
456
+ summary: session?.expired
457
+ ? `The session on ${origin} has expired, so nothing there was checked.`
458
+ : `Not signed in to ${origin}, so nothing there was checked.`
459
+ })
460
+ proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: `sign in, so the backend can be checked` })
330
461
  }
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
- }
462
+ return { checks, proposals, backend }
463
+ }
464
+
465
+ const client = new BackendClient({ token: session.token, args, command: 'Status' })
466
+ const ws = await resolveWorkspace({ client, args }).catch((err) => ({ refused: true, reason: err.message }))
467
+ if (ws.refused) {
468
+ checks.push({
469
+ id: 'app.session',
470
+ state: 'blocking',
471
+ summary: `Signed in to ${origin}, with no workspace chosen.`,
472
+ lines: String(ws.reason || '').split('\n').map((l) => l.trim()).filter(Boolean),
473
+ blocks: ['push', 'publish', 'pull']
474
+ })
475
+ proposals.push({
476
+ step: 'login',
477
+ command: uuid && readSiteWorkspace(siteDir, origin) ? `npx uniweb login --org ${readSiteWorkspace(siteDir, origin)}` : null,
478
+ why: 'choose the workspace to work in — the person decides which',
479
+ ask: true
480
+ })
481
+ return { checks, proposals, backend }
482
+ }
483
+ client.setWorkspace(ws.workspace, { source: ws.source })
484
+ backend.workspace = ws.workspace
485
+ checks.push({
486
+ id: 'app.session',
487
+ state: 'ok',
488
+ summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
489
+ })
490
+
491
+ const [state, changes, foundation] = await Promise.all([
492
+ uuid ? client.siteState(uuid) : null,
493
+ uuid ? readAppChanges({ client, siteDir, siteUuid: uuid }).catch((err) => ({ error: err })) : null,
494
+ foundationCheck(siteDir, siteYml, client).catch(() => null)
495
+ ])
496
+
497
+ if (state) {
498
+ // ⭐ `site_state` is the backend's word on this site — `live`, `gone` or `unknown` —
499
+ // for a script that decides on it: `gone` only when the backend says it holds no such
500
+ // site, never for a failure to reach it (`client.siteState`).
501
+ const detail = { site_state: state.state }
502
+ if (state.status === 401) {
503
+ checks.push({ id: 'app.site', state: 'unknown', summary: `${origin} did not accept the session — sign in again.`, detail })
504
+ proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: 'sign in again' })
505
+ } else if (state.state === 'gone') {
506
+ checks.push({
507
+ id: 'app.site',
508
+ state: 'blocking',
509
+ summary: `${origin} has no site ${uuid} — it was deleted there, or the backend was rebuilt.`,
510
+ lines: [`To push this as a new site: npx uniweb forget --server ${origin}, then npx uniweb push.`],
511
+ blocks: ['push', 'publish', 'pull'],
512
+ detail
513
+ })
514
+ } else if (state.state === 'live' && state.status === 409) {
515
+ checks.push({
516
+ id: 'app.site',
517
+ state: 'blocking',
518
+ summary: 'The site is kept in another workspace.',
519
+ lines: state.detail ? [state.detail] : [],
520
+ blocks: ['push', 'publish', 'pull'],
521
+ detail: { ...detail, workspace: state.workspace ?? null }
522
+ })
523
+ 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 })
524
+ } else if (state.state === 'live') {
525
+ checks.push({ id: 'app.site', state: 'ok', summary: `The site is in the app on ${origin}.`, quiet: true, detail })
526
+ } else {
527
+ checks.push({ id: 'app.site', state: 'unknown', summary: `Could not tell whether ${origin} holds this site${state.detail ? ` (${state.detail})` : ''}.`, detail })
528
+ }
529
+ }
530
+
531
+ if (changes) checks.push(...changeChecks(siteDir, changes, unpushedFiles, proposals))
532
+
533
+ const site = state?.site
534
+ if (site) {
535
+ if (!site.published) {
536
+ checks.push({ id: 'app.live', state: 'attention', summary: 'In the app, not live yet.' })
537
+ 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
+ } 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.' })
540
+ proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
541
+ } else {
542
+ checks.push({ id: 'app.live', state: 'ok', summary: 'Live, with the latest content.' })
343
543
  }
344
544
  // An array is the backend's answer, `[]` included; no key is a backend that does not
345
545
  // 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
- }
546
+ if (Array.isArray(site.editors)) {
547
+ const people = describeEditors(site.editors, { fileOf: fileByItemUuid(siteDir, origin) })
548
+ // One person is named once, in the summary; several, on each of their tabs.
549
+ const who = (p) => (people.length === 1 ? '' : `${p.person}: `)
550
+ checks.push(
551
+ people.length
552
+ ? {
553
+ id: 'app.editors',
554
+ state: 'attention',
555
+ summary: `Open in the app now: ${people.map((p) => p.person).join(', ')}.`,
556
+ lines: people.flatMap((p) => (p.tabs.length ? p.tabs.map((t) => `${who(p)}${t}`) : [`${who(p)}nothing open`])),
557
+ detail: { editors: site.editors }
558
+ }
559
+ : { id: 'app.editors', state: 'ok', summary: 'Nobody has the site open in the app.', detail: { editors: [] } }
560
+ )
561
+ }
562
+ }
563
+ if (foundation) checks.push(foundation)
564
+ return { checks, proposals, backend }
565
+ }
566
+
567
+ /** `app.changes`: what moved on the backend, and where it meets what moved here. */
568
+ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
569
+ if (changes.error) {
570
+ return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
571
+ }
572
+ const { content, records } = changes
573
+ const unknown = [content, records].filter((l) => l.state === 'unknown')
574
+ const files = [
575
+ ...filesOfUnits(siteDir, [...content.changed, ...content.removed]),
576
+ ...content.added.map((u) => `${u} (new)`)
577
+ ]
578
+ const lines = []
579
+ if (content.outsideUnits) lines.push("the site's settings — its services, languages or queries")
580
+ if (records.changed.length) lines.push(`records changed: ${records.changed.join(', ')}`)
581
+ if (records.added.length) lines.push(`records added: ${records.added.join(', ')}`)
582
+ if (records.removed) lines.push(`${count(records.removed, 'record')} removed`)
583
+ if (records.folder) lines.push('the records\' sub-folders (folder.yml)')
584
+ const moved = files.length + lines.length
585
+ if (!moved) {
586
+ return [
587
+ unknown.length
588
+ ? { 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.' }
590
+ ]
591
+ }
592
+ // Where the backend's changes meet this copy's: a push of those is refused until a pull
593
+ // merges them — `diffSiteUnits`' "changed on both sides".
594
+ const overlap = unpushedFiles?.length ? files.filter((f) => unpushedFiles.includes(f)) : []
595
+ const n = files.length + content.outsideUnits + records.changed.length + records.added.length + records.removed + records.folder
596
+ proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'change')} made on the backend` })
597
+ return [
598
+ {
599
+ id: 'app.changes',
600
+ state: overlap.length ? 'blocking' : 'attention',
601
+ summary:
602
+ `${count(n, 'change')} on the backend since this copy last synced — in the app, or pushed from another copy.` +
603
+ (overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
604
+ files,
605
+ lines,
606
+ ...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
607
+ detail: {
608
+ content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
609
+ records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
610
+ ...(overlap.length ? { overlap } : {}),
611
+ ...(unknown.length ? { unknown: unknown.map((l) => l.detail) } : {})
356
612
  }
357
613
  }
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}).`
614
+ ]
615
+ }
616
+
617
+ /**
618
+ * `app.foundation`: the local foundation against its registered version — what the next
619
+ * push or publish does with it (`backend/foundation-bring-along.js`).
620
+ */
621
+ async function foundationCheck(siteDir, siteYml, client) {
622
+ const fnd = foundationRef(siteYml)
623
+ const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
624
+ const local = resolveLocalFoundation(siteDir, siteYml)
625
+ const name = (local && (await foundationScopedName(local.dir))) || fndScope
626
+ if (!name) return null
627
+ const latest = await client.readFoundationLatest(name)
628
+ if (!latest?.latest_version) return null
629
+ if (local?.dir) {
630
+ const digest = latest.digest ? computeFoundationDigest(join(local.dir, 'dist')) : null
631
+ if (!digest) return null // not built, or the registry keeps no digest: nothing to compare
632
+ if (digest === latest.digest) return { id: 'app.foundation', state: 'ok', summary: 'The foundation matches its registered version.' }
633
+ if (compareSemverPrecedence(local.version, latest.latest_version) === -1) {
634
+ return {
635
+ id: 'app.foundation',
636
+ state: 'blocking',
637
+ 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.`,
638
+ blocks: ['push', 'publish']
639
+ }
640
+ }
641
+ 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." }
642
+ }
643
+ if (fndVersion && latest.latest_version !== fndVersion) {
644
+ return { id: 'app.foundation', state: 'attention', summary: `A newer version of the foundation (${latest.latest_version}) is registered than the site pins (${fndVersion}).` }
645
+ }
646
+ return { id: 'app.foundation', state: 'ok', summary: `The site pins the latest version of its foundation (${fndVersion}).` }
647
+ }
648
+
649
+ /** `uniweb.version`: a newer Uniweb, named the same way for every release. */
650
+ async function versionCheck(siteDir, { offline }) {
651
+ const runs = projectUniweb(siteDir)
652
+ if (offline) return { checks: [{ id: 'uniweb.version', state: 'skipped', summary: `This project runs Uniweb ${runs}; npm was not asked (--offline).` }], proposals: [] }
653
+ const latest = await getLatestVersion({ allowNetwork: true, timeoutMs: 2000, maxAgeMs: 60 * 60 * 1000 })
654
+ 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: [] }
655
+ if (!isBehind(runs, latest)) {
656
+ return { checks: [{ id: 'uniweb.version', state: 'ok', summary: `Uniweb ${runs}, the latest.`, detail: { runs, latest } }], proposals: [] }
657
+ }
658
+ return {
659
+ checks: [{ id: 'uniweb.version', state: 'attention', summary: `Uniweb ${latest} is out; this project runs ${runs}.`, detail: { runs, latest } }],
660
+ 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' }]
661
+ }
662
+ }
663
+
664
+ // Doctor's findings `uniweb update` answers: the project's dependencies, or its AGENTS.md,
665
+ // behind the CLI it runs.
666
+ const UPDATE_FINDINGS = new Set(['deps-behind-cli', 'agents-md-missing', 'agents-md-unstamped', 'agents-md-outdated'])
667
+
668
+ /**
669
+ * `health.doctor`: doctor's findings, read without printing them.
670
+ *
671
+ * ⚠️ doctor measures the project against the CLI that RUNS it. Run by a newer CLI than the
672
+ * project's (`npx uniweb@latest status`), its dependency and AGENTS.md findings are against
673
+ * that newer one — which the project's own `uniweb update` does not know — so the step that
674
+ * answers them is the newer CLI's.
675
+ */
676
+ async function doctorCheck(siteDir) {
677
+ try {
678
+ const { doctor } = await import('./doctor.js')
679
+ const res = await doctor([], { quiet: true })
680
+ const open = (res.issues || []).filter((i) => !i.fixed && (i.type === 'error' || i.type === 'warn'))
681
+ if (!open.length) return { checks: [{ id: 'health.doctor', state: 'ok', summary: 'doctor: every check passes.' }], proposals: [] }
682
+ const ids = [...new Set(open.map((i) => i.id).filter(Boolean))]
683
+ const parts = [res.errors && count(res.errors, 'error'), res.warnings && count(res.warnings, 'warning')].filter(Boolean)
684
+ const proposals = []
685
+ if (open.some((i) => UPDATE_FINDINGS.has(i.id))) {
686
+ const newer = isBehind(projectUniweb(siteDir), getCliVersion())
687
+ proposals.push(
688
+ newer
689
+ ? { 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' }
690
+ : { step: 'update', command: 'npx uniweb update', why: "align the dependencies and AGENTS.md with this project's Uniweb" }
365
691
  )
366
692
  }
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
- )
693
+ const rest = open.filter((i) => !UPDATE_FINDINGS.has(i.id))
694
+ if (rest.length) proposals.push({ step: 'doctor', command: 'npx uniweb doctor', why: `read its ${count(rest.length, 'finding')} — \`--fix\` repairs what it can` })
695
+ return {
696
+ checks: [
697
+ {
698
+ id: 'health.doctor',
699
+ state: 'attention',
700
+ summary: `doctor: ${parts.join(', ')}${ids.length ? ` (${ids.join(', ')})` : ''}.`,
701
+ detail: { errors: res.errors, warnings: res.warnings, issues: open.map(({ id, type, message }) => ({ id: id ?? null, type, message })) }
702
+ }
703
+ ],
704
+ proposals
705
+ }
706
+ } catch (err) {
707
+ return { checks: [{ id: 'health.doctor', state: 'unknown', summary: `doctor did not finish: ${err.message}` }], proposals: [] }
708
+ }
709
+ }
710
+
711
+ /** `dev.server`: the site's dev server, confirmed by asking it. */
712
+ async function devCheck(siteDir, { offline }) {
713
+ if (offline) return { id: 'dev.server', state: 'skipped', summary: 'The dev server was not asked (--offline).' }
714
+ try {
715
+ const { runningDevServer } = await import('@uniweb/build/site')
716
+ const running = await runningDevServer(siteDir, { timeout: 1000 })
717
+ return running
718
+ ? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url } }
719
+ : { id: 'dev.server', state: 'skipped', summary: 'The dev server is not running — `npx uniweb dev` starts it.' }
720
+ } catch (err) {
721
+ return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}` }
722
+ }
723
+ }
724
+
725
+ // ─── the command ────────────────────────────────────────────────────────────
726
+
727
+ const GROUPS = [
728
+ ['git', 'Git'],
729
+ ['app', 'App'],
730
+ ['uniweb', 'Uniweb'],
731
+ ['health', 'Health'],
732
+ ['dev', 'Dev server']
733
+ ]
734
+
735
+ const ICON = {
736
+ ok: `${c.green}✓${c.reset}`,
737
+ attention: `${c.cyan}→${c.reset}`,
738
+ blocking: `${c.red}✗${c.reset}`,
739
+ unknown: `${c.yellow}?${c.reset}`,
740
+ skipped: `${c.dim}·${c.reset}`
741
+ }
742
+
743
+ function render({ siteLabel, backend, checks, next }) {
744
+ const out = ['']
745
+ out.push(`${c.bold}Status of ${siteLabel}${c.reset}`)
746
+ for (const [prefix, title] of GROUPS) {
747
+ const group = checks.filter((k) => k.id.split('.')[0] === prefix && !(k.quiet && k.state === 'ok'))
748
+ if (!group.length) continue
749
+ out.push('')
750
+ out.push(prefix === 'app' ? `${c.bold}${title}${c.reset} ${c.dim}— ${backend.origin}${c.reset}` : `${c.bold}${title}${c.reset}`)
751
+ for (const k of group) {
752
+ out.push(` ${ICON[k.state]} ${k.state === 'skipped' ? `${c.dim}${k.summary}${c.reset}` : k.summary}`)
753
+ const files = k.files || []
754
+ for (const f of files.slice(0, 12)) out.push(` ${c.dim}${f}${c.reset}`)
755
+ if (files.length > 12) out.push(` ${c.dim}… and ${files.length - 12} more${c.reset}`)
756
+ for (const l of k.lines || []) out.push(` ${c.dim}${l}${c.reset}`)
757
+ }
758
+ }
759
+ out.push('')
760
+ if (!next.length) {
761
+ out.push(`${ICON.ok} ${c.bold}Nothing to do.${c.reset}`)
762
+ } else {
763
+ out.push(`${c.bold}Next${c.reset}`)
764
+ next.forEach((s, i) => {
765
+ const ask = s.ask ? `${c.yellow}ask the person first —${c.reset} ` : ''
766
+ if (s.command) {
767
+ out.push(` ${i + 1}. ${c.cyan}${s.command}${c.reset}`)
768
+ out.push(` ${ask}${c.dim}${s.why}${c.reset}`)
377
769
  } else {
378
- say.info(
379
- 'Local foundation differs from the registered version — the next `uniweb push` or `uniweb publish` releases it.'
380
- )
770
+ out.push(` ${i + 1}. ${ask}${s.why}`)
381
771
  }
382
- } else if (foundationFresh === true) {
383
- say.ok('Local foundation matches the registered version.')
384
- }
385
- if (!site && !fdnLatest) {
386
- say.dim('(No remote signals — the backend may not expose them yet.)')
772
+ })
773
+ }
774
+ const unknown = checks.filter((k) => k.state === 'unknown').map((k) => k.id)
775
+ if (unknown.length) {
776
+ out.push('')
777
+ out.push(`${c.dim}Not known: ${unknown.join(', ')} — see above.${c.reset}`)
778
+ }
779
+ out.push('')
780
+ return out.join('\n')
781
+ }
782
+
783
+ export async function status(args = []) {
784
+ // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
785
+ // Straight to stderr: this is a usage error, so it must not be mistaken for the
786
+ // status document even under --json.
787
+ const badFlag = checkFlags('status', args)
788
+ if (badFlag) {
789
+ console.error(`${c.red}✗${c.reset} ${badFlag.message}`)
790
+ return { exitCode: 2 }
791
+ }
792
+ const json = args.includes('--json')
793
+ const offline = args.includes('--offline')
794
+
795
+ const found = await findSite(process.cwd())
796
+ if (!found.siteDir) {
797
+ const sites = found.several || []
798
+ if (json) {
799
+ console.log(JSON.stringify({ schema: STATUS_SCHEMA, error: sites.length ? 'several-sites' : 'no-site', sites }))
800
+ } else if (sites.length) {
801
+ console.error(`${c.red}✗${c.reset} Several sites here — run status from the one you mean:`)
802
+ for (const s of sites) console.error(` ${c.cyan}cd ${s} && npx uniweb status${c.reset}`)
803
+ } else {
804
+ console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
387
805
  }
806
+ return { exitCode: 2 }
807
+ }
808
+ const siteDir = found.siteDir
809
+ const siteYml = readSiteYml(siteDir)
810
+ const origin = resolveBackendOrigin()
811
+
812
+ // ⭐ Under --json, stdout carries the document and nothing else: whatever a check's
813
+ // helpers say on the way goes to stderr.
814
+ const saved = { log: console.log, info: console.info, warn: console.warn }
815
+ if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
816
+ let git, app, version, health, dev
817
+ try {
818
+ git = await gitChecks(siteDir, { offline, origin })
819
+ ;[app, version, health, dev] = await Promise.all([
820
+ appChecks(siteDir, siteYml, { offline, args, git }),
821
+ versionCheck(siteDir, { offline }),
822
+ doctorCheck(siteDir),
823
+ devCheck(siteDir, { offline })
824
+ ])
825
+ } finally {
826
+ Object.assign(console, saved)
388
827
  }
828
+ const checks = [...git.checks, ...app.checks, ...version.checks, ...health.checks, dev]
829
+ const next = planNext([...git.proposals, ...app.proposals, ...version.proposals, ...health.proposals])
389
830
 
390
- console.log('')
831
+ const root = findWorkspaceRoot(siteDir) || siteDir
832
+ const site = relative(root, siteDir) || '.'
833
+ if (json) {
834
+ console.log(
835
+ JSON.stringify({
836
+ schema: STATUS_SCHEMA,
837
+ uniweb: getCliVersion(),
838
+ site,
839
+ backend: app.backend.origin,
840
+ workspace: app.backend.workspace,
841
+ offline,
842
+ checks: checks.map(({ quiet, id, state, summary, blocks = [], ...rest }) => ({ id, state, summary, blocks, ...rest })),
843
+ next
844
+ })
845
+ )
846
+ return { exitCode: 0 }
847
+ }
848
+ console.log(render({ siteLabel: site === '.' ? 'this site' : `${site}/`, backend: app.backend, checks, next }))
391
849
  return { exitCode: 0 }
392
850
  }
393
851