uniweb 1.20.4 → 1.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/partials/agents.md +24 -5
- package/partials/start.md +17 -0
- package/src/backend/app-changes.js +159 -0
- package/src/backend/client.js +3 -3
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/backend/service-request.js +21 -5
- package/src/commands/doctor.js +89 -10
- package/src/commands/pull.js +82 -9
- package/src/commands/register.js +15 -15
- package/src/commands/status.js +1020 -256
- package/src/framework-index.json +2 -2
- package/src/index.js +38 -2
- package/src/utils/code-upload.js +27 -27
- package/src/utils/flag-guard.js +17 -2
- package/src/utils/git.js +91 -1
- package/src/utils/status-next.js +47 -0
- package/src/utils/sync-point.js +20 -2
- package/src/utils/update-check.js +8 -6
- package/templates/site/site.yml.hbs +4 -5
package/src/commands/status.js
CHANGED
|
@@ -1,81 +1,101 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* uniweb status —
|
|
3
|
-
* its sync identity, unpushed content changes, and the foundation it references.
|
|
2
|
+
* uniweb status — what to know before working on a site, and what to run about it.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* ⭐ THE ONE QUESTION AN AGENT ASKS IN A PROJECT OTHERS EDIT. Authors change a synced site in
|
|
5
|
+
* the app at any time, teammates push to git, and Uniweb releases often — and nothing else
|
|
6
|
+
* in the CLI says so before a command is refused. So status checks each of them, and ends
|
|
7
|
+
* with the steps to take, in an order that works when followed from the top
|
|
8
|
+
* (`utils/status-next.js`):
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* git conflicts, a merge waiting, the site's files not committed, the git remote
|
|
11
|
+
* app the session, the site, what changed on the backend since this copy last
|
|
12
|
+
* synced, what is not pushed, whether the draft is live, who has the site open,
|
|
13
|
+
* the foundation
|
|
14
|
+
* uniweb a newer release
|
|
15
|
+
* health doctor's findings
|
|
16
|
+
* dev the dev server
|
|
17
|
+
*
|
|
18
|
+
* ⛔ READ-ONLY, AND IT NEVER PROMPTS. It signs nobody in — with no session it says how — and
|
|
19
|
+
* writes nothing a later command reads as taken: no ETag, version, hash or sync point
|
|
20
|
+
* (`probePull`, `backend/app-changes.js`). It writes the user-level npm cache `update` and
|
|
21
|
+
* `--version` keep, and — for a foundation named by catalog ref — the registered version's
|
|
22
|
+
* section types a pull keeps, which never change. A check it could not make is `unknown`,
|
|
23
|
+
* never `ok`.
|
|
24
|
+
*
|
|
25
|
+
* ⛔ THE GUARDS ARE THE VERBS' OWN. Where status says a push would be refused, it says so
|
|
26
|
+
* from the function the push calls (`unsettledFiles`, `uncommittedContent`, `untakenBase`),
|
|
27
|
+
* so the two cannot drift.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ AN UPDATE IS NOT AN ALARM [Diego, 2026-10-26]: "most 'breaking' changes don't break a
|
|
30
|
+
* project, so we don't want to worry agents unnecessarily." A newer release is named with
|
|
31
|
+
* the one way to take it, the same for every release; none is called breaking.
|
|
15
32
|
*
|
|
16
33
|
* Usage:
|
|
17
|
-
* uniweb status
|
|
18
|
-
* uniweb status --
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* uniweb status --json One JSON line (adds a `remote` object under --remote, whose
|
|
22
|
-
* `site_state` is live / gone / unknown — `gone` only on the
|
|
23
|
-
* backend's own word that it holds no such site)
|
|
34
|
+
* uniweb status every check
|
|
35
|
+
* uniweb status --offline only what this machine knows — no request at all
|
|
36
|
+
* uniweb status --json one JSON document (`schema: 1`)
|
|
37
|
+
* --org @x / --personal the workspace to ask the backend in, for this command only
|
|
24
38
|
*
|
|
25
|
-
*
|
|
39
|
+
* ⛔ Until 2026-10-26 a bare status was offline, `--remote` asked the backend, and `--json`
|
|
40
|
+
* was `{ synced, uuid, foundation, changed, unchanged, remote }`. `--remote` is refused now
|
|
41
|
+
* (`utils/flag-guard.js`).
|
|
26
42
|
*/
|
|
27
43
|
|
|
44
|
+
import { execFileSync } from 'node:child_process'
|
|
28
45
|
import { existsSync, readFileSync } from 'node:fs'
|
|
29
|
-
import { join } from 'node:path'
|
|
46
|
+
import { join, relative, resolve } from 'node:path'
|
|
30
47
|
import yaml from 'js-yaml'
|
|
31
48
|
|
|
32
|
-
import { resolveSiteDir } from './deploy.js'
|
|
33
|
-
import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
|
|
34
|
-
import { resolveWorkspace } from '../backend/workspace.js'
|
|
35
49
|
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} from '../backend/
|
|
50
|
+
probeUnpushed,
|
|
51
|
+
readItemUuids,
|
|
52
|
+
readSiteWorkspace,
|
|
53
|
+
sentRoots,
|
|
54
|
+
uncommittedContent,
|
|
55
|
+
unitFilesOf,
|
|
56
|
+
unsettledFiles,
|
|
57
|
+
untakenBase
|
|
58
|
+
} from '../backend/site-sync.js'
|
|
59
|
+
import { describeWorkspace, resolveWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
60
|
+
import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
|
|
61
|
+
import { readAppChanges } from '../backend/app-changes.js'
|
|
62
|
+
import { probePull } from './pull.js'
|
|
63
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
64
|
+
import { listSyncedBackends, readBackendState } from '@uniweb/build/uwx'
|
|
65
|
+
import { resolveLocalFoundation, foundationScopedName } from '../backend/foundation-bring-along.js'
|
|
45
66
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
46
67
|
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
47
68
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
69
|
+
import { isExpired, readRegistryAuth } from '../utils/registry-auth.js'
|
|
70
|
+
import {
|
|
71
|
+
filesDifferFrom,
|
|
72
|
+
ignoringRepo,
|
|
73
|
+
isAncestor,
|
|
74
|
+
mergeInProgress,
|
|
75
|
+
readSyncPoint,
|
|
76
|
+
repoOf,
|
|
77
|
+
unmergedPaths
|
|
78
|
+
} from '../utils/sync-point.js'
|
|
79
|
+
import { aheadBehind, remoteMoved, upstreamOf } from '../utils/git.js'
|
|
80
|
+
import { getLatestVersion, isBehind } from '../utils/update-check.js'
|
|
81
|
+
import { syncedElsewhere } from '../utils/site-identity.js'
|
|
82
|
+
import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
|
|
83
|
+
import { classifyPackage, findSites, findWorkspaceRoot } from '../utils/workspace.js'
|
|
84
|
+
import { getCliVersion } from '../versions.js'
|
|
85
|
+
import { planNext } from '../utils/status-next.js'
|
|
48
86
|
|
|
49
87
|
const c = {
|
|
50
88
|
reset: '\x1b[0m',
|
|
51
89
|
bold: '\x1b[1m',
|
|
52
90
|
dim: '\x1b[2m',
|
|
91
|
+
red: '\x1b[31m',
|
|
53
92
|
cyan: '\x1b[36m',
|
|
54
93
|
green: '\x1b[32m',
|
|
55
94
|
yellow: '\x1b[33m'
|
|
56
95
|
}
|
|
57
|
-
const say = {
|
|
58
|
-
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
59
|
-
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
60
|
-
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
61
|
-
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
62
|
-
}
|
|
63
96
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!existsSync(p)) return {}
|
|
67
|
-
try {
|
|
68
|
-
return yaml.load(readFileSync(p, 'utf8')) || {}
|
|
69
|
-
} catch {
|
|
70
|
-
return {}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function foundationRef(siteYml) {
|
|
75
|
-
const f = siteYml.foundation
|
|
76
|
-
if (!f) return null
|
|
77
|
-
return typeof f === 'string' ? f : f.ref || null
|
|
78
|
-
}
|
|
97
|
+
/** What the document's version says: a reader keyed on it never reads another shape. */
|
|
98
|
+
export const STATUS_SCHEMA = 1
|
|
79
99
|
|
|
80
100
|
// What an item that this copy has not synced is called — by its Section on the site.
|
|
81
101
|
const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
|
|
@@ -150,244 +170,988 @@ export function fileByItemUuid(siteDir, backend) {
|
|
|
150
170
|
}
|
|
151
171
|
}
|
|
152
172
|
|
|
153
|
-
//
|
|
154
|
-
|
|
173
|
+
// ─── reading the project ────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function readSiteYml(siteDir) {
|
|
176
|
+
const p = join(siteDir, 'site.yml')
|
|
177
|
+
if (!existsSync(p)) return {}
|
|
178
|
+
try {
|
|
179
|
+
return yaml.load(readFileSync(p, 'utf8')) || {}
|
|
180
|
+
} catch {
|
|
181
|
+
return {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The site this status is about — never asked for: a status prompts for nothing. A site
|
|
187
|
+
* folder is its own; a workspace with one site, that one; with several, none, and the
|
|
188
|
+
* caller names them.
|
|
189
|
+
*/
|
|
190
|
+
async function findSite(cwd) {
|
|
191
|
+
if ((await classifyPackage(cwd)) === 'site') return { siteDir: cwd }
|
|
192
|
+
const root = findWorkspaceRoot(cwd)
|
|
193
|
+
if (!root) return { none: true }
|
|
194
|
+
const sites = await findSites(root)
|
|
195
|
+
if (sites.length === 1) return { siteDir: resolve(root, sites[0]) }
|
|
196
|
+
return sites.length ? { several: sites, root } : { none: true, root }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The `uniweb` this project runs: the copy installed in it, else the version its
|
|
201
|
+
* `package.json` names, else this CLI's own.
|
|
202
|
+
*/
|
|
203
|
+
function projectUniweb(siteDir) {
|
|
204
|
+
const root = findWorkspaceRoot(siteDir) || siteDir
|
|
205
|
+
try {
|
|
206
|
+
const v = JSON.parse(readFileSync(join(root, 'node_modules', 'uniweb', 'package.json'), 'utf8')).version
|
|
207
|
+
if (typeof v === 'string') return v
|
|
208
|
+
} catch {
|
|
209
|
+
// not installed
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
213
|
+
const spec = pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
|
|
214
|
+
const m = typeof spec === 'string' ? spec.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/) : null
|
|
215
|
+
if (m) return m[0]
|
|
216
|
+
} catch {
|
|
217
|
+
// no package.json
|
|
218
|
+
}
|
|
219
|
+
return getCliVersion()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function foundationRef(siteYml) {
|
|
223
|
+
const f = siteYml.foundation
|
|
224
|
+
if (!f) return null
|
|
225
|
+
return typeof f === 'string' ? f : f.ref || null
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// A versioned registry ref `@org/name@1.2.3` → its scoped name and pinned version.
|
|
155
229
|
function splitFoundationRef(fnd) {
|
|
156
230
|
if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
|
|
157
231
|
const at = fnd.lastIndexOf('@')
|
|
158
|
-
return at > 0
|
|
159
|
-
? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) }
|
|
160
|
-
: { scope: null, version: null }
|
|
232
|
+
return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
|
|
161
233
|
}
|
|
162
234
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
235
|
+
const count = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
|
|
236
|
+
|
|
237
|
+
// ─── the checks ─────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Git: conflicts, a merge waiting, what is not committed, and the git remote.
|
|
241
|
+
* @returns {Promise<{ checks: object[], proposals: object[], localFiles: string[]|null }>}
|
|
242
|
+
*/
|
|
243
|
+
async function gitChecks(siteDir, { offline, origin }) {
|
|
244
|
+
const checks = []
|
|
245
|
+
const proposals = []
|
|
246
|
+
const repo = repoOf(siteDir)
|
|
247
|
+
if (!repo) {
|
|
248
|
+
const ignored = Boolean(ignoringRepo(siteDir))
|
|
249
|
+
checks.push({
|
|
250
|
+
id: 'git.repo',
|
|
251
|
+
state: 'skipped',
|
|
252
|
+
summary: ignored
|
|
253
|
+
? 'The git repository around this folder ignores it, so a pull here takes the backend\'s version over your files, asking first.'
|
|
254
|
+
: 'Not a git repository, so a pull here takes the backend\'s version over your files, asking first.'
|
|
255
|
+
})
|
|
256
|
+
return { checks, proposals, localFiles: null, repo: false }
|
|
171
257
|
}
|
|
172
|
-
|
|
173
|
-
const remote = args.includes('--remote')
|
|
174
|
-
const siteDir = await resolveSiteDir(args, 'status')
|
|
175
|
-
const siteYml = readSiteYml(siteDir)
|
|
176
|
-
const fnd = foundationRef(siteYml)
|
|
177
|
-
const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
|
|
258
|
+
checks.push({ id: 'git.repo', state: 'ok', summary: `In the git repository at ${repo.top}.`, quiet: true })
|
|
178
259
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// The workspace this status works in — the login's, unless this command names
|
|
214
|
-
// another (`workspace.js`). None chosen is reported, like a site outside it.
|
|
215
|
-
const ws = await resolveWorkspace({ client, args })
|
|
216
|
-
if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
|
|
217
|
-
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
218
|
-
if (uuid) {
|
|
219
|
-
siteState = await client.siteState(uuid)
|
|
220
|
-
if (siteState.state === 'live') {
|
|
221
|
-
site = siteState.site || null
|
|
222
|
-
// In another workspace: it exists, and the refusal says where — report it.
|
|
223
|
-
if (siteState.status === 409) remoteError = siteState.detail
|
|
224
|
-
} else if (siteState.refused) {
|
|
225
|
-
// The workspace this status works in, refused: say so — it is not a fact about the site.
|
|
226
|
-
remoteError = siteState.detail
|
|
260
|
+
if (unsettledFiles(siteDir)) {
|
|
261
|
+
const files = unmergedPaths(repo).filter((p) => !repo.prefix || p.startsWith(repo.prefix))
|
|
262
|
+
checks.push({
|
|
263
|
+
id: 'git.conflicts',
|
|
264
|
+
state: 'blocking',
|
|
265
|
+
summary: `${count(files.length, 'file')} here ${files.length === 1 ? 'is' : 'are'} in conflict.`,
|
|
266
|
+
files,
|
|
267
|
+
blocks: ['push', 'publish', 'pull', 'refresh']
|
|
268
|
+
})
|
|
269
|
+
proposals.push({ step: 'conflicts', command: null, why: `settle the conflict markers in ${count(files.length, 'file')}, then \`git add\` each` })
|
|
270
|
+
} else {
|
|
271
|
+
checks.push({ id: 'git.conflicts', state: 'ok', summary: 'Nothing in conflict.', quiet: true })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const merging = mergeInProgress(repo)
|
|
275
|
+
checks.push(
|
|
276
|
+
merging
|
|
277
|
+
? { id: 'git.merge', state: 'blocking', summary: 'A merge is waiting for its commit.', blocks: ['push', 'publish', 'pull', 'refresh'] }
|
|
278
|
+
: { id: 'git.merge', state: 'ok', summary: 'No merge in progress.', quiet: true }
|
|
279
|
+
)
|
|
280
|
+
if (merging) proposals.push({ step: 'merge', command: 'git commit', why: 'finish the merge in progress' })
|
|
281
|
+
|
|
282
|
+
const uncommitted = merging ? null : uncommittedContent(siteDir, { verb: 'push' })
|
|
283
|
+
const n = uncommitted?.paths.length || 0
|
|
284
|
+
checks.push(
|
|
285
|
+
uncommitted
|
|
286
|
+
? {
|
|
287
|
+
id: 'git.uncommitted',
|
|
288
|
+
state: 'attention',
|
|
289
|
+
summary: repo.head
|
|
290
|
+
? `${n} of the site's files ${n === 1 ? 'is' : 'are'} not committed — in git a push sends what is committed.`
|
|
291
|
+
: 'This repository has no commit yet — in git a push sends what is committed.',
|
|
292
|
+
files: uncommitted.paths,
|
|
293
|
+
blocks: ['push', 'publish']
|
|
227
294
|
}
|
|
295
|
+
: { id: 'git.uncommitted', state: 'ok', summary: "The site's files are committed." }
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
// What this copy changed since its last sync, file by file — while its branch holds the
|
|
299
|
+
// sync point that sync recorded.
|
|
300
|
+
let localFiles = null
|
|
301
|
+
const point = readSyncPoint(siteDir, origin)
|
|
302
|
+
if (repo.head && point && isAncestor(repo, point)) localFiles = filesDifferFrom(repo, point, sentRoots(siteDir))
|
|
303
|
+
|
|
304
|
+
const upstream = upstreamOf(siteDir)
|
|
305
|
+
if (!upstream) {
|
|
306
|
+
checks.push({ id: 'git.remote', state: 'skipped', summary: 'No upstream branch to compare with.' })
|
|
307
|
+
} else {
|
|
308
|
+
const counts = aheadBehind(siteDir) || { ahead: 0, behind: 0 }
|
|
309
|
+
let moved = null
|
|
310
|
+
let asked = false
|
|
311
|
+
if (offline) {
|
|
312
|
+
moved = counts.behind > 0
|
|
313
|
+
} else {
|
|
314
|
+
const answer = await remoteMoved(siteDir)
|
|
315
|
+
if (answer) {
|
|
316
|
+
moved = answer.moved
|
|
317
|
+
asked = true
|
|
228
318
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
319
|
+
}
|
|
320
|
+
const parts = []
|
|
321
|
+
if (moved) parts.push(`${upstream.remote} has commits this branch does not${asked ? '' : ' (as of the last fetch)'}.`)
|
|
322
|
+
if (counts.ahead) parts.push(`${count(counts.ahead, 'commit')} here ${counts.ahead === 1 ? 'is' : 'are'} not on ${upstream.tracking}.`)
|
|
323
|
+
if (moved === null) {
|
|
324
|
+
checks.push({
|
|
325
|
+
id: 'git.remote',
|
|
326
|
+
state: 'unknown',
|
|
327
|
+
summary: `Could not ask ${upstream.remote} for new commits — no network, or it needs a sign-in.`,
|
|
328
|
+
lines: parts,
|
|
329
|
+
detail: { ...counts, upstream: upstream.tracking }
|
|
330
|
+
})
|
|
331
|
+
} else {
|
|
332
|
+
checks.push({
|
|
333
|
+
id: 'git.remote',
|
|
334
|
+
state: parts.length ? 'attention' : 'ok',
|
|
335
|
+
summary: parts.length ? parts.join(' ') : `Up to date with ${upstream.tracking}${asked ? '' : ' (as of the last fetch)'}.`,
|
|
336
|
+
detail: { ...counts, upstream: upstream.tracking }
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
if (moved) proposals.push({ step: 'refresh', command: 'npx uniweb refresh', why: "take your teammates' commits" })
|
|
340
|
+
if (counts.ahead) proposals.push({ step: 'gitpush', command: 'git push', why: `share ${count(counts.ahead, 'commit')} with your team`, ask: true })
|
|
341
|
+
}
|
|
342
|
+
return { checks, proposals, localFiles, uncommitted: Boolean(uncommitted), merging, repo: Boolean(repo.head) }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The session for `origin`, read and never asked for: UNIWEB_TOKEN, else the stored one.
|
|
347
|
+
* @returns {Promise<{ token: string, handle: string|null } | { expired: true } | null>}
|
|
348
|
+
*/
|
|
349
|
+
async function sessionFor(origin) {
|
|
350
|
+
if (process.env.UNIWEB_TOKEN) return { token: process.env.UNIWEB_TOKEN, handle: null }
|
|
351
|
+
const stored = await readRegistryAuth(origin)
|
|
352
|
+
if (!stored?.token) return null
|
|
353
|
+
if (isExpired(stored)) return { expired: true }
|
|
354
|
+
return { token: stored.token, handle: stored.handle || stored.username || null }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The sign-in an agent runs: on this backend — a bare login goes to the default one — and in
|
|
359
|
+
* the workspace the site was created in, when this copy knows it.
|
|
360
|
+
*/
|
|
361
|
+
function loginCommand(siteDir, origin, uuid) {
|
|
362
|
+
const server = origin === DEFAULT_BACKEND_ORIGIN ? '' : ` --server ${origin}`
|
|
363
|
+
if (!uuid) return `npx uniweb login --browser${server}`
|
|
364
|
+
const ws = readSiteWorkspace(siteDir, origin)
|
|
365
|
+
return `npx uniweb login --browser${server} ${ws ? `--org ${ws}` : '--personal'}`
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Unit paths → the files the author keeps them in, as a pull writes them back. */
|
|
369
|
+
function filesOfUnits(siteDir, units) {
|
|
370
|
+
return units.map((u) => (u === 'site.yml' ? 'site.yml' : unitFilesOf(siteDir, u)[0] ?? u))
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The app: what the backend holds of this site, against this copy.
|
|
375
|
+
* @returns {Promise<{ checks: object[], proposals: object[], backend: object }>}
|
|
376
|
+
*/
|
|
377
|
+
async function appChecks(siteDir, siteYml, { offline, args, git }) {
|
|
378
|
+
const checks = []
|
|
379
|
+
const proposals = []
|
|
380
|
+
const origin = resolveBackendOrigin()
|
|
381
|
+
const uuid = readBackendState(siteDir, origin).site?.uuid || null
|
|
382
|
+
const backend = { origin, workspace: null }
|
|
383
|
+
|
|
384
|
+
// ── What this machine knows ──
|
|
385
|
+
const base = git.merging ? null : untakenBase(siteDir, origin)
|
|
386
|
+
if (base) {
|
|
387
|
+
checks.push({
|
|
388
|
+
id: 'app.base',
|
|
389
|
+
state: 'blocking',
|
|
390
|
+
summary: base.headline.replace(/^Not pushed — /, '').replace(/^./, (ch) => ch.toUpperCase()),
|
|
391
|
+
blocks: ['push', 'publish']
|
|
392
|
+
})
|
|
393
|
+
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: "merge the backend's content with yours" })
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// What this copy holds that the backend lacks: by file, from the sync point, in git; else
|
|
397
|
+
// the send-only-changed probe a push runs, which counts entities.
|
|
398
|
+
let unpushedFiles = null
|
|
399
|
+
let unpushed = false
|
|
400
|
+
if (!uuid) {
|
|
401
|
+
const elsewhere = process.env.UNIWEB_SERVER ? null : syncedElsewhere(siteDir, origin)
|
|
402
|
+
checks.push(
|
|
403
|
+
elsewhere
|
|
404
|
+
? {
|
|
405
|
+
id: 'app.site',
|
|
406
|
+
state: 'attention',
|
|
407
|
+
summary: `This project's site is on ${elsewhere.join(', ')}, not on ${origin}, where you are signed in.`,
|
|
408
|
+
lines: [`To work with it there: npx uniweb login --server ${elsewhere[0]}`]
|
|
409
|
+
}
|
|
410
|
+
: { id: 'app.unpushed', state: 'skipped', summary: `Not in the app on ${origin} yet — \`npx uniweb push\` puts it there.` }
|
|
411
|
+
)
|
|
412
|
+
} else if (git.localFiles) {
|
|
413
|
+
unpushedFiles = git.localFiles
|
|
414
|
+
unpushed = unpushedFiles.length > 0
|
|
415
|
+
checks.push(
|
|
416
|
+
unpushed
|
|
417
|
+
? {
|
|
418
|
+
id: 'app.unpushed',
|
|
419
|
+
state: 'attention',
|
|
420
|
+
summary: `${count(unpushedFiles.length, 'file')} here ${unpushedFiles.length === 1 ? 'holds' : 'hold'} changes the backend does not have yet.`,
|
|
421
|
+
files: unpushedFiles
|
|
422
|
+
}
|
|
423
|
+
: { id: 'app.unpushed', state: 'ok', summary: 'Nothing waiting to be pushed.' }
|
|
424
|
+
)
|
|
425
|
+
} else {
|
|
426
|
+
try {
|
|
427
|
+
const probe = await probeUnpushed(siteDir, { backend: origin })
|
|
428
|
+
unpushed = probe.changed > 0
|
|
429
|
+
checks.push({
|
|
430
|
+
id: 'app.unpushed',
|
|
431
|
+
state: unpushed ? 'attention' : 'ok',
|
|
432
|
+
summary: unpushed ? 'This copy holds changes the backend does not have yet.' : 'Nothing waiting to be pushed.',
|
|
433
|
+
detail: { entities: probe.changed, unchanged: probe.unchanged }
|
|
434
|
+
})
|
|
242
435
|
} catch (err) {
|
|
243
|
-
|
|
244
|
-
// backend does not work on this site from, or the deployment has another. Saying
|
|
245
|
-
// nothing would read as "fine".
|
|
246
|
-
if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
|
|
247
|
-
// Not asked, or not answered: that is not knowing, never `gone`.
|
|
248
|
-
if (uuid && !siteState) siteState = { state: 'unknown', status: null, detail: err?.message || null }
|
|
436
|
+
checks.push({ id: 'app.unpushed', state: 'unknown', summary: `Could not tell what is not pushed: ${err.message}` })
|
|
249
437
|
}
|
|
250
438
|
}
|
|
439
|
+
if (uuid && (unpushed || git.uncommitted)) {
|
|
440
|
+
proposals.push({
|
|
441
|
+
step: 'push',
|
|
442
|
+
command: git.uncommitted ? 'npx uniweb push --commit -m "<what you changed>"' : 'npx uniweb push',
|
|
443
|
+
why: git.uncommitted ? 'commit your changes and send them to the app' : 'send your changes to the app'
|
|
444
|
+
})
|
|
445
|
+
}
|
|
251
446
|
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
foundation_latest: fdnLatest?.latest_version ?? null,
|
|
270
|
-
foundation_fresh: foundationFresh,
|
|
271
|
-
...(remoteError ? { error: remoteError } : {})
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
: {})
|
|
447
|
+
if (offline) {
|
|
448
|
+
checks.push({ id: 'app.session', state: 'skipped', summary: 'The backend was not asked (--offline).' })
|
|
449
|
+
return { checks, proposals, backend }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── What the backend says ──
|
|
453
|
+
const session = await sessionFor(origin)
|
|
454
|
+
if (!session || session.expired) {
|
|
455
|
+
if (!uuid) {
|
|
456
|
+
checks.push({ id: 'app.session', state: 'skipped', summary: `Not signed in to ${origin} — needed to bring the site into the app.` })
|
|
457
|
+
} else {
|
|
458
|
+
checks.push({
|
|
459
|
+
id: 'app.session',
|
|
460
|
+
state: 'unknown',
|
|
461
|
+
summary: session?.expired
|
|
462
|
+
? `The session on ${origin} has expired, so nothing there was checked.`
|
|
463
|
+
: `Not signed in to ${origin}, so nothing there was checked.`
|
|
275
464
|
})
|
|
276
|
-
|
|
277
|
-
|
|
465
|
+
proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: `sign in, so the backend can be checked` })
|
|
466
|
+
}
|
|
467
|
+
return { checks, proposals, backend }
|
|
278
468
|
}
|
|
279
469
|
|
|
280
|
-
|
|
470
|
+
const client = new BackendClient({ token: session.token, args, command: 'Status' })
|
|
471
|
+
const ws = await resolveWorkspace({ client, args }).catch((err) => ({ refused: true, reason: err.message }))
|
|
472
|
+
if (ws.refused) {
|
|
473
|
+
checks.push({
|
|
474
|
+
id: 'app.session',
|
|
475
|
+
state: 'blocking',
|
|
476
|
+
summary: `Signed in to ${origin}, with no workspace chosen.`,
|
|
477
|
+
lines: String(ws.reason || '').split('\n').map((l) => l.trim()).filter(Boolean),
|
|
478
|
+
blocks: ['push', 'publish', 'pull']
|
|
479
|
+
})
|
|
480
|
+
proposals.push({
|
|
481
|
+
step: 'login',
|
|
482
|
+
command: uuid && readSiteWorkspace(siteDir, origin) ? `npx uniweb login --org ${readSiteWorkspace(siteDir, origin)}` : null,
|
|
483
|
+
why: 'choose the workspace to work in — the person decides which',
|
|
484
|
+
ask: true
|
|
485
|
+
})
|
|
486
|
+
return { checks, proposals, backend }
|
|
487
|
+
}
|
|
488
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
489
|
+
backend.workspace = ws.workspace
|
|
490
|
+
checks.push({
|
|
491
|
+
id: 'app.session',
|
|
492
|
+
state: 'ok',
|
|
493
|
+
summary: `Signed in to ${origin}${session.handle ? ` as @${String(session.handle).replace(/^@/, '')}` : ''}, working in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source] || ws.source}).`
|
|
494
|
+
})
|
|
281
495
|
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
496
|
+
// ⭐ WHAT CHANGED ON THE BACKEND, AS A PULL WOULD TAKE IT. In a repository, the pull itself,
|
|
497
|
+
// stopped before it takes anything (`probePull`): the files it would change, translations
|
|
498
|
+
// and records included. Where it cannot answer — outside git, no sync point to merge from —
|
|
499
|
+
// the backend's units against this copy's last sync (`backend/app-changes.js`).
|
|
500
|
+
const readChanges = async () => {
|
|
501
|
+
if (git.repo) {
|
|
502
|
+
const org = readOrgFlag(args)
|
|
503
|
+
const probed = await probePull([...(org ? ['--org', org] : []), ...(args.includes('--personal') ? ['--personal'] : [])], {
|
|
504
|
+
resolveSiteDir: async () => siteDir,
|
|
505
|
+
getToken: async () => session.token
|
|
506
|
+
})
|
|
507
|
+
if (probed) return { probe: probed }
|
|
508
|
+
}
|
|
509
|
+
return readAppChanges({ client, siteDir, siteUuid: uuid })
|
|
290
510
|
}
|
|
511
|
+
const [state, changes, foundation] = await Promise.all([
|
|
512
|
+
uuid ? client.siteState(uuid) : null,
|
|
513
|
+
uuid ? readChanges().catch((err) => ({ error: err })) : null,
|
|
514
|
+
foundationCheck(siteDir, siteYml, client).catch(() => null)
|
|
515
|
+
])
|
|
291
516
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
517
|
+
if (state) {
|
|
518
|
+
// ⭐ `site_state` is the backend's word on this site — `live`, `gone` or `unknown` —
|
|
519
|
+
// for a script that decides on it: `gone` only when the backend says it holds no such
|
|
520
|
+
// site, never for a failure to reach it (`client.siteState`).
|
|
521
|
+
const detail = { site_state: state.state }
|
|
522
|
+
if (state.status === 401) {
|
|
523
|
+
checks.push({ id: 'app.site', state: 'unknown', summary: `${origin} did not accept the session — sign in again.`, detail })
|
|
524
|
+
proposals.push({ step: 'login', command: loginCommand(siteDir, origin, uuid), why: 'sign in again' })
|
|
525
|
+
} else if (state.state === 'gone') {
|
|
526
|
+
checks.push({
|
|
527
|
+
id: 'app.site',
|
|
528
|
+
state: 'blocking',
|
|
529
|
+
summary: `${origin} has no site ${uuid} — it was deleted there, or the backend was rebuilt.`,
|
|
530
|
+
lines: [`To push this as a new site: npx uniweb forget --server ${origin}, then npx uniweb push.`],
|
|
531
|
+
blocks: ['push', 'publish', 'pull'],
|
|
532
|
+
detail
|
|
533
|
+
})
|
|
534
|
+
} else if (state.state === 'live' && state.status === 409) {
|
|
535
|
+
checks.push({
|
|
536
|
+
id: 'app.site',
|
|
537
|
+
state: 'blocking',
|
|
538
|
+
summary: 'The site is kept in another workspace.',
|
|
539
|
+
lines: state.detail ? [state.detail] : [],
|
|
540
|
+
blocks: ['push', 'publish', 'pull'],
|
|
541
|
+
detail: { ...detail, workspace: state.workspace ?? null }
|
|
542
|
+
})
|
|
543
|
+
if (state.workspace) proposals.push({ step: 'login', command: `npx uniweb login --org ${state.workspace}`, why: `work in ${state.workspace}, where the site is`, ask: true })
|
|
544
|
+
} else if (state.state === 'live') {
|
|
545
|
+
checks.push({ id: 'app.site', state: 'ok', summary: `The site is in the app on ${origin}.`, quiet: true, detail })
|
|
546
|
+
} else {
|
|
547
|
+
checks.push({ id: 'app.site', state: 'unknown', summary: `Could not tell whether ${origin} holds this site${state.detail ? ` (${state.detail})` : ''}.`, detail })
|
|
548
|
+
}
|
|
313
549
|
}
|
|
314
550
|
|
|
315
|
-
|
|
316
|
-
if (fnd) say.dim(`Foundation: ${fnd}`)
|
|
551
|
+
if (changes) checks.push(...changeChecks(siteDir, changes, unpushedFiles, proposals))
|
|
317
552
|
|
|
318
|
-
|
|
319
|
-
if (
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
553
|
+
const site = state?.site
|
|
554
|
+
if (site) {
|
|
555
|
+
// ⭐ WHERE THE SITE IS LIVE IS THE BACKEND'S TO SAY, never deploy.yml's: a receipt is
|
|
556
|
+
// what this copy published, and a publish from the app or another copy does not move it.
|
|
557
|
+
// ⛔ `last_published_url` outlives an unpublish (`client.siteStatus`), so it is said as
|
|
558
|
+
// where the site WAS live when it is not live now — never as where it is.
|
|
559
|
+
const url = typeof site.last_published_url === 'string' && site.last_published_url ? site.last_published_url : null
|
|
560
|
+
const when = site.last_published_at ? sinceLabel(site.last_published_at, Date.now()) : null
|
|
561
|
+
const at = `${url ? ` at ${url}` : ''}${when ? `, published ${when}` : ''}`
|
|
562
|
+
const detail = {
|
|
563
|
+
published: Boolean(site.published),
|
|
564
|
+
url,
|
|
565
|
+
published_at: site.last_published_at ?? null,
|
|
566
|
+
draft_dirty: Boolean(site.draft_dirty),
|
|
567
|
+
foundation: site.last_published_foundation ?? null,
|
|
568
|
+
runtime: site.last_published_runtime ?? null
|
|
330
569
|
}
|
|
331
|
-
if (site) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
570
|
+
if (!site.published) {
|
|
571
|
+
checks.push({
|
|
572
|
+
id: 'app.live',
|
|
573
|
+
state: 'attention',
|
|
574
|
+
summary: `On ${cloudName(origin)}, not live yet.`,
|
|
575
|
+
lines: url ? [`It was live at ${url} before.`] : [],
|
|
576
|
+
detail
|
|
577
|
+
})
|
|
578
|
+
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'take the site live — the person decides, and it may ask for payment', ask: true })
|
|
579
|
+
} else if (site.draft_dirty) {
|
|
580
|
+
checks.push({ id: 'app.live', state: 'attention', summary: `Live on ${cloudName(origin)}${at} — the app holds changes that are not live yet.`, detail })
|
|
581
|
+
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
|
|
582
|
+
} else {
|
|
583
|
+
checks.push({ id: 'app.live', state: 'ok', summary: `Live on ${cloudName(origin)}${at}, with the latest content.`, detail })
|
|
343
584
|
}
|
|
344
585
|
// An array is the backend's answer, `[]` included; no key is a backend that does not
|
|
345
586
|
// say, and then neither do we.
|
|
346
|
-
if (Array.isArray(site
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
587
|
+
if (Array.isArray(site.editors)) {
|
|
588
|
+
const people = describeEditors(site.editors, { fileOf: fileByItemUuid(siteDir, origin) })
|
|
589
|
+
// One person is named once, in the summary; several, on each of their tabs.
|
|
590
|
+
const who = (p) => (people.length === 1 ? '' : `${p.person}: `)
|
|
591
|
+
checks.push(
|
|
592
|
+
people.length
|
|
593
|
+
? {
|
|
594
|
+
id: 'app.editors',
|
|
595
|
+
state: 'attention',
|
|
596
|
+
summary: `Open in the app now: ${people.map((p) => p.person).join(', ')}.`,
|
|
597
|
+
lines: people.flatMap((p) => (p.tabs.length ? p.tabs.map((t) => `${who(p)}${t}`) : [`${who(p)}nothing open`])),
|
|
598
|
+
detail: { editors: site.editors }
|
|
599
|
+
}
|
|
600
|
+
: { id: 'app.editors', state: 'ok', summary: 'Nobody has the site open in the app.', detail: { editors: [] } }
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (foundation) checks.push(foundation)
|
|
605
|
+
return { checks, proposals, backend, site: site ?? null }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The name a backend's hosting goes by: Uniweb Cloud on the default backend, its origin elsewhere. */
|
|
609
|
+
const cloudName = (origin) => (origin === DEFAULT_BACKEND_ORIGIN ? 'Uniweb Cloud' : origin)
|
|
610
|
+
|
|
611
|
+
const CHANGE = { A: 'added', M: 'changed', D: 'removed' }
|
|
612
|
+
|
|
613
|
+
/** `app.changes`: what moved on the backend, and where it meets what moved here. */
|
|
614
|
+
function changeChecks(siteDir, changes, unpushedFiles, proposals) {
|
|
615
|
+
if (changes.error) {
|
|
616
|
+
return [{ id: 'app.changes', state: 'unknown', summary: `Could not read the site from the backend: ${changes.error.message}` }]
|
|
617
|
+
}
|
|
618
|
+
if (changes.probe) return probedChanges(changes.probe, unpushedFiles, proposals)
|
|
619
|
+
const { content, records } = changes
|
|
620
|
+
const unknown = [content, records].filter((l) => l.state === 'unknown')
|
|
621
|
+
const files = [
|
|
622
|
+
...filesOfUnits(siteDir, [...content.changed, ...content.removed]),
|
|
623
|
+
...content.added.map((u) => `${u} (new)`)
|
|
624
|
+
]
|
|
625
|
+
const lines = []
|
|
626
|
+
if (content.outsideUnits) lines.push("the site's settings — its services, languages or queries")
|
|
627
|
+
if (records.changed.length) lines.push(`records changed: ${records.changed.join(', ')}`)
|
|
628
|
+
if (records.added.length) lines.push(`records added: ${records.added.join(', ')}`)
|
|
629
|
+
if (records.removed) lines.push(`${count(records.removed, 'record')} removed`)
|
|
630
|
+
if (records.folder) lines.push('the records\' sub-folders (folder.yml)')
|
|
631
|
+
const moved = files.length + lines.length
|
|
632
|
+
if (!moved) {
|
|
633
|
+
return [
|
|
634
|
+
unknown.length
|
|
635
|
+
? { id: 'app.changes', state: 'unknown', summary: `Could not tell what changed on the backend: ${unknown.map((l) => l.detail).join('; ')}.` }
|
|
636
|
+
: { id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'units' } }
|
|
637
|
+
]
|
|
638
|
+
}
|
|
639
|
+
// Where the backend's changes meet this copy's: a push of those is refused until a pull
|
|
640
|
+
// merges them — `diffSiteUnits`' "changed on both sides".
|
|
641
|
+
const overlap = unpushedFiles?.length ? files.filter((f) => unpushedFiles.includes(f)) : []
|
|
642
|
+
const n = files.length + content.outsideUnits + records.changed.length + records.added.length + records.removed + records.folder
|
|
643
|
+
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'change')} made on the backend` })
|
|
644
|
+
return [
|
|
645
|
+
{
|
|
646
|
+
id: 'app.changes',
|
|
647
|
+
state: overlap.length ? 'blocking' : 'attention',
|
|
648
|
+
summary:
|
|
649
|
+
`${count(n, 'change')} on the backend since this copy last synced — in the app, or pushed from another copy.` +
|
|
650
|
+
(overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
|
|
651
|
+
files,
|
|
652
|
+
lines,
|
|
653
|
+
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
654
|
+
detail: {
|
|
655
|
+
source: 'units',
|
|
656
|
+
content: { state: content.state, changed: content.changed, added: content.added, removed: content.removed, outsideUnits: content.outsideUnits },
|
|
657
|
+
records: { state: records.state, changed: records.changed, added: records.added, removed: records.removed, folder: records.folder },
|
|
658
|
+
...(overlap.length ? { overlap } : {}),
|
|
659
|
+
...(unknown.length ? { unknown: unknown.map((l) => l.detail) } : {})
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
]
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* `app.changes` from the pull's own answer (`probePull`): the files it would change, each
|
|
667
|
+
* as the pull would write it.
|
|
668
|
+
*/
|
|
669
|
+
function probedChanges({ files: list, recordsNotPlaced }, unpushedFiles, proposals) {
|
|
670
|
+
if (!list.length) {
|
|
671
|
+
return [{ id: 'app.changes', state: 'ok', summary: 'Nothing changed on the backend since this copy last synced.', detail: { source: 'pull', files: [] } }]
|
|
672
|
+
}
|
|
673
|
+
const paths = list.map(([, p]) => p)
|
|
674
|
+
const files = list.map(([s, p]) => (s === 'A' ? `${p} (new)` : s === 'D' ? `${p} (removed)` : p))
|
|
675
|
+
// Where the backend's changes meet this copy's: the pull merges them, and a push of those
|
|
676
|
+
// before it is refused.
|
|
677
|
+
const overlap = unpushedFiles?.length ? paths.filter((p) => unpushedFiles.includes(p)) : []
|
|
678
|
+
const n = list.length
|
|
679
|
+
proposals.push({ step: 'pull', command: 'npx uniweb pull', why: `take the ${count(n, 'file')} changed on the backend` })
|
|
680
|
+
return [
|
|
681
|
+
{
|
|
682
|
+
id: 'app.changes',
|
|
683
|
+
state: overlap.length ? 'blocking' : 'attention',
|
|
684
|
+
summary:
|
|
685
|
+
`${count(n, 'file')} changed on the backend since this copy last synced — in the app, or pushed from another copy.` +
|
|
686
|
+
(overlap.length ? ` You changed ${count(overlap.length, 'of those files', 'of those files')} here too.` : ''),
|
|
687
|
+
files,
|
|
688
|
+
lines: recordsNotPlaced ? ['Some records could not be placed in files — `npx uniweb pull` says which, and why.'] : [],
|
|
689
|
+
...(overlap.length ? { blocks: ['push', 'publish'] } : {}),
|
|
690
|
+
detail: {
|
|
691
|
+
source: 'pull',
|
|
692
|
+
files: list.map(([s, p]) => ({ path: p, change: CHANGE[s] || s })),
|
|
693
|
+
...(overlap.length ? { overlap } : {})
|
|
356
694
|
}
|
|
357
695
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
696
|
+
]
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* `app.foundation`: the local foundation against its registered version — what the next
|
|
701
|
+
* push or publish does with it (`backend/foundation-bring-along.js`).
|
|
702
|
+
*/
|
|
703
|
+
async function foundationCheck(siteDir, siteYml, client) {
|
|
704
|
+
const fnd = foundationRef(siteYml)
|
|
705
|
+
const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
|
|
706
|
+
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
707
|
+
const name = (local && (await foundationScopedName(local.dir))) || fndScope
|
|
708
|
+
if (!name) return null
|
|
709
|
+
const latest = await client.readFoundationLatest(name)
|
|
710
|
+
if (!latest?.latest_version) return null
|
|
711
|
+
if (local?.dir) {
|
|
712
|
+
const digest = latest.digest ? computeFoundationDigest(join(local.dir, 'dist')) : null
|
|
713
|
+
if (!digest) return null // not built, or the registry keeps no digest: nothing to compare
|
|
714
|
+
if (digest === latest.digest) return { id: 'app.foundation', state: 'ok', summary: 'The foundation matches its registered version.' }
|
|
715
|
+
if (compareSemverPrecedence(local.version, latest.latest_version) === -1) {
|
|
716
|
+
return {
|
|
717
|
+
id: 'app.foundation',
|
|
718
|
+
state: 'blocking',
|
|
719
|
+
summary: `The registry holds foundation ${latest.latest_version}, newer than your ${local.version} — a push or publish stops until you pull that change, or pass --bump to release yours above it.`,
|
|
720
|
+
blocks: ['push', 'publish']
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return { id: 'app.foundation', state: 'attention', summary: "The foundation's code changed since it was registered — the next push or publish releases it as the next version." }
|
|
724
|
+
}
|
|
725
|
+
if (fndVersion && latest.latest_version !== fndVersion) {
|
|
726
|
+
return { id: 'app.foundation', state: 'attention', summary: `A newer version of the foundation (${latest.latest_version}) is registered than the site pins (${fndVersion}).` }
|
|
727
|
+
}
|
|
728
|
+
return { id: 'app.foundation', state: 'ok', summary: `The site pins the latest version of its foundation (${fndVersion}).` }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** `uniweb.version`: a newer Uniweb, named the same way for every release. */
|
|
732
|
+
async function versionCheck(siteDir, { offline }) {
|
|
733
|
+
const runs = projectUniweb(siteDir)
|
|
734
|
+
if (offline) return { checks: [{ id: 'uniweb.version', state: 'skipped', summary: `This project runs Uniweb ${runs}; npm was not asked (--offline).` }], proposals: [] }
|
|
735
|
+
const latest = await getLatestVersion({ allowNetwork: true, timeoutMs: 2000, maxAgeMs: 60 * 60 * 1000 })
|
|
736
|
+
if (!latest) return { checks: [{ id: 'uniweb.version', state: 'unknown', summary: `This project runs Uniweb ${runs}; npm did not answer, so a newer release is not known.` }], proposals: [] }
|
|
737
|
+
if (!isBehind(runs, latest)) {
|
|
738
|
+
return { checks: [{ id: 'uniweb.version', state: 'ok', summary: `Uniweb ${runs}, the latest.`, detail: { runs, latest } }], proposals: [] }
|
|
739
|
+
}
|
|
740
|
+
return {
|
|
741
|
+
checks: [{ id: 'uniweb.version', state: 'attention', summary: `Uniweb ${latest} is out; this project runs ${runs}.`, detail: { runs, latest } }],
|
|
742
|
+
proposals: [{ step: 'update', latest: true, command: 'npx uniweb@latest update', why: 'bring the project and its AGENTS.md up to date — then build, and look at the pages' }]
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Doctor's findings `uniweb update` answers: the project's dependencies, or its AGENTS.md,
|
|
747
|
+
// behind the CLI it runs.
|
|
748
|
+
const UPDATE_FINDINGS = new Set(['deps-behind-cli', 'agents-md-missing', 'agents-md-unstamped', 'agents-md-outdated'])
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* `health.doctor`: doctor's findings, read without printing them.
|
|
752
|
+
*
|
|
753
|
+
* ⚠️ doctor measures the project against the CLI that RUNS it. Run by a newer CLI than the
|
|
754
|
+
* project's (`npx uniweb@latest status`), its dependency and AGENTS.md findings are against
|
|
755
|
+
* that newer one — which the project's own `uniweb update` does not know — so the step that
|
|
756
|
+
* answers them is the newer CLI's.
|
|
757
|
+
*/
|
|
758
|
+
async function doctorCheck(siteDir) {
|
|
759
|
+
try {
|
|
760
|
+
const { doctor } = await import('./doctor.js')
|
|
761
|
+
const res = await doctor([], { quiet: true })
|
|
762
|
+
const open = (res.issues || []).filter((i) => !i.fixed && (i.type === 'error' || i.type === 'warn'))
|
|
763
|
+
if (!open.length) return { checks: [{ id: 'health.doctor', state: 'ok', summary: 'doctor: every check passes.' }], proposals: [] }
|
|
764
|
+
const ids = [...new Set(open.map((i) => i.id).filter(Boolean))]
|
|
765
|
+
const parts = [res.errors && count(res.errors, 'error'), res.warnings && count(res.warnings, 'warning')].filter(Boolean)
|
|
766
|
+
const proposals = []
|
|
767
|
+
if (open.some((i) => UPDATE_FINDINGS.has(i.id))) {
|
|
768
|
+
const newer = isBehind(projectUniweb(siteDir), getCliVersion())
|
|
769
|
+
proposals.push(
|
|
770
|
+
newer
|
|
771
|
+
? { step: 'update', latest: true, command: 'npx uniweb@latest update', why: 'bring the project and its AGENTS.md up to date — then build, and look at the pages' }
|
|
772
|
+
: { step: 'update', command: 'npx uniweb update', why: "align the dependencies and AGENTS.md with this project's Uniweb" }
|
|
365
773
|
)
|
|
366
774
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
775
|
+
const rest = open.filter((i) => !UPDATE_FINDINGS.has(i.id))
|
|
776
|
+
if (rest.length) proposals.push({ step: 'doctor', command: 'npx uniweb doctor', why: `read its ${count(rest.length, 'finding')} — \`--fix\` repairs what it can` })
|
|
777
|
+
return {
|
|
778
|
+
checks: [
|
|
779
|
+
{
|
|
780
|
+
id: 'health.doctor',
|
|
781
|
+
state: 'attention',
|
|
782
|
+
summary: `doctor: ${parts.join(', ')}${ids.length ? ` (${ids.join(', ')})` : ''}.`,
|
|
783
|
+
detail: { errors: res.errors, warnings: res.warnings, issues: open.map(({ id, type, message }) => ({ id: id ?? null, type, message })) }
|
|
784
|
+
}
|
|
785
|
+
],
|
|
786
|
+
proposals
|
|
787
|
+
}
|
|
788
|
+
} catch (err) {
|
|
789
|
+
return { checks: [{ id: 'health.doctor', state: 'unknown', summary: `doctor did not finish: ${err.message}` }], proposals: [] }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** `dev.server`: the site's dev server, confirmed by asking it. */
|
|
794
|
+
async function devCheck(siteDir, { offline }) {
|
|
795
|
+
if (offline) return { id: 'dev.server', state: 'skipped', summary: 'The dev server was not asked (--offline).' }
|
|
796
|
+
try {
|
|
797
|
+
const { runningDevServer } = await import('@uniweb/build/site')
|
|
798
|
+
const running = await runningDevServer(siteDir, { timeout: 1000 })
|
|
799
|
+
return running
|
|
800
|
+
? { id: 'dev.server', state: 'ok', summary: `The dev server is running at ${running.url}.`, detail: { url: running.url } }
|
|
801
|
+
: { id: 'dev.server', state: 'skipped', summary: 'The dev server is not running — `npx uniweb dev` starts it.' }
|
|
802
|
+
} catch (err) {
|
|
803
|
+
return { id: 'dev.server', state: 'unknown', summary: `Could not ask the dev server: ${err.message}` }
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ─── where the site is hosted ───────────────────────────────────────────────
|
|
808
|
+
|
|
809
|
+
/** A host adapter's name as people know it (`S3 + CloudFront`), or its id. */
|
|
810
|
+
async function hostTitle(host) {
|
|
811
|
+
try {
|
|
812
|
+
const { getAdapter } = await import('@uniweb/build/hosts')
|
|
813
|
+
return getAdapter(host)?.display?.title || host
|
|
814
|
+
} catch {
|
|
815
|
+
return host
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The CI workflow `uniweb add ci` writes for `host`, when it deploys this site: its file,
|
|
821
|
+
* and the branches whose pushes run it. A workspace with several sites deploys one per
|
|
822
|
+
* workflow, which names that site's build output.
|
|
823
|
+
*/
|
|
824
|
+
function ciWorkflow(repo, host) {
|
|
825
|
+
const rel = `.github/workflows/deploy-${host}.yml`
|
|
826
|
+
let text
|
|
827
|
+
try {
|
|
828
|
+
text = readFileSync(join(repo.top, rel), 'utf8')
|
|
829
|
+
} catch {
|
|
830
|
+
return null
|
|
831
|
+
}
|
|
832
|
+
if (repo.path !== '.' && !text.includes(`${repo.path}/dist`)) return null
|
|
833
|
+
let branches = []
|
|
834
|
+
try {
|
|
835
|
+
const doc = yaml.load(text)
|
|
836
|
+
branches = (doc?.on ?? doc?.[true])?.push?.branches ?? []
|
|
837
|
+
} catch {
|
|
838
|
+
// unreadable: the branches are not known
|
|
839
|
+
}
|
|
840
|
+
return { file: rel, branches: Array.isArray(branches) && branches.length ? branches.map(String) : [] }
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** The commits since `sha` that changed the site's files — null when this branch does not hold `sha`. */
|
|
844
|
+
function siteCommitsSince(repo, sha, siteDir) {
|
|
845
|
+
try {
|
|
846
|
+
if (!isAncestor(repo, sha)) return null
|
|
847
|
+
const roots = sentRoots(siteDir).map((r) => `${repo.prefix}${r}`)
|
|
848
|
+
const out = execFileSync('git', ['rev-list', '--count', `${sha}..HEAD`, '--', ...roots], {
|
|
849
|
+
cwd: repo.top,
|
|
850
|
+
encoding: 'utf8',
|
|
851
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
852
|
+
})
|
|
853
|
+
return Number(out.trim())
|
|
854
|
+
} catch {
|
|
855
|
+
return null
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* `hosting.deploys`: where else the site is deployed — `deploy.yml`'s targets on hosts that
|
|
861
|
+
* are not a backend — each by CI on every push, or by hand, and how far behind a
|
|
862
|
+
* hand-deployed one is.
|
|
863
|
+
*
|
|
864
|
+
* ⛔ FROM THIS COPY'S FILES ONLY. A host's own state is not asked, and a receipt
|
|
865
|
+
* (`deploys:`) says what this copy deployed — never what another copy, or a CI run, did.
|
|
866
|
+
*/
|
|
867
|
+
async function deployChecks(siteDir) {
|
|
868
|
+
let deployYml
|
|
869
|
+
try {
|
|
870
|
+
const { loadDeployYml } = await import('@uniweb/build/site')
|
|
871
|
+
deployYml = await loadDeployYml(siteDir)
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return { checks: [{ id: 'hosting.deploys', state: 'unknown', summary: `deploy.yml could not be read: ${err.message}` }], proposals: [], targets: [] }
|
|
874
|
+
}
|
|
875
|
+
const all = deployYml?.targets && typeof deployYml.targets === 'object' ? deployYml.targets : {}
|
|
876
|
+
const names = Object.keys(all)
|
|
877
|
+
const defaultName = deployYml?.default ?? (names.length === 1 ? names[0] : null)
|
|
878
|
+
const elsewhere = Object.entries(all).filter(([, t]) => t && typeof t === 'object' && t.host && t.host !== 'uniweb')
|
|
879
|
+
if (!elsewhere.length) {
|
|
880
|
+
return { checks: [{ id: 'hosting.deploys', state: 'skipped', summary: 'Deployed to no host but a backend.', quiet: true }], proposals: [], targets: [] }
|
|
881
|
+
}
|
|
882
|
+
const repo = repoOf(siteDir)
|
|
883
|
+
const proposals = []
|
|
884
|
+
const targets = []
|
|
885
|
+
const lines = []
|
|
886
|
+
let attention = false
|
|
887
|
+
for (const [name, t] of elsewhere) {
|
|
888
|
+
const title = await hostTitle(t.host)
|
|
889
|
+
const ci = repo?.head ? ciWorkflow(repo, t.host) : null
|
|
890
|
+
const receipt = deployYml.deploys?.[name] || null
|
|
891
|
+
const retired = !receipt && deployYml.lastDeploy?.[name] ? deployYml.lastDeploy[name] : null
|
|
892
|
+
const behind = receipt?.git?.sha && repo?.head ? siteCommitsSince(repo, receipt.git.sha, siteDir) : null
|
|
893
|
+
const when = receipt?.at ? sinceLabel(receipt.at, Date.now()) : null
|
|
894
|
+
let how
|
|
895
|
+
if (ci) {
|
|
896
|
+
how = `deployed by CI on every push${ci.branches.length ? ` to ${ci.branches.join(' or ')}` : ''} (${ci.file})`
|
|
897
|
+
} else if (receipt) {
|
|
898
|
+
how =
|
|
899
|
+
`deployed by hand, last ${when ?? 'at an unknown time'}${receipt.url ? ` to ${receipt.url}` : ''}` +
|
|
900
|
+
(behind > 0 ? ` — the site changed in ${count(behind, 'commit')} since` : behind === 0 ? ', and the site has not changed since' : '')
|
|
901
|
+
if (behind > 0) {
|
|
902
|
+
attention = true
|
|
903
|
+
proposals.push({ step: 'deploy', command: `npx uniweb deploy --target ${name}`, why: `update ${title}, ${count(behind, 'commit')} behind`, ask: true })
|
|
381
904
|
}
|
|
382
|
-
} else if (
|
|
383
|
-
|
|
905
|
+
} else if (retired) {
|
|
906
|
+
attention = true
|
|
907
|
+
how = `its last deploy (${String(retired.at ?? '').slice(0, 10) || 'undated'}) is under \`lastDeploy:\`, which nothing reads — \`npx uniweb doctor --fix deploy-yml-retired-key\` moves it`
|
|
908
|
+
} else {
|
|
909
|
+
how = 'never deployed from this copy'
|
|
384
910
|
}
|
|
385
|
-
|
|
386
|
-
|
|
911
|
+
lines.push(`${title} — target \`${name}\`${name === defaultName ? ', the one a bare `uniweb deploy` uses' : ''}: ${how}`)
|
|
912
|
+
targets.push({
|
|
913
|
+
name,
|
|
914
|
+
host: t.host,
|
|
915
|
+
title,
|
|
916
|
+
default: name === defaultName,
|
|
917
|
+
ci: ci ? { file: ci.file, branches: ci.branches } : null,
|
|
918
|
+
last: receipt ? { at: receipt.at ?? null, sha: receipt.git?.sha ?? null, url: receipt.url ?? null } : null,
|
|
919
|
+
behind,
|
|
920
|
+
...(retired ? { retired: true } : {})
|
|
921
|
+
})
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
checks: [
|
|
925
|
+
{
|
|
926
|
+
id: 'hosting.deploys',
|
|
927
|
+
state: attention ? 'attention' : 'ok',
|
|
928
|
+
summary: `Also deployed to ${targets.map((t) => t.title).join(' and ')}.`,
|
|
929
|
+
lines,
|
|
930
|
+
detail: { targets }
|
|
931
|
+
}
|
|
932
|
+
],
|
|
933
|
+
proposals,
|
|
934
|
+
targets
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const list = (names) => names.map((n) => `\`${n}\``).join(', ')
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* `app.services`: what `site.yml` asks for, beside what the backend holds — the request it
|
|
942
|
+
* stored, and what the last publish delivered (`last_published_services`). Comparing the
|
|
943
|
+
* file with those is how to tell it is stale, by the backend's own contract.
|
|
944
|
+
*
|
|
945
|
+
* ⛔ A request is never said as what the site has: what is live is the backend's to say.
|
|
946
|
+
*/
|
|
947
|
+
async function servicesCheck({ siteDir, siteYml, site }) {
|
|
948
|
+
const { readServicesRequest } = await import('@uniweb/build/uwx')
|
|
949
|
+
const { foundationSupports, serviceNotes } = await import('../backend/service-request.js')
|
|
950
|
+
// ⚠️ Two shapes, as the backend sends them (measured 2026-10-26 on uniweb.app): the stored
|
|
951
|
+
// request is rows (`{ name, enabled? }`), what the last publish delivered is names. A reader
|
|
952
|
+
// of rows alone reads every live service as none.
|
|
953
|
+
const on = (entries) =>
|
|
954
|
+
(Array.isArray(entries) ? entries : [])
|
|
955
|
+
.map((e) => (typeof e === 'string' ? e : e && typeof e.name === 'string' && e.enabled !== false ? e.name : null))
|
|
956
|
+
.filter(Boolean)
|
|
957
|
+
const asks = readServicesRequest(siteYml.services) || []
|
|
958
|
+
const asked = on(asks)
|
|
959
|
+
const lines = serviceNotes({ siteYml, supports: await foundationSupports(siteDir, siteYml) })
|
|
960
|
+
const proposals = []
|
|
961
|
+
if (!site) {
|
|
962
|
+
return {
|
|
963
|
+
checks: [
|
|
964
|
+
{
|
|
965
|
+
id: 'app.services',
|
|
966
|
+
state: lines.length ? 'attention' : 'ok',
|
|
967
|
+
summary: asked.length ? `site.yml asks for ${list(asked)}.` : 'site.yml asks for no services.',
|
|
968
|
+
lines,
|
|
969
|
+
detail: { asked, requested: null, live: null }
|
|
970
|
+
}
|
|
971
|
+
],
|
|
972
|
+
proposals
|
|
387
973
|
}
|
|
388
974
|
}
|
|
975
|
+
const live = on(site.last_published_services)
|
|
976
|
+
const requested = on(site.services)
|
|
977
|
+
const waiting = requested.filter((n) => !live.includes(n))
|
|
978
|
+
if (waiting.length && site.published) {
|
|
979
|
+
lines.push(`Requested, not live yet: ${list(waiting)} — publishing makes ${waiting.length === 1 ? 'it' : 'them'} live, and may ask for payment.`)
|
|
980
|
+
proposals.push({ step: 'publish', command: 'npx uniweb publish', why: 'make those changes live — the person decides', ask: true })
|
|
981
|
+
}
|
|
982
|
+
const unsent = asked.filter((n) => !requested.includes(n))
|
|
983
|
+
if (unsent.length) lines.push(`site.yml asks for ${list(unsent)}, which the backend does not hold yet — the next push sends the request.`)
|
|
984
|
+
const offHere = asks.filter((a) => a.enabled === false && live.includes(a.name)).map((a) => a.name)
|
|
985
|
+
if (offHere.length) {
|
|
986
|
+
lines.push(`site.yml turns off ${list(offHere)}, which the live site has — a request the owner settles; a paid service stays on until its subscription changes.`)
|
|
987
|
+
}
|
|
988
|
+
return {
|
|
989
|
+
checks: [
|
|
990
|
+
{
|
|
991
|
+
id: 'app.services',
|
|
992
|
+
state: lines.length ? 'attention' : 'ok',
|
|
993
|
+
summary: live.length ? `Services on the live site: ${list(live)}.` : site.published ? 'No services on the live site.' : 'No services live — the site is not.',
|
|
994
|
+
lines,
|
|
995
|
+
detail: { asked, requested, live }
|
|
996
|
+
}
|
|
997
|
+
],
|
|
998
|
+
proposals
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** What the site is, in one line: where it is synced, and where it is live. */
|
|
1003
|
+
function summaryLine({ name, synced, live, targets, origin }) {
|
|
1004
|
+
const parts = [synced.length ? `synced with ${synced.join(' and ')}` : 'not synced with a backend']
|
|
1005
|
+
if (live?.published) parts.push(`live on ${cloudName(origin)}${live.url ? ` at ${live.url}` : ''}`)
|
|
1006
|
+
else if (live) parts.push(`not live on ${cloudName(origin)} yet`)
|
|
1007
|
+
if (targets.length) parts.push(`${live?.published ? 'also deployed' : 'deployed'} to ${targets.map((t) => t.title).join(' and ')}`)
|
|
1008
|
+
else if (!synced.length) parts.push('not hosted anywhere yet')
|
|
1009
|
+
return `${name} — ${parts.join(' · ')}`
|
|
1010
|
+
}
|
|
389
1011
|
|
|
390
|
-
|
|
1012
|
+
// ─── the command ────────────────────────────────────────────────────────────
|
|
1013
|
+
|
|
1014
|
+
const GROUPS = [
|
|
1015
|
+
['git', 'Git'],
|
|
1016
|
+
['app', 'App'],
|
|
1017
|
+
['hosting', 'Hosting'],
|
|
1018
|
+
['uniweb', 'Uniweb'],
|
|
1019
|
+
['health', 'Health'],
|
|
1020
|
+
['dev', 'Dev server']
|
|
1021
|
+
]
|
|
1022
|
+
|
|
1023
|
+
// Where a check is shown: where the site is live, and what it serves, are its hosting —
|
|
1024
|
+
// `app.live` and `app.services` kept their ids, which the document had before the group.
|
|
1025
|
+
const groupOf = (id) => (id === 'app.live' || id === 'app.services' || id.startsWith('hosting.') ? 'hosting' : id.split('.')[0])
|
|
1026
|
+
|
|
1027
|
+
const ICON = {
|
|
1028
|
+
ok: `${c.green}✓${c.reset}`,
|
|
1029
|
+
attention: `${c.cyan}→${c.reset}`,
|
|
1030
|
+
blocking: `${c.red}✗${c.reset}`,
|
|
1031
|
+
unknown: `${c.yellow}?${c.reset}`,
|
|
1032
|
+
skipped: `${c.dim}·${c.reset}`
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function render({ summary, backend, checks, next }) {
|
|
1036
|
+
const out = ['']
|
|
1037
|
+
out.push(`${c.bold}${summary}${c.reset}`)
|
|
1038
|
+
for (const [prefix, title] of GROUPS) {
|
|
1039
|
+
const group = checks.filter((k) => groupOf(k.id) === prefix && !(k.quiet && (k.state === 'ok' || k.state === 'skipped')))
|
|
1040
|
+
if (!group.length) continue
|
|
1041
|
+
out.push('')
|
|
1042
|
+
out.push(prefix === 'app' ? `${c.bold}${title}${c.reset} ${c.dim}— ${backend.origin}${c.reset}` : `${c.bold}${title}${c.reset}`)
|
|
1043
|
+
for (const k of group) {
|
|
1044
|
+
out.push(` ${ICON[k.state]} ${k.state === 'skipped' ? `${c.dim}${k.summary}${c.reset}` : k.summary}`)
|
|
1045
|
+
const files = k.files || []
|
|
1046
|
+
for (const f of files.slice(0, 12)) out.push(` ${c.dim}${f}${c.reset}`)
|
|
1047
|
+
if (files.length > 12) out.push(` ${c.dim}… and ${files.length - 12} more${c.reset}`)
|
|
1048
|
+
for (const l of k.lines || []) out.push(` ${c.dim}${l}${c.reset}`)
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
out.push('')
|
|
1052
|
+
if (!next.length) {
|
|
1053
|
+
out.push(`${ICON.ok} ${c.bold}Nothing to do.${c.reset}`)
|
|
1054
|
+
} else {
|
|
1055
|
+
out.push(`${c.bold}Next${c.reset}`)
|
|
1056
|
+
next.forEach((s, i) => {
|
|
1057
|
+
const ask = s.ask ? `${c.yellow}ask the person first —${c.reset} ` : ''
|
|
1058
|
+
if (s.command) {
|
|
1059
|
+
out.push(` ${i + 1}. ${c.cyan}${s.command}${c.reset}`)
|
|
1060
|
+
out.push(` ${ask}${c.dim}${s.why}${c.reset}`)
|
|
1061
|
+
} else {
|
|
1062
|
+
out.push(` ${i + 1}. ${ask}${s.why}`)
|
|
1063
|
+
}
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
const unknown = checks.filter((k) => k.state === 'unknown').map((k) => k.id)
|
|
1067
|
+
if (unknown.length) {
|
|
1068
|
+
out.push('')
|
|
1069
|
+
out.push(`${c.dim}Not known: ${unknown.join(', ')} — see above.${c.reset}`)
|
|
1070
|
+
}
|
|
1071
|
+
out.push('')
|
|
1072
|
+
return out.join('\n')
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export async function status(args = []) {
|
|
1076
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
|
|
1077
|
+
// Straight to stderr: this is a usage error, so it must not be mistaken for the
|
|
1078
|
+
// status document even under --json.
|
|
1079
|
+
const badFlag = checkFlags('status', args)
|
|
1080
|
+
if (badFlag) {
|
|
1081
|
+
console.error(`${c.red}✗${c.reset} ${badFlag.message}`)
|
|
1082
|
+
return { exitCode: 2 }
|
|
1083
|
+
}
|
|
1084
|
+
const json = args.includes('--json')
|
|
1085
|
+
const offline = args.includes('--offline')
|
|
1086
|
+
|
|
1087
|
+
const found = await findSite(process.cwd())
|
|
1088
|
+
if (!found.siteDir) {
|
|
1089
|
+
const sites = found.several || []
|
|
1090
|
+
if (json) {
|
|
1091
|
+
console.log(JSON.stringify({ schema: STATUS_SCHEMA, error: sites.length ? 'several-sites' : 'no-site', sites }))
|
|
1092
|
+
} else if (sites.length) {
|
|
1093
|
+
console.error(`${c.red}✗${c.reset} Several sites here — run status from the one you mean:`)
|
|
1094
|
+
for (const s of sites) console.error(` ${c.cyan}cd ${s} && npx uniweb status${c.reset}`)
|
|
1095
|
+
} else {
|
|
1096
|
+
console.error(`${c.red}✗${c.reset} No Uniweb site here. Run status from a site, or a project with one site.`)
|
|
1097
|
+
}
|
|
1098
|
+
return { exitCode: 2 }
|
|
1099
|
+
}
|
|
1100
|
+
const siteDir = found.siteDir
|
|
1101
|
+
const siteYml = readSiteYml(siteDir)
|
|
1102
|
+
const origin = resolveBackendOrigin()
|
|
1103
|
+
|
|
1104
|
+
// ⭐ Under --json, stdout carries the document and nothing else: whatever a check's
|
|
1105
|
+
// helpers say on the way goes to stderr.
|
|
1106
|
+
const saved = { log: console.log, info: console.info, warn: console.warn }
|
|
1107
|
+
if (json) console.log = console.info = console.warn = (...a) => console.error(...a)
|
|
1108
|
+
let git, app, version, health, dev, deploys, services
|
|
1109
|
+
try {
|
|
1110
|
+
git = await gitChecks(siteDir, { offline, origin })
|
|
1111
|
+
;[app, version, health, dev, deploys] = await Promise.all([
|
|
1112
|
+
appChecks(siteDir, siteYml, { offline, args, git }),
|
|
1113
|
+
versionCheck(siteDir, { offline }),
|
|
1114
|
+
doctorCheck(siteDir),
|
|
1115
|
+
devCheck(siteDir, { offline }),
|
|
1116
|
+
deployChecks(siteDir)
|
|
1117
|
+
])
|
|
1118
|
+
services = await servicesCheck({ siteDir, siteYml, site: app.site })
|
|
1119
|
+
} finally {
|
|
1120
|
+
Object.assign(console, saved)
|
|
1121
|
+
}
|
|
1122
|
+
const checks = [...git.checks, ...app.checks, ...deploys.checks, ...services.checks, ...version.checks, ...health.checks, dev]
|
|
1123
|
+
const next = planNext([...git.proposals, ...app.proposals, ...deploys.proposals, ...services.proposals, ...version.proposals, ...health.proposals])
|
|
1124
|
+
|
|
1125
|
+
const root = findWorkspaceRoot(siteDir) || siteDir
|
|
1126
|
+
const site = relative(root, siteDir) || '.'
|
|
1127
|
+
// What the site is, before what to do about it: the backends it is synced with — every one
|
|
1128
|
+
// this copy holds a site on, custom servers included — and where it is live.
|
|
1129
|
+
const synced = listSyncedBackends(siteDir).filter((o) => readBackendState(siteDir, o).site?.uuid)
|
|
1130
|
+
const summary = summaryLine({
|
|
1131
|
+
name: typeof siteYml.name === 'string' && siteYml.name ? siteYml.name : site === '.' ? 'This site' : site,
|
|
1132
|
+
synced,
|
|
1133
|
+
live: checks.find((k) => k.id === 'app.live')?.detail ?? null,
|
|
1134
|
+
targets: deploys.targets,
|
|
1135
|
+
origin: app.backend.origin
|
|
1136
|
+
})
|
|
1137
|
+
if (json) {
|
|
1138
|
+
console.log(
|
|
1139
|
+
JSON.stringify({
|
|
1140
|
+
schema: STATUS_SCHEMA,
|
|
1141
|
+
uniweb: getCliVersion(),
|
|
1142
|
+
summary,
|
|
1143
|
+
site,
|
|
1144
|
+
backend: app.backend.origin,
|
|
1145
|
+
workspace: app.backend.workspace,
|
|
1146
|
+
synced,
|
|
1147
|
+
offline,
|
|
1148
|
+
checks: checks.map(({ quiet, id, state, summary, blocks = [], ...rest }) => ({ id, state, summary, blocks, ...rest })),
|
|
1149
|
+
next
|
|
1150
|
+
})
|
|
1151
|
+
)
|
|
1152
|
+
return { exitCode: 0 }
|
|
1153
|
+
}
|
|
1154
|
+
console.log(render({ summary, backend: app.backend, checks, next }))
|
|
391
1155
|
return { exitCode: 0 }
|
|
392
1156
|
}
|
|
393
1157
|
|