@curia-sh/cli 0.4.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/src/config.mjs ADDED
@@ -0,0 +1,289 @@
1
+ import { lstatSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeAtomically } from './atomic.mjs'
5
+
6
+ // The operator configuration contract: `config/config.yaml` inside the
7
+ // installation root. This module is the one reader, validator, and writer of
8
+ // that file. The lifecycle interface, the Curia service, and the Curia app all
9
+ // go through it, so one file means one thing in three processes and a refusal
10
+ // reads the same in each.
11
+ //
12
+ // The file holds operator intent and nothing else. Generated state, release
13
+ // metadata, and secrets have their own homes (`state/`, the release manifest,
14
+ // `secrets/`) and never appear here. Every key is optional: a key the operator
15
+ // does not set takes the service's shipped default, and the first installation
16
+ // writes `max_concurrent: 4` and no other value.
17
+ //
18
+ // The file is read as a plain subset of YAML: `key: value` lines, comments, and
19
+ // the `watch` list as `- repo: owner/name` entries. Anchors, flow collections,
20
+ // block scalars, and other YAML are refused by line rather than guessed at.
21
+ // That keeps the reader dependency-free, which the lifecycle interface has to
22
+ // be, and it keeps a hand edit either exactly what the operator wrote or an
23
+ // exact error. Curia never rewrites an edit into a different meaning.
24
+
25
+ export const WATCH_MODES = Object.freeze(['auto', 'map', 'ready-for-agent'])
26
+
27
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/
28
+
29
+ const wholeNumber = (text) => (/^\d+$/.test(text) ? Number(text) : undefined)
30
+ const number = (text) => (/^\d+(\.\d+)?$/.test(text) ? Number(text) : undefined)
31
+ const positiveInteger = (v) => Number.isInteger(v) && v > 0
32
+
33
+ // One rule per scalar key: how a written value reads, what a value must
34
+ // satisfy, and the words a refusal uses. The same `check` judges a value the
35
+ // app hands over as an object, so the two paths cannot disagree.
36
+ const SCALAR_RULES = {
37
+ max_concurrent: { parse: wholeNumber, check: positiveInteger, rule: 'a positive whole number' },
38
+ auto_dispatch: {
39
+ parse: (text) => ({ true: true, false: false })[text],
40
+ check: (v) => typeof v === 'boolean',
41
+ rule: 'true or false',
42
+ },
43
+ poll_interval_s: { parse: number, check: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0, rule: 'a positive number' },
44
+ prototype_variations: { parse: wholeNumber, check: positiveInteger, rule: 'a positive whole number' },
45
+ messages_per_send: {
46
+ parse: wholeNumber,
47
+ check: (v) => Number.isInteger(v) && v >= 1 && v <= 4,
48
+ rule: 'a whole number from 1 through 4',
49
+ },
50
+ live_pane_cap: { parse: wholeNumber, check: positiveInteger, rule: 'a positive whole number' },
51
+ }
52
+
53
+ // The keys, in the order the operator documentation lists them and the order
54
+ // a written file carries them.
55
+ export const OPERATOR_CONFIG_KEYS = Object.freeze([...Object.keys(SCALAR_RULES), 'watch'])
56
+
57
+ const WATCH_RULE = '`watch` must be a list written as `- repo: owner/name` lines'
58
+ const WATCH_EMPTY = '`watch` must list at least one `- repo: owner/name` entry'
59
+
60
+ export class ConfigError extends Error {
61
+ constructor(message) {
62
+ super(message)
63
+ this.name = 'ConfigError'
64
+ }
65
+ }
66
+
67
+ export function operatorConfigPath(root) {
68
+ return join(root, 'config', 'config.yaml')
69
+ }
70
+
71
+ // What `curia install` writes into a fresh root: the one value the first
72
+ // release fixes, and no operator-specific default.
73
+ export function initialOperatorConfig() {
74
+ return { max_concurrent: 4 }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // reading
79
+ // ---------------------------------------------------------------------------
80
+
81
+ // Parses the text of a configuration file and returns the validated
82
+ // configuration: an object holding only the keys the file sets, each in its
83
+ // checked form. `file` names the file in every message.
84
+ export function parseOperatorConfig(text, { file }) {
85
+ const at = (line, message) => new ConfigError(`${file} line ${line}: ${message}`)
86
+ const got = (value) => `(got ${value === '' ? 'nothing' : value})`
87
+ const config = {}
88
+ const lines = text.split('\n')
89
+
90
+ // The `watch` list under construction, or null outside it.
91
+ let list = null
92
+ const closeEntry = () => {
93
+ const entry = list.entries.at(-1)
94
+ if (!entry) return
95
+ if (entry.repo === undefined) throw at(entry.line, `\`watch\` entry ${list.entries.length} needs a \`repo\``)
96
+ }
97
+ const closeList = () => {
98
+ if (!list) return
99
+ if (list.entries.length === 0) throw at(list.line, WATCH_EMPTY)
100
+ closeEntry()
101
+ config.watch = list.entries.map((e) => ({ repo: e.repo, mode: e.mode ?? 'auto' }))
102
+ list = null
103
+ }
104
+
105
+ for (let i = 0; i < lines.length; i++) {
106
+ const n = i + 1
107
+ const raw = lines[i]
108
+ if (raw.includes('\t')) throw at(n, 'tabs are not allowed; indent with spaces')
109
+ const line = stripComment(raw)
110
+ if (line.trim() === '') continue
111
+ const indent = line.length - line.trimStart().length
112
+ const body = line.trim()
113
+
114
+ if (indent === 0) {
115
+ closeList()
116
+ const m = /^([A-Za-z_][A-Za-z0-9_]*):(?:\s+(.*))?$/.exec(body)
117
+ if (!m) throw at(n, 'expected `key: value`')
118
+ const [, key, value = ''] = m
119
+ if (!OPERATOR_CONFIG_KEYS.includes(key)) {
120
+ throw at(n, `\`${key}\` is not an operator configuration key. The keys are ${OPERATOR_CONFIG_KEYS.join(', ')}`)
121
+ }
122
+ if (key in config || (list && key === 'watch')) throw at(n, `\`${key}\` appears twice`)
123
+ if (key === 'watch') {
124
+ if (value !== '') throw at(n, WATCH_RULE)
125
+ list = { line: n, entries: [] }
126
+ continue
127
+ }
128
+ const rule = SCALAR_RULES[key]
129
+ const parsed = rule.parse(unquote(value))
130
+ if (parsed === undefined || !rule.check(parsed)) throw at(n, `\`${key}\` must be ${rule.rule} ${got(value)}`)
131
+ config[key] = parsed
132
+ continue
133
+ }
134
+
135
+ if (!list) throw at(n, 'expected `key: value` at the start of the line')
136
+ let entryBody = body
137
+ if (body.startsWith('- ')) {
138
+ if (indent !== 2) throw at(n, WATCH_RULE)
139
+ closeEntry()
140
+ list.entries.push({ line: n })
141
+ entryBody = body.slice(2).trim()
142
+ } else if (indent !== 4 || list.entries.length === 0) {
143
+ throw at(n, WATCH_RULE)
144
+ }
145
+ const entry = list.entries.at(-1)
146
+ const index = list.entries.length
147
+ const m = /^([A-Za-z_][A-Za-z0-9_]*):(?:\s+(.*))?$/.exec(entryBody)
148
+ if (!m) throw at(n, WATCH_RULE)
149
+ const [, key, value = ''] = m
150
+ if (key !== 'repo' && key !== 'mode') throw at(n, `\`watch\` entry ${index}: \`${key}\` is not a watch entry key`)
151
+ if (key in entry) throw at(n, `\`watch\` entry ${index}: \`${key}\` appears twice`)
152
+ const text = unquote(value)
153
+ if (key === 'repo') {
154
+ if (!REPO_RE.test(text)) throw at(n, `\`watch\` entry ${index}: \`repo\` must be \`owner/name\` ${got(value)}`)
155
+ if (list.entries.some((e) => e !== entry && e.repo === text)) throw at(n, `\`watch\` lists ${text} twice`)
156
+ } else if (!WATCH_MODES.includes(text)) {
157
+ throw at(n, `\`watch\` entry ${index}: \`mode\` must be one of ${WATCH_MODES.join(', ')} ${got(value)}`)
158
+ }
159
+ entry[key] = text
160
+ }
161
+ closeList()
162
+ return config
163
+ }
164
+
165
+ // A `#` starts a comment at the start of a line or after whitespace, outside
166
+ // quotes. Values here never legitimately hold one.
167
+ function stripComment(line) {
168
+ let quote = null
169
+ for (let i = 0; i < line.length; i++) {
170
+ const c = line[i]
171
+ if (quote) {
172
+ if (c === quote) quote = null
173
+ } else if (c === '"' || c === "'") {
174
+ quote = c
175
+ } else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
176
+ return line.slice(0, i)
177
+ }
178
+ }
179
+ return line
180
+ }
181
+
182
+ function unquote(value) {
183
+ const m = /^(["'])(.*)\1$/.exec(value)
184
+ return m ? m[2] : value
185
+ }
186
+
187
+ // Returns the validated configuration, or null when the file does not exist.
188
+ // A symbolic link at the path is refused: the file is owner-only and lives in
189
+ // `config/`, and a link would let it read from anywhere.
190
+ export function readOperatorConfig(path) {
191
+ let text
192
+ try {
193
+ if (lstatSync(path).isSymbolicLink()) {
194
+ throw new ConfigError(`${path} is a symbolic link. Replace the link with the real file.`)
195
+ }
196
+ text = readFileSync(path, 'utf8')
197
+ } catch (e) {
198
+ if (e.code === 'ENOENT') return null
199
+ throw e
200
+ }
201
+ return parseOperatorConfig(text, { file: path })
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // validating an object, and writing
206
+ // ---------------------------------------------------------------------------
207
+
208
+ // Judges a configuration handed over as an object (the app's save, the
209
+ // installer's initial file) by the same rules the reader applies, and returns
210
+ // it with the keys in contract order.
211
+ export function validateOperatorConfig(data) {
212
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
213
+ throw new ConfigError('the operator configuration must be a mapping of keys to values')
214
+ }
215
+ for (const key of Object.keys(data)) {
216
+ if (!OPERATOR_CONFIG_KEYS.includes(key)) {
217
+ throw new ConfigError(`\`${key}\` is not an operator configuration key. The keys are ${OPERATOR_CONFIG_KEYS.join(', ')}`)
218
+ }
219
+ }
220
+ const out = {}
221
+ for (const [key, rule] of Object.entries(SCALAR_RULES)) {
222
+ if (data[key] === undefined) continue
223
+ if (!rule.check(data[key])) throw new ConfigError(`\`${key}\` must be ${rule.rule} (got ${JSON.stringify(data[key])})`)
224
+ out[key] = data[key]
225
+ }
226
+ if (data.watch !== undefined) {
227
+ if (!Array.isArray(data.watch)) throw new ConfigError(WATCH_RULE)
228
+ if (data.watch.length === 0) throw new ConfigError(WATCH_EMPTY)
229
+ const seen = new Set()
230
+ out.watch = data.watch.map((entry, i) => {
231
+ const index = i + 1
232
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) throw new ConfigError(WATCH_RULE)
233
+ for (const key of Object.keys(entry)) {
234
+ if (key !== 'repo' && key !== 'mode') throw new ConfigError(`\`watch\` entry ${index}: \`${key}\` is not a watch entry key`)
235
+ }
236
+ if (typeof entry.repo !== 'string' || !REPO_RE.test(entry.repo)) {
237
+ throw new ConfigError(`\`watch\` entry ${index}: \`repo\` must be \`owner/name\` (got ${JSON.stringify(entry.repo ?? '')})`)
238
+ }
239
+ if (seen.has(entry.repo)) throw new ConfigError(`\`watch\` lists ${entry.repo} twice`)
240
+ seen.add(entry.repo)
241
+ const mode = entry.mode ?? 'auto'
242
+ if (!WATCH_MODES.includes(mode)) {
243
+ throw new ConfigError(`\`watch\` entry ${index}: \`mode\` must be one of ${WATCH_MODES.join(', ')} (got ${JSON.stringify(entry.mode)})`)
244
+ }
245
+ return { repo: entry.repo, mode }
246
+ })
247
+ }
248
+ return out
249
+ }
250
+
251
+ const HEADER = [
252
+ '# Curia operator configuration.',
253
+ '#',
254
+ '# Curia reads this file when the service starts and when the Curia app saves',
255
+ '# a setting. A key you leave out takes the shipped default. Curia checks the',
256
+ '# file before every write and refuses an invalid edit by line, so what is',
257
+ '# here is either exactly what you wrote or an exact error.',
258
+ '#',
259
+ '# The keys, and what each accepts, are documented in the operator guide under',
260
+ '# "Operator configuration". The Curia app rewrites this file when you save, and',
261
+ '# it keeps only the keys, not the comments.',
262
+ '',
263
+ ]
264
+
265
+ // The text of a configuration file for `config`, validated first. Keys print
266
+ // in contract order, and a watch entry prints its mode only when it is not
267
+ // the default, so a file round-trips through the reader unchanged in meaning.
268
+ export function renderOperatorConfig(data) {
269
+ const config = validateOperatorConfig(data)
270
+ const lines = [...HEADER]
271
+ for (const key of Object.keys(SCALAR_RULES)) {
272
+ if (config[key] !== undefined) lines.push(`${key}: ${config[key]}`)
273
+ }
274
+ if (config.watch) {
275
+ lines.push('watch:')
276
+ for (const entry of config.watch) {
277
+ lines.push(` - repo: ${entry.repo}`)
278
+ if (entry.mode !== 'auto') lines.push(` mode: ${entry.mode}`)
279
+ }
280
+ }
281
+ return `${lines.join('\n')}\n`
282
+ }
283
+
284
+ // Validates, renders, and writes the file atomically with owner-only mode. An
285
+ // invalid configuration throws before anything touches the disk.
286
+ export function writeOperatorConfig(path, data) {
287
+ const text = renderOperatorConfig(data)
288
+ writeAtomically(path, text, { mode: 0o600 })
289
+ }
package/src/doctor.mjs ADDED
@@ -0,0 +1,392 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import { BOOTSTRAP_COMMAND } from './acquire.mjs'
4
+ import { EXIT } from './exit.mjs'
5
+ import { ConfigError, operatorConfigPath, readOperatorConfig } from './config.mjs'
6
+ import { composeProject, dockerRunner, serviceStates } from './compose.mjs'
7
+ import { APP_SERVE_PORT } from './install.mjs'
8
+ import { launcherPath } from './launcher.mjs'
9
+ import { SERVICES } from './layout.mjs'
10
+ import { releaseProbes, verifyInstalledRelease } from './manifest.mjs'
11
+ import { hostProbes, preflight } from './preflight.mjs'
12
+ import { openRoot, versionPaths } from './root.mjs'
13
+ import { SECRET_NAMES, credentialsInEnvironment, secretsStatus } from './secrets.mjs'
14
+
15
+ // `curia doctor` (#881, implementing #857): one read-only pass over every
16
+ // direct check an installed Curia has, in the order an operator would look.
17
+ //
18
+ // host the supported-host preflight, the same checks `curia
19
+ // install` and `curia update` run
20
+ // installation the root boundary, the record, the active version's
21
+ // files, and the launcher
22
+ // configuration `config/config.yaml` through the operator configuration
23
+ // contract
24
+ // release the retained artifacts of the active version, the
25
+ // installed files, and the publication provenance
26
+ // secrets the four secret files by presence, and the shell
27
+ // environment by key
28
+ // containers the state and health of the five services from Compose
29
+ // service the service answers on loopback
30
+ // integrations the four cards and the Full-loop gate, as the running
31
+ // service verifies them on this read, and the operator it
32
+ // admits
33
+ // app the Curia app answers on loopback, and its tailnet address
34
+ //
35
+ // Every check is `{ name, status, observed, action }`, the shape the preflight
36
+ // and the release verification already use, with `status` one of `passed`,
37
+ // `warning`, `failed`, or `refused`. A failed or refused check carries one
38
+ // corrective action. The command reruns every applicable check on every
39
+ // invocation, keeps no history, and repairs nothing: it opens the root, reads
40
+ // files, asks Docker for `ps`, and sends two reads to the service and one to
41
+ // the app. It never escalates, retries in the background, or schedules
42
+ // anything. The exit code is `ok` when nothing failed, `failed` when a check
43
+ // failed or a host condition is refused, and `refused` only when the root
44
+ // boundary refuses before anything runs.
45
+ //
46
+ // Everything printed passes through `redactDiagnostic`, so a secret that
47
+ // reaches the doctor through an error message, a service answer, or a Docker
48
+ // message never reaches the terminal. The doctor reads no secret value
49
+ // itself: `secretsStatus` reports presence only.
50
+
51
+ export const DOCTOR_SECTIONS = Object.freeze(['host', 'installation', 'configuration', 'release', 'secrets', 'containers', 'service', 'integrations', 'app'])
52
+
53
+ // The service's own loopback listener (the `/ping` route the bundle's health
54
+ // check asks) and the Curia app's loopback port, `dashboard.port` in
55
+ // config/curia.yaml. daemon/test/preflightports.test.mjs keeps the app port
56
+ // equal to the shipped configuration.
57
+ export const SERVICE_PORT = 4271
58
+ export const APP_PORT = 4273
59
+
60
+ const READ_TIMEOUT_MS = 10_000
61
+ const BOOTSTRAP = BOOTSTRAP_COMMAND
62
+ const AGAIN = 'run curia doctor again'
63
+
64
+ const CARD_NAMES = Object.freeze({ github: 'GitHub', discord: 'Discord', tailscale: 'Tailscale', model: 'model provider' })
65
+
66
+ export async function runDoctor(
67
+ { env, stdout, uid, root },
68
+ { hostProbes: host = hostProbes, releaseProbes: release = releaseProbes, docker = dockerRunner, fetch: fetchImpl = globalThis.fetch } = {},
69
+ ) {
70
+ // The root boundary first, like every lifecycle command. A refusal here is
71
+ // the one refusal the doctor raises: it says nothing about the host until
72
+ // it knows the root is the operator's own.
73
+ const opened = openRoot(root, { uid })
74
+ const report = reporter(stdout)
75
+
76
+ report.section('host')
77
+ const hostReport = await preflight({ uid, root }, host)
78
+ report.checks(hostReport.checks)
79
+
80
+ report.section('installation')
81
+ report.checks([installationCheck({ opened, root, env })])
82
+ if (opened.status !== 'installed') {
83
+ report.note('the remaining checks need an installation.')
84
+ return report.finish()
85
+ }
86
+ const version = opened.record.activeVersion
87
+
88
+ report.section('configuration')
89
+ report.checks([configurationCheck(root)])
90
+
91
+ report.section('release')
92
+ const releaseReport = await verifyInstalledRelease({ root, version, stdout: { write: () => true } }, release)
93
+ report.checks(releaseReport.checks)
94
+
95
+ report.section('secrets')
96
+ report.checks([secretsCheck(root, uid), environmentCheck(env)])
97
+
98
+ report.section('containers')
99
+ const project = composeProject({ root, version })
100
+ report.checks([await containersCheck(project, docker)])
101
+
102
+ report.section('service')
103
+ const read = reader(fetchImpl)
104
+ const service = await serviceCheck(read, project)
105
+ report.checks([service])
106
+
107
+ report.section('integrations')
108
+ let address = null
109
+ if (service.status === 'passed') {
110
+ const setup = await read.json(`http://127.0.0.1:${SERVICE_PORT}/setup`)
111
+ const identity = await read.json(`http://127.0.0.1:${SERVICE_PORT}/identity`)
112
+ const checks = integrationChecks(setup)
113
+ report.checks([...checks.checks, operatorCheck(identity)])
114
+ address = checks.address
115
+ } else {
116
+ report.note('integrations not checked: the service did not answer.')
117
+ }
118
+
119
+ report.section('app')
120
+ report.checks([await appCheck(read, { project, address: address ?? hostReport.facts.tailscale?.certDomains?.[0] ?? null })])
121
+
122
+ return report.finish()
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // The checks. Each returns one result in the shared shape.
127
+
128
+ function passed(name, observed) { return { name, status: 'passed', observed, action: null } }
129
+ function warning(name, observed, action) { return { name, status: 'warning', observed, action } }
130
+ function failed(name, observed, action) { return { name, status: 'failed', observed, action } }
131
+
132
+ function installationCheck({ opened, root, env }) {
133
+ if (opened.status !== 'installed') {
134
+ return failed('installation', `${root} holds no installation.`, `Run the bootstrap: ${BOOTSTRAP}`)
135
+ }
136
+ const { activeVersion, installationId } = opened.record
137
+ const paths = versionPaths(root, activeVersion)
138
+ const missing = ['node', 'cli', 'manifest', 'package', 'bundleArchive', 'bundleChecksum', 'bundle'].filter((k) => !existsSync(paths[k]))
139
+ if (missing.length > 0) {
140
+ return failed('installation', `version ${activeVersion} is active, but ${paths.dir} lacks ${missing.map((k) => paths[k].slice(paths.dir.length + 1)).join(', ')}.`, `Run 'curia reinstall' to restore the version from the release, or the bootstrap: ${BOOTSTRAP}`)
141
+ }
142
+ const launcher = launcherPath(env)
143
+ const seen = `version ${activeVersion} (installation ${installationId}) at ${root}`
144
+ if (!existsSync(launcher)) {
145
+ return warning('installation', `${seen}; the launcher ${launcher} is missing.`, `Run 'curia reinstall' from the installed version to write the launcher again.`)
146
+ }
147
+ return passed('installation', `${seen}; launcher ${launcher}`)
148
+ }
149
+
150
+ function configurationCheck(root) {
151
+ const path = operatorConfigPath(root)
152
+ let config
153
+ try {
154
+ config = readOperatorConfig(path)
155
+ } catch (e) {
156
+ if (!(e instanceof ConfigError)) throw e
157
+ return failed('operator configuration', e.message, 'Fix that line or revert the file, then save any setting from the Curia app or restart the service.')
158
+ }
159
+ if (config === null) {
160
+ return warning('operator configuration', `${path} is absent, so the shipped defaults apply.`, `Run 'curia reinstall' to write the initial configuration, or create the file.`)
161
+ }
162
+ const keys = Object.entries(config).map(([k, v]) => (k === 'watch' ? `watch: ${v.length} ${v.length === 1 ? 'repository' : 'repositories'}` : `${k}: ${v}`))
163
+ return passed('operator configuration', `${path} is valid${keys.length ? ` (${keys.join(', ')})` : ''}`)
164
+ }
165
+
166
+ function secretsCheck(root, uid) {
167
+ const status = secretsStatus(root, { uid })
168
+ const of = (state) => SECRET_NAMES.filter((n) => status[n].state === state)
169
+ const presence = [
170
+ of('present').length ? `present: ${of('present').join(', ')}` : 'none present',
171
+ of('absent').length ? `absent: ${of('absent').join(', ')}` : null,
172
+ ].filter(Boolean).join('; ')
173
+ const refused = of('refused')
174
+ if (refused.length > 0) {
175
+ return failed('secret files', `${refused.map((n) => status[n].why).join(' ')} ${presence}.`, `After the fix, ${AGAIN}.`)
176
+ }
177
+ return passed('secret files', presence)
178
+ }
179
+
180
+ function environmentCheck(env) {
181
+ const keys = credentialsInEnvironment(env ?? {})
182
+ if (keys.length === 0) return passed('environment', 'no credential key is set in this shell')
183
+ return warning('environment', `${keys.join(', ')} ${keys.length === 1 ? 'is' : 'are'} set in this shell. Curia reads a credential from its secret file only, and the service refuses to boot with one of these keys set.`, `Unset ${keys.length === 1 ? 'it' : 'them'}. The secret files are ${SECRET_NAMES.map((n) => `secrets/${n}`).join(', ')}.`)
184
+ }
185
+
186
+ function logsCommand(project, service) {
187
+ return `docker compose --env-file ${project.envFile} -f ${project.file} logs ${service}`
188
+ }
189
+
190
+ async function containersCheck(project, docker) {
191
+ if (!existsSync(project.envFile)) {
192
+ return failed('containers', `${project.envFile} is missing, so the Compose project cannot be addressed.`, `Run 'curia reinstall' to write it and start the project again.`)
193
+ }
194
+ let states
195
+ try {
196
+ states = await serviceStates(project, { docker })
197
+ } catch (e) {
198
+ return failed('containers', oneLine(e.message), `Fix Docker access for this user, then ${AGAIN}.`)
199
+ }
200
+ const problems = []
201
+ const starting = []
202
+ for (const service of SERVICES) {
203
+ const s = states.find((x) => x.service === service)
204
+ if (!s) problems.push({ service, what: `${service} is not in the project` })
205
+ else if (s.state === 'exited' || s.state === 'dead') problems.push({ service, what: `${service} exited with code ${s.exitCode ?? '?'}` })
206
+ else if (s.health === 'unhealthy') problems.push({ service, what: `${service} is unhealthy` })
207
+ else if (s.health !== 'healthy') starting.push(service)
208
+ }
209
+ if (problems.length > 0) {
210
+ const all = [...problems.map((p) => p.what), ...starting.map((s) => `${s} is starting`)]
211
+ return failed('containers', `${all.join('; ')}.`, `Read its log with '${logsCommand(project, problems[0].service)}', fix the cause, and ${problems[0].what.endsWith('in the project') ? "run 'curia reinstall'" : 'start the service again'}.`)
212
+ }
213
+ if (starting.length > 0) {
214
+ return warning('containers', `${starting.map((s) => `${s} is starting`).join('; ')}; the rest are healthy.`, `Wait a minute and ${AGAIN}. A service still starting after four minutes is one to read the log of: '${logsCommand(project, starting[0])}'.`)
215
+ }
216
+ return passed('containers', `${SERVICES.join(', ')} healthy`)
217
+ }
218
+
219
+ async function serviceCheck(read, project) {
220
+ const at = `127.0.0.1:${SERVICE_PORT}`
221
+ const answer = await read.json(`http://${at}/ping`)
222
+ if (answer.ok) return passed('service', `the service answers on ${at}`)
223
+ return failed('service', `the service did not answer on ${at} (${answer.error}).`, `Read its log with '${logsCommand(project, 'daemon')}', fix the cause, and ${AGAIN}.`)
224
+ }
225
+
226
+ // The four cards and the gate, as the service verified them on this read.
227
+ // Nothing here comes from a file: a card is connected only because its
228
+ // verifier said so now.
229
+ function integrationChecks(setup) {
230
+ if (!setup.ok) {
231
+ return { checks: [failed('integrations', `the service did not answer GET /setup (${setup.error}).`, `Read its log and ${AGAIN}.`)], address: null }
232
+ }
233
+ const body = setup.body ?? {}
234
+ const cards = Array.isArray(body.cards) ? body.cards : []
235
+ const checks = []
236
+ let address = null
237
+ for (const key of Object.keys(CARD_NAMES)) {
238
+ const name = CARD_NAMES[key]
239
+ const card = cards.find((c) => c?.key === key)
240
+ if (!card) { checks.push(failed(name, 'the service reported no such card.', `Update Curia, then ${AGAIN}.`)); continue }
241
+ if (card.state === 'connected') {
242
+ const footer = card.footer ?? {}
243
+ checks.push(passed(name, [footer.primary, footer.secondary].filter(Boolean).join(' · ') || 'connected and verified'))
244
+ if (key === 'tailscale') address = card.detail?.app_url ?? (card.detail?.address ? `https://${card.detail.address}:${APP_SERVE_PORT}/` : null)
245
+ } else if (card.state === 'failed') {
246
+ checks.push(failed(name, String(card.error?.failed ?? 'the verification did not pass.'), String(card.error?.action ?? 'Open the Curia app, select Setup, and select Try again.')))
247
+ } else if (card.state === 'unavailable') {
248
+ checks.push(warning(name, 'not available in this release.', 'Update Curia when a release adds it.'))
249
+ } else {
250
+ checks.push(warning(name, 'not connected yet.', `Open the Curia app, select Setup, and connect ${key === 'model' ? 'a model provider' : name}.`))
251
+ }
252
+ }
253
+ const loop = body.full_loop ?? {}
254
+ if (loop.ready) checks.push(passed('Full loop', 'ready on this read: every card verified and handed its fact'))
255
+ else checks.push(warning('Full loop', String(loop.reason ?? 'not ready.'), 'Finish setup in the Curia app; Run Full loop enables when every card is connected on one read.'))
256
+ return { checks, address }
257
+ }
258
+
259
+ function operatorCheck(identity) {
260
+ if (!identity.ok) return failed('admitted operator', `the service did not answer GET /identity (${identity.error}).`, `Read its log and ${AGAIN}.`)
261
+ const allow = Array.isArray(identity.body?.allow) ? identity.body.allow.map(String) : []
262
+ if (allow.length > 0) return passed('admitted operator', allow.join(', '))
263
+ if (identity.body?.first_operator) {
264
+ return warning('admitted operator', 'no operator confirmed; the app admits the first tailnet identity to Setup only.', 'Open the Curia app from your tailnet and select Confirm operator and verify on the Tailscale card.')
265
+ }
266
+ return warning('admitted operator', 'the service admits no login.', 'Confirm the operator on the Tailscale card of Setup.')
267
+ }
268
+
269
+ async function appCheck(read, { project, address }) {
270
+ const at = `127.0.0.1:${APP_PORT}`
271
+ const answer = await read.status(`http://${at}/`)
272
+ const url = address ? (address.startsWith('https://') ? address : `https://${address}:${APP_SERVE_PORT}/`) : null
273
+ if (answer.ok) return passed('Curia app', `the app answers on ${at}${url ? `; open it at ${url}` : ''}`)
274
+ return failed('Curia app', `the app did not answer on ${at} (${answer.error}).`, `Read its log with '${logsCommand(project, 'dashboard')}', fix the cause, and ${AGAIN}.`)
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // The two reads: a JSON route and a bare status. Both answer instead of
279
+ // throwing, and both scrub what comes back before it is used.
280
+
281
+ function reader(fetchImpl) {
282
+ const get = (url) => fetchImpl(url, { redirect: 'manual', signal: AbortSignal.timeout(READ_TIMEOUT_MS) })
283
+ return {
284
+ async json(url) {
285
+ try {
286
+ const response = await get(url)
287
+ if (!response.ok) return { ok: false, error: `HTTP ${response.status}` }
288
+ return { ok: true, body: scrubFacts(await response.json()) }
289
+ } catch (e) {
290
+ return { ok: false, error: oneLine(e.cause?.message ?? e.message) }
291
+ }
292
+ },
293
+ async status(url) {
294
+ try {
295
+ const response = await get(url)
296
+ if (response.status >= 500) return { ok: false, error: `HTTP ${response.status}` }
297
+ return { ok: true, status: response.status }
298
+ } catch (e) {
299
+ return { ok: false, error: oneLine(e.cause?.message ?? e.message) }
300
+ }
301
+ },
302
+ }
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // Redaction. Two layers: values under a key that names a credential are
307
+ // dropped from every service answer before the doctor reads it, and every
308
+ // printed line loses any string shaped like a long-lived secret (a Discord
309
+ // bot token, a provider key, a GitHub token, a private key), a renewable or
310
+ // session token (a JWT, a bearer, a 64-hex agent or conversation token), or
311
+ // a one-turn value carried as `code=` or `token=`. A `sha256:` digest and the
312
+ // 32-hex installation ID are not secrets and stay.
313
+
314
+ const SECRET_KEY = /^(token|secret|password|pem|private_key|api_key|access_token|refresh_token|id_token|device_code|user_code|code|capability|authorization|bearer)$|(?:_|-)(token|secret|key|code|capability)$/i
315
+
316
+ export function scrubFacts(value) {
317
+ if (Array.isArray(value)) return value.map(scrubFacts)
318
+ if (value && typeof value === 'object') {
319
+ const out = {}
320
+ for (const [k, v] of Object.entries(value)) out[k] = SECRET_KEY.test(k) && typeof v === 'string' ? '[redacted]' : scrubFacts(v)
321
+ return out
322
+ }
323
+ return value
324
+ }
325
+
326
+ const REDACTIONS = [
327
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[redacted]'],
328
+ [/\b(Bearer|Bot|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, '$1 [redacted]'],
329
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted]'],
330
+ [/\b[A-Za-z0-9_-]{23,28}\.[A-Za-z0-9_-]{6,7}\.[A-Za-z0-9_-]{25,}/g, '[redacted]'],
331
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, '[redacted]'],
332
+ [/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{16,}/g, '[redacted]'],
333
+ [/\bgithub_pat_[A-Za-z0-9_]{16,}/g, '[redacted]'],
334
+ [/\b(?<!sha256:)[0-9a-f]{48,}\b/gi, '[redacted]'],
335
+ [/\b(token|secret|password|code|key|capability|session_id|api_key)=([^\s&"']+)/gi, '$1=[redacted]'],
336
+ ]
337
+
338
+ export function redactDiagnostic(text) {
339
+ let out = String(text)
340
+ for (const [pattern, replacement] of REDACTIONS) out = out.replace(pattern, replacement)
341
+ return out
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // The report: sections, one line per check in the preflight's format, and a
346
+ // summary that decides the exit code.
347
+
348
+ const STATUS_WORD = { passed: 'ok', warning: 'warning', failed: 'failed', refused: 'refused' }
349
+
350
+ function reporter(stdout) {
351
+ const all = []
352
+ let current = null
353
+ let pending = []
354
+ let notes = []
355
+ let printed = 0
356
+ const write = (text) => stdout.write(redactDiagnostic(text))
357
+ const flush = () => {
358
+ if (current === null) return
359
+ write(`${printed > 0 ? '\n' : ''}${current}\n`)
360
+ printed += 1
361
+ if (pending.length > 0) {
362
+ const width = Math.max(...pending.map((c) => c.name.length))
363
+ for (const c of pending) {
364
+ write(`${STATUS_WORD[c.status].padEnd(8)} ${c.name.padEnd(width)} ${oneLine(c.observed)}\n`)
365
+ if (c.action) write(`${''.padEnd(9 + width + 2)}${oneLine(c.action)}\n`)
366
+ }
367
+ }
368
+ for (const note of notes) write(`${note}\n`)
369
+ notes = []
370
+ pending = []
371
+ }
372
+ return {
373
+ section(name) { flush(); current = name },
374
+ checks(results) { for (const c of results) { all.push(c); pending.push(c) } },
375
+ note(text) { notes.push(text) },
376
+ finish() {
377
+ flush()
378
+ const count = (status) => all.filter((c) => c.status === status).length
379
+ const broken = count('failed') + count('refused')
380
+ const warnings = count('warning')
381
+ const summary = [`${count('passed')} checks passed`]
382
+ if (warnings > 0) summary.push(`${warnings} warning${warnings === 1 ? '' : 's'}`)
383
+ if (broken > 0) summary.push(`failed: ${broken} condition${broken === 1 ? '' : 's'}`)
384
+ write(`\n${summary.join(', ')}.\n`)
385
+ return broken > 0 ? EXIT.failed : EXIT.ok
386
+ },
387
+ }
388
+ }
389
+
390
+ function oneLine(text) {
391
+ return String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean).join(' ')
392
+ }