nervur 0.0.0 → 0.1.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/nervur.js +514 -0
- package/package.json +15 -4
- package/preflight.js +161 -0
- package/scaffold.js +50 -0
- package/README.md +0 -3
package/nervur.js
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nervur — the CLI: a CLIENT of a running carcass, never the runtime
|
|
3
|
+
// (papers/host.md — "the CLI talks to a carcass over its API; it is never the
|
|
4
|
+
// runtime"). `up`/`down`/`reset` drive the compose that holds the carcass; every
|
|
5
|
+
// other verb reaches the carcass's face over HTTP, so what the CLI reports is the
|
|
6
|
+
// LIVE ground, not a fresh in-process being.
|
|
7
|
+
|
|
8
|
+
import { spawnSync } from 'node:child_process'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { request as httpsRequest } from 'node:https'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { resolveTemplate } from '@nervur-org/kit/templates.js'
|
|
15
|
+
import { runPreflight, renderChain, blockingFailure } from './preflight.js'
|
|
16
|
+
import { scaffoldSpecies } from './scaffold.js'
|
|
17
|
+
|
|
18
|
+
// The one published tag — what `nervur install` pulls, and what a local
|
|
19
|
+
// `docker build` must name for install to find it (papers/dev.md — "the
|
|
20
|
+
// published install idiom").
|
|
21
|
+
const DEFAULT_IMAGE = 'nervur/carcass:latest'
|
|
22
|
+
|
|
23
|
+
// A `--flag value` reader for the being-family verbs — a fork is always a flag with
|
|
24
|
+
// a sane default, never a prompt (papers/dev.md).
|
|
25
|
+
const flagValue = (args, flag) => {
|
|
26
|
+
const i = args.indexOf(flag)
|
|
27
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
28
|
+
}
|
|
29
|
+
const positional = (args) => args.filter((a) => !a.startsWith('--'))
|
|
30
|
+
|
|
31
|
+
const sh = (cmd, args, opts = {}) => {
|
|
32
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
|
|
33
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
37
|
+
|
|
38
|
+
// Knock a being domain through the Caddy door over the dev CA — the same TLS path
|
|
39
|
+
// a browser and a remote caller ride (papers/dev.md). `address`/`servername` let a
|
|
40
|
+
// caller resolve like `curl --resolve` (connect to an IP, present the domain in
|
|
41
|
+
// SNI + Host) so the path is provable without /etc/hosts. Any error — DNS not
|
|
42
|
+
// resolving because dev/hosts.sh has not run, a TLS or connection failure —
|
|
43
|
+
// resolves false, and `up` falls back to the always-published localhost face; a
|
|
44
|
+
// missing hosts entry must never break `up` (the refusing preflight is later).
|
|
45
|
+
export function knockDomain(
|
|
46
|
+
domain,
|
|
47
|
+
ca,
|
|
48
|
+
path = '/health',
|
|
49
|
+
{ address, servername, timeout = 2500 } = {}
|
|
50
|
+
) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const req = httpsRequest(
|
|
53
|
+
{
|
|
54
|
+
host: address ?? domain,
|
|
55
|
+
servername: servername ?? domain,
|
|
56
|
+
port: 443,
|
|
57
|
+
path,
|
|
58
|
+
ca,
|
|
59
|
+
timeout,
|
|
60
|
+
headers: { host: domain }
|
|
61
|
+
},
|
|
62
|
+
(res) => {
|
|
63
|
+
let body = ''
|
|
64
|
+
res.on('data', (c) => (body += c))
|
|
65
|
+
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }))
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
req.on('error', () => resolve({ ok: false }))
|
|
69
|
+
req.on('timeout', () => {
|
|
70
|
+
req.destroy()
|
|
71
|
+
resolve({ ok: false })
|
|
72
|
+
})
|
|
73
|
+
req.end()
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The dev CA the door serves under — beside the certs script (dev/certs/gen.sh →
|
|
78
|
+
// dev/certs/out/ca.crt). Absent before the first `up`, so read lazily and tolerate
|
|
79
|
+
// its absence (the domain knock simply can't run yet).
|
|
80
|
+
function readCA(config) {
|
|
81
|
+
if (!config.certs) return undefined
|
|
82
|
+
try {
|
|
83
|
+
return readFileSync(join(dirname(config.certs), 'out', 'ca.crt'))
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const localOk = async (url) => {
|
|
90
|
+
try {
|
|
91
|
+
return (await fetch(url)).ok
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Knock the carcass until it answers, preferring the domain door when it can. Each
|
|
98
|
+
// tick tries the domain (over the dev CA) first, then the always-published
|
|
99
|
+
// localhost face — so a ground with dev/hosts.sh run reports through the real URL
|
|
100
|
+
// scheme, and one without it still comes up on localhost. Bounded but generous:
|
|
101
|
+
// the first boot installs node_modules (a native module) inside the container,
|
|
102
|
+
// which takes a few minutes, so the wait prints progress rather than looking hung.
|
|
103
|
+
async function knock({ localUrl, domain, ca, ms = 300_000 }) {
|
|
104
|
+
const until = Date.now() + ms
|
|
105
|
+
let noted = 0
|
|
106
|
+
for (;;) {
|
|
107
|
+
if (domain && ca && (await knockDomain(domain, ca)).ok) return { via: 'domain' }
|
|
108
|
+
if (await localOk(localUrl)) return { via: 'localhost' }
|
|
109
|
+
if (Date.now() > until)
|
|
110
|
+
throw new Error(`the carcass did not answer at ${localUrl} within ${Math.round(ms / 1000)}s`)
|
|
111
|
+
if (Date.now() - noted > 5000) {
|
|
112
|
+
process.stdout.write(
|
|
113
|
+
' · standing the carcass (first boot installs node_modules inside the container)…\n'
|
|
114
|
+
)
|
|
115
|
+
noted = Date.now()
|
|
116
|
+
}
|
|
117
|
+
await sleep(1000)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const getJson = async (url) => (await fetch(url)).json()
|
|
122
|
+
const postJson = async (url, body) =>
|
|
123
|
+
(
|
|
124
|
+
await fetch(url, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'content-type': 'application/json' },
|
|
127
|
+
body: JSON.stringify(body ?? {})
|
|
128
|
+
})
|
|
129
|
+
).json()
|
|
130
|
+
// Same as postJson but keeps the status code — the identity verbs need to tell a
|
|
131
|
+
// 409 (refused, still occupied) apart from success without throwing on either.
|
|
132
|
+
const postJsonStatus = async (url, body) => {
|
|
133
|
+
const res = await fetch(url, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify(body ?? {})
|
|
137
|
+
})
|
|
138
|
+
return { status: res.status, body: await res.json() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The install compose — the same shape carcass/compose.yml stands (carcass,
|
|
142
|
+
// durable named volume, image disposable/volume durable): one carcass container,
|
|
143
|
+
// its ground config bind-mounted read-only, its state on a volume, published to
|
|
144
|
+
// loopback via the NERVUR_PORT/NERVUR_FACES_PORT seams. Caddy fronting is parked
|
|
145
|
+
// (papers/prod.md — "arrives with the per-box configuration"); this is the honest
|
|
146
|
+
// floor today. Rewritten on every `install` — mechanical, never hand-edited.
|
|
147
|
+
function installCompose() {
|
|
148
|
+
return `name: nervur
|
|
149
|
+
|
|
150
|
+
# Generated by \`nervur install\` — the published carcass stand (papers/host.md,
|
|
151
|
+
# papers/dev.md § the published install idiom). Image disposable, volume durable:
|
|
152
|
+
# re-running install is image + boot, nothing wiped.
|
|
153
|
+
services:
|
|
154
|
+
carcass:
|
|
155
|
+
image: \${NERVUR_IMAGE:-${DEFAULT_IMAGE}}
|
|
156
|
+
environment:
|
|
157
|
+
NERVUR_GROUND: /app/ground.config.js
|
|
158
|
+
volumes:
|
|
159
|
+
- ./ground.config.js:/app/ground.config.js:ro
|
|
160
|
+
- nervur-state:/app/state
|
|
161
|
+
ports:
|
|
162
|
+
- '127.0.0.1:\${NERVUR_PORT:-4000}:4000'
|
|
163
|
+
- '127.0.0.1:\${NERVUR_FACES_PORT:-4400}:4400'
|
|
164
|
+
- '127.0.0.1:\${NERVUR_CONSOLE_PORT:-4200}:4200'
|
|
165
|
+
restart: unless-stopped
|
|
166
|
+
|
|
167
|
+
volumes:
|
|
168
|
+
nervur-state:
|
|
169
|
+
`
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The ground config the carcass reads INSIDE the container — internal ports are
|
|
173
|
+
// fixed (4000/4400); the compose above remaps them to the host's NERVUR_PORT/
|
|
174
|
+
// NERVUR_FACES_PORT, so changing the host port never touches this file. Written
|
|
175
|
+
// once and kept (host.md — install delivers code, boot converges state); edit it
|
|
176
|
+
// freely, a re-install never overwrites it.
|
|
177
|
+
function installGroundConfig() {
|
|
178
|
+
return `// nervur ground — generated once by \`nervur install\`; edit freely, it is kept.
|
|
179
|
+
// The state root a mounted volume converges on every boot (papers/host.md); no
|
|
180
|
+
// stations stand bare — \`nervur add\` makes beings on top of this.
|
|
181
|
+
export default {
|
|
182
|
+
version: '0.1.0',
|
|
183
|
+
ground: '/app/state',
|
|
184
|
+
face: { port: 4000 },
|
|
185
|
+
faces: { port: 4400 },
|
|
186
|
+
console: { port: 4200 },
|
|
187
|
+
stations: {}
|
|
188
|
+
}
|
|
189
|
+
`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function main() {
|
|
193
|
+
const configPath = process.env.NERVUR_GROUND ?? 'dev/ground.config.js'
|
|
194
|
+
// The ground config is tolerant of absence: the being-family author verb
|
|
195
|
+
// `scaffold` is client-local and needs no ground at all, so a missing config
|
|
196
|
+
// must not block it. A verb that needs the face resolves it through face()
|
|
197
|
+
// below, which throws a clear error when the port is absent.
|
|
198
|
+
let config = {}
|
|
199
|
+
try {
|
|
200
|
+
config = (await import(pathToFileURL(configPath))).default
|
|
201
|
+
} catch {
|
|
202
|
+
// no ground config here — the NERVUR_PORT fallback below still reaches an
|
|
203
|
+
// installed carcass (nervur install has no dev/ground.config.js to hand it)
|
|
204
|
+
}
|
|
205
|
+
// NERVUR_PORT, when set, always wins — it is the explicit seam `nervur install`
|
|
206
|
+
// published the carcass on, and must override even a resolved dev/ground.config.js
|
|
207
|
+
// (running from inside the monorepo with NERVUR_PORT set is exactly how this probe,
|
|
208
|
+
// and any contributor testing `install`, points the client at their OWN carcass
|
|
209
|
+
// rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
|
|
210
|
+
// is the convenience default; absent both, a published install's own default (4000).
|
|
211
|
+
const faceUrl = process.env.NERVUR_PORT
|
|
212
|
+
? `http://localhost:${process.env.NERVUR_PORT}`
|
|
213
|
+
: config.face?.port
|
|
214
|
+
? `http://localhost:${config.face.port}`
|
|
215
|
+
: 'http://localhost:4000'
|
|
216
|
+
const face = () => faceUrl
|
|
217
|
+
|
|
218
|
+
// `--as <name>` is a global flag — it rides before OR after the verb (`nervur
|
|
219
|
+
// --as acme add empty` and `nervur add empty --as acme` both work), so pull it
|
|
220
|
+
// out of the raw argv before the verb is read off position 0.
|
|
221
|
+
const rawArgs = process.argv.slice(2)
|
|
222
|
+
const asIdx = rawArgs.indexOf('--as')
|
|
223
|
+
const asFlag = asIdx >= 0 ? rawArgs[asIdx + 1] : undefined
|
|
224
|
+
if (asIdx >= 0) rawArgs.splice(asIdx, 2)
|
|
225
|
+
|
|
226
|
+
const faceDomain = config.domains?.face
|
|
227
|
+
|
|
228
|
+
const [verb = 'whoami', ...args] = rawArgs
|
|
229
|
+
const print = (v) => console.log(JSON.stringify(v, null, 2))
|
|
230
|
+
|
|
231
|
+
// Which identity the being-family verbs act as (papers/host.md — "switching
|
|
232
|
+
// identity is a client act"). Precedence: --as <name> flag > NERVUR_IDENTITY env
|
|
233
|
+
// > sticky file beside the resolved ground config > 'main'. Never a prompt.
|
|
234
|
+
// The sticky file rides beside whatever ground config actually resolved; a
|
|
235
|
+
// published install has none, so it rides beside NERVUR_HOME instead — never a
|
|
236
|
+
// relative 'dev/' path that doesn't exist outside the monorepo.
|
|
237
|
+
const stickyHome = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
|
|
238
|
+
const stickyPath = existsSync(configPath)
|
|
239
|
+
? join(dirname(configPath), '.nervur-identity')
|
|
240
|
+
: join(stickyHome, '.nervur-identity')
|
|
241
|
+
const readSticky = () => {
|
|
242
|
+
try {
|
|
243
|
+
return readFileSync(stickyPath, 'utf8').trim() || null
|
|
244
|
+
} catch {
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const selectedIdentity = asFlag ?? process.env.NERVUR_IDENTITY ?? readSticky() ?? 'main'
|
|
249
|
+
|
|
250
|
+
// The door is open by default; `--no-door` (or NERVUR_DOOR=0) closes it for
|
|
251
|
+
// headless/CI — a command with a sane default, never a prompt. Closed, the hosts
|
|
252
|
+
// check degrades to advisory and 443 is skipped: the ground stands on localhost.
|
|
253
|
+
const doorOpen = !(args.includes('--no-door') || process.env.NERVUR_DOOR === '0')
|
|
254
|
+
|
|
255
|
+
switch (verb) {
|
|
256
|
+
// The published install (papers/host.md — "the CLI, client, never runtime";
|
|
257
|
+
// papers/dev.md — "the published install idiom"): Docker preflight, then the
|
|
258
|
+
// carcass image, a compose + volume in NERVUR_HOME, up, knock /health, whoami.
|
|
259
|
+
// Installer dumb, boot smart — existing state (the volume) is never touched;
|
|
260
|
+
// re-running this is image + boot, nothing asked, nothing lost.
|
|
261
|
+
case 'install': {
|
|
262
|
+
// Only the docker check applies here — an empty config skips certs/hosts/
|
|
263
|
+
// ca-trust/port-443 (those are the DEV BENCH's own preconditions, preflight.js
|
|
264
|
+
// runs them only when the config declares certs/domains).
|
|
265
|
+
const results = await runPreflight({}, { door: false })
|
|
266
|
+
console.log(renderChain(results))
|
|
267
|
+
const blocked = blockingFailure(results)
|
|
268
|
+
if (blocked) {
|
|
269
|
+
console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
|
|
270
|
+
process.exit(1)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const home = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
|
|
274
|
+
const image = process.env.NERVUR_IMAGE ?? DEFAULT_IMAGE
|
|
275
|
+
const port = process.env.NERVUR_PORT ?? '4000'
|
|
276
|
+
const facesPort = process.env.NERVUR_FACES_PORT ?? '4400'
|
|
277
|
+
|
|
278
|
+
// The carcass image: present locally, or pullable. No registry is published
|
|
279
|
+
// yet, so a pull will fail honestly — refuse with the exact local fix rather
|
|
280
|
+
// than raising a compose against an image that isn't there.
|
|
281
|
+
const inspect = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' })
|
|
282
|
+
if (inspect.status !== 0) {
|
|
283
|
+
console.log(` · image ${image} not found locally — trying \`docker pull\`…`)
|
|
284
|
+
const pull = spawnSync('docker', ['pull', image], { stdio: 'inherit' })
|
|
285
|
+
if (pull.status !== 0) {
|
|
286
|
+
console.error(
|
|
287
|
+
`\n refusing: no carcass image '${image}' locally, and no registry to pull from yet.\n` +
|
|
288
|
+
` fix: \`docker build -f carcass/Dockerfile -t ${image} .\` builds it, or \`docker pull ${image}\` once a registry is published`
|
|
289
|
+
)
|
|
290
|
+
process.exit(1)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Lay down the ground. The compose is rewritten every run (mechanical, not
|
|
295
|
+
// state); the ground config is written once and kept (edit it freely — the
|
|
296
|
+
// boot converges whatever it finds, papers/host.md).
|
|
297
|
+
mkdirSync(home, { recursive: true })
|
|
298
|
+
const composePath = join(home, 'compose.yml')
|
|
299
|
+
writeFileSync(composePath, installCompose())
|
|
300
|
+
const groundPath = join(home, 'ground.config.js')
|
|
301
|
+
if (!existsSync(groundPath)) writeFileSync(groundPath, installGroundConfig())
|
|
302
|
+
|
|
303
|
+
sh('docker', ['compose', '-f', composePath, 'up', '-d'], {
|
|
304
|
+
env: {
|
|
305
|
+
...process.env,
|
|
306
|
+
NERVUR_IMAGE: image,
|
|
307
|
+
NERVUR_PORT: String(port),
|
|
308
|
+
NERVUR_FACES_PORT: String(facesPort)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
const localUrl = `http://localhost:${port}`
|
|
313
|
+
await knock({ localUrl: `${localUrl}/health` })
|
|
314
|
+
console.log(` · carcass standing at ${home} — face on ${localUrl}`)
|
|
315
|
+
print(await getJson(`${localUrl}/whoami`))
|
|
316
|
+
break
|
|
317
|
+
}
|
|
318
|
+
case 'preflight': {
|
|
319
|
+
// The chain on its own: report the bench's readiness without standing it.
|
|
320
|
+
// Exit code reflects the verdict so a script can gate on it.
|
|
321
|
+
const results = await runPreflight(config, { door: doorOpen })
|
|
322
|
+
console.log(renderChain(results))
|
|
323
|
+
process.exit(blockingFailure(results) ? 1 : 0)
|
|
324
|
+
break
|
|
325
|
+
}
|
|
326
|
+
case 'up': {
|
|
327
|
+
// The preflight runs FIRST (papers/dev.md): refuse at the door on the first
|
|
328
|
+
// blocking failure, pointing at the fix, rather than raising a compose that
|
|
329
|
+
// will fail as a runtime surprise.
|
|
330
|
+
const results = await runPreflight(config, { door: doorOpen })
|
|
331
|
+
console.log(renderChain(results))
|
|
332
|
+
const blocked = blockingFailure(results)
|
|
333
|
+
if (blocked) {
|
|
334
|
+
console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
|
|
335
|
+
process.exit(1)
|
|
336
|
+
}
|
|
337
|
+
if (config.certs) sh('bash', [config.certs])
|
|
338
|
+
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'])
|
|
339
|
+
const ca = readCA(config)
|
|
340
|
+
// Door open: knock the domain first, so a bench with hosts run reports through
|
|
341
|
+
// the real URL scheme (slice-2 behavior). Door closed: localhost only.
|
|
342
|
+
const { via } = await knock({
|
|
343
|
+
localUrl: `${face()}/health`,
|
|
344
|
+
domain: doorOpen ? faceDomain : null,
|
|
345
|
+
ca
|
|
346
|
+
})
|
|
347
|
+
if (via === 'domain') {
|
|
348
|
+
console.log(` · carcass answered through the door: https://${faceDomain}`)
|
|
349
|
+
print(JSON.parse((await knockDomain(faceDomain, ca, '/whoami')).body))
|
|
350
|
+
} else {
|
|
351
|
+
if (!doorOpen) console.log(' · door closed — ground stands degraded (localhost only)')
|
|
352
|
+
else if (faceDomain)
|
|
353
|
+
console.log(
|
|
354
|
+
` · carcass on ${face()} — run \`sudo dev/hosts.sh\` to reach it at https://${faceDomain}`
|
|
355
|
+
)
|
|
356
|
+
print(await getJson(`${face()}/whoami`))
|
|
357
|
+
}
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
case 'down':
|
|
361
|
+
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'])
|
|
362
|
+
console.log('down')
|
|
363
|
+
break
|
|
364
|
+
case 'reset':
|
|
365
|
+
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'])
|
|
366
|
+
for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
|
|
367
|
+
console.log('reset')
|
|
368
|
+
break
|
|
369
|
+
case 'whoami':
|
|
370
|
+
print(await getJson(`${face()}/whoami`))
|
|
371
|
+
break
|
|
372
|
+
case 'status':
|
|
373
|
+
print(await getJson(`${face()}/status`))
|
|
374
|
+
break
|
|
375
|
+
case 'add':
|
|
376
|
+
print(
|
|
377
|
+
await postJson(`${face()}/fleet/add`, {
|
|
378
|
+
kind: args[0] ?? 'empty',
|
|
379
|
+
identity: selectedIdentity
|
|
380
|
+
})
|
|
381
|
+
)
|
|
382
|
+
break
|
|
383
|
+
case 'list':
|
|
384
|
+
print(await getJson(`${face()}/fleet/list`))
|
|
385
|
+
break
|
|
386
|
+
case 'remove':
|
|
387
|
+
if (!args[0]) throw new Error('usage: nervur remove <name>')
|
|
388
|
+
print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
|
|
389
|
+
break
|
|
390
|
+
// ── identities (papers/host.md — "The identities"): locally one carcass
|
|
391
|
+
// custodies many, one vault each. `add` mints silently, `list` shows the
|
|
392
|
+
// public ids only, `remove` refuses (409) while any being is attached —
|
|
393
|
+
// never force, never prompt.
|
|
394
|
+
case 'identity': {
|
|
395
|
+
const [sub, name] = positional(args)
|
|
396
|
+
if (sub === 'add') {
|
|
397
|
+
if (!name) throw new Error('usage: nervur identity add <name>')
|
|
398
|
+
print(await postJson(`${face()}/identities/add`, { name }))
|
|
399
|
+
} else if (sub === 'list') {
|
|
400
|
+
const list = await getJson(`${face()}/identities`)
|
|
401
|
+
for (const i of list)
|
|
402
|
+
console.log(`${i.name === selectedIdentity ? '*' : ' '} ${i.name} ${i.id}`)
|
|
403
|
+
} else if (sub === 'remove') {
|
|
404
|
+
if (!name) throw new Error('usage: nervur identity remove <name>')
|
|
405
|
+
const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
|
|
406
|
+
if (status === 409) {
|
|
407
|
+
console.error(
|
|
408
|
+
`refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
|
|
409
|
+
)
|
|
410
|
+
for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
|
|
411
|
+
process.exit(1)
|
|
412
|
+
}
|
|
413
|
+
if (status >= 400) {
|
|
414
|
+
console.error(`nervur: ${body.error}`)
|
|
415
|
+
process.exit(1)
|
|
416
|
+
}
|
|
417
|
+
print(body)
|
|
418
|
+
} else {
|
|
419
|
+
throw new Error('usage: nervur identity <add|list|remove> <name>')
|
|
420
|
+
}
|
|
421
|
+
break
|
|
422
|
+
}
|
|
423
|
+
// Client-side sticky selection (papers/host.md — switching is a client act; it
|
|
424
|
+
// selects which vault the verbs act on, stands no container, crosses no wall).
|
|
425
|
+
case 'use': {
|
|
426
|
+
const name = positional(args)[0]
|
|
427
|
+
if (!name) {
|
|
428
|
+
console.log(selectedIdentity)
|
|
429
|
+
break
|
|
430
|
+
}
|
|
431
|
+
mkdirSync(dirname(stickyPath), { recursive: true })
|
|
432
|
+
writeFileSync(stickyPath, name)
|
|
433
|
+
console.log(`using identity '${name}'`)
|
|
434
|
+
break
|
|
435
|
+
}
|
|
436
|
+
// ── the being family: scaffold (client-local) authors a species; deploy/species
|
|
437
|
+
// reach the carcass over its face, like every other verb (papers/host.md).
|
|
438
|
+
case 'scaffold': {
|
|
439
|
+
// Mint a species repo from a template kind into a target dir — the DNA a git
|
|
440
|
+
// repo from birth. Local, like up/down: it touches no carcass.
|
|
441
|
+
const name = positional(args)[0]
|
|
442
|
+
if (!name) throw new Error('usage: nervur scaffold <name> [--kind empty] [--dir <path>]')
|
|
443
|
+
const kind = flagValue(args, '--kind') ?? 'empty'
|
|
444
|
+
print(
|
|
445
|
+
scaffoldSpecies({
|
|
446
|
+
name,
|
|
447
|
+
template: resolveTemplate(kind, { from: join(process.cwd(), 'noop.js') }),
|
|
448
|
+
dir: flagValue(args, '--dir')
|
|
449
|
+
})
|
|
450
|
+
)
|
|
451
|
+
break
|
|
452
|
+
}
|
|
453
|
+
case 'deploy': {
|
|
454
|
+
// Grant this carcass a species repo at a pinned full-hash release, materialize
|
|
455
|
+
// and run it. Re-deploy with a new hash = upgrade or rollback, same verb.
|
|
456
|
+
const [name, repo, ref] = positional(args)
|
|
457
|
+
if (!name || !repo || !ref)
|
|
458
|
+
throw new Error('usage: nervur deploy <name> <repo> <full-commit-hash>')
|
|
459
|
+
print(await postJson(`${face()}/deploy`, { name, repo, ref }))
|
|
460
|
+
break
|
|
461
|
+
}
|
|
462
|
+
case 'species':
|
|
463
|
+
print(await getJson(`${face()}/deploy/list`))
|
|
464
|
+
break
|
|
465
|
+
case 'help':
|
|
466
|
+
case '--help':
|
|
467
|
+
case '-h':
|
|
468
|
+
console.log(
|
|
469
|
+
'nervur — client of a running carcass\n\n' +
|
|
470
|
+
'carcass family: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove\n' +
|
|
471
|
+
'being family: scaffold, deploy, species\n\n' +
|
|
472
|
+
' install Docker preflight, then stand a carcass at NERVUR_HOME (default ~/.nervur)\n' +
|
|
473
|
+
' [NERVUR_HOME, NERVUR_IMAGE, NERVUR_PORT, NERVUR_FACES_PORT]\n' +
|
|
474
|
+
' up [--no-door] (dev bench) run the bench preflight, then stand the ground\n' +
|
|
475
|
+
' preflight [--no-door] run the bench preflight and report (exit code = verdict)\n' +
|
|
476
|
+
' identity add <name> mint an identity on this machine (silent, idempotent)\n' +
|
|
477
|
+
' identity list list identities on this machine · name + public id\n' +
|
|
478
|
+
' identity remove <name> destroy an identity — refused (409) while beings remain\n' +
|
|
479
|
+
' use [name] select which identity add/list act as (client-only); no args prints it\n' +
|
|
480
|
+
' add <kind> [--as name] add a being under the selected (or --as) identity\n' +
|
|
481
|
+
' scaffold <name> mint a species repo (the DNA) [--kind empty] [--dir <path>]\n' +
|
|
482
|
+
' deploy <name> <repo> <full-commit-hash> grant + pin + run a species\n' +
|
|
483
|
+
' species list the deployed species and their pins\n\n' +
|
|
484
|
+
'the door is open by default; --no-door (or NERVUR_DOOR=0) stands the ground\n' +
|
|
485
|
+
'on localhost only for headless/CI — the hosts check degrades, 443 is skipped'
|
|
486
|
+
)
|
|
487
|
+
break
|
|
488
|
+
default:
|
|
489
|
+
console.error(
|
|
490
|
+
`nervur: unknown verb: ${verb} (have: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove, scaffold, deploy, species)`
|
|
491
|
+
)
|
|
492
|
+
process.exit(1)
|
|
493
|
+
}
|
|
494
|
+
process.exit(0)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Run only as the CLI; importing this module (e.g. to exercise knockDomain against
|
|
498
|
+
// the door) must not fire main(). Resolve the invoked path through symlinks — `npx
|
|
499
|
+
// nervur` / `node_modules/.bin/nervur` reach here via a symlink, and import.meta.url
|
|
500
|
+
// is already the realpath, so compare realpath to realpath or the door never opens.
|
|
501
|
+
function invokedAsCli() {
|
|
502
|
+
if (!process.argv[1]) return false
|
|
503
|
+
try {
|
|
504
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
505
|
+
} catch {
|
|
506
|
+
return false
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (invokedAsCli()) {
|
|
510
|
+
main().catch((e) => {
|
|
511
|
+
console.error(`nervur: ${e.message}`)
|
|
512
|
+
process.exit(1)
|
|
513
|
+
})
|
|
514
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nervur",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "nervur —
|
|
5
|
-
"
|
|
6
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The nervur CLI — a client of a running carcass, never the runtime (papers/host.md).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nervur": "./nervur.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"nervur.js",
|
|
11
|
+
"preflight.js",
|
|
12
|
+
"scaffold.js"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@nervur-org/kit": "*"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0"
|
|
7
18
|
}
|
package/preflight.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// nervur — the bench preflight (papers/dev.md — "the bench preflight"; host.md —
|
|
2
|
+
// "the CLI ... refuses at the door, not as a runtime bug"). A chain of checks
|
|
3
|
+
// `nervur up` runs FIRST, derived entirely from the ground config: a check only
|
|
4
|
+
// runs for what the config declares — a config with no `domains` gets no domain
|
|
5
|
+
// checks, a config with no `certs` gets no cert checks. Each check returns a plain
|
|
6
|
+
// result {name, status, note, fix}; the CLI prints the chain and refuses on the
|
|
7
|
+
// first blocking failure, pointing at the fix without running it.
|
|
8
|
+
//
|
|
9
|
+
// Pure node built-ins, no runtime state: docker / lsof / security are shelled out,
|
|
10
|
+
// dns.lookup honors /etc/hosts. The module is importable so a probe can drive the
|
|
11
|
+
// chain against a crafted config without standing a ground.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { lookup } from 'node:dns/promises'
|
|
17
|
+
|
|
18
|
+
// Status vocabulary: 'ok' passes; 'fail' blocks (refuse at the door); 'warn' is
|
|
19
|
+
// advisory — a real finding that never blocks (browser trust, the door-closed
|
|
20
|
+
// degrade); 'skip' is a check the config didn't ask for. Only 'fail' blocks.
|
|
21
|
+
const ok = (name, note) => ({ name, status: 'ok', note, fix: null })
|
|
22
|
+
const fail = (name, note, fix) => ({ name, status: 'fail', note, fix })
|
|
23
|
+
const warn = (name, note, fix) => ({ name, status: 'warn', note, fix })
|
|
24
|
+
const skip = (name, note) => ({ name, status: 'skip', note, fix: null })
|
|
25
|
+
|
|
26
|
+
const domainNames = (config) => Object.keys(config.domains?.routes ?? {})
|
|
27
|
+
const isLoopback = (addr) => addr === '::1' || /^127\./.test(addr)
|
|
28
|
+
const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many)
|
|
29
|
+
|
|
30
|
+
// docker — binary present → daemon answering → permission ok. Each failure its own
|
|
31
|
+
// precise one-line diagnosis + fix. Always runs; always blocking.
|
|
32
|
+
function checkDocker() {
|
|
33
|
+
const v = spawnSync('docker', ['--version'], { encoding: 'utf8' })
|
|
34
|
+
if (v.error || v.status !== 0)
|
|
35
|
+
return fail(
|
|
36
|
+
'docker',
|
|
37
|
+
'the docker binary is not on PATH',
|
|
38
|
+
'install Docker — https://docs.docker.com/get-docker/'
|
|
39
|
+
)
|
|
40
|
+
const info = spawnSync('docker', ['info'], { encoding: 'utf8' })
|
|
41
|
+
if (info.status === 0) return ok('docker', `daemon answering (${(v.stdout || '').trim()})`)
|
|
42
|
+
const err = `${info.stderr || ''}${info.stdout || ''}`.toLowerCase()
|
|
43
|
+
if (err.includes('permission denied'))
|
|
44
|
+
return fail(
|
|
45
|
+
'docker',
|
|
46
|
+
'the Docker socket denies this user',
|
|
47
|
+
'add your user to the docker group (or run Docker Desktop as this user), then re-login'
|
|
48
|
+
)
|
|
49
|
+
return fail(
|
|
50
|
+
'docker',
|
|
51
|
+
'the Docker daemon is not responding',
|
|
52
|
+
'start Docker (`open -a Docker` on macOS), then retry'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// certs — the dev CA and a cert per declared domain exist, and the cert set covers
|
|
57
|
+
// every declared domain (a config edit that outran gen.sh is caught here, not as a
|
|
58
|
+
// TLS surprise). Only runs when config.certs is declared; blocking.
|
|
59
|
+
function checkCerts(config) {
|
|
60
|
+
if (!config.certs) return skip('certs', 'no certs declared')
|
|
61
|
+
const outDir = join(dirname(config.certs), 'out')
|
|
62
|
+
const fix = `bash ${config.certs}`
|
|
63
|
+
if (!existsSync(join(outDir, 'ca.crt'))) return fail('certs', 'the dev CA is not minted', fix)
|
|
64
|
+
const names = domainNames(config)
|
|
65
|
+
const missing = names.filter((n) => !existsSync(join(outDir, `${n}.crt`)))
|
|
66
|
+
if (missing.length)
|
|
67
|
+
return fail('certs', `no cert for ${missing.join(', ')} — the config outran the cert set`, fix)
|
|
68
|
+
return ok('certs', `dev CA + ${names.length} domain ${plural(names.length, 'cert')}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// hosts — every declared domain resolves to loopback (dns.lookup honors /etc/hosts).
|
|
72
|
+
// Only runs when config.domains is declared. Blocking WITH the door; door closed
|
|
73
|
+
// (--no-door / NERVUR_DOOR=0) demotes it to an advisory degrade — the ground stands
|
|
74
|
+
// on localhost only.
|
|
75
|
+
async function checkHosts(config, door) {
|
|
76
|
+
const names = domainNames(config)
|
|
77
|
+
if (!names.length) return skip('hosts', 'no domains declared')
|
|
78
|
+
const unresolved = []
|
|
79
|
+
for (const n of names) {
|
|
80
|
+
try {
|
|
81
|
+
const { address } = await lookup(n)
|
|
82
|
+
if (!isLoopback(address)) unresolved.push(`${n}→${address}`)
|
|
83
|
+
} catch {
|
|
84
|
+
unresolved.push(n)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const fix = 'sudo dev/hosts.sh'
|
|
88
|
+
if (!unresolved.length)
|
|
89
|
+
return ok('hosts', `${names.length} ${plural(names.length, 'domain')} resolve to loopback`)
|
|
90
|
+
const summary = `${unresolved.length}/${names.length} ${plural(unresolved.length, 'domain')} do not resolve to loopback`
|
|
91
|
+
if (door) return fail('hosts', summary, fix)
|
|
92
|
+
return warn('hosts', `door closed — ground stands degraded (localhost only); ${summary}`, fix)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// CA browser trust — macOS keychain only, ADVISORY: print the one-line fix, never
|
|
96
|
+
// block, never run it. Only meaningful when a dev CA is declared.
|
|
97
|
+
function checkCaTrust(config) {
|
|
98
|
+
if (!config.certs) return skip('ca-trust', 'no certs declared')
|
|
99
|
+
if (process.platform !== 'darwin') return skip('ca-trust', 'not macOS')
|
|
100
|
+
const caPath = join(dirname(config.certs), 'out', 'ca.crt')
|
|
101
|
+
if (!existsSync(caPath)) return skip('ca-trust', 'no dev CA yet')
|
|
102
|
+
const r = spawnSync('security', ['verify-cert', '-c', caPath], { encoding: 'utf8' })
|
|
103
|
+
if (r.status === 0) return ok('ca-trust', 'dev CA trusted by the keychain')
|
|
104
|
+
return warn(
|
|
105
|
+
'ca-trust',
|
|
106
|
+
'dev CA not trusted by the keychain — the lens will warn until you trust it',
|
|
107
|
+
`security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ${caPath}`
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// port 443 — free, or already held by OUR Docker/Caddy (then fine). A stranger is a
|
|
112
|
+
// blocking diagnosis naming the holder (lsof). Skipped with the door closed, and
|
|
113
|
+
// skipped when the config declares no domains — no door exists to hold 443 for.
|
|
114
|
+
function checkPort443(config, door) {
|
|
115
|
+
if (!config.domains) return skip('port-443', 'no domains declared')
|
|
116
|
+
if (!door) return skip('port-443', 'door closed')
|
|
117
|
+
// `+c0` disables lsof's 9-char COMMAND truncation, so `com.docker.backend` reads
|
|
118
|
+
// whole and our own Docker is never mistaken for a stranger.
|
|
119
|
+
const r = spawnSync('lsof', ['+c0', '-nP', '-iTCP:443', '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
120
|
+
if (r.error) return warn('port-443', 'cannot probe 443 (lsof unavailable)', null)
|
|
121
|
+
const rows = (r.stdout || '')
|
|
122
|
+
.trim()
|
|
123
|
+
.split('\n')
|
|
124
|
+
.slice(1)
|
|
125
|
+
.filter((l) => l.trim())
|
|
126
|
+
if (!rows.length) return ok('port-443', '443 free')
|
|
127
|
+
if (rows.every((l) => /docker|com\.docker|vpnkit|caddy/i.test(l)))
|
|
128
|
+
return ok('port-443', '443 held by our Docker/Caddy')
|
|
129
|
+
const [cmd, pid] = rows[0].split(/\s+/)
|
|
130
|
+
return fail(
|
|
131
|
+
'port-443',
|
|
132
|
+
`443 held by a stranger: ${cmd} (pid ${pid})`,
|
|
133
|
+
'stop the process holding 443, or free the port'
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The chain, in door order: docker → certs → hosts → ca-trust → port-443. Every
|
|
138
|
+
// check runs so the report is complete; the caller refuses on the first blocker.
|
|
139
|
+
export async function runPreflight(config, { door = true } = {}) {
|
|
140
|
+
return [
|
|
141
|
+
checkDocker(),
|
|
142
|
+
checkCerts(config),
|
|
143
|
+
await checkHosts(config, door),
|
|
144
|
+
checkCaTrust(config),
|
|
145
|
+
checkPort443(config, door)
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const GLYPH = { ok: '✓', fail: '✗', warn: '!', skip: '·' }
|
|
150
|
+
|
|
151
|
+
export function renderChain(results) {
|
|
152
|
+
const lines = []
|
|
153
|
+
for (const r of results) {
|
|
154
|
+
lines.push(` ${GLYPH[r.status] ?? '·'} ${r.name.padEnd(9)} ${r.note}`)
|
|
155
|
+
if ((r.status === 'fail' || r.status === 'warn') && r.fix) lines.push(` ↳ fix: ${r.fix}`)
|
|
156
|
+
}
|
|
157
|
+
return lines.join('\n')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The first blocking failure, or undefined — the CLI refuses on this and exits 1.
|
|
161
|
+
export const blockingFailure = (results) => results.find((r) => r.status === 'fail')
|
package/scaffold.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Scaffold a species repo (papers/host.md — "a species is a template … and
|
|
2
|
+
// physically a git repo: the DNA. It is scaffolded by the CLI as a repo of its
|
|
3
|
+
// own"). A being-family author verb, CLIENT-LOCAL like `up`/`down`: it mints a
|
|
4
|
+
// species from a template kind into a target dir — the template files, a named
|
|
5
|
+
// package.json, `git init`, and an initial commit — so the DNA is a git repo from
|
|
6
|
+
// birth. No prompt: the target defaults to ./<name>, flag-overridable; the kind
|
|
7
|
+
// defaults to the kit's bundled `empty`, any other resolved on the npm rail by the
|
|
8
|
+
// caller. Nothing here reaches a carcass — a species is authored, not deployed.
|
|
9
|
+
|
|
10
|
+
import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
|
|
14
|
+
// git is ambient on the bench and in the image (papers/host.md — code travels as
|
|
15
|
+
// DNA over bare repos); no npm dependency stands in for it. Every call runs in
|
|
16
|
+
// `cwd` and throws git's own stderr on nonzero, so a failure is never silent.
|
|
17
|
+
function defaultGit(args, cwd) {
|
|
18
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
19
|
+
if (r.status !== 0)
|
|
20
|
+
throw new Error(`git ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`)
|
|
21
|
+
return (r.stdout || '').trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// scaffoldSpecies({ name, template, dir?, git? }) → { name, dir, head }. `template`
|
|
25
|
+
// is a resolved directory (the caller resolves the kind — bundled or npm). Returns
|
|
26
|
+
// the initial commit hash: the first pin a deploy can grant against.
|
|
27
|
+
export function scaffoldSpecies({ name, template, dir, git = defaultGit } = {}) {
|
|
28
|
+
if (!name) throw new Error('scaffold: a species name is required')
|
|
29
|
+
if (!template || !existsSync(template))
|
|
30
|
+
throw new Error(`scaffold: no template at ${template} — bundle it, or \`npm i\` it first`)
|
|
31
|
+
const target = dir ?? join(process.cwd(), name)
|
|
32
|
+
if (existsSync(target)) throw new Error(`scaffold: ${target} already exists — pick another --dir`)
|
|
33
|
+
|
|
34
|
+
cpSync(template, target, { recursive: true })
|
|
35
|
+
|
|
36
|
+
// Name the species in its package.json (the template ships a generic name); the
|
|
37
|
+
// DNA carries its own identity from birth.
|
|
38
|
+
const pkgPath = join(target, 'package.json')
|
|
39
|
+
if (existsSync(pkgPath)) {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
41
|
+
pkg.name = name
|
|
42
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
git(['init', '-q'], target)
|
|
46
|
+
git(['add', '-A'], target)
|
|
47
|
+
git(['commit', '-q', '-m', `scaffold ${name}`], target)
|
|
48
|
+
const head = git(['rev-parse', 'HEAD'], target)
|
|
49
|
+
return { name, dir: target, head }
|
|
50
|
+
}
|
package/README.md
DELETED