nervur 0.3.0 → 0.6.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 DELETED
@@ -1,672 +0,0 @@
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 { createHash } from 'node:crypto'
10
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
11
- import { homedir } from 'node:os'
12
- import { dirname, join, relative } from 'node:path'
13
- import { request as httpsRequest } from 'node:https'
14
- import { pathToFileURL } from 'node:url'
15
- import { resolveTemplate } from '@nervur-org/kit/templates.js'
16
- import { runPreflight, renderChain, blockingFailure } from './preflight.js'
17
- import { scaffoldSpecies } from './scaffold.js'
18
- import { completionScript, wireCompletion, VERBS } from './completion.js'
19
- import { banner, bold, columns, cyan, dim, heading, identityTree, red } from './style.js'
20
-
21
- // The one published tag — what `nervur install` pulls, and what a local
22
- // `docker build` must name for install to find it (papers/dev.md — "the
23
- // published install idiom").
24
- const DEFAULT_IMAGE = 'nervur/carcass:latest'
25
-
26
- // A `--flag value` reader for the being-family verbs — a fork is always a flag with
27
- // a sane default, never a prompt (papers/dev.md).
28
- const flagValue = (args, flag) => {
29
- const i = args.indexOf(flag)
30
- return i >= 0 ? args[i + 1] : undefined
31
- }
32
- const positional = (args) => args.filter((a) => !a.startsWith('--'))
33
-
34
- const sh = (cmd, args, opts = {}) => {
35
- const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
36
- if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`)
37
- }
38
-
39
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
40
-
41
- // Knock a being domain through the Caddy door over the dev CA — the same TLS path
42
- // a browser and a remote caller ride (papers/dev.md). `address`/`servername` let a
43
- // caller resolve like `curl --resolve` (connect to an IP, present the domain in
44
- // SNI + Host) so the path is provable without /etc/hosts. Any error — DNS not
45
- // resolving because dev/hosts.sh has not run, a TLS or connection failure —
46
- // resolves false, and `up` falls back to the always-published localhost face; a
47
- // missing hosts entry must never break `up` (the refusing preflight is later).
48
- export function knockDomain(
49
- domain,
50
- ca,
51
- path = '/health',
52
- { address, servername, timeout = 2500 } = {}
53
- ) {
54
- return new Promise((resolve) => {
55
- const req = httpsRequest(
56
- {
57
- host: address ?? domain,
58
- servername: servername ?? domain,
59
- port: 443,
60
- path,
61
- ca,
62
- timeout,
63
- headers: { host: domain }
64
- },
65
- (res) => {
66
- let body = ''
67
- res.on('data', (c) => (body += c))
68
- res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }))
69
- }
70
- )
71
- req.on('error', () => resolve({ ok: false }))
72
- req.on('timeout', () => {
73
- req.destroy()
74
- resolve({ ok: false })
75
- })
76
- req.end()
77
- })
78
- }
79
-
80
- // The dev CA the door serves under — beside the certs script (dev/certs/gen.sh →
81
- // dev/certs/out/ca.crt). Absent before the first `up`, so read lazily and tolerate
82
- // its absence (the domain knock simply can't run yet).
83
- function readCA(config) {
84
- if (!config.certs) return undefined
85
- try {
86
- return readFileSync(join(dirname(config.certs), 'out', 'ca.crt'))
87
- } catch {
88
- return undefined
89
- }
90
- }
91
-
92
- const localOk = async (url) => {
93
- try {
94
- return (await fetch(url)).ok
95
- } catch {
96
- return false
97
- }
98
- }
99
-
100
- // Knock the carcass until it answers, preferring the domain door when it can. Each
101
- // tick tries the domain (over the dev CA) first, then the always-published
102
- // localhost face — so a ground with dev/hosts.sh run reports through the real URL
103
- // scheme, and one without it still comes up on localhost. Bounded but generous:
104
- // the first boot installs node_modules (a native module) inside the container,
105
- // which takes a few minutes, so the wait prints progress rather than looking hung.
106
- async function knock({ localUrl, domain, ca, ms = 600_000 }) {
107
- const until = Date.now() + ms
108
- let noted = 0
109
- for (;;) {
110
- if (domain && ca && (await knockDomain(domain, ca)).ok) return { via: 'domain' }
111
- if (await localOk(localUrl)) return { via: 'localhost' }
112
- if (Date.now() > until)
113
- throw new Error(`the carcass did not answer at ${localUrl} within ${Math.round(ms / 1000)}s`)
114
- if (Date.now() - noted > 5000) {
115
- process.stdout.write(
116
- ' · standing the carcass (first boot installs node_modules inside the container)…\n'
117
- )
118
- noted = Date.now()
119
- }
120
- await sleep(1000)
121
- }
122
- }
123
-
124
- const getJson = async (url) => (await fetch(url)).json()
125
- const postJson = async (url, body) =>
126
- (
127
- await fetch(url, {
128
- method: 'POST',
129
- headers: { 'content-type': 'application/json' },
130
- body: JSON.stringify(body ?? {})
131
- })
132
- ).json()
133
- // Same as postJson but keeps the status code — the identity verbs need to tell a
134
- // 409 (refused, still occupied) apart from success without throwing on either.
135
- const postJsonStatus = async (url, body) => {
136
- const res = await fetch(url, {
137
- method: 'POST',
138
- headers: { 'content-type': 'application/json' },
139
- body: JSON.stringify(body ?? {})
140
- })
141
- return { status: res.status, body: await res.json() }
142
- }
143
-
144
- // The install compose — the same shape carcass/compose.yml stands (carcass,
145
- // durable named volume, image disposable/volume durable): one carcass container,
146
- // its ground config bind-mounted read-only, its state on a volume, published to
147
- // loopback via the NERVUR_PORT/NERVUR_FACES_PORT seams. Caddy fronting is parked
148
- // (papers/prod.md — "arrives with the per-box configuration"); this is the honest
149
- // floor today. Rewritten on every `install` — mechanical, never hand-edited.
150
- // Docker derives volume/network names from the project name — a fixed name would
151
- // share one state volume across every NERVUR_HOME on the machine.
152
- function installCompose(home) {
153
- const scope = createHash('sha256').update(realpathSync(home)).digest('hex').slice(0, 8)
154
- return `name: nervur-${scope}
155
-
156
- # Generated by \`nervur install\` — the published carcass stand (papers/host.md,
157
- # papers/dev.md § the published install idiom). Image disposable, volume durable:
158
- # re-running install is image + boot, nothing wiped.
159
- services:
160
- carcass:
161
- image: \${NERVUR_IMAGE:-${DEFAULT_IMAGE}}
162
- environment:
163
- NERVUR_GROUND: /app/ground.config.js
164
- volumes:
165
- - ./ground.config.js:/app/ground.config.js:ro
166
- - nervur-state:/app/state
167
- ports:
168
- - '127.0.0.1:\${NERVUR_PORT:-4000}:4000'
169
- - '127.0.0.1:\${NERVUR_FACES_PORT:-4400}:4400'
170
- - '127.0.0.1:\${NERVUR_CONSOLE_PORT:-4200}:4200'
171
- restart: unless-stopped
172
-
173
- volumes:
174
- nervur-state:
175
- `
176
- }
177
-
178
- // The ground config the carcass reads INSIDE the container — internal ports are
179
- // fixed (4000/4400); the compose above remaps them to the host's NERVUR_PORT/
180
- // NERVUR_FACES_PORT, so changing the host port never touches this file. Written
181
- // once and kept (host.md — install delivers code, boot converges state); edit it
182
- // freely, a re-install never overwrites it.
183
- function installGroundConfig() {
184
- return `// nervur ground — generated once by \`nervur install\`; edit freely, it is kept.
185
- // The state root a mounted volume converges on every boot (papers/host.md); no
186
- // stations stand bare — \`nervur add\` makes beings on top of this.
187
- export default {
188
- version: '0.1.0',
189
- ground: '/app/state',
190
- face: { port: 4000 },
191
- faces: { port: 4400 },
192
- console: { port: 4200 },
193
- stations: {}
194
- }
195
- `
196
- }
197
-
198
- async function main() {
199
- const configPath = process.env.NERVUR_GROUND ?? 'dev/ground.config.js'
200
- // The ground config is tolerant of absence: the being-family author verb
201
- // `scaffold` is client-local and needs no ground at all, so a missing config
202
- // must not block it. A verb that needs the face resolves it through face()
203
- // below, which throws a clear error when the port is absent.
204
- let config = {}
205
- try {
206
- config = (await import(pathToFileURL(configPath))).default
207
- } catch {
208
- // no ground config here — the NERVUR_PORT fallback below still reaches an
209
- // installed carcass (nervur install has no dev/ground.config.js to hand it)
210
- }
211
- // NERVUR_PORT, when set, always wins — it is the explicit seam `nervur install`
212
- // published the carcass on, and must override even a resolved dev/ground.config.js
213
- // (running from inside the monorepo with NERVUR_PORT set is exactly how this probe,
214
- // and any contributor testing `install`, points the client at their OWN carcass
215
- // rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
216
- // is the convenience default; absent both, a published install's own default (4000).
217
- // Reached at 127.0.0.1, never `localhost`: the compose binds IPv4 loopback
218
- // only, and macOS launchd holds ::1 listeners (AirPlay on 5000) that accept
219
- // and hang a localhost knock. Host ports must also dodge the WHATWG fetch
220
- // bad-ports list (6000/X11 and kin) — node's fetch refuses those outright.
221
- const faceUrl = process.env.NERVUR_PORT
222
- ? `http://127.0.0.1:${process.env.NERVUR_PORT}`
223
- : (config.hostPorts?.face ?? config.face?.port)
224
- ? `http://127.0.0.1:${config.hostPorts?.face ?? config.face.port}`
225
- : 'http://127.0.0.1:4000'
226
- const face = () => faceUrl
227
-
228
- // A config without project/hostPorts/domains (the published install path)
229
- // gets none of these vars — the compose defaults hold.
230
- const composeEnv = () => {
231
- const env = { ...process.env }
232
- if (config.project) env.COMPOSE_PROJECT_NAME = config.project
233
- if (config.domains) env.COMPOSE_PROFILES = 'door'
234
- const seam = {
235
- face: 'FACE',
236
- console: 'CONSOLE',
237
- faces: 'FACES',
238
- ctlKeeper: 'CTL_KEEPER',
239
- ctlUpsy: 'CTL_UPSY',
240
- ctlFedexy: 'CTL_FEDEXY',
241
- dpUpsy: 'DP_UPSY',
242
- dpFedexy1: 'DP_FEDEXY1',
243
- dpFedexy2: 'DP_FEDEXY2'
244
- }
245
- for (const [key, name] of Object.entries(seam))
246
- if (config.hostPorts?.[key] != null) env[`NERVUR_HOST_${name}`] = String(config.hostPorts[key])
247
- if (config.compose) {
248
- const rel = configPath.startsWith('/') ? relative(process.cwd(), configPath) : configPath
249
- env.NERVUR_GROUND_IN = `/app/${rel.replace(/^\.\//, '')}`
250
- }
251
- return env
252
- }
253
-
254
- // `--as <name>` is a global flag — it rides before OR after the verb (`nervur
255
- // --as acme add empty` and `nervur add empty --as acme` both work), so pull it
256
- // out of the raw argv before the verb is read off position 0.
257
- const rawArgs = process.argv.slice(2)
258
- const asIdx = rawArgs.indexOf('--as')
259
- const asFlag = asIdx >= 0 ? rawArgs[asIdx + 1] : undefined
260
- if (asIdx >= 0) rawArgs.splice(asIdx, 2)
261
-
262
- const faceDomain = config.domains?.face
263
-
264
- const bare = rawArgs.length === 0
265
- const [verb = 'whoami', ...args] = rawArgs
266
- const print = (v) => console.log(JSON.stringify(v, null, 2))
267
-
268
- // Which identity the being-family verbs act as (papers/host.md — "switching
269
- // identity is a client act"). Precedence: --as <name> flag > NERVUR_IDENTITY env
270
- // > sticky file beside the resolved ground config > 'main'. Never a prompt.
271
- // The sticky file rides beside whatever ground config actually resolved; a
272
- // published install has none, so it rides beside NERVUR_HOME instead — never a
273
- // relative 'dev/' path that doesn't exist outside the monorepo.
274
- const stickyHome = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
275
- const stickyPath = existsSync(configPath)
276
- ? join(dirname(configPath), '.nervur-identity')
277
- : join(stickyHome, '.nervur-identity')
278
- const readSticky = () => {
279
- try {
280
- return readFileSync(stickyPath, 'utf8').trim() || null
281
- } catch {
282
- return null
283
- }
284
- }
285
- const selectedIdentity = asFlag ?? process.env.NERVUR_IDENTITY ?? readSticky() ?? 'main'
286
-
287
- // The door is open by default; `--no-door` (or NERVUR_DOOR=0) closes it for
288
- // headless/CI — a command with a sane default, never a prompt. Closed, the hosts
289
- // check degrades to advisory and 443 is skipped: the ground stands on localhost.
290
- const doorOpen = !(args.includes('--no-door') || process.env.NERVUR_DOOR === '0')
291
-
292
- switch (verb) {
293
- // The published install (papers/host.md — "the CLI, client, never runtime";
294
- // papers/dev.md — "the published install idiom"): Docker preflight, then the
295
- // carcass image, a compose + volume in NERVUR_HOME, up, knock /health, whoami.
296
- // Installer dumb, boot smart — existing state (the volume) is never touched;
297
- // re-running this is image + boot, nothing asked, nothing lost.
298
- case 'install': {
299
- // Only the docker check applies here — an empty config skips certs/hosts/
300
- // ca-trust/port-443 (those are the DEV BENCH's own preconditions, preflight.js
301
- // runs them only when the config declares certs/domains).
302
- const results = await runPreflight({}, { door: false })
303
- console.log(renderChain(results))
304
- const blocked = blockingFailure(results)
305
- if (blocked) {
306
- console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
307
- process.exit(1)
308
- }
309
-
310
- const home = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
311
- const image = process.env.NERVUR_IMAGE ?? DEFAULT_IMAGE
312
- const port = process.env.NERVUR_PORT ?? '4000'
313
- const facesPort = process.env.NERVUR_FACES_PORT ?? '4400'
314
-
315
- // Refused here, before the compose — `compose up` against a missing image
316
- // fails too, but far less legibly than this message.
317
- const inspect = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' })
318
- if (inspect.status !== 0) {
319
- console.log(` · image ${image} not found locally — trying \`docker pull\`…`)
320
- const pull = spawnSync('docker', ['pull', image], { stdio: 'inherit' })
321
- if (pull.status !== 0) {
322
- console.error(
323
- `\n refusing: no carcass image '${image}' locally, and the pull failed.\n` +
324
- ` fix: check the network and the image name — \`docker pull ${image}\` by hand shows the registry's answer; \`docker build -f carcass/Dockerfile -t ${image} .\` builds it locally`
325
- )
326
- process.exit(1)
327
- }
328
- }
329
-
330
- // Lay down the ground. The compose is rewritten every run (mechanical, not
331
- // state); the ground config is written once and kept (edit it freely — the
332
- // boot converges whatever it finds, papers/host.md).
333
- mkdirSync(home, { recursive: true })
334
- const composePath = join(home, 'compose.yml')
335
- writeFileSync(composePath, installCompose(home))
336
- const groundPath = join(home, 'ground.config.js')
337
- if (!existsSync(groundPath)) writeFileSync(groundPath, installGroundConfig())
338
-
339
- sh('docker', ['compose', '-f', composePath, 'up', '-d'], {
340
- env: {
341
- ...process.env,
342
- NERVUR_IMAGE: image,
343
- NERVUR_PORT: String(port),
344
- NERVUR_FACES_PORT: String(facesPort)
345
- }
346
- })
347
-
348
- const localUrl = `http://127.0.0.1:${port}`
349
- await knock({ localUrl: `${localUrl}/health` })
350
- print(await getJson(`${localUrl}/whoami`))
351
-
352
- // TTY-gated: CI, probes, and scripts must never gain an rc edit as a
353
- // side effect of install; NERVUR_COMPLETION=0 opts a human out too.
354
- let completionRow = ['shell', 'nervur completion', 'prints the autocomplete script']
355
- if (process.stdout.isTTY && process.env.NERVUR_COMPLETION !== '0') {
356
- const wired = wireCompletion(home)
357
- if (wired.wired)
358
- completionRow = ['shell', `${wired.shell} autocomplete wired`, `${wired.rc} — new shells complete`]
359
- }
360
-
361
- const consolePort = process.env.NERVUR_CONSOLE_PORT ?? '4200'
362
- console.log(
363
- banner('nervur is standing', [
364
- ['console', cyan(`http://localhost:${consolePort}`), 'open in your browser'],
365
- ['home', home, 'compose + ground config, kept across installs'],
366
- completionRow,
367
- ['next', 'nervur add empty', 'stand your first being']
368
- ]) + `\n ${dim(`api ${localUrl}`)}\n`
369
- )
370
- break
371
- }
372
- case 'preflight': {
373
- // The chain on its own: report the bench's readiness without standing it.
374
- // Exit code reflects the verdict so a script can gate on it.
375
- const results = await runPreflight(config, { door: doorOpen })
376
- console.log(renderChain(results))
377
- process.exit(blockingFailure(results) ? 1 : 0)
378
- break
379
- }
380
- case 'up': {
381
- // The preflight runs FIRST (papers/dev.md): refuse at the door on the first
382
- // blocking failure, pointing at the fix, rather than raising a compose that
383
- // will fail as a runtime surprise.
384
- const results = await runPreflight(config, { door: doorOpen })
385
- console.log(renderChain(results))
386
- const blocked = blockingFailure(results)
387
- if (blocked) {
388
- console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
389
- process.exit(1)
390
- }
391
- if (config.certs) sh('bash', [config.certs])
392
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'], { env: composeEnv() })
393
- const ca = readCA(config)
394
- // Door open: knock the domain first, so a bench with hosts run reports through
395
- // the real URL scheme (slice-2 behavior). Door closed: localhost only.
396
- const { via } = await knock({
397
- localUrl: `${face()}/health`,
398
- domain: doorOpen ? faceDomain : null,
399
- ca
400
- })
401
- if (via === 'domain') {
402
- console.log(` · carcass answered through the door: https://${faceDomain}`)
403
- print(JSON.parse((await knockDomain(faceDomain, ca, '/whoami')).body))
404
- } else {
405
- if (!doorOpen) console.log(' · door closed — ground stands degraded (localhost only)')
406
- else if (faceDomain)
407
- console.log(
408
- ` · carcass on ${face()} — run \`sudo dev/hosts.sh\` to reach it at https://${faceDomain}`
409
- )
410
- print(await getJson(`${face()}/whoami`))
411
- }
412
- // The console is loopback-only by design (host.md — "One operator, one
413
- // console"), so its URL is localhost even when the face answers through
414
- // the domain door.
415
- console.log(
416
- banner('ground is standing', [
417
- ['console', cyan(`http://localhost:${config.hostPorts?.console ?? config.console?.port ?? 4200}`), 'open in your browser']
418
- ]) + `\n ${dim(`api ${via === 'domain' ? `https://${faceDomain}` : face()}`)}\n`
419
- )
420
- break
421
- }
422
- case 'down':
423
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'], { env: composeEnv() })
424
- console.log('down')
425
- break
426
- case 'reset':
427
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'], { env: composeEnv() })
428
- for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
429
- console.log('reset')
430
- break
431
- case 'whoami': {
432
- const who = await getJson(`${face()}/whoami`)
433
- if (!bare || !process.stdout.isTTY) {
434
- print(who)
435
- break
436
- }
437
- const consoleUrl = `http://localhost:${process.env.NERVUR_CONSOLE_PORT ?? config.console?.port ?? 4200}`
438
- const beings = who.beings ?? []
439
- const tree = identityTree(who.identities ?? [], beings, selectedIdentity)
440
- const hint = beings.length
441
- ? ''
442
- : `\n\n ${dim('nervur add empty stands your first being')}`
443
- console.log(
444
- `\n${heading(`nervur — carcass ${who.health} ${dim(`· v${who.version}`)}`)}\n\n${tree}${hint}\n\n` +
445
- `${columns([['console', cyan(consoleUrl), 'open in your browser']])}\n\n` +
446
- ` ${dim(`api ${face()} · faces ${who.faces ?? '—'}`)}\n`
447
- )
448
- break
449
- }
450
- case 'status':
451
- print(await getJson(`${face()}/status`))
452
- break
453
- case 'add':
454
- print(
455
- await postJson(`${face()}/fleet/add`, {
456
- kind: args[0] ?? 'empty',
457
- identity: selectedIdentity
458
- })
459
- )
460
- break
461
- case 'list': {
462
- const fleet = await getJson(`${face()}/fleet/list`)
463
- if (!process.stdout.isTTY) {
464
- print(fleet)
465
- break
466
- }
467
- const identities = (await getJson(`${face()}/identities`)).map((i) => i.name)
468
- console.log(`\n${identityTree(identities, fleet, selectedIdentity)}\n`)
469
- break
470
- }
471
- case 'remove':
472
- if (!args[0]) throw new Error('usage: nervur remove <name>')
473
- print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
474
- break
475
- // ── identities (papers/host.md — "The identities"): locally one carcass
476
- // custodies many, one vault each. `add` mints silently — or adopts a brought
477
- // key (`--key`, the bring-your-own-key seed; a different key never replaces
478
- // an owner, 409). `list` shows the public ids only; `key` prints the owner's
479
- // private key (the export half, hand-to-self); `remove` refuses (409) while
480
- // any being is attached — never force, never prompt.
481
- case 'identity': {
482
- const [sub, name] = positional(args)
483
- if (sub === 'add') {
484
- if (!name) throw new Error('usage: nervur identity add <name> [--key <file>]')
485
- const keyFile = flagValue(args, '--key')
486
- const key = keyFile ? readFileSync(keyFile, 'utf8').trim() : undefined
487
- const { status, body } = await postJsonStatus(`${face()}/identities/add`, { name, key })
488
- if (status >= 400) {
489
- console.error(`nervur: ${body.error}`)
490
- process.exit(1)
491
- }
492
- print(body)
493
- } else if (sub === 'key') {
494
- if (!name) throw new Error('usage: nervur identity key <name>')
495
- const { status, body } = await postJsonStatus(`${face()}/identities/key`, { name })
496
- if (status >= 400) {
497
- console.error(`nervur: ${body.error}`)
498
- process.exit(1)
499
- }
500
- // The secret alone, pipe-friendly (`nervur identity key acme > key.txt`) —
501
- // base64url PKCS8 DER, the exact shape `identity add --key` accepts back.
502
- console.log(body.secret)
503
- } else if (sub === 'list') {
504
- const list = await getJson(`${face()}/identities`)
505
- for (const i of list)
506
- console.log(`${i.name === selectedIdentity ? '*' : ' '} ${i.name} ${i.id}`)
507
- } else if (sub === 'remove') {
508
- if (!name) throw new Error('usage: nervur identity remove <name>')
509
- const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
510
- if (status === 409) {
511
- if (body.beings) {
512
- console.error(
513
- `refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
514
- )
515
- for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
516
- } else {
517
- console.error(
518
- `refused: identity '${name}' still holds sealed lineages: ${body.lineages.join(', ')}`
519
- )
520
- console.error(' lineage destruction has no verb yet — the sealed custody stays')
521
- }
522
- process.exit(1)
523
- }
524
- if (status >= 400) {
525
- console.error(`nervur: ${body.error}`)
526
- process.exit(1)
527
- }
528
- print(body)
529
- } else {
530
- throw new Error('usage: nervur identity <add|key|list|remove> <name>')
531
- }
532
- break
533
- }
534
- // Client-side sticky selection (papers/host.md — switching is a client act; it
535
- // selects which vault the verbs act on, stands no container, crosses no wall).
536
- case 'use': {
537
- const name = positional(args)[0]
538
- if (!name) {
539
- console.log(selectedIdentity)
540
- break
541
- }
542
- // Sticky selection is a client act, but it selects a real vault — an
543
- // unknown name is refused against the live list here, never stored to
544
- // fail later as a runtime surprise on some other verb.
545
- let known
546
- try {
547
- known = (await getJson(`${face()}/identities`)).map((i) => i.name)
548
- } catch {
549
- console.error(
550
- `nervur: ${red(`cannot verify identity '${name}'`)} — no carcass answering at ${face()}\n` +
551
- ` ${dim('fix:')} stand it first — nervur up (bench) or nervur install (published)`
552
- )
553
- process.exit(1)
554
- }
555
- if (!known.includes(name)) {
556
- console.error(
557
- `nervur: ${red(`no identity '${name}'`)} on this carcass — have: ${known.join(' · ') || '(none)'}\n` +
558
- ` ${dim('fix:')} nervur identity add ${name}`
559
- )
560
- process.exit(1)
561
- }
562
- mkdirSync(dirname(stickyPath), { recursive: true })
563
- writeFileSync(stickyPath, name)
564
- console.log(`using identity '${name}'`)
565
- break
566
- }
567
- // ── the being family: scaffold (client-local) authors a species; deploy/species
568
- // reach the carcass over its face, like every other verb (papers/host.md).
569
- case 'scaffold': {
570
- // Mint a species repo from a template kind into a target dir — the DNA a git
571
- // repo from birth. Local, like up/down: it touches no carcass.
572
- const name = positional(args)[0]
573
- if (!name) throw new Error('usage: nervur scaffold <name> [--kind empty] [--dir <path>]')
574
- const kind = flagValue(args, '--kind') ?? 'empty'
575
- print(
576
- scaffoldSpecies({
577
- name,
578
- template: resolveTemplate(kind, { from: join(process.cwd(), 'noop.js') }),
579
- dir: flagValue(args, '--dir')
580
- })
581
- )
582
- break
583
- }
584
- case 'deploy': {
585
- // Grant this carcass a species repo at a pinned full-hash release, materialize
586
- // and run it. Re-deploy with a new hash = upgrade or rollback, same verb.
587
- const [name, repo, ref] = positional(args)
588
- if (!name || !repo || !ref)
589
- throw new Error('usage: nervur deploy <name> <repo> <full-commit-hash>')
590
- print(await postJson(`${face()}/deploy`, { name, repo, ref }))
591
- break
592
- }
593
- case 'species':
594
- print(await getJson(`${face()}/deploy/list`))
595
- break
596
- case 'completion':
597
- process.stdout.write(completionScript(positional(args)[0]))
598
- break
599
- case 'help':
600
- case '--help':
601
- case '-h': {
602
- // Help renders from the same VERBS table completion generates from — a
603
- // verb missing here was never added to cli/completion.js.
604
- const sig = {
605
- up: '[--no-door]',
606
- preflight: '[--no-door]',
607
- identity: '<add|key|list|remove> [name] [--key <file>]',
608
- use: '[name]',
609
- add: '<kind> [--as <identity>]',
610
- remove: '<name>',
611
- scaffold: '<name> [--kind empty] [--dir <path>]',
612
- deploy: '<name> <repo> <full-commit-hash>',
613
- completion: '[bash|zsh]'
614
- }
615
- const carcass = new Set([
616
- 'install', 'up', 'down', 'reset', 'preflight', 'whoami', 'status',
617
- 'identity', 'use', 'add', 'list', 'remove'
618
- ])
619
- // Pad on the UNSTYLED signature so ANSI codes never skew the column.
620
- const width = Math.max(...VERBS.map(([v]) => `${v} ${sig[v] ?? ''}`.trim().length))
621
- const row = ([v, d]) => {
622
- const plain = `${v}${sig[v] ? ` ${sig[v]}` : ''}`
623
- const left = `${bold(v)}${sig[v] ? ` ${dim(sig[v])}` : ''}`
624
- return ` ${left}${' '.repeat(width - plain.length + 2)}${d}`
625
- }
626
- console.log(
627
- `${bold('nervur')} — client of a running carcass\n\n` +
628
- `${dim('usage')} nervur <verb> [args] ${dim('[--as <identity>]')}\n\n` +
629
- `${cyan('carcass family')}\n` +
630
- VERBS.filter(([v]) => carcass.has(v)).map(row).join('\n') +
631
- `\n\n${cyan('being family')}\n` +
632
- VERBS.filter(([v]) => !carcass.has(v) && v !== 'help').map(row).join('\n') +
633
- `\n\n${dim(
634
- 'install honors NERVUR_HOME · NERVUR_IMAGE · NERVUR_PORT · NERVUR_FACES_PORT · NERVUR_CONSOLE_PORT\n' +
635
- 'the door is open by default; --no-door (or NERVUR_DOOR=0) stands the ground\n' +
636
- 'on localhost only for headless/CI — the hosts check degrades, 443 is skipped'
637
- )}`
638
- )
639
- break
640
- }
641
- default: {
642
- const known = VERBS.map(([v]) => v)
643
- const near = known.filter((v) => v.startsWith(verb) || verb.startsWith(v.slice(0, 3)))
644
- console.error(
645
- `nervur: ${red(`unknown verb: ${verb}`)}` +
646
- (near.length ? ` — did you mean ${near.map(bold).join(' · ')}?` : '') +
647
- `\n${dim(`have: ${known.join(', ')}`)}`
648
- )
649
- process.exit(1)
650
- }
651
- }
652
- process.exit(0)
653
- }
654
-
655
- // Run only as the CLI; importing this module (e.g. to exercise knockDomain against
656
- // the door) must not fire main(). Resolve the invoked path through symlinks — `npx
657
- // nervur` / `node_modules/.bin/nervur` reach here via a symlink, and import.meta.url
658
- // is already the realpath, so compare realpath to realpath or the door never opens.
659
- function invokedAsCli() {
660
- if (!process.argv[1]) return false
661
- try {
662
- return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
663
- } catch {
664
- return false
665
- }
666
- }
667
- if (invokedAsCli()) {
668
- main().catch((e) => {
669
- console.error(`nervur: ${red(e.message)}`)
670
- process.exit(1)
671
- })
672
- }