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 +7 -2
- package/completion.js +122 -0
- package/nervur.js +163 -38
- package/package.json +5 -3
- package/preflight.js +5 -4
- package/style.js +82 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
'
|
|
474
|
-
'
|
|
475
|
-
|
|
476
|
-
'
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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": "
|
|
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
|
-
|
|
155
|
-
|
|
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
|
+
}
|