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/nervur.js
DELETED
|
@@ -1,642 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// nervur — the CLI: a CLIENT of a running carcass, never the runtime
|
|
3
|
-
// (papers/host.md — "the CLI talks to a carcass over its API; it is never the
|
|
4
|
-
// runtime"). `up`/`down`/`reset` drive the compose that holds the carcass; every
|
|
5
|
-
// other verb reaches the carcass's face over HTTP, so what the CLI reports is the
|
|
6
|
-
// LIVE ground, not a fresh in-process being.
|
|
7
|
-
|
|
8
|
-
import { spawnSync } from 'node:child_process'
|
|
9
|
-
import { createHash } from 'node:crypto'
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import { homedir } from 'node:os'
|
|
12
|
-
import { dirname, join } from 'node:path'
|
|
13
|
-
import { request as httpsRequest } from 'node:https'
|
|
14
|
-
import { pathToFileURL } from 'node:url'
|
|
15
|
-
import { resolveTemplate } from '@nervur-org/kit/templates.js'
|
|
16
|
-
import { runPreflight, renderChain, blockingFailure } from './preflight.js'
|
|
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'
|
|
20
|
-
|
|
21
|
-
// The one published tag — what `nervur install` pulls, and what a local
|
|
22
|
-
// `docker build` must name for install to find it (papers/dev.md — "the
|
|
23
|
-
// published install idiom").
|
|
24
|
-
const DEFAULT_IMAGE = 'nervur/carcass:latest'
|
|
25
|
-
|
|
26
|
-
// A `--flag value` reader for the being-family verbs — a fork is always a flag with
|
|
27
|
-
// a sane default, never a prompt (papers/dev.md).
|
|
28
|
-
const flagValue = (args, flag) => {
|
|
29
|
-
const i = args.indexOf(flag)
|
|
30
|
-
return i >= 0 ? args[i + 1] : undefined
|
|
31
|
-
}
|
|
32
|
-
const positional = (args) => args.filter((a) => !a.startsWith('--'))
|
|
33
|
-
|
|
34
|
-
const sh = (cmd, args, opts = {}) => {
|
|
35
|
-
const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
|
|
36
|
-
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
40
|
-
|
|
41
|
-
// Knock a being domain through the Caddy door over the dev CA — the same TLS path
|
|
42
|
-
// a browser and a remote caller ride (papers/dev.md). `address`/`servername` let a
|
|
43
|
-
// caller resolve like `curl --resolve` (connect to an IP, present the domain in
|
|
44
|
-
// SNI + Host) so the path is provable without /etc/hosts. Any error — DNS not
|
|
45
|
-
// resolving because dev/hosts.sh has not run, a TLS or connection failure —
|
|
46
|
-
// resolves false, and `up` falls back to the always-published localhost face; a
|
|
47
|
-
// missing hosts entry must never break `up` (the refusing preflight is later).
|
|
48
|
-
export function knockDomain(
|
|
49
|
-
domain,
|
|
50
|
-
ca,
|
|
51
|
-
path = '/health',
|
|
52
|
-
{ address, servername, timeout = 2500 } = {}
|
|
53
|
-
) {
|
|
54
|
-
return new Promise((resolve) => {
|
|
55
|
-
const req = httpsRequest(
|
|
56
|
-
{
|
|
57
|
-
host: address ?? domain,
|
|
58
|
-
servername: servername ?? domain,
|
|
59
|
-
port: 443,
|
|
60
|
-
path,
|
|
61
|
-
ca,
|
|
62
|
-
timeout,
|
|
63
|
-
headers: { host: domain }
|
|
64
|
-
},
|
|
65
|
-
(res) => {
|
|
66
|
-
let body = ''
|
|
67
|
-
res.on('data', (c) => (body += c))
|
|
68
|
-
res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }))
|
|
69
|
-
}
|
|
70
|
-
)
|
|
71
|
-
req.on('error', () => resolve({ ok: false }))
|
|
72
|
-
req.on('timeout', () => {
|
|
73
|
-
req.destroy()
|
|
74
|
-
resolve({ ok: false })
|
|
75
|
-
})
|
|
76
|
-
req.end()
|
|
77
|
-
})
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// The dev CA the door serves under — beside the certs script (dev/certs/gen.sh →
|
|
81
|
-
// dev/certs/out/ca.crt). Absent before the first `up`, so read lazily and tolerate
|
|
82
|
-
// its absence (the domain knock simply can't run yet).
|
|
83
|
-
function readCA(config) {
|
|
84
|
-
if (!config.certs) return undefined
|
|
85
|
-
try {
|
|
86
|
-
return readFileSync(join(dirname(config.certs), 'out', 'ca.crt'))
|
|
87
|
-
} catch {
|
|
88
|
-
return undefined
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const localOk = async (url) => {
|
|
93
|
-
try {
|
|
94
|
-
return (await fetch(url)).ok
|
|
95
|
-
} catch {
|
|
96
|
-
return false
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Knock the carcass until it answers, preferring the domain door when it can. Each
|
|
101
|
-
// tick tries the domain (over the dev CA) first, then the always-published
|
|
102
|
-
// localhost face — so a ground with dev/hosts.sh run reports through the real URL
|
|
103
|
-
// scheme, and one without it still comes up on localhost. Bounded but generous:
|
|
104
|
-
// the first boot installs node_modules (a native module) inside the container,
|
|
105
|
-
// which takes a few minutes, so the wait prints progress rather than looking hung.
|
|
106
|
-
async function knock({ localUrl, domain, ca, ms = 300_000 }) {
|
|
107
|
-
const until = Date.now() + ms
|
|
108
|
-
let noted = 0
|
|
109
|
-
for (;;) {
|
|
110
|
-
if (domain && ca && (await knockDomain(domain, ca)).ok) return { via: 'domain' }
|
|
111
|
-
if (await localOk(localUrl)) return { via: 'localhost' }
|
|
112
|
-
if (Date.now() > until)
|
|
113
|
-
throw new Error(`the carcass did not answer at ${localUrl} within ${Math.round(ms / 1000)}s`)
|
|
114
|
-
if (Date.now() - noted > 5000) {
|
|
115
|
-
process.stdout.write(
|
|
116
|
-
' · standing the carcass (first boot installs node_modules inside the container)…\n'
|
|
117
|
-
)
|
|
118
|
-
noted = Date.now()
|
|
119
|
-
}
|
|
120
|
-
await sleep(1000)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const getJson = async (url) => (await fetch(url)).json()
|
|
125
|
-
const postJson = async (url, body) =>
|
|
126
|
-
(
|
|
127
|
-
await fetch(url, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: { 'content-type': 'application/json' },
|
|
130
|
-
body: JSON.stringify(body ?? {})
|
|
131
|
-
})
|
|
132
|
-
).json()
|
|
133
|
-
// Same as postJson but keeps the status code — the identity verbs need to tell a
|
|
134
|
-
// 409 (refused, still occupied) apart from success without throwing on either.
|
|
135
|
-
const postJsonStatus = async (url, body) => {
|
|
136
|
-
const res = await fetch(url, {
|
|
137
|
-
method: 'POST',
|
|
138
|
-
headers: { 'content-type': 'application/json' },
|
|
139
|
-
body: JSON.stringify(body ?? {})
|
|
140
|
-
})
|
|
141
|
-
return { status: res.status, body: await res.json() }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// The install compose — the same shape carcass/compose.yml stands (carcass,
|
|
145
|
-
// durable named volume, image disposable/volume durable): one carcass container,
|
|
146
|
-
// its ground config bind-mounted read-only, its state on a volume, published to
|
|
147
|
-
// loopback via the NERVUR_PORT/NERVUR_FACES_PORT seams. Caddy fronting is parked
|
|
148
|
-
// (papers/prod.md — "arrives with the per-box configuration"); this is the honest
|
|
149
|
-
// floor today. Rewritten on every `install` — mechanical, never hand-edited.
|
|
150
|
-
// Docker derives volume/network names from the project name — a fixed name would
|
|
151
|
-
// share one state volume across every NERVUR_HOME on the machine.
|
|
152
|
-
function installCompose(home) {
|
|
153
|
-
const scope = createHash('sha256').update(realpathSync(home)).digest('hex').slice(0, 8)
|
|
154
|
-
return `name: nervur-${scope}
|
|
155
|
-
|
|
156
|
-
# Generated by \`nervur install\` — the published carcass stand (papers/host.md,
|
|
157
|
-
# papers/dev.md § the published install idiom). Image disposable, volume durable:
|
|
158
|
-
# re-running install is image + boot, nothing wiped.
|
|
159
|
-
services:
|
|
160
|
-
carcass:
|
|
161
|
-
image: \${NERVUR_IMAGE:-${DEFAULT_IMAGE}}
|
|
162
|
-
environment:
|
|
163
|
-
NERVUR_GROUND: /app/ground.config.js
|
|
164
|
-
volumes:
|
|
165
|
-
- ./ground.config.js:/app/ground.config.js:ro
|
|
166
|
-
- nervur-state:/app/state
|
|
167
|
-
ports:
|
|
168
|
-
- '127.0.0.1:\${NERVUR_PORT:-4000}:4000'
|
|
169
|
-
- '127.0.0.1:\${NERVUR_FACES_PORT:-4400}:4400'
|
|
170
|
-
- '127.0.0.1:\${NERVUR_CONSOLE_PORT:-4200}:4200'
|
|
171
|
-
restart: unless-stopped
|
|
172
|
-
|
|
173
|
-
volumes:
|
|
174
|
-
nervur-state:
|
|
175
|
-
`
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// The ground config the carcass reads INSIDE the container — internal ports are
|
|
179
|
-
// fixed (4000/4400); the compose above remaps them to the host's NERVUR_PORT/
|
|
180
|
-
// NERVUR_FACES_PORT, so changing the host port never touches this file. Written
|
|
181
|
-
// once and kept (host.md — install delivers code, boot converges state); edit it
|
|
182
|
-
// freely, a re-install never overwrites it.
|
|
183
|
-
function installGroundConfig() {
|
|
184
|
-
return `// nervur ground — generated once by \`nervur install\`; edit freely, it is kept.
|
|
185
|
-
// The state root a mounted volume converges on every boot (papers/host.md); no
|
|
186
|
-
// stations stand bare — \`nervur add\` makes beings on top of this.
|
|
187
|
-
export default {
|
|
188
|
-
version: '0.1.0',
|
|
189
|
-
ground: '/app/state',
|
|
190
|
-
face: { port: 4000 },
|
|
191
|
-
faces: { port: 4400 },
|
|
192
|
-
console: { port: 4200 },
|
|
193
|
-
stations: {}
|
|
194
|
-
}
|
|
195
|
-
`
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function main() {
|
|
199
|
-
const configPath = process.env.NERVUR_GROUND ?? 'dev/ground.config.js'
|
|
200
|
-
// The ground config is tolerant of absence: the being-family author verb
|
|
201
|
-
// `scaffold` is client-local and needs no ground at all, so a missing config
|
|
202
|
-
// must not block it. A verb that needs the face resolves it through face()
|
|
203
|
-
// below, which throws a clear error when the port is absent.
|
|
204
|
-
let config = {}
|
|
205
|
-
try {
|
|
206
|
-
config = (await import(pathToFileURL(configPath))).default
|
|
207
|
-
} catch {
|
|
208
|
-
// no ground config here — the NERVUR_PORT fallback below still reaches an
|
|
209
|
-
// installed carcass (nervur install has no dev/ground.config.js to hand it)
|
|
210
|
-
}
|
|
211
|
-
// NERVUR_PORT, when set, always wins — it is the explicit seam `nervur install`
|
|
212
|
-
// published the carcass on, and must override even a resolved dev/ground.config.js
|
|
213
|
-
// (running from inside the monorepo with NERVUR_PORT set is exactly how this probe,
|
|
214
|
-
// and any contributor testing `install`, points the client at their OWN carcass
|
|
215
|
-
// rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
|
|
216
|
-
// is the convenience default; absent both, a published install's own default (4000).
|
|
217
|
-
const faceUrl = process.env.NERVUR_PORT
|
|
218
|
-
? `http://localhost:${process.env.NERVUR_PORT}`
|
|
219
|
-
: config.face?.port
|
|
220
|
-
? `http://localhost:${config.face.port}`
|
|
221
|
-
: 'http://localhost:4000'
|
|
222
|
-
const face = () => faceUrl
|
|
223
|
-
|
|
224
|
-
// `--as <name>` is a global flag — it rides before OR after the verb (`nervur
|
|
225
|
-
// --as acme add empty` and `nervur add empty --as acme` both work), so pull it
|
|
226
|
-
// out of the raw argv before the verb is read off position 0.
|
|
227
|
-
const rawArgs = process.argv.slice(2)
|
|
228
|
-
const asIdx = rawArgs.indexOf('--as')
|
|
229
|
-
const asFlag = asIdx >= 0 ? rawArgs[asIdx + 1] : undefined
|
|
230
|
-
if (asIdx >= 0) rawArgs.splice(asIdx, 2)
|
|
231
|
-
|
|
232
|
-
const faceDomain = config.domains?.face
|
|
233
|
-
|
|
234
|
-
const bare = rawArgs.length === 0
|
|
235
|
-
const [verb = 'whoami', ...args] = rawArgs
|
|
236
|
-
const print = (v) => console.log(JSON.stringify(v, null, 2))
|
|
237
|
-
|
|
238
|
-
// Which identity the being-family verbs act as (papers/host.md — "switching
|
|
239
|
-
// identity is a client act"). Precedence: --as <name> flag > NERVUR_IDENTITY env
|
|
240
|
-
// > sticky file beside the resolved ground config > 'main'. Never a prompt.
|
|
241
|
-
// The sticky file rides beside whatever ground config actually resolved; a
|
|
242
|
-
// published install has none, so it rides beside NERVUR_HOME instead — never a
|
|
243
|
-
// relative 'dev/' path that doesn't exist outside the monorepo.
|
|
244
|
-
const stickyHome = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
|
|
245
|
-
const stickyPath = existsSync(configPath)
|
|
246
|
-
? join(dirname(configPath), '.nervur-identity')
|
|
247
|
-
: join(stickyHome, '.nervur-identity')
|
|
248
|
-
const readSticky = () => {
|
|
249
|
-
try {
|
|
250
|
-
return readFileSync(stickyPath, 'utf8').trim() || null
|
|
251
|
-
} catch {
|
|
252
|
-
return null
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
const selectedIdentity = asFlag ?? process.env.NERVUR_IDENTITY ?? readSticky() ?? 'main'
|
|
256
|
-
|
|
257
|
-
// The door is open by default; `--no-door` (or NERVUR_DOOR=0) closes it for
|
|
258
|
-
// headless/CI — a command with a sane default, never a prompt. Closed, the hosts
|
|
259
|
-
// check degrades to advisory and 443 is skipped: the ground stands on localhost.
|
|
260
|
-
const doorOpen = !(args.includes('--no-door') || process.env.NERVUR_DOOR === '0')
|
|
261
|
-
|
|
262
|
-
switch (verb) {
|
|
263
|
-
// The published install (papers/host.md — "the CLI, client, never runtime";
|
|
264
|
-
// papers/dev.md — "the published install idiom"): Docker preflight, then the
|
|
265
|
-
// carcass image, a compose + volume in NERVUR_HOME, up, knock /health, whoami.
|
|
266
|
-
// Installer dumb, boot smart — existing state (the volume) is never touched;
|
|
267
|
-
// re-running this is image + boot, nothing asked, nothing lost.
|
|
268
|
-
case 'install': {
|
|
269
|
-
// Only the docker check applies here — an empty config skips certs/hosts/
|
|
270
|
-
// ca-trust/port-443 (those are the DEV BENCH's own preconditions, preflight.js
|
|
271
|
-
// runs them only when the config declares certs/domains).
|
|
272
|
-
const results = await runPreflight({}, { door: false })
|
|
273
|
-
console.log(renderChain(results))
|
|
274
|
-
const blocked = blockingFailure(results)
|
|
275
|
-
if (blocked) {
|
|
276
|
-
console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
|
|
277
|
-
process.exit(1)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const home = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
|
|
281
|
-
const image = process.env.NERVUR_IMAGE ?? DEFAULT_IMAGE
|
|
282
|
-
const port = process.env.NERVUR_PORT ?? '4000'
|
|
283
|
-
const facesPort = process.env.NERVUR_FACES_PORT ?? '4400'
|
|
284
|
-
|
|
285
|
-
// Refused here, before the compose — `compose up` against a missing image
|
|
286
|
-
// fails too, but far less legibly than this message.
|
|
287
|
-
const inspect = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' })
|
|
288
|
-
if (inspect.status !== 0) {
|
|
289
|
-
console.log(` · image ${image} not found locally — trying \`docker pull\`…`)
|
|
290
|
-
const pull = spawnSync('docker', ['pull', image], { stdio: 'inherit' })
|
|
291
|
-
if (pull.status !== 0) {
|
|
292
|
-
console.error(
|
|
293
|
-
`\n refusing: no carcass image '${image}' locally, and the pull failed.\n` +
|
|
294
|
-
` fix: check the network and the image name — \`docker pull ${image}\` by hand shows the registry's answer; \`docker build -f carcass/Dockerfile -t ${image} .\` builds it locally`
|
|
295
|
-
)
|
|
296
|
-
process.exit(1)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Lay down the ground. The compose is rewritten every run (mechanical, not
|
|
301
|
-
// state); the ground config is written once and kept (edit it freely — the
|
|
302
|
-
// boot converges whatever it finds, papers/host.md).
|
|
303
|
-
mkdirSync(home, { recursive: true })
|
|
304
|
-
const composePath = join(home, 'compose.yml')
|
|
305
|
-
writeFileSync(composePath, installCompose(home))
|
|
306
|
-
const groundPath = join(home, 'ground.config.js')
|
|
307
|
-
if (!existsSync(groundPath)) writeFileSync(groundPath, installGroundConfig())
|
|
308
|
-
|
|
309
|
-
sh('docker', ['compose', '-f', composePath, 'up', '-d'], {
|
|
310
|
-
env: {
|
|
311
|
-
...process.env,
|
|
312
|
-
NERVUR_IMAGE: image,
|
|
313
|
-
NERVUR_PORT: String(port),
|
|
314
|
-
NERVUR_FACES_PORT: String(facesPort)
|
|
315
|
-
}
|
|
316
|
-
})
|
|
317
|
-
|
|
318
|
-
const localUrl = `http://localhost:${port}`
|
|
319
|
-
await knock({ localUrl: `${localUrl}/health` })
|
|
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
|
-
)
|
|
340
|
-
break
|
|
341
|
-
}
|
|
342
|
-
case 'preflight': {
|
|
343
|
-
// The chain on its own: report the bench's readiness without standing it.
|
|
344
|
-
// Exit code reflects the verdict so a script can gate on it.
|
|
345
|
-
const results = await runPreflight(config, { door: doorOpen })
|
|
346
|
-
console.log(renderChain(results))
|
|
347
|
-
process.exit(blockingFailure(results) ? 1 : 0)
|
|
348
|
-
break
|
|
349
|
-
}
|
|
350
|
-
case 'up': {
|
|
351
|
-
// The preflight runs FIRST (papers/dev.md): refuse at the door on the first
|
|
352
|
-
// blocking failure, pointing at the fix, rather than raising a compose that
|
|
353
|
-
// will fail as a runtime surprise.
|
|
354
|
-
const results = await runPreflight(config, { door: doorOpen })
|
|
355
|
-
console.log(renderChain(results))
|
|
356
|
-
const blocked = blockingFailure(results)
|
|
357
|
-
if (blocked) {
|
|
358
|
-
console.error(`\n ${red('refusing:')} ${blocked.name} — ${blocked.note}\n ${dim('fix:')} ${blocked.fix}`)
|
|
359
|
-
process.exit(1)
|
|
360
|
-
}
|
|
361
|
-
if (config.certs) sh('bash', [config.certs])
|
|
362
|
-
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'])
|
|
363
|
-
const ca = readCA(config)
|
|
364
|
-
// Door open: knock the domain first, so a bench with hosts run reports through
|
|
365
|
-
// the real URL scheme (slice-2 behavior). Door closed: localhost only.
|
|
366
|
-
const { via } = await knock({
|
|
367
|
-
localUrl: `${face()}/health`,
|
|
368
|
-
domain: doorOpen ? faceDomain : null,
|
|
369
|
-
ca
|
|
370
|
-
})
|
|
371
|
-
if (via === 'domain') {
|
|
372
|
-
console.log(` · carcass answered through the door: https://${faceDomain}`)
|
|
373
|
-
print(JSON.parse((await knockDomain(faceDomain, ca, '/whoami')).body))
|
|
374
|
-
} else {
|
|
375
|
-
if (!doorOpen) console.log(' · door closed — ground stands degraded (localhost only)')
|
|
376
|
-
else if (faceDomain)
|
|
377
|
-
console.log(
|
|
378
|
-
` · carcass on ${face()} — run \`sudo dev/hosts.sh\` to reach it at https://${faceDomain}`
|
|
379
|
-
)
|
|
380
|
-
print(await getJson(`${face()}/whoami`))
|
|
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
|
-
)
|
|
390
|
-
break
|
|
391
|
-
}
|
|
392
|
-
case 'down':
|
|
393
|
-
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'])
|
|
394
|
-
console.log('down')
|
|
395
|
-
break
|
|
396
|
-
case 'reset':
|
|
397
|
-
if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'])
|
|
398
|
-
for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
|
|
399
|
-
console.log('reset')
|
|
400
|
-
break
|
|
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
|
-
)
|
|
418
|
-
break
|
|
419
|
-
}
|
|
420
|
-
case 'status':
|
|
421
|
-
print(await getJson(`${face()}/status`))
|
|
422
|
-
break
|
|
423
|
-
case 'add':
|
|
424
|
-
print(
|
|
425
|
-
await postJson(`${face()}/fleet/add`, {
|
|
426
|
-
kind: args[0] ?? 'empty',
|
|
427
|
-
identity: selectedIdentity
|
|
428
|
-
})
|
|
429
|
-
)
|
|
430
|
-
break
|
|
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`)
|
|
439
|
-
break
|
|
440
|
-
}
|
|
441
|
-
case 'remove':
|
|
442
|
-
if (!args[0]) throw new Error('usage: nervur remove <name>')
|
|
443
|
-
print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
|
|
444
|
-
break
|
|
445
|
-
// ── identities (papers/host.md — "The identities"): locally one carcass
|
|
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.
|
|
451
|
-
case 'identity': {
|
|
452
|
-
const [sub, name] = positional(args)
|
|
453
|
-
if (sub === 'add') {
|
|
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)
|
|
473
|
-
} else if (sub === 'list') {
|
|
474
|
-
const list = await getJson(`${face()}/identities`)
|
|
475
|
-
for (const i of list)
|
|
476
|
-
console.log(`${i.name === selectedIdentity ? '*' : ' '} ${i.name} ${i.id}`)
|
|
477
|
-
} else if (sub === 'remove') {
|
|
478
|
-
if (!name) throw new Error('usage: nervur identity remove <name>')
|
|
479
|
-
const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
|
|
480
|
-
if (status === 409) {
|
|
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
|
-
}
|
|
492
|
-
process.exit(1)
|
|
493
|
-
}
|
|
494
|
-
if (status >= 400) {
|
|
495
|
-
console.error(`nervur: ${body.error}`)
|
|
496
|
-
process.exit(1)
|
|
497
|
-
}
|
|
498
|
-
print(body)
|
|
499
|
-
} else {
|
|
500
|
-
throw new Error('usage: nervur identity <add|key|list|remove> <name>')
|
|
501
|
-
}
|
|
502
|
-
break
|
|
503
|
-
}
|
|
504
|
-
// Client-side sticky selection (papers/host.md — switching is a client act; it
|
|
505
|
-
// selects which vault the verbs act on, stands no container, crosses no wall).
|
|
506
|
-
case 'use': {
|
|
507
|
-
const name = positional(args)[0]
|
|
508
|
-
if (!name) {
|
|
509
|
-
console.log(selectedIdentity)
|
|
510
|
-
break
|
|
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
|
-
}
|
|
532
|
-
mkdirSync(dirname(stickyPath), { recursive: true })
|
|
533
|
-
writeFileSync(stickyPath, name)
|
|
534
|
-
console.log(`using identity '${name}'`)
|
|
535
|
-
break
|
|
536
|
-
}
|
|
537
|
-
// ── the being family: scaffold (client-local) authors a species; deploy/species
|
|
538
|
-
// reach the carcass over its face, like every other verb (papers/host.md).
|
|
539
|
-
case 'scaffold': {
|
|
540
|
-
// Mint a species repo from a template kind into a target dir — the DNA a git
|
|
541
|
-
// repo from birth. Local, like up/down: it touches no carcass.
|
|
542
|
-
const name = positional(args)[0]
|
|
543
|
-
if (!name) throw new Error('usage: nervur scaffold <name> [--kind empty] [--dir <path>]')
|
|
544
|
-
const kind = flagValue(args, '--kind') ?? 'empty'
|
|
545
|
-
print(
|
|
546
|
-
scaffoldSpecies({
|
|
547
|
-
name,
|
|
548
|
-
template: resolveTemplate(kind, { from: join(process.cwd(), 'noop.js') }),
|
|
549
|
-
dir: flagValue(args, '--dir')
|
|
550
|
-
})
|
|
551
|
-
)
|
|
552
|
-
break
|
|
553
|
-
}
|
|
554
|
-
case 'deploy': {
|
|
555
|
-
// Grant this carcass a species repo at a pinned full-hash release, materialize
|
|
556
|
-
// and run it. Re-deploy with a new hash = upgrade or rollback, same verb.
|
|
557
|
-
const [name, repo, ref] = positional(args)
|
|
558
|
-
if (!name || !repo || !ref)
|
|
559
|
-
throw new Error('usage: nervur deploy <name> <repo> <full-commit-hash>')
|
|
560
|
-
print(await postJson(`${face()}/deploy`, { name, repo, ref }))
|
|
561
|
-
break
|
|
562
|
-
}
|
|
563
|
-
case 'species':
|
|
564
|
-
print(await getJson(`${face()}/deploy/list`))
|
|
565
|
-
break
|
|
566
|
-
case 'completion':
|
|
567
|
-
process.stdout.write(completionScript(positional(args)[0]))
|
|
568
|
-
break
|
|
569
|
-
case 'help':
|
|
570
|
-
case '--help':
|
|
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
|
-
}
|
|
596
|
-
console.log(
|
|
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
|
-
)}`
|
|
608
|
-
)
|
|
609
|
-
break
|
|
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)))
|
|
614
|
-
console.error(
|
|
615
|
-
`nervur: ${red(`unknown verb: ${verb}`)}` +
|
|
616
|
-
(near.length ? ` — did you mean ${near.map(bold).join(' · ')}?` : '') +
|
|
617
|
-
`\n${dim(`have: ${known.join(', ')}`)}`
|
|
618
|
-
)
|
|
619
|
-
process.exit(1)
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
process.exit(0)
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// Run only as the CLI; importing this module (e.g. to exercise knockDomain against
|
|
626
|
-
// the door) must not fire main(). Resolve the invoked path through symlinks — `npx
|
|
627
|
-
// nervur` / `node_modules/.bin/nervur` reach here via a symlink, and import.meta.url
|
|
628
|
-
// is already the realpath, so compare realpath to realpath or the door never opens.
|
|
629
|
-
function invokedAsCli() {
|
|
630
|
-
if (!process.argv[1]) return false
|
|
631
|
-
try {
|
|
632
|
-
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
|
|
633
|
-
} catch {
|
|
634
|
-
return false
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
if (invokedAsCli()) {
|
|
638
|
-
main().catch((e) => {
|
|
639
|
-
console.error(`nervur: ${red(e.message)}`)
|
|
640
|
-
process.exit(1)
|
|
641
|
-
})
|
|
642
|
-
}
|