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 +7 -2
- package/completion.js +122 -0
- package/nervur.js +203 -48
- 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
|
@@ -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 =
|
|
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://
|
|
217
|
-
: config.face?.port
|
|
218
|
-
? `http://
|
|
219
|
-
: 'http://
|
|
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://
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
//
|
|
396
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
'
|
|
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'
|
|
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
|
-
|
|
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}
|
|
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.
|
|
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": "
|
|
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
|
+
}
|