nervur 0.0.0 → 0.1.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/nervur.js ADDED
@@ -0,0 +1,514 @@
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 { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { dirname, join } from 'node:path'
12
+ import { request as httpsRequest } from 'node:https'
13
+ import { pathToFileURL } from 'node:url'
14
+ import { resolveTemplate } from '@nervur-org/kit/templates.js'
15
+ import { runPreflight, renderChain, blockingFailure } from './preflight.js'
16
+ import { scaffoldSpecies } from './scaffold.js'
17
+
18
+ // The one published tag — what `nervur install` pulls, and what a local
19
+ // `docker build` must name for install to find it (papers/dev.md — "the
20
+ // published install idiom").
21
+ const DEFAULT_IMAGE = 'nervur/carcass:latest'
22
+
23
+ // A `--flag value` reader for the being-family verbs — a fork is always a flag with
24
+ // a sane default, never a prompt (papers/dev.md).
25
+ const flagValue = (args, flag) => {
26
+ const i = args.indexOf(flag)
27
+ return i >= 0 ? args[i + 1] : undefined
28
+ }
29
+ const positional = (args) => args.filter((a) => !a.startsWith('--'))
30
+
31
+ const sh = (cmd, args, opts = {}) => {
32
+ const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
33
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`)
34
+ }
35
+
36
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
37
+
38
+ // Knock a being domain through the Caddy door over the dev CA — the same TLS path
39
+ // a browser and a remote caller ride (papers/dev.md). `address`/`servername` let a
40
+ // caller resolve like `curl --resolve` (connect to an IP, present the domain in
41
+ // SNI + Host) so the path is provable without /etc/hosts. Any error — DNS not
42
+ // resolving because dev/hosts.sh has not run, a TLS or connection failure —
43
+ // resolves false, and `up` falls back to the always-published localhost face; a
44
+ // missing hosts entry must never break `up` (the refusing preflight is later).
45
+ export function knockDomain(
46
+ domain,
47
+ ca,
48
+ path = '/health',
49
+ { address, servername, timeout = 2500 } = {}
50
+ ) {
51
+ return new Promise((resolve) => {
52
+ const req = httpsRequest(
53
+ {
54
+ host: address ?? domain,
55
+ servername: servername ?? domain,
56
+ port: 443,
57
+ path,
58
+ ca,
59
+ timeout,
60
+ headers: { host: domain }
61
+ },
62
+ (res) => {
63
+ let body = ''
64
+ res.on('data', (c) => (body += c))
65
+ res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }))
66
+ }
67
+ )
68
+ req.on('error', () => resolve({ ok: false }))
69
+ req.on('timeout', () => {
70
+ req.destroy()
71
+ resolve({ ok: false })
72
+ })
73
+ req.end()
74
+ })
75
+ }
76
+
77
+ // The dev CA the door serves under — beside the certs script (dev/certs/gen.sh →
78
+ // dev/certs/out/ca.crt). Absent before the first `up`, so read lazily and tolerate
79
+ // its absence (the domain knock simply can't run yet).
80
+ function readCA(config) {
81
+ if (!config.certs) return undefined
82
+ try {
83
+ return readFileSync(join(dirname(config.certs), 'out', 'ca.crt'))
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+
89
+ const localOk = async (url) => {
90
+ try {
91
+ return (await fetch(url)).ok
92
+ } catch {
93
+ return false
94
+ }
95
+ }
96
+
97
+ // Knock the carcass until it answers, preferring the domain door when it can. Each
98
+ // tick tries the domain (over the dev CA) first, then the always-published
99
+ // localhost face — so a ground with dev/hosts.sh run reports through the real URL
100
+ // scheme, and one without it still comes up on localhost. Bounded but generous:
101
+ // the first boot installs node_modules (a native module) inside the container,
102
+ // which takes a few minutes, so the wait prints progress rather than looking hung.
103
+ async function knock({ localUrl, domain, ca, ms = 300_000 }) {
104
+ const until = Date.now() + ms
105
+ let noted = 0
106
+ for (;;) {
107
+ if (domain && ca && (await knockDomain(domain, ca)).ok) return { via: 'domain' }
108
+ if (await localOk(localUrl)) return { via: 'localhost' }
109
+ if (Date.now() > until)
110
+ throw new Error(`the carcass did not answer at ${localUrl} within ${Math.round(ms / 1000)}s`)
111
+ if (Date.now() - noted > 5000) {
112
+ process.stdout.write(
113
+ ' · standing the carcass (first boot installs node_modules inside the container)…\n'
114
+ )
115
+ noted = Date.now()
116
+ }
117
+ await sleep(1000)
118
+ }
119
+ }
120
+
121
+ const getJson = async (url) => (await fetch(url)).json()
122
+ const postJson = async (url, body) =>
123
+ (
124
+ await fetch(url, {
125
+ method: 'POST',
126
+ headers: { 'content-type': 'application/json' },
127
+ body: JSON.stringify(body ?? {})
128
+ })
129
+ ).json()
130
+ // Same as postJson but keeps the status code — the identity verbs need to tell a
131
+ // 409 (refused, still occupied) apart from success without throwing on either.
132
+ const postJsonStatus = async (url, body) => {
133
+ const res = await fetch(url, {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json' },
136
+ body: JSON.stringify(body ?? {})
137
+ })
138
+ return { status: res.status, body: await res.json() }
139
+ }
140
+
141
+ // The install compose — the same shape carcass/compose.yml stands (carcass,
142
+ // durable named volume, image disposable/volume durable): one carcass container,
143
+ // its ground config bind-mounted read-only, its state on a volume, published to
144
+ // loopback via the NERVUR_PORT/NERVUR_FACES_PORT seams. Caddy fronting is parked
145
+ // (papers/prod.md — "arrives with the per-box configuration"); this is the honest
146
+ // floor today. Rewritten on every `install` — mechanical, never hand-edited.
147
+ function installCompose() {
148
+ return `name: nervur
149
+
150
+ # Generated by \`nervur install\` — the published carcass stand (papers/host.md,
151
+ # papers/dev.md § the published install idiom). Image disposable, volume durable:
152
+ # re-running install is image + boot, nothing wiped.
153
+ services:
154
+ carcass:
155
+ image: \${NERVUR_IMAGE:-${DEFAULT_IMAGE}}
156
+ environment:
157
+ NERVUR_GROUND: /app/ground.config.js
158
+ volumes:
159
+ - ./ground.config.js:/app/ground.config.js:ro
160
+ - nervur-state:/app/state
161
+ ports:
162
+ - '127.0.0.1:\${NERVUR_PORT:-4000}:4000'
163
+ - '127.0.0.1:\${NERVUR_FACES_PORT:-4400}:4400'
164
+ - '127.0.0.1:\${NERVUR_CONSOLE_PORT:-4200}:4200'
165
+ restart: unless-stopped
166
+
167
+ volumes:
168
+ nervur-state:
169
+ `
170
+ }
171
+
172
+ // The ground config the carcass reads INSIDE the container — internal ports are
173
+ // fixed (4000/4400); the compose above remaps them to the host's NERVUR_PORT/
174
+ // NERVUR_FACES_PORT, so changing the host port never touches this file. Written
175
+ // once and kept (host.md — install delivers code, boot converges state); edit it
176
+ // freely, a re-install never overwrites it.
177
+ function installGroundConfig() {
178
+ return `// nervur ground — generated once by \`nervur install\`; edit freely, it is kept.
179
+ // The state root a mounted volume converges on every boot (papers/host.md); no
180
+ // stations stand bare — \`nervur add\` makes beings on top of this.
181
+ export default {
182
+ version: '0.1.0',
183
+ ground: '/app/state',
184
+ face: { port: 4000 },
185
+ faces: { port: 4400 },
186
+ console: { port: 4200 },
187
+ stations: {}
188
+ }
189
+ `
190
+ }
191
+
192
+ async function main() {
193
+ const configPath = process.env.NERVUR_GROUND ?? 'dev/ground.config.js'
194
+ // The ground config is tolerant of absence: the being-family author verb
195
+ // `scaffold` is client-local and needs no ground at all, so a missing config
196
+ // must not block it. A verb that needs the face resolves it through face()
197
+ // below, which throws a clear error when the port is absent.
198
+ let config = {}
199
+ try {
200
+ config = (await import(pathToFileURL(configPath))).default
201
+ } catch {
202
+ // no ground config here — the NERVUR_PORT fallback below still reaches an
203
+ // installed carcass (nervur install has no dev/ground.config.js to hand it)
204
+ }
205
+ // NERVUR_PORT, when set, always wins — it is the explicit seam `nervur install`
206
+ // published the carcass on, and must override even a resolved dev/ground.config.js
207
+ // (running from inside the monorepo with NERVUR_PORT set is exactly how this probe,
208
+ // and any contributor testing `install`, points the client at their OWN carcass
209
+ // rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
210
+ // is the convenience default; absent both, a published install's own default (4000).
211
+ const faceUrl = process.env.NERVUR_PORT
212
+ ? `http://localhost:${process.env.NERVUR_PORT}`
213
+ : config.face?.port
214
+ ? `http://localhost:${config.face.port}`
215
+ : 'http://localhost:4000'
216
+ const face = () => faceUrl
217
+
218
+ // `--as <name>` is a global flag — it rides before OR after the verb (`nervur
219
+ // --as acme add empty` and `nervur add empty --as acme` both work), so pull it
220
+ // out of the raw argv before the verb is read off position 0.
221
+ const rawArgs = process.argv.slice(2)
222
+ const asIdx = rawArgs.indexOf('--as')
223
+ const asFlag = asIdx >= 0 ? rawArgs[asIdx + 1] : undefined
224
+ if (asIdx >= 0) rawArgs.splice(asIdx, 2)
225
+
226
+ const faceDomain = config.domains?.face
227
+
228
+ const [verb = 'whoami', ...args] = rawArgs
229
+ const print = (v) => console.log(JSON.stringify(v, null, 2))
230
+
231
+ // Which identity the being-family verbs act as (papers/host.md — "switching
232
+ // identity is a client act"). Precedence: --as <name> flag > NERVUR_IDENTITY env
233
+ // > sticky file beside the resolved ground config > 'main'. Never a prompt.
234
+ // The sticky file rides beside whatever ground config actually resolved; a
235
+ // published install has none, so it rides beside NERVUR_HOME instead — never a
236
+ // relative 'dev/' path that doesn't exist outside the monorepo.
237
+ const stickyHome = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
238
+ const stickyPath = existsSync(configPath)
239
+ ? join(dirname(configPath), '.nervur-identity')
240
+ : join(stickyHome, '.nervur-identity')
241
+ const readSticky = () => {
242
+ try {
243
+ return readFileSync(stickyPath, 'utf8').trim() || null
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+ const selectedIdentity = asFlag ?? process.env.NERVUR_IDENTITY ?? readSticky() ?? 'main'
249
+
250
+ // The door is open by default; `--no-door` (or NERVUR_DOOR=0) closes it for
251
+ // headless/CI — a command with a sane default, never a prompt. Closed, the hosts
252
+ // check degrades to advisory and 443 is skipped: the ground stands on localhost.
253
+ const doorOpen = !(args.includes('--no-door') || process.env.NERVUR_DOOR === '0')
254
+
255
+ switch (verb) {
256
+ // The published install (papers/host.md — "the CLI, client, never runtime";
257
+ // papers/dev.md — "the published install idiom"): Docker preflight, then the
258
+ // carcass image, a compose + volume in NERVUR_HOME, up, knock /health, whoami.
259
+ // Installer dumb, boot smart — existing state (the volume) is never touched;
260
+ // re-running this is image + boot, nothing asked, nothing lost.
261
+ case 'install': {
262
+ // Only the docker check applies here — an empty config skips certs/hosts/
263
+ // ca-trust/port-443 (those are the DEV BENCH's own preconditions, preflight.js
264
+ // runs them only when the config declares certs/domains).
265
+ const results = await runPreflight({}, { door: false })
266
+ console.log(renderChain(results))
267
+ const blocked = blockingFailure(results)
268
+ if (blocked) {
269
+ console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
270
+ process.exit(1)
271
+ }
272
+
273
+ const home = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
274
+ const image = process.env.NERVUR_IMAGE ?? DEFAULT_IMAGE
275
+ const port = process.env.NERVUR_PORT ?? '4000'
276
+ const facesPort = process.env.NERVUR_FACES_PORT ?? '4400'
277
+
278
+ // The carcass image: present locally, or pullable. No registry is published
279
+ // yet, so a pull will fail honestly — refuse with the exact local fix rather
280
+ // than raising a compose against an image that isn't there.
281
+ const inspect = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' })
282
+ if (inspect.status !== 0) {
283
+ console.log(` · image ${image} not found locally — trying \`docker pull\`…`)
284
+ const pull = spawnSync('docker', ['pull', image], { stdio: 'inherit' })
285
+ if (pull.status !== 0) {
286
+ console.error(
287
+ `\n refusing: no carcass image '${image}' locally, and no registry to pull from yet.\n` +
288
+ ` fix: \`docker build -f carcass/Dockerfile -t ${image} .\` builds it, or \`docker pull ${image}\` once a registry is published`
289
+ )
290
+ process.exit(1)
291
+ }
292
+ }
293
+
294
+ // Lay down the ground. The compose is rewritten every run (mechanical, not
295
+ // state); the ground config is written once and kept (edit it freely — the
296
+ // boot converges whatever it finds, papers/host.md).
297
+ mkdirSync(home, { recursive: true })
298
+ const composePath = join(home, 'compose.yml')
299
+ writeFileSync(composePath, installCompose())
300
+ const groundPath = join(home, 'ground.config.js')
301
+ if (!existsSync(groundPath)) writeFileSync(groundPath, installGroundConfig())
302
+
303
+ sh('docker', ['compose', '-f', composePath, 'up', '-d'], {
304
+ env: {
305
+ ...process.env,
306
+ NERVUR_IMAGE: image,
307
+ NERVUR_PORT: String(port),
308
+ NERVUR_FACES_PORT: String(facesPort)
309
+ }
310
+ })
311
+
312
+ const localUrl = `http://localhost:${port}`
313
+ await knock({ localUrl: `${localUrl}/health` })
314
+ console.log(` · carcass standing at ${home} — face on ${localUrl}`)
315
+ print(await getJson(`${localUrl}/whoami`))
316
+ break
317
+ }
318
+ case 'preflight': {
319
+ // The chain on its own: report the bench's readiness without standing it.
320
+ // Exit code reflects the verdict so a script can gate on it.
321
+ const results = await runPreflight(config, { door: doorOpen })
322
+ console.log(renderChain(results))
323
+ process.exit(blockingFailure(results) ? 1 : 0)
324
+ break
325
+ }
326
+ case 'up': {
327
+ // The preflight runs FIRST (papers/dev.md): refuse at the door on the first
328
+ // blocking failure, pointing at the fix, rather than raising a compose that
329
+ // will fail as a runtime surprise.
330
+ const results = await runPreflight(config, { door: doorOpen })
331
+ console.log(renderChain(results))
332
+ const blocked = blockingFailure(results)
333
+ if (blocked) {
334
+ console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
335
+ process.exit(1)
336
+ }
337
+ if (config.certs) sh('bash', [config.certs])
338
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'])
339
+ const ca = readCA(config)
340
+ // Door open: knock the domain first, so a bench with hosts run reports through
341
+ // the real URL scheme (slice-2 behavior). Door closed: localhost only.
342
+ const { via } = await knock({
343
+ localUrl: `${face()}/health`,
344
+ domain: doorOpen ? faceDomain : null,
345
+ ca
346
+ })
347
+ if (via === 'domain') {
348
+ console.log(` · carcass answered through the door: https://${faceDomain}`)
349
+ print(JSON.parse((await knockDomain(faceDomain, ca, '/whoami')).body))
350
+ } else {
351
+ if (!doorOpen) console.log(' · door closed — ground stands degraded (localhost only)')
352
+ else if (faceDomain)
353
+ console.log(
354
+ ` · carcass on ${face()} — run \`sudo dev/hosts.sh\` to reach it at https://${faceDomain}`
355
+ )
356
+ print(await getJson(`${face()}/whoami`))
357
+ }
358
+ break
359
+ }
360
+ case 'down':
361
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'])
362
+ console.log('down')
363
+ break
364
+ case 'reset':
365
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'])
366
+ for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
367
+ console.log('reset')
368
+ break
369
+ case 'whoami':
370
+ print(await getJson(`${face()}/whoami`))
371
+ break
372
+ case 'status':
373
+ print(await getJson(`${face()}/status`))
374
+ break
375
+ case 'add':
376
+ print(
377
+ await postJson(`${face()}/fleet/add`, {
378
+ kind: args[0] ?? 'empty',
379
+ identity: selectedIdentity
380
+ })
381
+ )
382
+ break
383
+ case 'list':
384
+ print(await getJson(`${face()}/fleet/list`))
385
+ break
386
+ case 'remove':
387
+ if (!args[0]) throw new Error('usage: nervur remove <name>')
388
+ print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
389
+ break
390
+ // ── identities (papers/host.md — "The identities"): locally one carcass
391
+ // custodies many, one vault each. `add` mints silently, `list` shows the
392
+ // public ids only, `remove` refuses (409) while any being is attached —
393
+ // never force, never prompt.
394
+ case 'identity': {
395
+ const [sub, name] = positional(args)
396
+ if (sub === 'add') {
397
+ if (!name) throw new Error('usage: nervur identity add <name>')
398
+ print(await postJson(`${face()}/identities/add`, { name }))
399
+ } else if (sub === 'list') {
400
+ const list = await getJson(`${face()}/identities`)
401
+ for (const i of list)
402
+ console.log(`${i.name === selectedIdentity ? '*' : ' '} ${i.name} ${i.id}`)
403
+ } else if (sub === 'remove') {
404
+ if (!name) throw new Error('usage: nervur identity remove <name>')
405
+ const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
406
+ if (status === 409) {
407
+ console.error(
408
+ `refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
409
+ )
410
+ for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
411
+ process.exit(1)
412
+ }
413
+ if (status >= 400) {
414
+ console.error(`nervur: ${body.error}`)
415
+ process.exit(1)
416
+ }
417
+ print(body)
418
+ } else {
419
+ throw new Error('usage: nervur identity <add|list|remove> <name>')
420
+ }
421
+ break
422
+ }
423
+ // Client-side sticky selection (papers/host.md — switching is a client act; it
424
+ // selects which vault the verbs act on, stands no container, crosses no wall).
425
+ case 'use': {
426
+ const name = positional(args)[0]
427
+ if (!name) {
428
+ console.log(selectedIdentity)
429
+ break
430
+ }
431
+ mkdirSync(dirname(stickyPath), { recursive: true })
432
+ writeFileSync(stickyPath, name)
433
+ console.log(`using identity '${name}'`)
434
+ break
435
+ }
436
+ // ── the being family: scaffold (client-local) authors a species; deploy/species
437
+ // reach the carcass over its face, like every other verb (papers/host.md).
438
+ case 'scaffold': {
439
+ // Mint a species repo from a template kind into a target dir — the DNA a git
440
+ // repo from birth. Local, like up/down: it touches no carcass.
441
+ const name = positional(args)[0]
442
+ if (!name) throw new Error('usage: nervur scaffold <name> [--kind empty] [--dir <path>]')
443
+ const kind = flagValue(args, '--kind') ?? 'empty'
444
+ print(
445
+ scaffoldSpecies({
446
+ name,
447
+ template: resolveTemplate(kind, { from: join(process.cwd(), 'noop.js') }),
448
+ dir: flagValue(args, '--dir')
449
+ })
450
+ )
451
+ break
452
+ }
453
+ case 'deploy': {
454
+ // Grant this carcass a species repo at a pinned full-hash release, materialize
455
+ // and run it. Re-deploy with a new hash = upgrade or rollback, same verb.
456
+ const [name, repo, ref] = positional(args)
457
+ if (!name || !repo || !ref)
458
+ throw new Error('usage: nervur deploy <name> <repo> <full-commit-hash>')
459
+ print(await postJson(`${face()}/deploy`, { name, repo, ref }))
460
+ break
461
+ }
462
+ case 'species':
463
+ print(await getJson(`${face()}/deploy/list`))
464
+ break
465
+ case 'help':
466
+ case '--help':
467
+ case '-h':
468
+ console.log(
469
+ 'nervur — client of a running carcass\n\n' +
470
+ 'carcass family: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove\n' +
471
+ 'being family: scaffold, deploy, species\n\n' +
472
+ ' install Docker preflight, then stand a carcass at NERVUR_HOME (default ~/.nervur)\n' +
473
+ ' [NERVUR_HOME, NERVUR_IMAGE, NERVUR_PORT, NERVUR_FACES_PORT]\n' +
474
+ ' up [--no-door] (dev bench) run the bench preflight, then stand the ground\n' +
475
+ ' preflight [--no-door] run the bench preflight and report (exit code = verdict)\n' +
476
+ ' identity add <name> mint an identity on this machine (silent, idempotent)\n' +
477
+ ' identity list list identities on this machine · name + public id\n' +
478
+ ' identity remove <name> destroy an identity — refused (409) while beings remain\n' +
479
+ ' use [name] select which identity add/list act as (client-only); no args prints it\n' +
480
+ ' add <kind> [--as name] add a being under the selected (or --as) identity\n' +
481
+ ' scaffold <name> mint a species repo (the DNA) [--kind empty] [--dir <path>]\n' +
482
+ ' deploy <name> <repo> <full-commit-hash> grant + pin + run a species\n' +
483
+ ' species list the deployed species and their pins\n\n' +
484
+ 'the door is open by default; --no-door (or NERVUR_DOOR=0) stands the ground\n' +
485
+ 'on localhost only for headless/CI — the hosts check degrades, 443 is skipped'
486
+ )
487
+ break
488
+ default:
489
+ console.error(
490
+ `nervur: unknown verb: ${verb} (have: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove, scaffold, deploy, species)`
491
+ )
492
+ process.exit(1)
493
+ }
494
+ process.exit(0)
495
+ }
496
+
497
+ // Run only as the CLI; importing this module (e.g. to exercise knockDomain against
498
+ // the door) must not fire main(). Resolve the invoked path through symlinks — `npx
499
+ // nervur` / `node_modules/.bin/nervur` reach here via a symlink, and import.meta.url
500
+ // is already the realpath, so compare realpath to realpath or the door never opens.
501
+ function invokedAsCli() {
502
+ if (!process.argv[1]) return false
503
+ try {
504
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
505
+ } catch {
506
+ return false
507
+ }
508
+ }
509
+ if (invokedAsCli()) {
510
+ main().catch((e) => {
511
+ console.error(`nervur: ${e.message}`)
512
+ process.exit(1)
513
+ })
514
+ }
package/package.json CHANGED
@@ -1,7 +1,18 @@
1
1
  {
2
2
  "name": "nervur",
3
- "version": "0.0.0",
4
- "description": "nervur — reserved. Not yet released.",
5
- "license": "Apache-2.0",
6
- "type": "module"
3
+ "version": "0.1.0",
4
+ "description": "The nervur CLI — a client of a running carcass, never the runtime (papers/host.md).",
5
+ "type": "module",
6
+ "bin": {
7
+ "nervur": "./nervur.js"
8
+ },
9
+ "files": [
10
+ "nervur.js",
11
+ "preflight.js",
12
+ "scaffold.js"
13
+ ],
14
+ "dependencies": {
15
+ "@nervur-org/kit": "*"
16
+ },
17
+ "license": "Apache-2.0"
7
18
  }
package/preflight.js ADDED
@@ -0,0 +1,161 @@
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
+
18
+ // Status vocabulary: 'ok' passes; 'fail' blocks (refuse at the door); 'warn' is
19
+ // advisory — a real finding that never blocks (browser trust, the door-closed
20
+ // degrade); 'skip' is a check the config didn't ask for. Only 'fail' blocks.
21
+ const ok = (name, note) => ({ name, status: 'ok', note, fix: null })
22
+ const fail = (name, note, fix) => ({ name, status: 'fail', note, fix })
23
+ const warn = (name, note, fix) => ({ name, status: 'warn', note, fix })
24
+ const skip = (name, note) => ({ name, status: 'skip', note, fix: null })
25
+
26
+ const domainNames = (config) => Object.keys(config.domains?.routes ?? {})
27
+ const isLoopback = (addr) => addr === '::1' || /^127\./.test(addr)
28
+ const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many)
29
+
30
+ // docker — binary present → daemon answering → permission ok. Each failure its own
31
+ // precise one-line diagnosis + fix. Always runs; always blocking.
32
+ function checkDocker() {
33
+ const v = spawnSync('docker', ['--version'], { encoding: 'utf8' })
34
+ if (v.error || v.status !== 0)
35
+ return fail(
36
+ 'docker',
37
+ 'the docker binary is not on PATH',
38
+ 'install Docker — https://docs.docker.com/get-docker/'
39
+ )
40
+ const info = spawnSync('docker', ['info'], { encoding: 'utf8' })
41
+ if (info.status === 0) return ok('docker', `daemon answering (${(v.stdout || '').trim()})`)
42
+ const err = `${info.stderr || ''}${info.stdout || ''}`.toLowerCase()
43
+ if (err.includes('permission denied'))
44
+ return fail(
45
+ 'docker',
46
+ 'the Docker socket denies this user',
47
+ 'add your user to the docker group (or run Docker Desktop as this user), then re-login'
48
+ )
49
+ return fail(
50
+ 'docker',
51
+ 'the Docker daemon is not responding',
52
+ 'start Docker (`open -a Docker` on macOS), then retry'
53
+ )
54
+ }
55
+
56
+ // certs — the dev CA and a cert per declared domain exist, and the cert set covers
57
+ // every declared domain (a config edit that outran gen.sh is caught here, not as a
58
+ // TLS surprise). Only runs when config.certs is declared; blocking.
59
+ function checkCerts(config) {
60
+ if (!config.certs) return skip('certs', 'no certs declared')
61
+ const outDir = join(dirname(config.certs), 'out')
62
+ const fix = `bash ${config.certs}`
63
+ if (!existsSync(join(outDir, 'ca.crt'))) return fail('certs', 'the dev CA is not minted', fix)
64
+ const names = domainNames(config)
65
+ const missing = names.filter((n) => !existsSync(join(outDir, `${n}.crt`)))
66
+ if (missing.length)
67
+ return fail('certs', `no cert for ${missing.join(', ')} — the config outran the cert set`, fix)
68
+ return ok('certs', `dev CA + ${names.length} domain ${plural(names.length, 'cert')}`)
69
+ }
70
+
71
+ // hosts — every declared domain resolves to loopback (dns.lookup honors /etc/hosts).
72
+ // Only runs when config.domains is declared. Blocking WITH the door; door closed
73
+ // (--no-door / NERVUR_DOOR=0) demotes it to an advisory degrade — the ground stands
74
+ // on localhost only.
75
+ async function checkHosts(config, door) {
76
+ const names = domainNames(config)
77
+ if (!names.length) return skip('hosts', 'no domains declared')
78
+ const unresolved = []
79
+ for (const n of names) {
80
+ try {
81
+ const { address } = await lookup(n)
82
+ if (!isLoopback(address)) unresolved.push(`${n}→${address}`)
83
+ } catch {
84
+ unresolved.push(n)
85
+ }
86
+ }
87
+ const fix = 'sudo dev/hosts.sh'
88
+ if (!unresolved.length)
89
+ return ok('hosts', `${names.length} ${plural(names.length, 'domain')} resolve to loopback`)
90
+ const summary = `${unresolved.length}/${names.length} ${plural(unresolved.length, 'domain')} do not resolve to loopback`
91
+ if (door) return fail('hosts', summary, fix)
92
+ return warn('hosts', `door closed — ground stands degraded (localhost only); ${summary}`, fix)
93
+ }
94
+
95
+ // CA browser trust — macOS keychain only, ADVISORY: print the one-line fix, never
96
+ // block, never run it. Only meaningful when a dev CA is declared.
97
+ function checkCaTrust(config) {
98
+ if (!config.certs) return skip('ca-trust', 'no certs declared')
99
+ if (process.platform !== 'darwin') return skip('ca-trust', 'not macOS')
100
+ const caPath = join(dirname(config.certs), 'out', 'ca.crt')
101
+ if (!existsSync(caPath)) return skip('ca-trust', 'no dev CA yet')
102
+ const r = spawnSync('security', ['verify-cert', '-c', caPath], { encoding: 'utf8' })
103
+ if (r.status === 0) return ok('ca-trust', 'dev CA trusted by the keychain')
104
+ return warn(
105
+ 'ca-trust',
106
+ 'dev CA not trusted by the keychain — the lens will warn until you trust it',
107
+ `security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ${caPath}`
108
+ )
109
+ }
110
+
111
+ // port 443 — free, or already held by OUR Docker/Caddy (then fine). A stranger is a
112
+ // blocking diagnosis naming the holder (lsof). Skipped with the door closed, and
113
+ // skipped when the config declares no domains — no door exists to hold 443 for.
114
+ function checkPort443(config, door) {
115
+ if (!config.domains) return skip('port-443', 'no domains declared')
116
+ if (!door) return skip('port-443', 'door closed')
117
+ // `+c0` disables lsof's 9-char COMMAND truncation, so `com.docker.backend` reads
118
+ // whole and our own Docker is never mistaken for a stranger.
119
+ const r = spawnSync('lsof', ['+c0', '-nP', '-iTCP:443', '-sTCP:LISTEN'], { encoding: 'utf8' })
120
+ if (r.error) return warn('port-443', 'cannot probe 443 (lsof unavailable)', null)
121
+ const rows = (r.stdout || '')
122
+ .trim()
123
+ .split('\n')
124
+ .slice(1)
125
+ .filter((l) => l.trim())
126
+ if (!rows.length) return ok('port-443', '443 free')
127
+ if (rows.every((l) => /docker|com\.docker|vpnkit|caddy/i.test(l)))
128
+ return ok('port-443', '443 held by our Docker/Caddy')
129
+ const [cmd, pid] = rows[0].split(/\s+/)
130
+ return fail(
131
+ 'port-443',
132
+ `443 held by a stranger: ${cmd} (pid ${pid})`,
133
+ 'stop the process holding 443, or free the port'
134
+ )
135
+ }
136
+
137
+ // The chain, in door order: docker → certs → hosts → ca-trust → port-443. Every
138
+ // check runs so the report is complete; the caller refuses on the first blocker.
139
+ export async function runPreflight(config, { door = true } = {}) {
140
+ return [
141
+ checkDocker(),
142
+ checkCerts(config),
143
+ await checkHosts(config, door),
144
+ checkCaTrust(config),
145
+ checkPort443(config, door)
146
+ ]
147
+ }
148
+
149
+ const GLYPH = { ok: '✓', fail: '✗', warn: '!', skip: '·' }
150
+
151
+ export function renderChain(results) {
152
+ const lines = []
153
+ for (const r of results) {
154
+ lines.push(` ${GLYPH[r.status] ?? '·'} ${r.name.padEnd(9)} ${r.note}`)
155
+ if ((r.status === 'fail' || r.status === 'warn') && r.fix) lines.push(` ↳ fix: ${r.fix}`)
156
+ }
157
+ return lines.join('\n')
158
+ }
159
+
160
+ // The first blocking failure, or undefined — the CLI refuses on this and exits 1.
161
+ export const blockingFailure = (results) => results.find((r) => r.status === 'fail')
package/scaffold.js ADDED
@@ -0,0 +1,50 @@
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/README.md DELETED
@@ -1,3 +0,0 @@
1
- # nervur
2
-
3
- This name is reserved. nervur is not yet released.