nervur 0.1.1 → 0.2.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
@@ -15,6 +15,8 @@ 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
@@ -229,6 +231,7 @@ async function main() {
229
231
 
230
232
  const faceDomain = config.domains?.face
231
233
 
234
+ const bare = rawArgs.length === 0
232
235
  const [verb = 'whoami', ...args] = rawArgs
233
236
  const print = (v) => console.log(JSON.stringify(v, null, 2))
234
237
 
@@ -270,7 +273,7 @@ async function main() {
270
273
  console.log(renderChain(results))
271
274
  const blocked = blockingFailure(results)
272
275
  if (blocked) {
273
- console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
276
+ console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
274
277
  process.exit(1)
275
278
  }
276
279
 
@@ -314,8 +317,26 @@ async function main() {
314
317
 
315
318
  const localUrl = `http://localhost:${port}`
316
319
  await knock({ localUrl: `${localUrl}/health` })
317
- console.log(` · carcass standing at ${home} — face on ${localUrl}`)
318
320
  print(await getJson(`${localUrl}/whoami`))
321
+
322
+ // TTY-gated: CI, probes, and scripts must never gain an rc edit as a
323
+ // side effect of install; NERVUR_COMPLETION=0 opts a human out too.
324
+ let completionRow = ['shell', 'nervur completion', 'prints the autocomplete script']
325
+ if (process.stdout.isTTY && process.env.NERVUR_COMPLETION !== '0') {
326
+ const wired = wireCompletion(home)
327
+ if (wired.wired)
328
+ completionRow = ['shell', `${wired.shell} autocomplete wired`, `${wired.rc} — new shells complete`]
329
+ }
330
+
331
+ const consolePort = process.env.NERVUR_CONSOLE_PORT ?? '4200'
332
+ console.log(
333
+ banner('nervur is standing', [
334
+ ['console', cyan(`http://localhost:${consolePort}`), 'open in your browser'],
335
+ ['home', home, 'compose + ground config, kept across installs'],
336
+ completionRow,
337
+ ['next', 'nervur add empty', 'stand your first being']
338
+ ]) + `\n ${dim(`api ${localUrl}`)}\n`
339
+ )
319
340
  break
320
341
  }
321
342
  case 'preflight': {
@@ -334,7 +355,7 @@ async function main() {
334
355
  console.log(renderChain(results))
335
356
  const blocked = blockingFailure(results)
336
357
  if (blocked) {
337
- console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
358
+ console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
338
359
  process.exit(1)
339
360
  }
340
361
  if (config.certs) sh('bash', [config.certs])
@@ -358,6 +379,14 @@ async function main() {
358
379
  )
359
380
  print(await getJson(`${face()}/whoami`))
360
381
  }
382
+ // The console is loopback-only by design (host.md — "One operator, one
383
+ // console"), so its URL is localhost even when the face answers through
384
+ // the domain door.
385
+ console.log(
386
+ banner('ground is standing', [
387
+ ['console', cyan(`http://localhost:${config.console?.port ?? 4200}`), 'open in your browser']
388
+ ]) + `\n ${dim(`api ${via === 'domain' ? `https://${faceDomain}` : face()}`)}\n`
389
+ )
361
390
  break
362
391
  }
363
392
  case 'down':
@@ -369,9 +398,25 @@ async function main() {
369
398
  for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
370
399
  console.log('reset')
371
400
  break
372
- case 'whoami':
373
- print(await getJson(`${face()}/whoami`))
401
+ case 'whoami': {
402
+ const who = await getJson(`${face()}/whoami`)
403
+ if (!bare || !process.stdout.isTTY) {
404
+ print(who)
405
+ break
406
+ }
407
+ const consoleUrl = `http://localhost:${process.env.NERVUR_CONSOLE_PORT ?? config.console?.port ?? 4200}`
408
+ const beings = who.beings ?? []
409
+ const tree = identityTree(who.identities ?? [], beings, selectedIdentity)
410
+ const hint = beings.length
411
+ ? ''
412
+ : `\n\n ${dim('nervur add empty stands your first being')}`
413
+ console.log(
414
+ `\n${heading(`nervur — carcass ${who.health} ${dim(`· v${who.version}`)}`)}\n\n${tree}${hint}\n\n` +
415
+ `${columns([['console', cyan(consoleUrl), 'open in your browser']])}\n\n` +
416
+ ` ${dim(`api ${face()} · faces ${who.faces ?? '—'}`)}\n`
417
+ )
374
418
  break
419
+ }
375
420
  case 'status':
376
421
  print(await getJson(`${face()}/status`))
377
422
  break
@@ -383,22 +428,48 @@ async function main() {
383
428
  })
384
429
  )
385
430
  break
386
- case 'list':
387
- print(await getJson(`${face()}/fleet/list`))
431
+ case 'list': {
432
+ const fleet = await getJson(`${face()}/fleet/list`)
433
+ if (!process.stdout.isTTY) {
434
+ print(fleet)
435
+ break
436
+ }
437
+ const identities = (await getJson(`${face()}/identities`)).map((i) => i.name)
438
+ console.log(`\n${identityTree(identities, fleet, selectedIdentity)}\n`)
388
439
  break
440
+ }
389
441
  case 'remove':
390
442
  if (!args[0]) throw new Error('usage: nervur remove <name>')
391
443
  print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
392
444
  break
393
445
  // ── 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.
446
+ // custodies many, one vault each. `add` mints silently — or adopts a brought
447
+ // key (`--key`, the bring-your-own-key seed; a different key never replaces
448
+ // an owner, 409). `list` shows the public ids only; `key` prints the owner's
449
+ // private key (the export half, hand-to-self); `remove` refuses (409) while
450
+ // any being is attached — never force, never prompt.
397
451
  case 'identity': {
398
452
  const [sub, name] = positional(args)
399
453
  if (sub === 'add') {
400
- if (!name) throw new Error('usage: nervur identity add <name>')
401
- print(await postJson(`${face()}/identities/add`, { name }))
454
+ if (!name) throw new Error('usage: nervur identity add <name> [--key <file>]')
455
+ const keyFile = flagValue(args, '--key')
456
+ const key = keyFile ? readFileSync(keyFile, 'utf8').trim() : undefined
457
+ const { status, body } = await postJsonStatus(`${face()}/identities/add`, { name, key })
458
+ if (status >= 400) {
459
+ console.error(`nervur: ${body.error}`)
460
+ process.exit(1)
461
+ }
462
+ print(body)
463
+ } else if (sub === 'key') {
464
+ if (!name) throw new Error('usage: nervur identity key <name>')
465
+ const { status, body } = await postJsonStatus(`${face()}/identities/key`, { name })
466
+ if (status >= 400) {
467
+ console.error(`nervur: ${body.error}`)
468
+ process.exit(1)
469
+ }
470
+ // The secret alone, pipe-friendly (`nervur identity key acme > key.txt`) —
471
+ // base64url PKCS8 DER, the exact shape `identity add --key` accepts back.
472
+ console.log(body.secret)
402
473
  } else if (sub === 'list') {
403
474
  const list = await getJson(`${face()}/identities`)
404
475
  for (const i of list)
@@ -407,10 +478,17 @@ async function main() {
407
478
  if (!name) throw new Error('usage: nervur identity remove <name>')
408
479
  const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
409
480
  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}`)
481
+ if (body.beings) {
482
+ console.error(
483
+ `refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
484
+ )
485
+ for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
486
+ } else {
487
+ console.error(
488
+ `refused: identity '${name}' still holds sealed lineages: ${body.lineages.join(', ')}`
489
+ )
490
+ console.error(' lineage destruction has no verb yet — the sealed custody stays')
491
+ }
414
492
  process.exit(1)
415
493
  }
416
494
  if (status >= 400) {
@@ -419,7 +497,7 @@ async function main() {
419
497
  }
420
498
  print(body)
421
499
  } else {
422
- throw new Error('usage: nervur identity <add|list|remove> <name>')
500
+ throw new Error('usage: nervur identity <add|key|list|remove> <name>')
423
501
  }
424
502
  break
425
503
  }
@@ -431,6 +509,26 @@ async function main() {
431
509
  console.log(selectedIdentity)
432
510
  break
433
511
  }
512
+ // Sticky selection is a client act, but it selects a real vault — an
513
+ // unknown name is refused against the live list here, never stored to
514
+ // fail later as a runtime surprise on some other verb.
515
+ let known
516
+ try {
517
+ known = (await getJson(`${face()}/identities`)).map((i) => i.name)
518
+ } catch {
519
+ console.error(
520
+ `nervur: ${red(`cannot verify identity '${name}'`)} — no carcass answering at ${face()}\n` +
521
+ ` ${dim('fix:')} stand it first — nervur up (bench) or nervur install (published)`
522
+ )
523
+ process.exit(1)
524
+ }
525
+ if (!known.includes(name)) {
526
+ console.error(
527
+ `nervur: ${red(`no identity '${name}'`)} on this carcass — have: ${known.join(' · ') || '(none)'}\n` +
528
+ ` ${dim('fix:')} nervur identity add ${name}`
529
+ )
530
+ process.exit(1)
531
+ }
434
532
  mkdirSync(dirname(stickyPath), { recursive: true })
435
533
  writeFileSync(stickyPath, name)
436
534
  console.log(`using identity '${name}'`)
@@ -465,34 +563,61 @@ async function main() {
465
563
  case 'species':
466
564
  print(await getJson(`${face()}/deploy/list`))
467
565
  break
566
+ case 'completion':
567
+ process.stdout.write(completionScript(positional(args)[0]))
568
+ break
468
569
  case 'help':
469
570
  case '--help':
470
- case '-h':
571
+ case '-h': {
572
+ // Help renders from the same VERBS table completion generates from — a
573
+ // verb missing here was never added to cli/completion.js.
574
+ const sig = {
575
+ up: '[--no-door]',
576
+ preflight: '[--no-door]',
577
+ identity: '<add|key|list|remove> [name] [--key <file>]',
578
+ use: '[name]',
579
+ add: '<kind> [--as <identity>]',
580
+ remove: '<name>',
581
+ scaffold: '<name> [--kind empty] [--dir <path>]',
582
+ deploy: '<name> <repo> <full-commit-hash>',
583
+ completion: '[bash|zsh]'
584
+ }
585
+ const carcass = new Set([
586
+ 'install', 'up', 'down', 'reset', 'preflight', 'whoami', 'status',
587
+ 'identity', 'use', 'add', 'list', 'remove'
588
+ ])
589
+ // Pad on the UNSTYLED signature so ANSI codes never skew the column.
590
+ const width = Math.max(...VERBS.map(([v]) => `${v} ${sig[v] ?? ''}`.trim().length))
591
+ const row = ([v, d]) => {
592
+ const plain = `${v}${sig[v] ? ` ${sig[v]}` : ''}`
593
+ const left = `${bold(v)}${sig[v] ? ` ${dim(sig[v])}` : ''}`
594
+ return ` ${left}${' '.repeat(width - plain.length + 2)}${d}`
595
+ }
471
596
  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'
597
+ `${bold('nervur')} — client of a running carcass\n\n` +
598
+ `${dim('usage')} nervur <verb> [args] ${dim('[--as <identity>]')}\n\n` +
599
+ `${cyan('carcass family')}\n` +
600
+ VERBS.filter(([v]) => carcass.has(v)).map(row).join('\n') +
601
+ `\n\n${cyan('being family')}\n` +
602
+ VERBS.filter(([v]) => !carcass.has(v) && v !== 'help').map(row).join('\n') +
603
+ `\n\n${dim(
604
+ 'install honors NERVUR_HOME · NERVUR_IMAGE · NERVUR_PORT · NERVUR_FACES_PORT · NERVUR_CONSOLE_PORT\n' +
605
+ 'the door is open by default; --no-door (or NERVUR_DOOR=0) stands the ground\n' +
606
+ 'on localhost only for headless/CI — the hosts check degrades, 443 is skipped'
607
+ )}`
489
608
  )
490
609
  break
491
- default:
610
+ }
611
+ default: {
612
+ const known = VERBS.map(([v]) => v)
613
+ const near = known.filter((v) => v.startsWith(verb) || verb.startsWith(v.slice(0, 3)))
492
614
  console.error(
493
- `nervur: unknown verb: ${verb} (have: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove, scaffold, deploy, species)`
615
+ `nervur: ${red(`unknown verb: ${verb}`)}` +
616
+ (near.length ? ` — did you mean ${near.map(bold).join(' · ')}?` : '') +
617
+ `\n${dim(`have: ${known.join(', ')}`)}`
494
618
  )
495
619
  process.exit(1)
620
+ }
496
621
  }
497
622
  process.exit(0)
498
623
  }
@@ -511,7 +636,7 @@ function invokedAsCli() {
511
636
  }
512
637
  if (invokedAsCli()) {
513
638
  main().catch((e) => {
514
- console.error(`nervur: ${e.message}`)
639
+ console.error(`nervur: ${red(e.message)}`)
515
640
  process.exit(1)
516
641
  })
517
642
  }
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.1.1",
3
+ "version": "0.2.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
+ }