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