nervur 0.2.0 → 0.5.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/LICENSE +0 -1
- package/NOTICE +2 -0
- package/README.md +325 -43
- package/admin.mjs +58 -0
- package/being.mjs +365 -0
- package/blueprint.mjs +281 -0
- package/client.mjs +43 -0
- package/contract.mjs +96 -0
- package/ground.mjs +196 -0
- package/index.mjs +10 -0
- package/modules.mjs +110 -0
- package/package.json +14 -12
- package/pointer.mjs +79 -0
- package/program.mjs +74 -0
- package/tools.mjs +139 -0
- package/voice.mjs +90 -0
- package/completion.js +0 -122
- package/nervur.js +0 -642
- package/preflight.js +0 -162
- package/scaffold.js +0 -50
- package/style.js +0 -82
package/preflight.js
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
// nervur — the bench preflight (papers/dev.md — "the bench preflight"; host.md —
|
|
2
|
-
// "the CLI ... refuses at the door, not as a runtime bug"). A chain of checks
|
|
3
|
-
// `nervur up` runs FIRST, derived entirely from the ground config: a check only
|
|
4
|
-
// runs for what the config declares — a config with no `domains` gets no domain
|
|
5
|
-
// checks, a config with no `certs` gets no cert checks. Each check returns a plain
|
|
6
|
-
// result {name, status, note, fix}; the CLI prints the chain and refuses on the
|
|
7
|
-
// first blocking failure, pointing at the fix without running it.
|
|
8
|
-
//
|
|
9
|
-
// Pure node built-ins, no runtime state: docker / lsof / security are shelled out,
|
|
10
|
-
// dns.lookup honors /etc/hosts. The module is importable so a probe can drive the
|
|
11
|
-
// chain against a crafted config without standing a ground.
|
|
12
|
-
|
|
13
|
-
import { spawnSync } from 'node:child_process'
|
|
14
|
-
import { existsSync } from 'node:fs'
|
|
15
|
-
import { dirname, join } from 'node:path'
|
|
16
|
-
import { lookup } from 'node:dns/promises'
|
|
17
|
-
import { dim, glyph } from './style.js'
|
|
18
|
-
|
|
19
|
-
// Status vocabulary: 'ok' passes; 'fail' blocks (refuse at the door); 'warn' is
|
|
20
|
-
// advisory — a real finding that never blocks (browser trust, the door-closed
|
|
21
|
-
// degrade); 'skip' is a check the config didn't ask for. Only 'fail' blocks.
|
|
22
|
-
const ok = (name, note) => ({ name, status: 'ok', note, fix: null })
|
|
23
|
-
const fail = (name, note, fix) => ({ name, status: 'fail', note, fix })
|
|
24
|
-
const warn = (name, note, fix) => ({ name, status: 'warn', note, fix })
|
|
25
|
-
const skip = (name, note) => ({ name, status: 'skip', note, fix: null })
|
|
26
|
-
|
|
27
|
-
const domainNames = (config) => Object.keys(config.domains?.routes ?? {})
|
|
28
|
-
const isLoopback = (addr) => addr === '::1' || /^127\./.test(addr)
|
|
29
|
-
const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many)
|
|
30
|
-
|
|
31
|
-
// docker — binary present → daemon answering → permission ok. Each failure its own
|
|
32
|
-
// precise one-line diagnosis + fix. Always runs; always blocking.
|
|
33
|
-
function checkDocker() {
|
|
34
|
-
const v = spawnSync('docker', ['--version'], { encoding: 'utf8' })
|
|
35
|
-
if (v.error || v.status !== 0)
|
|
36
|
-
return fail(
|
|
37
|
-
'docker',
|
|
38
|
-
'the docker binary is not on PATH',
|
|
39
|
-
'install Docker — https://docs.docker.com/get-docker/'
|
|
40
|
-
)
|
|
41
|
-
const info = spawnSync('docker', ['info'], { encoding: 'utf8' })
|
|
42
|
-
if (info.status === 0) return ok('docker', `daemon answering (${(v.stdout || '').trim()})`)
|
|
43
|
-
const err = `${info.stderr || ''}${info.stdout || ''}`.toLowerCase()
|
|
44
|
-
if (err.includes('permission denied'))
|
|
45
|
-
return fail(
|
|
46
|
-
'docker',
|
|
47
|
-
'the Docker socket denies this user',
|
|
48
|
-
'add your user to the docker group (or run Docker Desktop as this user), then re-login'
|
|
49
|
-
)
|
|
50
|
-
return fail(
|
|
51
|
-
'docker',
|
|
52
|
-
'the Docker daemon is not responding',
|
|
53
|
-
'start Docker (`open -a Docker` on macOS), then retry'
|
|
54
|
-
)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// certs — the dev CA and a cert per declared domain exist, and the cert set covers
|
|
58
|
-
// every declared domain (a config edit that outran gen.sh is caught here, not as a
|
|
59
|
-
// TLS surprise). Only runs when config.certs is declared; blocking.
|
|
60
|
-
function checkCerts(config) {
|
|
61
|
-
if (!config.certs) return skip('certs', 'no certs declared')
|
|
62
|
-
const outDir = join(dirname(config.certs), 'out')
|
|
63
|
-
const fix = `bash ${config.certs}`
|
|
64
|
-
if (!existsSync(join(outDir, 'ca.crt'))) return fail('certs', 'the dev CA is not minted', fix)
|
|
65
|
-
const names = domainNames(config)
|
|
66
|
-
const missing = names.filter((n) => !existsSync(join(outDir, `${n}.crt`)))
|
|
67
|
-
if (missing.length)
|
|
68
|
-
return fail('certs', `no cert for ${missing.join(', ')} — the config outran the cert set`, fix)
|
|
69
|
-
return ok('certs', `dev CA + ${names.length} domain ${plural(names.length, 'cert')}`)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// hosts — every declared domain resolves to loopback (dns.lookup honors /etc/hosts).
|
|
73
|
-
// Only runs when config.domains is declared. Blocking WITH the door; door closed
|
|
74
|
-
// (--no-door / NERVUR_DOOR=0) demotes it to an advisory degrade — the ground stands
|
|
75
|
-
// on localhost only.
|
|
76
|
-
async function checkHosts(config, door) {
|
|
77
|
-
const names = domainNames(config)
|
|
78
|
-
if (!names.length) return skip('hosts', 'no domains declared')
|
|
79
|
-
const unresolved = []
|
|
80
|
-
for (const n of names) {
|
|
81
|
-
try {
|
|
82
|
-
const { address } = await lookup(n)
|
|
83
|
-
if (!isLoopback(address)) unresolved.push(`${n}→${address}`)
|
|
84
|
-
} catch {
|
|
85
|
-
unresolved.push(n)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
const fix = 'sudo dev/hosts.sh'
|
|
89
|
-
if (!unresolved.length)
|
|
90
|
-
return ok('hosts', `${names.length} ${plural(names.length, 'domain')} resolve to loopback`)
|
|
91
|
-
const summary = `${unresolved.length}/${names.length} ${plural(unresolved.length, 'domain')} do not resolve to loopback`
|
|
92
|
-
if (door) return fail('hosts', summary, fix)
|
|
93
|
-
return warn('hosts', `door closed — ground stands degraded (localhost only); ${summary}`, fix)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// CA browser trust — macOS keychain only, ADVISORY: print the one-line fix, never
|
|
97
|
-
// block, never run it. Only meaningful when a dev CA is declared.
|
|
98
|
-
function checkCaTrust(config) {
|
|
99
|
-
if (!config.certs) return skip('ca-trust', 'no certs declared')
|
|
100
|
-
if (process.platform !== 'darwin') return skip('ca-trust', 'not macOS')
|
|
101
|
-
const caPath = join(dirname(config.certs), 'out', 'ca.crt')
|
|
102
|
-
if (!existsSync(caPath)) return skip('ca-trust', 'no dev CA yet')
|
|
103
|
-
const r = spawnSync('security', ['verify-cert', '-c', caPath], { encoding: 'utf8' })
|
|
104
|
-
if (r.status === 0) return ok('ca-trust', 'dev CA trusted by the keychain')
|
|
105
|
-
return warn(
|
|
106
|
-
'ca-trust',
|
|
107
|
-
'dev CA not trusted by the keychain — the lens will warn until you trust it',
|
|
108
|
-
`security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ${caPath}`
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// port 443 — free, or already held by OUR Docker/Caddy (then fine). A stranger is a
|
|
113
|
-
// blocking diagnosis naming the holder (lsof). Skipped with the door closed, and
|
|
114
|
-
// skipped when the config declares no domains — no door exists to hold 443 for.
|
|
115
|
-
function checkPort443(config, door) {
|
|
116
|
-
if (!config.domains) return skip('port-443', 'no domains declared')
|
|
117
|
-
if (!door) return skip('port-443', 'door closed')
|
|
118
|
-
// `+c0` disables lsof's 9-char COMMAND truncation, so `com.docker.backend` reads
|
|
119
|
-
// whole and our own Docker is never mistaken for a stranger.
|
|
120
|
-
const r = spawnSync('lsof', ['+c0', '-nP', '-iTCP:443', '-sTCP:LISTEN'], { encoding: 'utf8' })
|
|
121
|
-
if (r.error) return warn('port-443', 'cannot probe 443 (lsof unavailable)', null)
|
|
122
|
-
const rows = (r.stdout || '')
|
|
123
|
-
.trim()
|
|
124
|
-
.split('\n')
|
|
125
|
-
.slice(1)
|
|
126
|
-
.filter((l) => l.trim())
|
|
127
|
-
if (!rows.length) return ok('port-443', '443 free')
|
|
128
|
-
if (rows.every((l) => /docker|com\.docker|vpnkit|caddy/i.test(l)))
|
|
129
|
-
return ok('port-443', '443 held by our Docker/Caddy')
|
|
130
|
-
const [cmd, pid] = rows[0].split(/\s+/)
|
|
131
|
-
return fail(
|
|
132
|
-
'port-443',
|
|
133
|
-
`443 held by a stranger: ${cmd} (pid ${pid})`,
|
|
134
|
-
'stop the process holding 443, or free the port'
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// The chain, in door order: docker → certs → hosts → ca-trust → port-443. Every
|
|
139
|
-
// check runs so the report is complete; the caller refuses on the first blocker.
|
|
140
|
-
export async function runPreflight(config, { door = true } = {}) {
|
|
141
|
-
return [
|
|
142
|
-
checkDocker(),
|
|
143
|
-
checkCerts(config),
|
|
144
|
-
await checkHosts(config, door),
|
|
145
|
-
checkCaTrust(config),
|
|
146
|
-
checkPort443(config, door)
|
|
147
|
-
]
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function renderChain(results) {
|
|
151
|
-
const lines = []
|
|
152
|
-
for (const r of results) {
|
|
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}`)
|
|
157
|
-
}
|
|
158
|
-
return lines.join('\n')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// The first blocking failure, or undefined — the CLI refuses on this and exits 1.
|
|
162
|
-
export const blockingFailure = (results) => results.find((r) => r.status === 'fail')
|
package/scaffold.js
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
// Scaffold a species repo (papers/host.md — "a species is a template … and
|
|
2
|
-
// physically a git repo: the DNA. It is scaffolded by the CLI as a repo of its
|
|
3
|
-
// own"). A being-family author verb, CLIENT-LOCAL like `up`/`down`: it mints a
|
|
4
|
-
// species from a template kind into a target dir — the template files, a named
|
|
5
|
-
// package.json, `git init`, and an initial commit — so the DNA is a git repo from
|
|
6
|
-
// birth. No prompt: the target defaults to ./<name>, flag-overridable; the kind
|
|
7
|
-
// defaults to the kit's bundled `empty`, any other resolved on the npm rail by the
|
|
8
|
-
// caller. Nothing here reaches a carcass — a species is authored, not deployed.
|
|
9
|
-
|
|
10
|
-
import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { join } from 'node:path'
|
|
12
|
-
import { spawnSync } from 'node:child_process'
|
|
13
|
-
|
|
14
|
-
// git is ambient on the bench and in the image (papers/host.md — code travels as
|
|
15
|
-
// DNA over bare repos); no npm dependency stands in for it. Every call runs in
|
|
16
|
-
// `cwd` and throws git's own stderr on nonzero, so a failure is never silent.
|
|
17
|
-
function defaultGit(args, cwd) {
|
|
18
|
-
const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
|
|
19
|
-
if (r.status !== 0)
|
|
20
|
-
throw new Error(`git ${args[0]} failed: ${(r.stderr || r.stdout || '').trim()}`)
|
|
21
|
-
return (r.stdout || '').trim()
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// scaffoldSpecies({ name, template, dir?, git? }) → { name, dir, head }. `template`
|
|
25
|
-
// is a resolved directory (the caller resolves the kind — bundled or npm). Returns
|
|
26
|
-
// the initial commit hash: the first pin a deploy can grant against.
|
|
27
|
-
export function scaffoldSpecies({ name, template, dir, git = defaultGit } = {}) {
|
|
28
|
-
if (!name) throw new Error('scaffold: a species name is required')
|
|
29
|
-
if (!template || !existsSync(template))
|
|
30
|
-
throw new Error(`scaffold: no template at ${template} — bundle it, or \`npm i\` it first`)
|
|
31
|
-
const target = dir ?? join(process.cwd(), name)
|
|
32
|
-
if (existsSync(target)) throw new Error(`scaffold: ${target} already exists — pick another --dir`)
|
|
33
|
-
|
|
34
|
-
cpSync(template, target, { recursive: true })
|
|
35
|
-
|
|
36
|
-
// Name the species in its package.json (the template ships a generic name); the
|
|
37
|
-
// DNA carries its own identity from birth.
|
|
38
|
-
const pkgPath = join(target, 'package.json')
|
|
39
|
-
if (existsSync(pkgPath)) {
|
|
40
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
41
|
-
pkg.name = name
|
|
42
|
-
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
git(['init', '-q'], target)
|
|
46
|
-
git(['add', '-A'], target)
|
|
47
|
-
git(['commit', '-q', '-m', `scaffold ${name}`], target)
|
|
48
|
-
const head = git(['rev-parse', 'HEAD'], target)
|
|
49
|
-
return { name, dir: target, head }
|
|
50
|
-
}
|
package/style.js
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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
|
-
}
|