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.
- package/package.json +3 -3
- package/partials/agents.md +11 -3
- package/src/backend/app-changes.js +6 -0
- package/src/backend/service-request.js +21 -5
- package/src/commands/doctor.js +48 -0
- package/src/commands/pull.js +65 -7
- package/src/commands/status.js +654 -38
- package/src/framework-index.json +2 -2
- package/src/index.js +13 -5
- package/src/utils/status-next.js +2 -2
- package/src/utils/sync-point.js +20 -2
package/src/commands/status.js
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
* dev the dev server
|
|
17
17
|
*
|
|
18
18
|
* ⛔ READ-ONLY, AND IT NEVER PROMPTS. It signs nobody in — with no session it says how — and
|
|
19
|
-
* writes nothing a later command reads as taken: no ETag, version or
|
|
20
|
-
* (`backend/app-changes.js`).
|
|
21
|
-
* `--version` keep
|
|
19
|
+
* writes nothing a later command reads as taken: no ETag, version, hash or sync point
|
|
20
|
+
* (`probePull`, `backend/app-changes.js`). It writes the user-level npm cache `update` and
|
|
21
|
+
* `--version` keep, and — for a foundation named by catalog ref — the registered version's
|
|
22
|
+
* section types a pull keeps, which never change. A check it could not make is `unknown`,
|
|
23
|
+
* never `ok`.
|
|
22
24
|
*
|
|
23
25
|
* ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
|
|
24
26
|
* from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
|
|
@@ -39,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 {
|
|
62
|
+
import { probePull } from './pull.js'
|
|
63
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
64
|
+
import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
|
|
60
65
|
import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
|
|
61
66
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
62
67
|
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
@@ -248,7 +253,7 @@ async function gitChecks(siteDir, { offline, origin }) {
|
|
|
248
253
|
? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
|
|
249
254
|
: 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
|
|
250
255
|
})
|
|
251
|
-
return { checks, proposals, localFiles: null }
|
|
256
|
+
return { checks, proposals, localFiles: null, repo: false }
|
|
252
257
|
}
|
|
253
258
|
checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
|
|
254
259
|
|
|
@@ -334,7 +339,7 @@ async function gitChecks(siteDir, { offline, origin }) {
|
|
|
334
339
|
if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
|
|
335
340
|
if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
|
|
336
341
|
}
|
|
337
|
-
return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging }
|
|
342
|
+
return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging, repo: Boolean(repo.head) }
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
/**
|
|
@@ -488,9 +493,24 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
488
493
|
summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
|
|
489
494
|
})
|
|
490
495
|
|
|
496
|
+
// ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
|
|
497
|
+
// stopped before it takes anything (`probePull`): the files it would change, translations
|
|
498
|
+
// and records included. Where it cannot answer — outside git, no sync point to merge from —
|
|
499
|
+
// the backend's units against this copy's last sync (`backend/app-changes.js`).
|
|
500
|
+
const readChanges = async () => {
|
|
501
|
+
if (git.repo) {
|
|
502
|
+
const org = readOrgFlag(args)
|
|
503
|
+
const probed = await probePull([...(org ? ['--org', org] : []), ...(args.includes('--personal') ? ['--personal'] : [])], {
|
|
504
|
+
resolveSiteDir: async () => siteDir,
|
|
505
|
+
getToken: async () => session.token
|
|
506
|
+
})
|
|
507
|
+
if (probed) return { probe: probed }
|
|
508
|
+
}
|
|
509
|
+
return readAppChanges({ client, siteDir, siteUuid: uuid })
|
|
510
|
+
}
|
|
491
511
|
const [state, changes, foundation] = await Promise.all([
|
|
492
512
|
uuid ? client.siteState(uuid) : null,
|
|
493
|
-
uuid ?
|
|
513
|
+
uuid ? readChanges().catch((err) => ({ error: err })) : null,
|
|
494
514
|
foundationCheck(siteDir, siteYml, client).catch(() => null)
|
|
495
515
|
])
|
|
496
516
|
|
|
@@ -532,14 +552,35 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
532
552
|
|
|
533
553
|
const site = state?.site
|
|
534
554
|
if (site) {
|
|
555
|
+
// ⭐ WHERE THE SITE IS LIVE IS THE BACKEND'S TO SAY, never deploy.yml's: a receipt is
|
|
556
|
+
// what this copy published, and a publish from the app or another copy does not move it.
|
|
557
|
+
// ⛔ `last_published_url` outlives an unpublish (`client.siteStatus`), so it is said as
|
|
558
|
+
// where the site WAS live when it is not live now — never as where it is.
|
|
559
|
+
const url = typeof site.last_published_url === 'string' && site.last_published_url ? site.last_published_url : null
|
|
560
|
+
const when = site.last_published_at ? sinceLabel(site.last_published_at, Date.now()) : null
|
|
561
|
+
const at = `${url ? ` at ${url}` : ''}${when ? `, published ${when}` : ''}`
|
|
562
|
+
const detail = {
|
|
563
|
+
published: Boolean(site.published),
|
|
564
|
+
url,
|
|
565
|
+
published_at: site.last_published_at ?? null,
|
|
566
|
+
draft_dirty: Boolean(site.draft_dirty),
|
|
567
|
+
foundation: site.last_published_foundation ?? null,
|
|
568
|
+
runtime: site.last_published_runtime ?? null
|
|
569
|
+
}
|
|
535
570
|
if (!site.published) {
|
|
536
|
-
checks.push({
|
|
571
|
+
checks.push({
|
|
572
|
+
id: 'app.live',
|
|
573
|
+
state: 'attention',
|
|
574
|
+
summary: `On ${cloudName(origin)}, not live yet.`,
|
|
575
|
+
lines: url ? [`It was live at ${url} before.`] : [],
|
|
576
|
+
detail
|
|
577
|
+
})
|
|
537
578
|
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'take the site live — the person decides, and it may ask for payment', ask: true })
|
|
538
579
|
} else if (site.draft_dirty) {
|
|
539
|
-
checks.push({ id: 'app.live', state: 'attention', summary:
|
|
580
|
+
checks.push({ id: 'app.live', state: 'attention', summary: `Live on ${cloudName(origin)}${at} — the app holds changes that are not live yet.`, detail })
|
|
540
581
|
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
|
|
541
582
|
} else {
|
|
542
|
-
checks.push({ id: 'app.live', state: 'ok', summary:
|
|
583
|
+
checks.push({ id: 'app.live', state: 'ok', summary: `Live on ${cloudName(origin)}${at}, with the latest content.`, detail })
|
|
543
584
|
}
|
|
544
585
|
// An array is the backend's answer, `[]` included; no key is a backend that does not
|
|
545
586
|
// say, and then neither do we.
|
|
@@ -561,14 +602,20 @@ async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
|
561
602
|
}
|
|
562
603
|
}
|
|
563
604
|
if (foundation) checks.push(foundation)
|
|
564
|
-
return { checks, proposals, backend }
|
|
605
|
+
return { checks, proposals, backend, site: site ?? null }
|
|
565
606
|
}
|
|
566
607
|
|
|
608
|
+
/** The name a backend's hosting goes by: Uniweb Cloud on the default backend, its origin elsewhere. */
|
|
609
|
+
const cloudName = (origin) => (origin === DEFAULT_BACKEND_ORIGIN ? 'Uniweb Cloud' : origin)
|
|
610
|
+
|
|
611
|
+
const CHANGE = { A: 'added', M: 'changed', D: 'removed' }
|
|
612
|
+
|
|
567
613
|
/** `app.changes`: what moved on the backend, and where it meets what moved here. */
|
|
568
614
|
function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
569
615
|
if (changes.error) {
|
|
570
616
|
return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
|
|
571
617
|
}
|
|
618
|
+
if (changes.probe) return probedChanges(changes.probe, unpushedFiles, proposals)
|
|
572
619
|
const { content, records } = changes
|
|
573
620
|
const unknown = [content, records].filter((l) => l.state === 'unknown')
|
|
574
621
|
const files = [
|
|
@@ -586,7 +633,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
586
633
|
return [
|
|
587
634
|
unknown.length
|
|
588
635
|
? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
|
|
589
|
-
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.' }
|
|
636
|
+
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
|
|
590
637
|
]
|
|
591
638
|
}
|
|
592
639
|
// Where the backend's changes meet this copy's: a push of those is refused until a pull
|
|
@@ -605,6 +652,7 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
605
652
|
lines,
|
|
606
653
|
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
607
654
|
detail: {
|
|
655
|
+
source: 'units',
|
|
608
656
|
content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
|
|
609
657
|
records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
|
|
610
658
|
...(overlap.length ? { overlap } : {}),
|
|
@@ -614,6 +662,40 @@ function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
|
614
662
|
]
|
|
615
663
|
}
|
|
616
664
|
|
|
665
|
+
/**
|
|
666
|
+
* `app.changes` from the pull's own answer (`probePull`): the files it would change, each
|
|
667
|
+
* as the pull would write it.
|
|
668
|
+
*/
|
|
669
|
+
function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
|
|
670
|
+
if (!list.length) {
|
|
671
|
+
return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
|
|
672
|
+
}
|
|
673
|
+
const paths = list.map(([, p]) => p)
|
|
674
|
+
const files = list.map(([s, p]) => (s === 'A' ? `${p} (new)` : s === 'D' ? `${p} (removed)` : p))
|
|
675
|
+
// Where the backend's changes meet this copy's: the pull merges them, and a push of those
|
|
676
|
+
// before it is refused.
|
|
677
|
+
const overlap = unpushedFiles?.length ? paths.filter((p) => unpushedFiles.includes(p)) : []
|
|
678
|
+
const n = list.length
|
|
679
|
+
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'file')} changed on the backend` })
|
|
680
|
+
return [
|
|
681
|
+
{
|
|
682
|
+
id: 'app.changes',
|
|
683
|
+
state: overlap.length ? 'blocking' : 'attention',
|
|
684
|
+
summary:
|
|
685
|
+
`${count(n, 'file')} changed on the backend since this copy last synced — in the app, or pushed from another copy.` +
|
|
686
|
+
(overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
|
|
687
|
+
files,
|
|
688
|
+
lines: recordsNotPlaced ? ['Some records could not be placed in files — `npx uniweb pull` says which, and why.'] : [],
|
|
689
|
+
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
690
|
+
detail: {
|
|
691
|
+
source: 'pull',
|
|
692
|
+
files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })),
|
|
693
|
+
...(overlap.length ? { overlap } : {})
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
]
|
|
697
|
+
}
|
|
698
|
+
|
|
617
699
|
/**
|
|
618
700
|
* `app.foundation`: the local foundation against its registered version — what the next
|
|
619
701
|
* push or publish does with it (`backend/foundation-bring-along.js`).
|
|
@@ -708,30 +790,462 @@ async function doctorCheck(siteDir) {
|
|
|
708
790
|
}
|
|
709
791
|
}
|
|
710
792
|
|
|
711
|
-
/**
|
|
712
|
-
|
|
713
|
-
|
|
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:
|
|
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: '
|
|
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({
|
|
1257
|
+
function render({ summary, backend, checks, next }) {
|
|
744
1258
|
const out = ['']
|
|
745
|
-
out.push(`${c.bold}
|
|
1259
|
+
out.push(`${c.bold}${summary}${c.reset}`)
|
|
746
1260
|
for (const [prefix, title] of GROUPS) {
|
|
747
|
-
const group = checks.filter((k) => k.id
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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({
|
|
1464
|
+
console.log(render({ summary, backend: app.backend, checks, next }))
|
|
849
1465
|
return { exitCode: 0 }
|
|
850
1466
|
}
|
|
851
1467
|
|