@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/README.md +259 -0
- package/bin/curia.mjs +15 -0
- package/package.json +36 -0
- package/src/acquire.mjs +188 -0
- package/src/archive.mjs +154 -0
- package/src/atomic.mjs +38 -0
- package/src/bundle.mjs +119 -0
- package/src/cli.mjs +73 -0
- package/src/commands.mjs +45 -0
- package/src/compose.mjs +137 -0
- package/src/config.mjs +289 -0
- package/src/doctor.mjs +392 -0
- package/src/exit.mjs +32 -0
- package/src/install.mjs +194 -0
- package/src/launcher.mjs +53 -0
- package/src/layout.mjs +95 -0
- package/src/lock.mjs +74 -0
- package/src/manifest.mjs +571 -0
- package/src/preflight.mjs +593 -0
- package/src/purge.mjs +230 -0
- package/src/resources.mjs +146 -0
- package/src/rollback.mjs +131 -0
- package/src/root.mjs +196 -0
- package/src/secrets.mjs +149 -0
- package/src/stable.mjs +314 -0
- package/src/stage.mjs +141 -0
- package/src/steps.mjs +25 -0
- package/src/switch.mjs +199 -0
- package/src/tailscale.mjs +156 -0
- package/src/uninstall.mjs +190 -0
- package/src/update.mjs +174 -0
- package/stable-index.pub +3 -0
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
|
+
}
|