nervur 0.1.1 → 0.3.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/README.md CHANGED
@@ -29,9 +29,14 @@ being family: scaffold, deploy, species
29
29
  install Docker preflight, then stand a carcass at
30
30
  NERVUR_HOME (default ~/.nervur)
31
31
  whoami · status the carcass answers with its identity and state
32
- identity add <name> mint an identity on this machine (silent, idempotent)
32
+ identity add <name> mint an identity on this machine (silent, idempotent);
33
+ --key <file> adopts a brought ed25519 key instead —
34
+ it never replaces an existing different owner
35
+ identity key <name> print the identity's private key (base64url PKCS8),
36
+ the exact shape --key accepts back
33
37
  identity list identities on this machine · name + public id
34
- identity remove <name> destroy an identity — refused while beings remain
38
+ identity remove <name> destroy an identity — refused while beings or
39
+ sealed lineages remain (only an empty one goes)
35
40
  use [name] select which identity add/list act as
36
41
  add <kind> [--as name] add a being under the selected identity
37
42
  list · remove <name> the fleet registry · retire a being
package/completion.js ADDED
@@ -0,0 +1,122 @@
1
+ // nervur — shell completion (`nervur completion [bash|zsh]`). Client-local like
2
+ // scaffold: touches no carcass. One verb table is the single truth; both scripts
3
+ // are generated from it so a new verb lands in completion by editing one list.
4
+ // No prompt anywhere: the shell is a positional with a sane default ($SHELL).
5
+
6
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
7
+ import { homedir } from 'node:os'
8
+ import { join } from 'node:path'
9
+
10
+ // verb → one-line description; subs/kinds drive second-position completion.
11
+ export const VERBS = [
12
+ ['install', 'stand a published carcass at NERVUR_HOME'],
13
+ ['up', '(dev bench) preflight, then stand the ground'],
14
+ ['down', 'stop the ground'],
15
+ ['reset', 'stop the ground and wipe its data'],
16
+ ['preflight', 'run the bench preflight and report'],
17
+ ['whoami', 'ask the carcass who it is'],
18
+ ['status', 'ask the carcass for its status'],
19
+ ['identity', 'manage identities (add · key · list · remove)'],
20
+ ['use', 'select the identity the verbs act as'],
21
+ ['add', 'add a being under the selected identity'],
22
+ ['list', 'list the beings on the ground'],
23
+ ['remove', 'remove a being'],
24
+ ['scaffold', 'mint a species repo from a template'],
25
+ ['deploy', 'grant + pin + run a species'],
26
+ ['species', 'list deployed species and their pins'],
27
+ ['completion', 'print the shell completion script (bash · zsh)'],
28
+ ['help', 'show help']
29
+ ]
30
+
31
+ const names = VERBS.map(([v]) => v).join(' ')
32
+
33
+ const bash = () => `# nervur completion (bash)
34
+ # load now: eval "$(nervur completion bash)"
35
+ # persist: nervur completion bash >> ~/.bashrc (or into bash_completion.d)
36
+ _nervur() {
37
+ local cur prev
38
+ cur="\${COMP_WORDS[COMP_CWORD]}"
39
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
40
+ case "$prev" in
41
+ identity) COMPREPLY=($(compgen -W "add key list remove" -- "$cur")); return ;;
42
+ completion) COMPREPLY=($(compgen -W "bash zsh" -- "$cur")); return ;;
43
+ add|--kind) COMPREPLY=($(compgen -W "empty" -- "$cur")); return ;;
44
+ --dir) COMPREPLY=($(compgen -d -- "$cur")); return ;;
45
+ esac
46
+ case "$cur" in
47
+ --*) COMPREPLY=($(compgen -W "--as --no-door --kind --dir --key" -- "$cur")); return ;;
48
+ esac
49
+ if [ "$COMP_CWORD" -eq 1 ]; then
50
+ COMPREPLY=($(compgen -W "${names}" -- "$cur"))
51
+ fi
52
+ }
53
+ complete -F _nervur nervur
54
+ `
55
+
56
+ const zsh = () => `#compdef nervur
57
+ # nervur completion (zsh)
58
+ # load now: eval "$(nervur completion zsh)"
59
+ # persist: nervur completion zsh > "\${fpath[1]}/_nervur" && rm -f ~/.zcompdump; compinit
60
+ _nervur() {
61
+ local -a verbs
62
+ verbs=(
63
+ ${VERBS.map(([v, d]) => ` '${v}:${d.replace(/'/g, "'\\''")}'`).join('\n')}
64
+ )
65
+ if (( CURRENT == 2 )); then
66
+ _describe 'verb' verbs
67
+ return
68
+ fi
69
+ case "\${words[2]}" in
70
+ identity) (( CURRENT == 3 )) && _values 'sub' add key list remove ;;
71
+ completion) (( CURRENT == 3 )) && _values 'shell' bash zsh ;;
72
+ add) (( CURRENT == 3 )) && _values 'kind' empty ;;
73
+ scaffold) _arguments '--kind[template kind]:kind:(empty)' '--dir[target directory]:dir:_directories' ;;
74
+ up|preflight) _arguments '--no-door[stand on localhost only (headless/CI)]' ;;
75
+ esac
76
+ }
77
+ if (( ! $+functions[compdef] )); then
78
+ autoload -Uz compinit
79
+ compinit
80
+ fi
81
+ compdef _nervur nervur
82
+ `
83
+
84
+ export function completionScript(shell) {
85
+ const which = shell ?? (process.env.SHELL?.endsWith('zsh') ? 'zsh' : 'bash')
86
+ if (which === 'zsh') return zsh()
87
+ if (which === 'bash') return bash()
88
+ throw new Error(`usage: nervur completion [bash|zsh] — '${which}' is not a shell I know`)
89
+ }
90
+
91
+ const MARK_OPEN = '# >>> nervur completion >>>'
92
+ const MARK_CLOSE = '# <<< nervur completion <<<'
93
+
94
+ // Wire completion for the operator's shell: the script lands as a file in the
95
+ // nervur home and the rc file gains one marked source-block — sourcing a file
96
+ // costs nothing per shell start, where `eval "$(nervur completion)"` would
97
+ // spawn node every time. Idempotent by the marker block (the dev/hosts.sh
98
+ // idiom): re-wiring rewrites the block in place, never stacks a second one.
99
+ export function wireCompletion(home) {
100
+ const shell = process.env.SHELL?.endsWith('zsh')
101
+ ? 'zsh'
102
+ : process.env.SHELL?.endsWith('bash')
103
+ ? 'bash'
104
+ : null
105
+ if (!shell) return { wired: false }
106
+ mkdirSync(home, { recursive: true })
107
+ const scriptPath = join(home, `completion.${shell}`)
108
+ writeFileSync(scriptPath, completionScript(shell))
109
+ const rc = join(homedir(), shell === 'zsh' ? '.zshrc' : '.bashrc')
110
+ const block = `${MARK_OPEN}\n[ -f "${scriptPath}" ] && source "${scriptPath}"\n${MARK_CLOSE}\n`
111
+ const current = existsSync(rc) ? readFileSync(rc, 'utf8') : ''
112
+ if (current.includes(MARK_OPEN)) {
113
+ const next = current.replace(
114
+ current.slice(current.indexOf(MARK_OPEN), current.indexOf(MARK_CLOSE) + MARK_CLOSE.length + 1),
115
+ block
116
+ )
117
+ if (next !== current) writeFileSync(rc, next)
118
+ } else {
119
+ appendFileSync(rc, `${current && !current.endsWith('\n') ? '\n' : ''}\n${block}`)
120
+ }
121
+ return { wired: true, shell, rc, scriptPath }
122
+ }
package/nervur.js CHANGED
@@ -9,12 +9,14 @@ import { spawnSync } from 'node:child_process'
9
9
  import { createHash } from 'node:crypto'
10
10
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
11
11
  import { homedir } from 'node:os'
12
- import { dirname, join } from 'node:path'
12
+ import { dirname, join, relative } from 'node:path'
13
13
  import { request as httpsRequest } from 'node:https'
14
14
  import { pathToFileURL } from 'node:url'
15
15
  import { resolveTemplate } from '@nervur-org/kit/templates.js'
16
16
  import { runPreflight, renderChain, blockingFailure } from './preflight.js'
17
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'
18
20
 
19
21
  // The one published tag — what `nervur install` pulls, and what a local
20
22
  // `docker build` must name for install to find it (papers/dev.md — "the
@@ -101,7 +103,7 @@ const localOk = async (url) => {
101
103
  // scheme, and one without it still comes up on localhost. Bounded but generous:
102
104
  // the first boot installs node_modules (a native module) inside the container,
103
105
  // which takes a few minutes, so the wait prints progress rather than looking hung.
104
- async function knock({ localUrl, domain, ca, ms = 300_000 }) {
106
+ async function knock({ localUrl, domain, ca, ms = 600_000 }) {
105
107
  const until = Date.now() + ms
106
108
  let noted = 0
107
109
  for (;;) {
@@ -212,13 +214,43 @@ async function main() {
212
214
  // and any contributor testing `install`, points the client at their OWN carcass
213
215
  // rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
214
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.
215
221
  const faceUrl = process.env.NERVUR_PORT
216
- ? `http://localhost:${process.env.NERVUR_PORT}`
217
- : config.face?.port
218
- ? `http://localhost:${config.face.port}`
219
- : 'http://localhost:4000'
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'
220
226
  const face = () => faceUrl
221
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
+
222
254
  // `--as <name>` is a global flag — it rides before OR after the verb (`nervur
223
255
  // --as acme add empty` and `nervur add empty --as acme` both work), so pull it
224
256
  // out of the raw argv before the verb is read off position 0.
@@ -229,6 +261,7 @@ async function main() {
229
261
 
230
262
  const faceDomain = config.domains?.face
231
263
 
264
+ const bare = rawArgs.length === 0
232
265
  const [verb = 'whoami', ...args] = rawArgs
233
266
  const print = (v) => console.log(JSON.stringify(v, null, 2))
234
267
 
@@ -270,7 +303,7 @@ async function main() {
270
303
  console.log(renderChain(results))
271
304
  const blocked = blockingFailure(results)
272
305
  if (blocked) {
273
- console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
306
+ console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
274
307
  process.exit(1)
275
308
  }
276
309
 
@@ -312,10 +345,28 @@ async function main() {
312
345
  }
313
346
  })
314
347
 
315
- const localUrl = `http://localhost:${port}`
348
+ const localUrl = `http://127.0.0.1:${port}`
316
349
  await knock({ localUrl: `${localUrl}/health` })
317
- console.log(` · carcass standing at ${home} — face on ${localUrl}`)
318
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
+ )
319
370
  break
320
371
  }
321
372
  case 'preflight': {
@@ -334,11 +385,11 @@ async function main() {
334
385
  console.log(renderChain(results))
335
386
  const blocked = blockingFailure(results)
336
387
  if (blocked) {
337
- console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
388
+ console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
338
389
  process.exit(1)
339
390
  }
340
391
  if (config.certs) sh('bash', [config.certs])
341
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'])
392
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'], { env: composeEnv() })
342
393
  const ca = readCA(config)
343
394
  // Door open: knock the domain first, so a bench with hosts run reports through
344
395
  // the real URL scheme (slice-2 behavior). Door closed: localhost only.
@@ -358,20 +409,44 @@ async function main() {
358
409
  )
359
410
  print(await getJson(`${face()}/whoami`))
360
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
+ )
361
420
  break
362
421
  }
363
422
  case 'down':
364
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'])
423
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'], { env: composeEnv() })
365
424
  console.log('down')
366
425
  break
367
426
  case 'reset':
368
- if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'])
427
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'], { env: composeEnv() })
369
428
  for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
370
429
  console.log('reset')
371
430
  break
372
- case 'whoami':
373
- print(await getJson(`${face()}/whoami`))
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
+ )
374
448
  break
449
+ }
375
450
  case 'status':
376
451
  print(await getJson(`${face()}/status`))
377
452
  break
@@ -383,22 +458,48 @@ async function main() {
383
458
  })
384
459
  )
385
460
  break
386
- case 'list':
387
- print(await getJson(`${face()}/fleet/list`))
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`)
388
469
  break
470
+ }
389
471
  case 'remove':
390
472
  if (!args[0]) throw new Error('usage: nervur remove <name>')
391
473
  print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
392
474
  break
393
475
  // ── identities (papers/host.md — "The identities"): locally one carcass
394
- // custodies many, one vault each. `add` mints silently, `list` shows the
395
- // public ids only, `remove` refuses (409) while any being is attached —
396
- // never force, never prompt.
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.
397
481
  case 'identity': {
398
482
  const [sub, name] = positional(args)
399
483
  if (sub === 'add') {
400
- if (!name) throw new Error('usage: nervur identity add <name>')
401
- print(await postJson(`${face()}/identities/add`, { name }))
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)
402
503
  } else if (sub === 'list') {
403
504
  const list = await getJson(`${face()}/identities`)
404
505
  for (const i of list)
@@ -407,10 +508,17 @@ async function main() {
407
508
  if (!name) throw new Error('usage: nervur identity remove <name>')
408
509
  const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
409
510
  if (status === 409) {
410
- console.error(
411
- `refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
412
- )
413
- for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
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
+ }
414
522
  process.exit(1)
415
523
  }
416
524
  if (status >= 400) {
@@ -419,7 +527,7 @@ async function main() {
419
527
  }
420
528
  print(body)
421
529
  } else {
422
- throw new Error('usage: nervur identity <add|list|remove> <name>')
530
+ throw new Error('usage: nervur identity <add|key|list|remove> <name>')
423
531
  }
424
532
  break
425
533
  }
@@ -431,6 +539,26 @@ async function main() {
431
539
  console.log(selectedIdentity)
432
540
  break
433
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
+ }
434
562
  mkdirSync(dirname(stickyPath), { recursive: true })
435
563
  writeFileSync(stickyPath, name)
436
564
  console.log(`using identity '${name}'`)
@@ -465,34 +593,61 @@ async function main() {
465
593
  case 'species':
466
594
  print(await getJson(`${face()}/deploy/list`))
467
595
  break
596
+ case 'completion':
597
+ process.stdout.write(completionScript(positional(args)[0]))
598
+ break
468
599
  case 'help':
469
600
  case '--help':
470
- case '-h':
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
+ }
471
626
  console.log(
472
- 'nervur — client of a running carcass\n\n' +
473
- 'carcass family: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove\n' +
474
- 'being family: scaffold, deploy, species\n\n' +
475
- ' install Docker preflight, then stand a carcass at NERVUR_HOME (default ~/.nervur)\n' +
476
- ' [NERVUR_HOME, NERVUR_IMAGE, NERVUR_PORT, NERVUR_FACES_PORT]\n' +
477
- ' up [--no-door] (dev bench) run the bench preflight, then stand the ground\n' +
478
- ' preflight [--no-door] run the bench preflight and report (exit code = verdict)\n' +
479
- ' identity add <name> mint an identity on this machine (silent, idempotent)\n' +
480
- ' identity list list identities on this machine · name + public id\n' +
481
- ' identity remove <name> destroy an identity — refused (409) while beings remain\n' +
482
- ' use [name] select which identity add/list act as (client-only); no args prints it\n' +
483
- ' add <kind> [--as name] add a being under the selected (or --as) identity\n' +
484
- ' scaffold <name> mint a species repo (the DNA) [--kind empty] [--dir <path>]\n' +
485
- ' deploy <name> <repo> <full-commit-hash> grant + pin + run a species\n' +
486
- ' species list the deployed species and their pins\n\n' +
487
- 'the door is open by default; --no-door (or NERVUR_DOOR=0) stands the ground\n' +
488
- 'on localhost only for headless/CI — the hosts check degrades, 443 is skipped'
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
+ )}`
489
638
  )
490
639
  break
491
- default:
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)))
492
644
  console.error(
493
- `nervur: unknown verb: ${verb} (have: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove, scaffold, deploy, species)`
645
+ `nervur: ${red(`unknown verb: ${verb}`)}` +
646
+ (near.length ? ` — did you mean ${near.map(bold).join(' · ')}?` : '') +
647
+ `\n${dim(`have: ${known.join(', ')}`)}`
494
648
  )
495
649
  process.exit(1)
650
+ }
496
651
  }
497
652
  process.exit(0)
498
653
  }
@@ -511,7 +666,7 @@ function invokedAsCli() {
511
666
  }
512
667
  if (invokedAsCli()) {
513
668
  main().catch((e) => {
514
- console.error(`nervur: ${e.message}`)
669
+ console.error(`nervur: ${red(e.message)}`)
515
670
  process.exit(1)
516
671
  })
517
672
  }
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "The nervur CLI — a client of a running carcass, never the runtime (papers/host.md).",
5
5
  "type": "module",
6
6
  "bin": {
7
- "nervur": "./nervur.js"
7
+ "nervur": "nervur.js"
8
8
  },
9
9
  "files": [
10
10
  "nervur.js",
11
11
  "preflight.js",
12
- "scaffold.js"
12
+ "scaffold.js",
13
+ "completion.js",
14
+ "style.js"
13
15
  ],
14
16
  "dependencies": {
15
17
  "@nervur-org/kit": "^0.1.0"
package/preflight.js CHANGED
@@ -14,6 +14,7 @@ import { spawnSync } from 'node:child_process'
14
14
  import { existsSync } from 'node:fs'
15
15
  import { dirname, join } from 'node:path'
16
16
  import { lookup } from 'node:dns/promises'
17
+ import { dim, glyph } from './style.js'
17
18
 
18
19
  // Status vocabulary: 'ok' passes; 'fail' blocks (refuse at the door); 'warn' is
19
20
  // advisory — a real finding that never blocks (browser trust, the door-closed
@@ -146,13 +147,13 @@ export async function runPreflight(config, { door = true } = {}) {
146
147
  ]
147
148
  }
148
149
 
149
- const GLYPH = { ok: '✓', fail: '✗', warn: '!', skip: '·' }
150
-
151
150
  export function renderChain(results) {
152
151
  const lines = []
153
152
  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}`)
153
+ const note = r.status === 'skip' ? dim(r.note) : r.note
154
+ lines.push(` ${glyph[r.status] ?? glyph.skip} ${r.name.padEnd(9)} ${note}`)
155
+ if ((r.status === 'fail' || r.status === 'warn') && r.fix)
156
+ lines.push(` ${dim('↳ fix:')} ${r.fix}`)
156
157
  }
157
158
  return lines.join('\n')
158
159
  }
package/style.js ADDED
@@ -0,0 +1,82 @@
1
+ // nervur — CLI presentation: color and the standing banner. Pure node:util
2
+ // styleText, zero dependencies; color only when stdout is a TTY and NO_COLOR is
3
+ // unset, so piped output (probes, scripts) stays plain and machine-stable.
4
+
5
+ import * as util from 'node:util'
6
+
7
+ // util.styleText arrives in node 20.12/21.7 — a named import would crash the
8
+ // whole CLI on anything older (an operator's nvm default may lag), so ink is
9
+ // feature-detected: absent, everything renders plain instead of dying.
10
+ const styleText = util.styleText ?? ((_format, text) => text)
11
+ const stripVTControlCharacters = util.stripVTControlCharacters ?? ((s) => s)
12
+
13
+ const on = () => process.stdout.isTTY && !process.env.NO_COLOR
14
+
15
+ // paint(format, text) — styleText that degrades to plain text off-TTY. `format`
16
+ // is a styleText format or array of formats ('green', ['bold','cyan'], …).
17
+ export const paint = (format, text) => (on() ? styleText(format, text) : text)
18
+
19
+ export const dim = (t) => paint('dim', t)
20
+ export const bold = (t) => paint('bold', t)
21
+ export const red = (t) => paint('red', t)
22
+ export const green = (t) => paint('green', t)
23
+ export const yellow = (t) => paint('yellow', t)
24
+ export const cyan = (t) => paint('cyan', t)
25
+
26
+ // The status glyphs the preflight chain and the probes share, colored: the
27
+ // vocabulary stays ✓ ✗ ! · (preflight.js), only the ink changes.
28
+ export const glyph = {
29
+ ok: green('✓'),
30
+ fail: red('✗'),
31
+ warn: yellow('!'),
32
+ skip: dim('·')
33
+ }
34
+
35
+ // The standing banner — printed once the carcass answers, on `install` and `up`.
36
+ // The console line leads: it is the operator's browser door (host.md — "One
37
+ // operator, one console"), and the one URL a fresh operator should open first.
38
+ // rows: [label, value, note?]; labels and notes align in columns, notes ride
39
+ // dim. Values may carry ANSI ink, so columns measure the stripped length.
40
+ const plainLength = (s) => stripVTControlCharacters(s).length
41
+
42
+ export function columns(rows) {
43
+ const labelW = Math.max(...rows.map(([label]) => label.length))
44
+ const valueW = Math.max(...rows.map(([, value]) => plainLength(value)))
45
+ return rows
46
+ .map(([label, value, note]) => {
47
+ const pad = ' '.repeat(valueW - plainLength(value))
48
+ const noteStr = note ? `${pad} ${dim(note)}` : ''
49
+ return ` ${dim(label.padEnd(labelW))} ${value}${noteStr}`
50
+ })
51
+ .join('\n')
52
+ }
53
+
54
+ export const heading = (title) => ` ${paint(['bold', 'cyan'], '◆')} ${bold(title)}`
55
+
56
+ export function banner(title, rows) {
57
+ return `\n${heading(title)}\n\n${columns(rows)}\n`
58
+ }
59
+
60
+ // The identity → beings tree — the ground's real shape (beings live under
61
+ // identities, never flat). `identities` may lag `beings` (a being can outlive
62
+ // its vault's listing), so the tree is keyed by the union of both.
63
+ export function identityTree(identities, beings, selected) {
64
+ const byId = new Map(identities.map((i) => [i, []]))
65
+ for (const b of beings) {
66
+ if (!byId.has(b.identity)) byId.set(b.identity, [])
67
+ byId.get(b.identity).push(b)
68
+ }
69
+ const nameW = Math.max(0, ...beings.map((b) => b.name.length))
70
+ const lines = []
71
+ for (const [id, bs] of byId) {
72
+ const marks = [
73
+ id === selected ? cyan(' ← selected') : '',
74
+ identities.includes(id) ? '' : dim(' (no vault)')
75
+ ].join('')
76
+ lines.push(` ${bold(id)}${marks}`)
77
+ if (!bs.length) lines.push(dim(' · no beings'))
78
+ for (const b of bs)
79
+ lines.push(` ${b.name.padEnd(nameW)} ${dim(`${b.kind} · ${b.state}`)}`)
80
+ }
81
+ return lines.join('\n')
82
+ }