uniweb 1.21.0 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,9 +16,11 @@
16
16
  * dev the dev server
17
17
  *
18
18
  * ⛔ READ-ONLY, AND IT NEVER PROMPTS. It signs nobody in — with no session it says how — and
19
- * writes nothing a later command reads as taken: no ETag, version or hash
20
- * (`backend/app-changes.js`). Its one write is the user-level npm cache `update` and
21
- * `--version` keep. A check it could not make is `unknown`, never `ok`.
19
+ * writes nothing a later command reads as taken: no ETag, version, hash or sync point
20
+ * (`probePull`, `backend/app-changes.js`). It writes the user-level npm cache `update` and
21
+ * `--version` keep, and — for a foundation named by catalog ref — the registered version's
22
+ * section types a pull keeps, which never change. A check it could not make is `unknown`,
23
+ * never `ok`.
22
24
  *
23
25
  * ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
24
26
  * from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
@@ -39,8 +41,9 @@
39
41
  * (`utils/flag-guard.js`).
40
42
  */
41
43
 
44
+ import { execFileSync } from 'node:child_process'
42
45
  import { existsSync, readFileSync } from 'node:fs'
43
- import { join, relative, resolve } from 'node:path'
46
+ import { basename, join, relative, resolve } from 'node:path'
44
47
  import yaml from 'js-yaml'
45
48
 
46
49
  import {
@@ -56,7 +59,9 @@ import {
56
59
  import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
57
60
  import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
58
61
  import { readAppChanges } from '../backend/app-changes.js'
59
- import { readBackendState } from '@uniweb/build/uwx'
62
+ import { probePull } from './pull.js'
63
+ import { readOrgFlag } from '../utils/args.js'
64
+ import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
60
65
  import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
61
66
  import { computeFoundationDigest } from '../utils/code-upload.js'
62
67
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
@@ -248,7 +253,7 @@ async function gitChecks(siteDir, { offline, origin }) {
248
253
  ? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
249
254
  : 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
250
255
  })
251
- return { checks, proposals, localFiles: null }
256
+ return { checks, proposals, localFiles: null, repo: false }
252
257
  }
253
258
  checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
254
259
 
@@ -334,7 +339,7 @@ async function gitChecks(siteDir, { offline, origin }) {
334
339
  if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
335
340
  if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
336
341
  }
337
- return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging }
342
+ return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging, repo: Boolean(repo.head) }
338
343
  }
339
344
 
340
345
  /**
@@ -488,9 +493,24 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
488
493
  summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
489
494
  })
490
495
 
496
+ // ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
497
+ // stopped before it takes anything (`probePull`): the files it would change, translations
498
+ // and records included. Where it cannot answer — outside git, no sync point to merge from —
499
+ // the backend's units against this copy's last sync (`backend/app-changes.js`).
500
+ const readChanges = async () => {
501
+ if (git.repo) {
502
+ const org = readOrgFlag(args)
503
+ const probed = await probePull([...(org ? ['--org', org] : []), ...(args.includes('--personal') ? ['--personal'] : [])], {
504
+ resolveSiteDir: async () => siteDir,
505
+ getToken: async () => session.token
506
+ })
507
+ if (probed) return { probe: probed }
508
+ }
509
+ return readAppChanges({ client, siteDir, siteUuid: uuid })
510
+ }
491
511
  const [state, changes, foundation] = await Promise.all([
492
512
  uuid ? client.siteState(uuid) : null,
493
- uuid ? readAppChanges({ client, siteDir, siteUuid: uuid }).catch((err) => ({ error: err })) : null,
513
+ uuid ? readChanges().catch((err) => ({ error: err })) : null,
494
514
  foundationCheck(siteDir, siteYml, client).catch(() => null)
495
515
  ])
496
516
 
@@ -532,14 +552,35 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
532
552
 
533
553
  const site = state?.site
534
554
  if (site) {
555
+ // ⭐ WHERE THE SITE IS LIVE IS THE BACKEND'S TO SAY, never deploy.yml's: a receipt is
556
+ // what this copy published, and a publish from the app or another copy does not move it.
557
+ // ⛔ `last_published_url` outlives an unpublish (`client.siteStatus`), so it is said as
558
+ // where the site WAS live when it is not live now — never as where it is.
559
+ const url = typeof site.last_published_url === 'string' && site.last_published_url ? site.last_published_url : null
560
+ const when = site.last_published_at ? sinceLabel(site.last_published_at, Date.now()) : null
561
+ const at = `${url ? ` at ${url}` : ''}${when ? `, published ${when}` : ''}`
562
+ const detail = {
563
+ published: Boolean(site.published),
564
+ url,
565
+ published_at: site.last_published_at ?? null,
566
+ draft_dirty: Boolean(site.draft_dirty),
567
+ foundation: site.last_published_foundation ?? null,
568
+ runtime: site.last_published_runtime ?? null
569
+ }
535
570
  if (!site.published) {
536
- checks.push({ id: 'app.live', state: 'attention', summary: 'In the app, not live yet.' })
571
+ checks.push({
572
+ id: 'app.live',
573
+ state: 'attention',
574
+ summary: `On ${cloudName(origin)}, not live yet.`,
575
+ lines: url ? [`It was live at ${url} before.`] : [],
576
+ detail
577
+ })
537
578
  proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'take the site live — the person decides, and it may ask for payment', ask: true })
538
579
  } else if (site.draft_dirty) {
539
- checks.push({ id: 'app.live', state: 'attention', summary: 'Live, with changes in the app that are not live yet.' })
580
+ checks.push({ id: 'app.live', state: 'attention', summary: `Live on ${cloudName(origin)}${at} — the app holds changes that are not live yet.`, detail })
540
581
  proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
541
582
  } else {
542
- checks.push({ id: 'app.live', state: 'ok', summary: 'Live, with the latest content.' })
583
+ checks.push({ id: 'app.live', state: 'ok', summary: `Live on ${cloudName(origin)}${at}, with the latest content.`, detail })
543
584
  }
544
585
  // An array is the backend's answer, `[]` included; no key is a backend that does not
545
586
  // say, and then neither do we.
@@ -561,14 +602,20 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
561
602
  }
562
603
  }
563
604
  if (foundation) checks.push(foundation)
564
- return { checks, proposals, backend }
605
+ return { checks, proposals, backend, site: site ?? null }
565
606
  }
566
607
 
608
+ /** The name a backend's hosting goes by: Uniweb Cloud on the default backend, its origin elsewhere. */
609
+ const cloudName = (origin) => (origin === DEFAULT_BACKEND_ORIGIN ? 'Uniweb Cloud' : origin)
610
+
611
+ const CHANGE = { A: 'added', M: 'changed', D: 'removed' }
612
+
567
613
  /** `app.changes`: what moved on the backend, and where it meets what moved here. */
568
614
  function changeChecks(siteDir, changes, unpushedFiles, proposals) {
569
615
  if (changes.error) {
570
616
  return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
571
617
  }
618
+ if (changes.probe) return probedChanges(changes.probe, unpushedFiles, proposals)
572
619
  const { content, records } = changes
573
620
  const unknown = [content, records].filter((l) => l.state === 'unknown')
574
621
  const files = [
@@ -586,7 +633,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
586
633
  return [
587
634
  unknown.length
588
635
  ? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
589
- : { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.' }
636
+ : { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
590
637
  ]
591
638
  }
592
639
  // Where the backend's changes meet this copy's: a push of those is refused until a pull
@@ -605,6 +652,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
605
652
  lines,
606
653
  ...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
607
654
  detail: {
655
+ source: 'units',
608
656
  content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
609
657
  records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
610
658
  ...(overlap.length ? { overlap } : {}),
@@ -614,6 +662,40 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
614
662
  ]
615
663
  }
616
664
 
665
+ /**
666
+ * `app.changes` from the pull's own answer (`probePull`): the files it would change, each
667
+ * as the pull would write it.
668
+ */
669
+ function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
670
+ if (!list.length) {
671
+ return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
672
+ }
673
+ const paths = list.map(([, p]) => p)
674
+ const files = list.map(([s, p]) => (s === 'A' ? `${p} (new)` : s === 'D' ? `${p} (removed)` : p))
675
+ // Where the backend's changes meet this copy's: the pull merges them, and a push of those
676
+ // before it is refused.
677
+ const overlap = unpushedFiles?.length ? paths.filter((p) => unpushedFiles.includes(p)) : []
678
+ const n = list.length
679
+ proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'file')} changed on the backend` })
680
+ return [
681
+ {
682
+ id: 'app.changes',
683
+ state: overlap.length ? 'blocking' : 'attention',
684
+ summary:
685
+ `${count(n, 'file')} changed on the backend since this copy last synced — in the app, or pushed from another copy.` +
686
+ (overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
687
+ files,
688
+ lines: recordsNotPlaced ? ['Some records could not be placed in files — `npx uniweb pull` says which, and why.'] : [],
689
+ ...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
690
+ detail: {
691
+ source: 'pull',
692
+ files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })),
693
+ ...(overlap.length ? { overlap } : {})
694
+ }
695
+ }
696
+ ]
697
+ }
698
+
617
699
  /**
618
700
  * `app.foundation`: the local foundation against its registered version — what the next
619
701
  * push or publish does with it (`backend/foundation-bring-along.js`).
@@ -708,30 +790,462 @@ async function doctorCheck(siteDir) {
708
790
  }
709
791
  }
710
792
 
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).' }
793
+ /**
794
+ * `dev.server`: the site's dev server, confirmed by asking it — and the command that starts it
795
+ * for this site. In a workspace with several, a bare `uniweb dev` starts the first one found
796
+ * (`commands/dev.js`), which may not be this one.
797
+ */
798
+ async function devCheck(siteDir, { offline, sites = [] }) {
799
+ const root = findWorkspaceRoot(siteDir) || siteDir
800
+ const mine = sites.find((s) => resolve(root, s.path) === resolve(siteDir))
801
+ const first = sites[0]
802
+ const command = sites.length > 1 && mine && first !== mine ? `npx uniweb dev ${mine.name}` : 'npx uniweb dev'
803
+ const note = command === 'npx uniweb dev' ? '' : ` (a bare \`uniweb dev\` starts ${first.name})`
804
+ if (offline) return { id: 'dev.server', state: 'skipped', summary: `The dev server was not asked (--offline); \`${command}\` starts it${note}.`, detail: { command } }
714
805
  try {
715
806
  const { runningDevServer } = await import('@uniweb/build/site')
716
807
  const running = await runningDevServer(siteDir, { timeout: 1000 })
717
808
  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.' }
809
+ ? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url, command } }
810
+ : { id: 'dev.server', state: 'skipped', summary: `The dev server is not running — \`${command}\` starts it${note}.`, detail: { command } }
811
+ } catch (err) {
812
+ return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}`, detail: { command } }
813
+ }
814
+ }
815
+
816
+ // ─── the project ────────────────────────────────────────────────────────────
817
+
818
+ /**
819
+ * The workspace, as its wiring says: the sites and foundations its globs hold, by the build's
820
+ * classifier (`utils/discover.js`), and the globs themselves.
821
+ *
822
+ * ⛔ NO LAYOUT NAME. "Single", "segregated" and the rest are conventions — the globs and the
823
+ * wiring are the truth — and a name guessed from folders is wrong where a workspace mixes them.
824
+ */
825
+ async function workspaceFacts(root) {
826
+ const [{ discoverSites, discoverFoundations }, { readWorkspaceConfig }, { isExtensionPackage }] = await Promise.all([
827
+ import('../utils/discover.js'),
828
+ import('../utils/config.js'),
829
+ import('@uniweb/build')
830
+ ])
831
+ const [sites, packages, config] = await Promise.all([
832
+ discoverSites(root),
833
+ discoverFoundations(root),
834
+ readWorkspaceConfig(root).catch(() => ({ packages: [] }))
835
+ ])
836
+ const extension = (f) => {
837
+ try {
838
+ return isExtensionPackage(join(root, f.path))
839
+ } catch {
840
+ return false
841
+ }
842
+ }
843
+ return {
844
+ root,
845
+ patterns: Array.isArray(config.packages) ? config.packages : [],
846
+ sites,
847
+ foundations: packages.filter((f) => !extension(f)),
848
+ extensions: packages.filter(extension)
849
+ }
850
+ }
851
+
852
+ function workspaceLine(ws) {
853
+ const { sites, foundations, extensions, patterns } = ws
854
+ if (sites.length === 1 && foundations.length === 1 && !extensions.length) {
855
+ return `One site and one foundation in this workspace: ${sites[0].path}/ and ${foundations[0].path}/.`
856
+ }
857
+ const parts = [count(sites.length, 'site'), count(foundations.length, 'foundation')]
858
+ if (extensions.length) parts.push(count(extensions.length, 'extension'))
859
+ const from = patterns.length ? `, from ${list(patterns)}` : ''
860
+ return `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)} in this workspace${from}.`
861
+ }
862
+
863
+ /**
864
+ * A site's foundation, as the build finds it — the one resolver (`detectFoundationType`), so
865
+ * what status names is what the build would build. A catalog ref, which a build refuses, is
866
+ * named as one.
867
+ */
868
+ async function foundationOf(siteDir, siteYml, root) {
869
+ const declared = siteYml?.foundation
870
+ if (!declared) return { kind: 'none' }
871
+ const name = typeof declared === 'string' ? declared : declared.ref || declared.name || declared.url || null
872
+ const [{ detectFoundationType }, { parseCatalogRef }] = await Promise.all([import('@uniweb/build'), import('@uniweb/build/uwx')])
873
+ if (parseCatalogRef(name)) return { kind: 'catalog', name, ref: name }
874
+ try {
875
+ const found = detectFoundationType(declared, siteDir)
876
+ if (found.type === 'local') return { kind: 'local', name: found.name, path: relative(root, found.path) || '.', abs: resolve(found.path) }
877
+ return { kind: 'url', name, url: found.url }
878
+ } catch (err) {
879
+ return { kind: 'unresolved', name, error: String(err?.message || err).split('\n')[0] }
880
+ }
881
+ }
882
+
883
+ /** A site's extensions, each where it is found: a folder here, the catalog, or a URL. */
884
+ async function extensionsOf(siteDir, siteYml, root) {
885
+ const declared = Array.isArray(siteYml?.extensions) ? siteYml.extensions : []
886
+ if (!declared.length) return []
887
+ const [{ findSiteExtension, extensionNameOf }, { parseCatalogRef }] = await Promise.all([import('@uniweb/build'), import('@uniweb/build/uwx')])
888
+ return declared.map((entry) => {
889
+ const value = entry && typeof entry === 'object' ? entry.url || entry.ref || entry.name : entry
890
+ if (typeof value !== 'string' || !value) return { value: String(value), kind: 'unresolved' }
891
+ if (parseCatalogRef(value)) return { value, kind: 'catalog' }
892
+ if (/^https?:\/\//.test(value)) return { value, kind: 'url' }
893
+ // A site-relative URL names an extension by its first segment; a bare name is that name.
894
+ const name = value.startsWith('/') ? extensionNameOf(value, siteYml.base)?.name : value
895
+ const dir = name ? findSiteExtension(name, siteDir) : null
896
+ return dir ? { value, kind: 'local', name, path: relative(root, dir) || '.' } : { value, kind: 'unresolved', name: name ?? null }
897
+ })
898
+ }
899
+
900
+ /**
901
+ * `project.*`: where this site sits — the workspace's sites and foundations, the foundation it
902
+ * is built with and what else uses it, its extensions, pages it takes from outside its folder,
903
+ * and the languages it is written in. All from files: no request.
904
+ */
905
+ async function projectChecks(siteDir, siteYml) {
906
+ const inWorkspace = Boolean(findWorkspaceRoot(siteDir))
907
+ const root = findWorkspaceRoot(siteDir) || siteDir
908
+ const ws = await workspaceFacts(root)
909
+ const checks = []
910
+ checks.push({
911
+ id: 'project.workspace',
912
+ state: 'ok',
913
+ summary: inWorkspace ? workspaceLine(ws) : 'This site is on its own, in no workspace.',
914
+ detail: { patterns: ws.patterns, sites: ws.sites, foundations: ws.foundations, extensions: ws.extensions }
915
+ })
916
+
917
+ // ⭐ The foundation, and what a change to it reaches: every site that resolves to the same one.
918
+ const fnd = await foundationOf(siteDir, siteYml, root)
919
+ const here = resolve(siteDir)
920
+ const sharedWith = []
921
+ if (fnd.kind === 'local') {
922
+ for (const s of ws.sites) {
923
+ const dir = resolve(root, s.path)
924
+ if (dir === here) continue
925
+ const other = await foundationOf(dir, readSiteYml(dir), root)
926
+ if (other.kind === 'local' && other.abs === fnd.abs) sharedWith.push(s.path)
927
+ }
928
+ }
929
+ const extensions = await extensionsOf(siteDir, siteYml, root)
930
+ let summary
931
+ let state = 'ok'
932
+ if (fnd.kind === 'local') {
933
+ summary =
934
+ `Foundation ${fnd.name}, in ${fnd.path}/ — its source is here` +
935
+ (sharedWith.length
936
+ ? `, and ${sharedWith.length === 1 ? 'another site uses' : `${sharedWith.length} other sites use`} it too: ${sharedWith.join(', ')} — a change to a section type changes ${sharedWith.length === 1 ? 'it' : 'them'} as well.`
937
+ : ws.sites.length > 1 ? '; no other site here uses it.' : '.')
938
+ } else if (fnd.kind === 'catalog') {
939
+ summary = `Foundation ${fnd.ref}, from the catalog — its source is not in this workspace; work within the section types it offers.`
940
+ } else if (fnd.kind === 'url') {
941
+ summary = `Foundation loaded from ${fnd.url} — its source is not in this workspace; work within the section types it offers.`
942
+ } else if (fnd.kind === 'unresolved') {
943
+ state = 'attention'
944
+ summary = `site.yml names the foundation \`${fnd.name}\`, which does not resolve: ${fnd.error}`
945
+ } else {
946
+ state = 'attention'
947
+ summary = 'site.yml names no foundation.'
948
+ }
949
+ const extensionLines = extensions.map((e) =>
950
+ e.kind === 'local'
951
+ ? `Extension ${e.name}, in ${e.path}/ — its source is here.`
952
+ : e.kind === 'catalog'
953
+ ? `Extension ${e.value}, from the catalog — its source is not in this workspace.`
954
+ : e.kind === 'url'
955
+ ? `Extension loaded from ${e.value} — its source is not in this workspace.`
956
+ : `Extension \`${e.value}\` names ${e.name ? `\`${e.name}\`, which no folder here holds` : 'nothing this site can find'}.`
957
+ )
958
+ if (extensions.some((e) => e.kind === 'unresolved')) state = 'attention'
959
+ checks.push({
960
+ id: 'project.foundation',
961
+ state,
962
+ summary,
963
+ lines: extensionLines,
964
+ detail: {
965
+ kind: fnd.kind,
966
+ name: fnd.name ?? null,
967
+ path: fnd.path ?? null,
968
+ url: fnd.url ?? null,
969
+ editable: fnd.kind === 'local',
970
+ sharedWith,
971
+ extensions: extensions.map(({ value, kind, name, path }) => ({ value, kind, name: name ?? null, path: path ?? null }))
972
+ }
973
+ })
974
+
975
+ // ⭐ Pages from outside the site's folder (`paths:` mounts, by the build's own rule): a pull
976
+ // never writes them (`uwx/git-authored.js`), and one in its own repository is edited there.
977
+ const { mountEntriesOf } = await import('@uniweb/build/site')
978
+ const siteRepo = repoOf(siteDir)?.top ?? null
979
+ const mounts = mountEntriesOf(siteYml?.paths).map(([segment, dir]) => {
980
+ const abs = resolve(siteDir, String(dir))
981
+ const exists = existsSync(abs)
982
+ const top = exists ? repoOf(abs)?.top ?? null : null
983
+ const repository = !exists ? null : !top ? 'none' : top === siteRepo ? 'same' : 'other'
984
+ return { segment, dir: String(dir), exists, repository }
985
+ })
986
+ const mountLine = (m) =>
987
+ !m.exists
988
+ ? `pages/${m.segment} names ${m.dir}, which does not exist.`
989
+ : m.repository === 'other'
990
+ ? `pages/${m.segment} comes from ${m.dir}, its own git repository — edits there are commits in it.`
991
+ : `pages/${m.segment} comes from ${m.dir}, outside the site's folder.`
992
+ checks.push(
993
+ mounts.length
994
+ ? {
995
+ id: 'project.mounts',
996
+ state: mounts.some((m) => !m.exists) ? 'attention' : 'ok',
997
+ summary: mounts.length === 1 ? mountLine(mounts[0]) : `${count(mounts.length, 'page folder')} come from outside the site's folder.`,
998
+ lines: [
999
+ ...(mounts.length === 1 ? [] : mounts.map(mountLine)),
1000
+ 'An edit made in the app to those pages does not come back on a pull, and the next push replaces it with the files.'
1001
+ ],
1002
+ detail: { mounts }
1003
+ }
1004
+ : { id: 'project.mounts', state: 'skipped', summary: "All the site's pages are in its own folder.", quiet: true, detail: { mounts: [] } }
1005
+ )
1006
+
1007
+ // The languages, by core's own rules: the one the files are written in, and the rest.
1008
+ const { normalizeLanguageList, resolveDefaultLocale, isWildcardLanguages } = await import('@uniweb/core/locale-config')
1009
+ const source = resolveDefaultLocale(siteYml || {})
1010
+ const wildcard = isWildcardLanguages(siteYml?.languages)
1011
+ const translations = normalizeLanguageList(siteYml?.languages).filter((code) => code !== source && code !== '*')
1012
+ checks.push(
1013
+ translations.length || wildcard
1014
+ ? {
1015
+ id: 'project.languages',
1016
+ state: 'ok',
1017
+ summary:
1018
+ `Written in ${source}, and translated into ${wildcard ? 'every language in locales/' : translations.join(', ')} — ` +
1019
+ `a text you change in ${source} needs translating again.`,
1020
+ lines: ['After a change: build, then `npx uniweb i18n sync`, and `npx uniweb i18n status` lists what is left.'],
1021
+ detail: { source, translations, wildcard }
1022
+ }
1023
+ : { id: 'project.languages', state: 'ok', summary: `One language: ${source}.`, detail: { source, translations: [], wildcard: false } }
1024
+ )
1025
+ return { checks, ws }
1026
+ }
1027
+
1028
+ // ─── where the site is hosted ───────────────────────────────────────────────
1029
+
1030
+ /** A host adapter's name as people know it (`S3 + CloudFront`), or its id. */
1031
+ async function hostTitle(host) {
1032
+ try {
1033
+ const { getAdapter } = await import('@uniweb/build/hosts')
1034
+ return getAdapter(host)?.display?.title || host
1035
+ } catch {
1036
+ return host
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * The CI workflow `uniweb add ci` writes for `host`, when it deploys this site: its file,
1042
+ * and the branches whose pushes run it. A workspace with several sites deploys one per
1043
+ * workflow, which names that site's build output.
1044
+ */
1045
+ function ciWorkflow(repo, host) {
1046
+ const rel = `.github/workflows/deploy-${host}.yml`
1047
+ let text
1048
+ try {
1049
+ text = readFileSync(join(repo.top, rel), 'utf8')
1050
+ } catch {
1051
+ return null
1052
+ }
1053
+ if (repo.path !== '.' && !text.includes(`${repo.path}/dist`)) return null
1054
+ let branches = []
1055
+ try {
1056
+ const doc = yaml.load(text)
1057
+ branches = (doc?.on ?? doc?.[true])?.push?.branches ?? []
1058
+ } catch {
1059
+ // unreadable: the branches are not known
1060
+ }
1061
+ return { file: rel, branches: Array.isArray(branches) && branches.length ? branches.map(String) : [] }
1062
+ }
1063
+
1064
+ /** The commits since `sha` that changed the site's files — null when this branch does not hold `sha`. */
1065
+ function siteCommitsSince(repo, sha, siteDir) {
1066
+ try {
1067
+ if (!isAncestor(repo, sha)) return null
1068
+ const roots = sentRoots(siteDir).map((r) => `${repo.prefix}${r}`)
1069
+ const out = execFileSync('git', ['rev-list', '--count', `${sha}..HEAD`, '--', ...roots], {
1070
+ cwd: repo.top,
1071
+ encoding: 'utf8',
1072
+ stdio: ['ignore', 'pipe', 'ignore']
1073
+ })
1074
+ return Number(out.trim())
1075
+ } catch {
1076
+ return null
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * `hosting.deploys`: where else the site is deployed — `deploy.yml`'s targets on hosts that
1082
+ * are not a backend — each by CI on every push, or by hand, and how far behind a
1083
+ * hand-deployed one is.
1084
+ *
1085
+ * ⛔ FROM THIS COPY'S FILES ONLY. A host's own state is not asked, and a receipt
1086
+ * (`deploys:`) says what this copy deployed — never what another copy, or a CI run, did.
1087
+ */
1088
+ async function deployChecks(siteDir) {
1089
+ let deployYml
1090
+ try {
1091
+ const { loadDeployYml } = await import('@uniweb/build/site')
1092
+ deployYml = await loadDeployYml(siteDir)
720
1093
  } catch (err) {
721
- return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}` }
1094
+ return { checks: [{ id: 'hosting.deploys', state: 'unknown', summary: `deploy.yml could not be read: ${err.message}` }], proposals: [], targets: [] }
1095
+ }
1096
+ const all = deployYml?.targets && typeof deployYml.targets === 'object' ? deployYml.targets : {}
1097
+ const names = Object.keys(all)
1098
+ const defaultName = deployYml?.default ?? (names.length === 1 ? names[0] : null)
1099
+ const elsewhere = Object.entries(all).filter(([, t]) => t && typeof t === 'object' && t.host && t.host !== 'uniweb')
1100
+ if (!elsewhere.length) {
1101
+ return { checks: [{ id: 'hosting.deploys', state: 'skipped', summary: 'Deployed to no host but a backend.', quiet: true }], proposals: [], targets: [] }
1102
+ }
1103
+ const repo = repoOf(siteDir)
1104
+ const proposals = []
1105
+ const targets = []
1106
+ const lines = []
1107
+ let attention = false
1108
+ for (const [name, t] of elsewhere) {
1109
+ const title = await hostTitle(t.host)
1110
+ const ci = repo?.head ? ciWorkflow(repo, t.host) : null
1111
+ const receipt = deployYml.deploys?.[name] || null
1112
+ const retired = !receipt && deployYml.lastDeploy?.[name] ? deployYml.lastDeploy[name] : null
1113
+ const behind = receipt?.git?.sha && repo?.head ? siteCommitsSince(repo, receipt.git.sha, siteDir) : null
1114
+ const when = receipt?.at ? sinceLabel(receipt.at, Date.now()) : null
1115
+ let how
1116
+ if (ci) {
1117
+ how = `deployed by CI on every push${ci.branches.length ? ` to ${ci.branches.join(' or ')}` : ''} (${ci.file})`
1118
+ } else if (receipt) {
1119
+ how =
1120
+ `deployed by hand, last ${when ?? 'at an unknown time'}${receipt.url ? ` to ${receipt.url}` : ''}` +
1121
+ (behind > 0 ? ` — the site changed in ${count(behind, 'commit')} since` : behind === 0 ? ', and the site has not changed since' : '')
1122
+ if (behind > 0) {
1123
+ attention = true
1124
+ proposals.push({ step: 'deploy', command: `npx uniweb deploy --target ${name}`, why: `update ${title}, ${count(behind, 'commit')} behind`, ask: true })
1125
+ }
1126
+ } else if (retired) {
1127
+ attention = true
1128
+ 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`
1129
+ } else {
1130
+ how = 'never deployed from this copy'
1131
+ }
1132
+ lines.push(`${title} — target \`${name}\`${name === defaultName ? ', the one a bare `uniweb deploy` uses' : ''}: ${how}`)
1133
+ targets.push({
1134
+ name,
1135
+ host: t.host,
1136
+ title,
1137
+ default: name === defaultName,
1138
+ ci: ci ? { file: ci.file, branches: ci.branches } : null,
1139
+ last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
1140
+ behind,
1141
+ ...(retired ? { retired: true } : {})
1142
+ })
1143
+ }
1144
+ return {
1145
+ checks: [
1146
+ {
1147
+ id: 'hosting.deploys',
1148
+ state: attention ? 'attention' : 'ok',
1149
+ summary: `Also deployed to ${targets.map((t) => t.title).join(' and ')}.`,
1150
+ lines,
1151
+ detail: { targets }
1152
+ }
1153
+ ],
1154
+ proposals,
1155
+ targets
722
1156
  }
723
1157
  }
724
1158
 
1159
+ const list = (names) => names.map((n) => `\`${n}\``).join(', ')
1160
+
1161
+ /**
1162
+ * `app.services`: what `site.yml` asks for, beside what the backend holds — the request it
1163
+ * stored, and what the last publish delivered (`last_published_services`). Comparing the
1164
+ * file with those is how to tell it is stale, by the backend's own contract.
1165
+ *
1166
+ * ⛔ A request is never said as what the site has: what is live is the backend's to say.
1167
+ */
1168
+ async function servicesCheck({ siteDir, siteYml, site }) {
1169
+ const { readServicesRequest } = await import('@uniweb/build/uwx')
1170
+ const { foundationSupports, serviceNotes } = await import('../backend/service-request.js')
1171
+ // ⚠️ Two shapes, as the backend sends them (measured 2026-10-26 on uniweb.app): the stored
1172
+ // request is rows (`{ name, enabled? }`), what the last publish delivered is names. A reader
1173
+ // of rows alone reads every live service as none.
1174
+ const on = (entries) =>
1175
+ (Array.isArray(entries) ? entries : [])
1176
+ .map((e) => (typeof e === 'string' ? e : e && typeof e.name === 'string' && e.enabled !== false ? e.name : null))
1177
+ .filter(Boolean)
1178
+ const asks = readServicesRequest(siteYml.services) || []
1179
+ const asked = on(asks)
1180
+ const lines = serviceNotes({ siteYml, supports: await foundationSupports(siteDir, siteYml) })
1181
+ const proposals = []
1182
+ if (!site) {
1183
+ return {
1184
+ checks: [
1185
+ {
1186
+ id: 'app.services',
1187
+ state: lines.length ? 'attention' : 'ok',
1188
+ summary: asked.length ? `site.yml asks for ${list(asked)}.` : 'site.yml asks for no services.',
1189
+ lines,
1190
+ detail: { asked, requested: null, live: null }
1191
+ }
1192
+ ],
1193
+ proposals
1194
+ }
1195
+ }
1196
+ const live = on(site.last_published_services)
1197
+ const requested = on(site.services)
1198
+ const waiting = requested.filter((n) => !live.includes(n))
1199
+ if (waiting.length && site.published) {
1200
+ lines.push(`Requested, not live yet: ${list(waiting)} — publishing makes ${waiting.length === 1 ? 'it' : 'them'} live, and may ask for payment.`)
1201
+ proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
1202
+ }
1203
+ const unsent = asked.filter((n) => !requested.includes(n))
1204
+ if (unsent.length) lines.push(`site.yml asks for ${list(unsent)}, which the backend does not hold yet — the next push sends the request.`)
1205
+ const offHere = asks.filter((a) => a.enabled === false && live.includes(a.name)).map((a) => a.name)
1206
+ if (offHere.length) {
1207
+ 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.`)
1208
+ }
1209
+ return {
1210
+ checks: [
1211
+ {
1212
+ id: 'app.services',
1213
+ state: lines.length ? 'attention' : 'ok',
1214
+ 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.',
1215
+ lines,
1216
+ detail: { asked, requested, live }
1217
+ }
1218
+ ],
1219
+ proposals
1220
+ }
1221
+ }
1222
+
1223
+ /** What the site is, in one line: where it is synced, and where it is live. */
1224
+ function summaryLine({ name, synced, live, targets, origin }) {
1225
+ const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
1226
+ if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
1227
+ else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
1228
+ if (targets.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${targets.map((t) => t.title).join(' and ')}`)
1229
+ else if (!synced.length) parts.push('not hosted anywhere yet')
1230
+ return `${name} — ${parts.join(' · ')}`
1231
+ }
1232
+
725
1233
  // ─── the command ────────────────────────────────────────────────────────────
726
1234
 
727
1235
  const GROUPS = [
1236
+ ['project', 'Project'],
728
1237
  ['git', 'Git'],
729
1238
  ['app', 'App'],
1239
+ ['hosting', 'Hosting'],
730
1240
  ['uniweb', 'Uniweb'],
731
1241
  ['health', 'Health'],
732
1242
  ['dev', 'Dev server']
733
1243
  ]
734
1244
 
1245
+ // Where a check is shown: where the site is live, and what it serves, are its hosting —
1246
+ // `app.live` and `app.services` kept their ids, which the document had before the group.
1247
+ const groupOf = (id) => (id === 'app.live' || id === 'app.services' || id.startsWith('hosting.') ? 'hosting' : id.split('.')[0])
1248
+
735
1249
  const ICON = {
736
1250
  ok: `${c.green}✓${c.reset}`,
737
1251
  attention: `${c.cyan}→${c.reset}`,
@@ -740,11 +1254,11 @@ const ICON = {
740
1254
  skipped: `${c.dim}·${c.reset}`
741
1255
  }
742
1256
 
743
- function render({ siteLabel, backend, checks, next }) {
1257
+ function render({ summary, backend, checks, next }) {
744
1258
  const out = ['']
745
- out.push(`${c.bold}Status of ${siteLabel}${c.reset}`)
1259
+ out.push(`${c.bold}${summary}${c.reset}`)
746
1260
  for (const [prefix, title] of GROUPS) {
747
- const group = checks.filter((k) => k.id.split('.')[0] === prefix && !(k.quiet && k.state === 'ok'))
1261
+ const group = checks.filter((k) => groupOf(k.id) === prefix && !(k.quiet && (k.state === 'ok' || k.state === 'skipped')))
748
1262
  if (!group.length) continue
749
1263
  out.push('')
750
1264
  out.push(prefix === 'app' ? `${c.bold}${title}${c.reset} ${c.dim}— ${backend.origin}${c.reset}` : `${c.bold}${title}${c.reset}`)
@@ -780,6 +1294,97 @@ function render({ siteLabel, backend, checks, next }) {
780
1294
  return out.join('\n')
781
1295
  }
782
1296
 
1297
+ /**
1298
+ * At a workspace with several sites: what each one is — synced where, deployed where, built
1299
+ * with which foundation, with pages from where, in how many languages — from its files alone,
1300
+ * and the way to one site's checks. Nothing is asked of a backend: what changed in the app is
1301
+ * a site's question.
1302
+ */
1303
+ async function workspaceOverview(root, { json }) {
1304
+ const ws = await workspaceFacts(root)
1305
+ const { mountEntriesOf } = await import('@uniweb/build/site')
1306
+ const { normalizeLanguageList } = await import('@uniweb/core/locale-config')
1307
+ const sites = []
1308
+ for (const s of ws.sites) {
1309
+ const dir = resolve(root, s.path)
1310
+ const yml = readSiteYml(dir)
1311
+ const synced = listSyncedBackends(dir).filter((o) => readBackendState(dir, o).site?.uuid)
1312
+ const { targets } = await deployChecks(dir)
1313
+ const fnd = await foundationOf(dir, yml, root)
1314
+ const siteRepo = repoOf(dir)?.top ?? null
1315
+ const mounts = mountEntriesOf(yml.paths).map(([segment, d]) => {
1316
+ const abs = resolve(dir, String(d))
1317
+ const top = existsSync(abs) ? repoOf(abs)?.top ?? null : null
1318
+ return { segment, dir: String(d), repository: !existsSync(abs) ? null : !top ? 'none' : top === siteRepo ? 'same' : 'other' }
1319
+ })
1320
+ const languages = normalizeLanguageList(yml.languages)
1321
+ const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced']
1322
+ if (targets.length) parts.push(`deployed to ${targets.map((t) => t.title).join(' and ')}`)
1323
+ parts.push(
1324
+ fnd.kind === 'local'
1325
+ ? `foundation ${fnd.name}`
1326
+ : fnd.kind === 'catalog' || fnd.kind === 'url'
1327
+ ? `foundation ${fnd.name}, not in this workspace`
1328
+ : `foundation ${fnd.name ? `\`${fnd.name}\`, unresolved` : 'not named'}`
1329
+ )
1330
+ for (const m of mounts) parts.push(`pages/${m.segment} from ${m.dir}${m.repository === 'other' ? ', its own repository' : ''}`)
1331
+ if (languages.length > 1) parts.push(`${languages.length} languages`)
1332
+ sites.push({
1333
+ path: s.path,
1334
+ name: s.name,
1335
+ summary: `${s.path} — ${parts.join(' · ')}`,
1336
+ synced,
1337
+ deploys: targets.map(({ name, host, title, default: isDefault, ci }) => ({ name, host, title, default: isDefault, ci })),
1338
+ foundation: { kind: fnd.kind, name: fnd.name ?? null, path: fnd.path ?? null },
1339
+ mounts,
1340
+ languages
1341
+ })
1342
+ }
1343
+ // The repository the workspace is in, as far as this machine knows — no remote is asked.
1344
+ const repo = repoOf(root)
1345
+ let git = null
1346
+ if (repo?.head) {
1347
+ let uncommitted = 0
1348
+ try {
1349
+ uncommitted = execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
1350
+ .split('\n')
1351
+ .filter(Boolean).length
1352
+ } catch {
1353
+ uncommitted = 0
1354
+ }
1355
+ git = { uncommitted, ...(aheadBehind(root) || {}), upstream: upstreamOf(root)?.tracking ?? null }
1356
+ }
1357
+ const summary = `${basename(root)} — ${workspaceLine(ws).replace(/\.$/, '')}`
1358
+ const first = ws.sites[0]
1359
+ if (json) {
1360
+ console.log(
1361
+ JSON.stringify({
1362
+ schema: STATUS_SCHEMA,
1363
+ uniweb: getCliVersion(),
1364
+ scope: 'workspace',
1365
+ summary,
1366
+ workspace: { patterns: ws.patterns, sites: ws.sites, foundations: ws.foundations, extensions: ws.extensions },
1367
+ sites,
1368
+ git,
1369
+ checks: [],
1370
+ next: [{ command: null, why: "run `npx uniweb status` in a site's folder for its checks — what changed in the app, in git, and where it is hosted", ask: false }]
1371
+ })
1372
+ )
1373
+ return { exitCode: 0 }
1374
+ }
1375
+ const out = ['', `${c.bold}${summary}${c.reset}`, '']
1376
+ for (const s of sites) out.push(` ${s.summary}`)
1377
+ if (git) {
1378
+ const said = [git.uncommitted ? `${count(git.uncommitted, 'file')} not committed` : 'all committed']
1379
+ if (git.upstream) said.push(git.ahead || git.behind ? `${git.ahead} ahead of, ${git.behind} behind ${git.upstream} as of the last fetch` : `level with ${git.upstream} as of the last fetch`)
1380
+ out.push('', `${c.dim}Git: ${said.join(' · ')}${c.reset}`)
1381
+ }
1382
+ out.push('', `A bare \`uniweb dev\` starts ${first.name}. For one site's checks — what changed in the app, in git, and where it is hosted — run status in its folder, for example:`)
1383
+ out.push(` ${c.cyan}cd ${first.path} && npx uniweb status${c.reset}`, '')
1384
+ console.log(out.join('\n'))
1385
+ return { exitCode: 0 }
1386
+ }
1387
+
783
1388
  export async function status(args = []) {
784
1389
  // See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
785
1390
  // Straight to stderr: this is a usage error, so it must not be mistaken for the
@@ -794,15 +1399,11 @@ export async function status(args = []) {
794
1399
 
795
1400
  const found = await findSite(process.cwd())
796
1401
  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.`)
805
- }
1402
+ // ⭐ A WORKSPACE OF SEVERAL SITES IS ANSWERED, not refused: what each one is, from its
1403
+ // files, and the way to one site's checks.
1404
+ if (found.several?.length) return workspaceOverview(found.root, { json })
1405
+ if (json) console.log(JSON.stringify({ schema: STATUS_SCHEMA, scope: null, error: 'no-site' }))
1406
+ else console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
806
1407
  return { exitCode: 2 }
807
1408
  }
808
1409
  const siteDir = found.siteDir
@@ -813,31 +1414,46 @@ export async function status(args = []) {
813
1414
  // helpers say on the way goes to stderr.
814
1415
  const saved = { log: console.log, info: console.info, warn: console.warn }
815
1416
  if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
816
- let git, app, version, health, dev
1417
+ let project, git, app, version, health, dev, deploys, services
817
1418
  try {
818
- git = await gitChecks(siteDir, { offline, origin })
819
- ;[app, version, health, dev] = await Promise.all([
1419
+ ;[project, git] = await Promise.all([projectChecks(siteDir, siteYml), gitChecks(siteDir, { offline, origin })])
1420
+ ;[app, version, health, dev, deploys] = await Promise.all([
820
1421
  appChecks(siteDir, siteYml, { offline, args, git }),
821
1422
  versionCheck(siteDir, { offline }),
822
1423
  doctorCheck(siteDir),
823
- devCheck(siteDir, { offline })
1424
+ devCheck(siteDir, { offline, sites: project.ws.sites }),
1425
+ deployChecks(siteDir)
824
1426
  ])
1427
+ services = await servicesCheck({ siteDir, siteYml, site: app.site })
825
1428
  } finally {
826
1429
  Object.assign(console, saved)
827
1430
  }
828
- const checks = [...git.checks, ...app.checks, ...version.checks, ...health.checks, dev]
829
- const next = planNext([...git.proposals, ...app.proposals, ...version.proposals, ...health.proposals])
1431
+ const checks = [...project.checks, ...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
1432
+ const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
830
1433
 
831
1434
  const root = findWorkspaceRoot(siteDir) || siteDir
832
1435
  const site = relative(root, siteDir) || '.'
1436
+ // What the site is, before what to do about it: the backends it is synced with — every one
1437
+ // this copy holds a site on, custom servers included — and where it is live.
1438
+ const synced = listSyncedBackends(siteDir).filter((o) => readBackendState(siteDir, o).site?.uuid)
1439
+ const summary = summaryLine({
1440
+ name: typeof siteYml.name === 'string' && siteYml.name ? siteYml.name : site === '.' ? 'This site' : site,
1441
+ synced,
1442
+ live: checks.find((k) => k.id === 'app.live')?.detail ?? null,
1443
+ targets: deploys.targets,
1444
+ origin: app.backend.origin
1445
+ })
833
1446
  if (json) {
834
1447
  console.log(
835
1448
  JSON.stringify({
836
1449
  schema: STATUS_SCHEMA,
837
1450
  uniweb: getCliVersion(),
1451
+ scope: 'site',
1452
+ summary,
838
1453
  site,
839
1454
  backend: app.backend.origin,
840
1455
  workspace: app.backend.workspace,
1456
+ synced,
841
1457
  offline,
842
1458
  checks: checks.map(({ quiet, id, state, summary, blocks = [], ...rest }) => ({ id, state, summary, blocks, ...rest })),
843
1459
  next
@@ -845,7 +1461,7 @@ export async function status(args = []) {
845
1461
  )
846
1462
  return { exitCode: 0 }
847
1463
  }
848
- console.log(render({ siteLabel: site === '.' ? 'this site' : `${site}/`, backend: app.backend, checks, next }))
1464
+ console.log(render({ summary, backend: app.backend, checks, next }))
849
1465
  return { exitCode: 0 }
850
1466
  }
851
1467