nervur 0.0.0 → 0.1.1

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 ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
1
  # nervur
2
2
 
3
- This name is reserved. nervur is not yet released.
3
+ The nervur CLI — the operator's command-line face over a running carcass.
4
+ It is a client, never the runtime: every verb speaks to the carcass's HTTP
5
+ API, and the same verbs exist at parity on the nervur console, the
6
+ machine-level face the carcass serves on loopback.
7
+
8
+ nervur is neutral ground for inter-company work: two companies are two
9
+ cryptographic identities meeting in one shared datastore that neither hosts
10
+ and both can read. Humans and AI agents are first-class colleagues on it.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm create nervur
16
+ ```
17
+
18
+ One command stands the whole thing: verifies Docker (binary · daemon ·
19
+ permission, each failure a one-line fix), pulls the carcass image, lays down
20
+ compose + volume under `~/.nervur`, and mints your first identity.
21
+
22
+ ## Verbs
23
+
24
+ ```text
25
+ carcass family: install, up, down, reset, preflight, whoami, status,
26
+ identity, use, add, list, remove
27
+ being family: scaffold, deploy, species
28
+
29
+ install Docker preflight, then stand a carcass at
30
+ NERVUR_HOME (default ~/.nervur)
31
+ whoami · status the carcass answers with its identity and state
32
+ identity add <name> mint an identity on this machine (silent, idempotent)
33
+ identity list identities on this machine · name + public id
34
+ identity remove <name> destroy an identity — refused while beings remain
35
+ use [name] select which identity add/list act as
36
+ add <kind> [--as name] add a being under the selected identity
37
+ list · remove <name> the fleet registry · retire a being
38
+ scaffold <name> mint a species repo (the DNA)
39
+ deploy <name> <repo> <hash> grant + pin + run a species
40
+ species list deployed species and their pins
41
+ ```
42
+
43
+ No verb ever prompts — every fork is a flag or env var with a sane default,
44
+ and destructive turns are their own explicitly named verbs.
45
+
46
+ ## License
47
+
48
+ Apache-2.0 — see [LICENSE](./LICENSE).
package/nervur.js ADDED
@@ -0,0 +1,517 @@
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
+
19
+ // The one published tag — what `nervur install` pulls, and what a local
20
+ // `docker build` must name for install to find it (papers/dev.md — "the
21
+ // published install idiom").
22
+ const DEFAULT_IMAGE = 'nervur/carcass:latest'
23
+
24
+ // A `--flag value` reader for the being-family verbs — a fork is always a flag with
25
+ // a sane default, never a prompt (papers/dev.md).
26
+ const flagValue = (args, flag) => {
27
+ const i = args.indexOf(flag)
28
+ return i >= 0 ? args[i + 1] : undefined
29
+ }
30
+ const positional = (args) => args.filter((a) => !a.startsWith('--'))
31
+
32
+ const sh = (cmd, args, opts = {}) => {
33
+ const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts })
34
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} exited ${r.status}`)
35
+ }
36
+
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
38
+
39
+ // Knock a being domain through the Caddy door over the dev CA — the same TLS path
40
+ // a browser and a remote caller ride (papers/dev.md). `address`/`servername` let a
41
+ // caller resolve like `curl --resolve` (connect to an IP, present the domain in
42
+ // SNI + Host) so the path is provable without /etc/hosts. Any error — DNS not
43
+ // resolving because dev/hosts.sh has not run, a TLS or connection failure —
44
+ // resolves false, and `up` falls back to the always-published localhost face; a
45
+ // missing hosts entry must never break `up` (the refusing preflight is later).
46
+ export function knockDomain(
47
+ domain,
48
+ ca,
49
+ path = '/health',
50
+ { address, servername, timeout = 2500 } = {}
51
+ ) {
52
+ return new Promise((resolve) => {
53
+ const req = httpsRequest(
54
+ {
55
+ host: address ?? domain,
56
+ servername: servername ?? domain,
57
+ port: 443,
58
+ path,
59
+ ca,
60
+ timeout,
61
+ headers: { host: domain }
62
+ },
63
+ (res) => {
64
+ let body = ''
65
+ res.on('data', (c) => (body += c))
66
+ res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, body }))
67
+ }
68
+ )
69
+ req.on('error', () => resolve({ ok: false }))
70
+ req.on('timeout', () => {
71
+ req.destroy()
72
+ resolve({ ok: false })
73
+ })
74
+ req.end()
75
+ })
76
+ }
77
+
78
+ // The dev CA the door serves under — beside the certs script (dev/certs/gen.sh →
79
+ // dev/certs/out/ca.crt). Absent before the first `up`, so read lazily and tolerate
80
+ // its absence (the domain knock simply can't run yet).
81
+ function readCA(config) {
82
+ if (!config.certs) return undefined
83
+ try {
84
+ return readFileSync(join(dirname(config.certs), 'out', 'ca.crt'))
85
+ } catch {
86
+ return undefined
87
+ }
88
+ }
89
+
90
+ const localOk = async (url) => {
91
+ try {
92
+ return (await fetch(url)).ok
93
+ } catch {
94
+ return false
95
+ }
96
+ }
97
+
98
+ // Knock the carcass until it answers, preferring the domain door when it can. Each
99
+ // tick tries the domain (over the dev CA) first, then the always-published
100
+ // localhost face — so a ground with dev/hosts.sh run reports through the real URL
101
+ // scheme, and one without it still comes up on localhost. Bounded but generous:
102
+ // the first boot installs node_modules (a native module) inside the container,
103
+ // which takes a few minutes, so the wait prints progress rather than looking hung.
104
+ async function knock({ localUrl, domain, ca, ms = 300_000 }) {
105
+ const until = Date.now() + ms
106
+ let noted = 0
107
+ for (;;) {
108
+ if (domain && ca && (await knockDomain(domain, ca)).ok) return { via: 'domain' }
109
+ if (await localOk(localUrl)) return { via: 'localhost' }
110
+ if (Date.now() > until)
111
+ throw new Error(`the carcass did not answer at ${localUrl} within ${Math.round(ms / 1000)}s`)
112
+ if (Date.now() - noted > 5000) {
113
+ process.stdout.write(
114
+ ' · standing the carcass (first boot installs node_modules inside the container)…\n'
115
+ )
116
+ noted = Date.now()
117
+ }
118
+ await sleep(1000)
119
+ }
120
+ }
121
+
122
+ const getJson = async (url) => (await fetch(url)).json()
123
+ const postJson = async (url, body) =>
124
+ (
125
+ await fetch(url, {
126
+ method: 'POST',
127
+ headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify(body ?? {})
129
+ })
130
+ ).json()
131
+ // Same as postJson but keeps the status code — the identity verbs need to tell a
132
+ // 409 (refused, still occupied) apart from success without throwing on either.
133
+ const postJsonStatus = async (url, body) => {
134
+ const res = await fetch(url, {
135
+ method: 'POST',
136
+ headers: { 'content-type': 'application/json' },
137
+ body: JSON.stringify(body ?? {})
138
+ })
139
+ return { status: res.status, body: await res.json() }
140
+ }
141
+
142
+ // The install compose — the same shape carcass/compose.yml stands (carcass,
143
+ // durable named volume, image disposable/volume durable): one carcass container,
144
+ // its ground config bind-mounted read-only, its state on a volume, published to
145
+ // loopback via the NERVUR_PORT/NERVUR_FACES_PORT seams. Caddy fronting is parked
146
+ // (papers/prod.md — "arrives with the per-box configuration"); this is the honest
147
+ // floor today. Rewritten on every `install` — mechanical, never hand-edited.
148
+ // Docker derives volume/network names from the project name — a fixed name would
149
+ // share one state volume across every NERVUR_HOME on the machine.
150
+ function installCompose(home) {
151
+ const scope = createHash('sha256').update(realpathSync(home)).digest('hex').slice(0, 8)
152
+ return `name: nervur-${scope}
153
+
154
+ # Generated by \`nervur install\` — the published carcass stand (papers/host.md,
155
+ # papers/dev.md § the published install idiom). Image disposable, volume durable:
156
+ # re-running install is image + boot, nothing wiped.
157
+ services:
158
+ carcass:
159
+ image: \${NERVUR_IMAGE:-${DEFAULT_IMAGE}}
160
+ environment:
161
+ NERVUR_GROUND: /app/ground.config.js
162
+ volumes:
163
+ - ./ground.config.js:/app/ground.config.js:ro
164
+ - nervur-state:/app/state
165
+ ports:
166
+ - '127.0.0.1:\${NERVUR_PORT:-4000}:4000'
167
+ - '127.0.0.1:\${NERVUR_FACES_PORT:-4400}:4400'
168
+ - '127.0.0.1:\${NERVUR_CONSOLE_PORT:-4200}:4200'
169
+ restart: unless-stopped
170
+
171
+ volumes:
172
+ nervur-state:
173
+ `
174
+ }
175
+
176
+ // The ground config the carcass reads INSIDE the container — internal ports are
177
+ // fixed (4000/4400); the compose above remaps them to the host's NERVUR_PORT/
178
+ // NERVUR_FACES_PORT, so changing the host port never touches this file. Written
179
+ // once and kept (host.md — install delivers code, boot converges state); edit it
180
+ // freely, a re-install never overwrites it.
181
+ function installGroundConfig() {
182
+ return `// nervur ground — generated once by \`nervur install\`; edit freely, it is kept.
183
+ // The state root a mounted volume converges on every boot (papers/host.md); no
184
+ // stations stand bare — \`nervur add\` makes beings on top of this.
185
+ export default {
186
+ version: '0.1.0',
187
+ ground: '/app/state',
188
+ face: { port: 4000 },
189
+ faces: { port: 4400 },
190
+ console: { port: 4200 },
191
+ stations: {}
192
+ }
193
+ `
194
+ }
195
+
196
+ async function main() {
197
+ const configPath = process.env.NERVUR_GROUND ?? 'dev/ground.config.js'
198
+ // The ground config is tolerant of absence: the being-family author verb
199
+ // `scaffold` is client-local and needs no ground at all, so a missing config
200
+ // must not block it. A verb that needs the face resolves it through face()
201
+ // below, which throws a clear error when the port is absent.
202
+ let config = {}
203
+ try {
204
+ config = (await import(pathToFileURL(configPath))).default
205
+ } catch {
206
+ // no ground config here — the NERVUR_PORT fallback below still reaches an
207
+ // installed carcass (nervur install has no dev/ground.config.js to hand it)
208
+ }
209
+ // NERVUR_PORT, when set, always wins — it is the explicit seam `nervur install`
210
+ // published the carcass on, and must override even a resolved dev/ground.config.js
211
+ // (running from inside the monorepo with NERVUR_PORT set is exactly how this probe,
212
+ // and any contributor testing `install`, points the client at their OWN carcass
213
+ // rather than the dev bench's). Absent NERVUR_PORT, the dev bench's config.face.port
214
+ // is the convenience default; absent both, a published install's own default (4000).
215
+ const faceUrl = process.env.NERVUR_PORT
216
+ ? `http://localhost:${process.env.NERVUR_PORT}`
217
+ : config.face?.port
218
+ ? `http://localhost:${config.face.port}`
219
+ : 'http://localhost:4000'
220
+ const face = () => faceUrl
221
+
222
+ // `--as <name>` is a global flag — it rides before OR after the verb (`nervur
223
+ // --as acme add empty` and `nervur add empty --as acme` both work), so pull it
224
+ // out of the raw argv before the verb is read off position 0.
225
+ const rawArgs = process.argv.slice(2)
226
+ const asIdx = rawArgs.indexOf('--as')
227
+ const asFlag = asIdx >= 0 ? rawArgs[asIdx + 1] : undefined
228
+ if (asIdx >= 0) rawArgs.splice(asIdx, 2)
229
+
230
+ const faceDomain = config.domains?.face
231
+
232
+ const [verb = 'whoami', ...args] = rawArgs
233
+ const print = (v) => console.log(JSON.stringify(v, null, 2))
234
+
235
+ // Which identity the being-family verbs act as (papers/host.md — "switching
236
+ // identity is a client act"). Precedence: --as <name> flag > NERVUR_IDENTITY env
237
+ // > sticky file beside the resolved ground config > 'main'. Never a prompt.
238
+ // The sticky file rides beside whatever ground config actually resolved; a
239
+ // published install has none, so it rides beside NERVUR_HOME instead — never a
240
+ // relative 'dev/' path that doesn't exist outside the monorepo.
241
+ const stickyHome = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
242
+ const stickyPath = existsSync(configPath)
243
+ ? join(dirname(configPath), '.nervur-identity')
244
+ : join(stickyHome, '.nervur-identity')
245
+ const readSticky = () => {
246
+ try {
247
+ return readFileSync(stickyPath, 'utf8').trim() || null
248
+ } catch {
249
+ return null
250
+ }
251
+ }
252
+ const selectedIdentity = asFlag ?? process.env.NERVUR_IDENTITY ?? readSticky() ?? 'main'
253
+
254
+ // The door is open by default; `--no-door` (or NERVUR_DOOR=0) closes it for
255
+ // headless/CI — a command with a sane default, never a prompt. Closed, the hosts
256
+ // check degrades to advisory and 443 is skipped: the ground stands on localhost.
257
+ const doorOpen = !(args.includes('--no-door') || process.env.NERVUR_DOOR === '0')
258
+
259
+ switch (verb) {
260
+ // The published install (papers/host.md — "the CLI, client, never runtime";
261
+ // papers/dev.md — "the published install idiom"): Docker preflight, then the
262
+ // carcass image, a compose + volume in NERVUR_HOME, up, knock /health, whoami.
263
+ // Installer dumb, boot smart — existing state (the volume) is never touched;
264
+ // re-running this is image + boot, nothing asked, nothing lost.
265
+ case 'install': {
266
+ // Only the docker check applies here — an empty config skips certs/hosts/
267
+ // ca-trust/port-443 (those are the DEV BENCH's own preconditions, preflight.js
268
+ // runs them only when the config declares certs/domains).
269
+ const results = await runPreflight({}, { door: false })
270
+ console.log(renderChain(results))
271
+ const blocked = blockingFailure(results)
272
+ if (blocked) {
273
+ console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
274
+ process.exit(1)
275
+ }
276
+
277
+ const home = process.env.NERVUR_HOME ?? join(homedir(), '.nervur')
278
+ const image = process.env.NERVUR_IMAGE ?? DEFAULT_IMAGE
279
+ const port = process.env.NERVUR_PORT ?? '4000'
280
+ const facesPort = process.env.NERVUR_FACES_PORT ?? '4400'
281
+
282
+ // Refused here, before the compose — `compose up` against a missing image
283
+ // fails too, but far less legibly than this message.
284
+ const inspect = spawnSync('docker', ['image', 'inspect', image], { stdio: 'ignore' })
285
+ if (inspect.status !== 0) {
286
+ console.log(` · image ${image} not found locally — trying \`docker pull\`…`)
287
+ const pull = spawnSync('docker', ['pull', image], { stdio: 'inherit' })
288
+ if (pull.status !== 0) {
289
+ console.error(
290
+ `\n refusing: no carcass image '${image}' locally, and the pull failed.\n` +
291
+ ` 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`
292
+ )
293
+ process.exit(1)
294
+ }
295
+ }
296
+
297
+ // Lay down the ground. The compose is rewritten every run (mechanical, not
298
+ // state); the ground config is written once and kept (edit it freely — the
299
+ // boot converges whatever it finds, papers/host.md).
300
+ mkdirSync(home, { recursive: true })
301
+ const composePath = join(home, 'compose.yml')
302
+ writeFileSync(composePath, installCompose(home))
303
+ const groundPath = join(home, 'ground.config.js')
304
+ if (!existsSync(groundPath)) writeFileSync(groundPath, installGroundConfig())
305
+
306
+ sh('docker', ['compose', '-f', composePath, 'up', '-d'], {
307
+ env: {
308
+ ...process.env,
309
+ NERVUR_IMAGE: image,
310
+ NERVUR_PORT: String(port),
311
+ NERVUR_FACES_PORT: String(facesPort)
312
+ }
313
+ })
314
+
315
+ const localUrl = `http://localhost:${port}`
316
+ await knock({ localUrl: `${localUrl}/health` })
317
+ console.log(` · carcass standing at ${home} — face on ${localUrl}`)
318
+ print(await getJson(`${localUrl}/whoami`))
319
+ break
320
+ }
321
+ case 'preflight': {
322
+ // The chain on its own: report the bench's readiness without standing it.
323
+ // Exit code reflects the verdict so a script can gate on it.
324
+ const results = await runPreflight(config, { door: doorOpen })
325
+ console.log(renderChain(results))
326
+ process.exit(blockingFailure(results) ? 1 : 0)
327
+ break
328
+ }
329
+ case 'up': {
330
+ // The preflight runs FIRST (papers/dev.md): refuse at the door on the first
331
+ // blocking failure, pointing at the fix, rather than raising a compose that
332
+ // will fail as a runtime surprise.
333
+ const results = await runPreflight(config, { door: doorOpen })
334
+ console.log(renderChain(results))
335
+ const blocked = blockingFailure(results)
336
+ if (blocked) {
337
+ console.error(`\n refusing: ${blocked.name} — ${blocked.note}\n fix: ${blocked.fix}`)
338
+ process.exit(1)
339
+ }
340
+ if (config.certs) sh('bash', [config.certs])
341
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'up', '-d'])
342
+ const ca = readCA(config)
343
+ // Door open: knock the domain first, so a bench with hosts run reports through
344
+ // the real URL scheme (slice-2 behavior). Door closed: localhost only.
345
+ const { via } = await knock({
346
+ localUrl: `${face()}/health`,
347
+ domain: doorOpen ? faceDomain : null,
348
+ ca
349
+ })
350
+ if (via === 'domain') {
351
+ console.log(` · carcass answered through the door: https://${faceDomain}`)
352
+ print(JSON.parse((await knockDomain(faceDomain, ca, '/whoami')).body))
353
+ } else {
354
+ if (!doorOpen) console.log(' · door closed — ground stands degraded (localhost only)')
355
+ else if (faceDomain)
356
+ console.log(
357
+ ` · carcass on ${face()} — run \`sudo dev/hosts.sh\` to reach it at https://${faceDomain}`
358
+ )
359
+ print(await getJson(`${face()}/whoami`))
360
+ }
361
+ break
362
+ }
363
+ case 'down':
364
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down'])
365
+ console.log('down')
366
+ break
367
+ case 'reset':
368
+ if (config.compose) sh('docker', ['compose', '-f', config.compose, 'down', '-v'])
369
+ for (const dir of config.dataDirs ?? []) rmSync(dir, { recursive: true, force: true })
370
+ console.log('reset')
371
+ break
372
+ case 'whoami':
373
+ print(await getJson(`${face()}/whoami`))
374
+ break
375
+ case 'status':
376
+ print(await getJson(`${face()}/status`))
377
+ break
378
+ case 'add':
379
+ print(
380
+ await postJson(`${face()}/fleet/add`, {
381
+ kind: args[0] ?? 'empty',
382
+ identity: selectedIdentity
383
+ })
384
+ )
385
+ break
386
+ case 'list':
387
+ print(await getJson(`${face()}/fleet/list`))
388
+ break
389
+ case 'remove':
390
+ if (!args[0]) throw new Error('usage: nervur remove <name>')
391
+ print(await postJson(`${face()}/fleet/remove`, { name: args[0] }))
392
+ break
393
+ // ── identities (papers/host.md — "The identities"): locally one carcass
394
+ // custodies many, one vault each. `add` mints silently, `list` shows the
395
+ // public ids only, `remove` refuses (409) while any being is attached —
396
+ // never force, never prompt.
397
+ case 'identity': {
398
+ const [sub, name] = positional(args)
399
+ if (sub === 'add') {
400
+ if (!name) throw new Error('usage: nervur identity add <name>')
401
+ print(await postJson(`${face()}/identities/add`, { name }))
402
+ } else if (sub === 'list') {
403
+ const list = await getJson(`${face()}/identities`)
404
+ for (const i of list)
405
+ console.log(`${i.name === selectedIdentity ? '*' : ' '} ${i.name} ${i.id}`)
406
+ } else if (sub === 'remove') {
407
+ if (!name) throw new Error('usage: nervur identity remove <name>')
408
+ const { status, body } = await postJsonStatus(`${face()}/identities/remove`, { name })
409
+ if (status === 409) {
410
+ console.error(
411
+ `refused: identity '${name}' still has beings attached: ${body.beings.join(', ')}`
412
+ )
413
+ for (const b of body.beings) console.error(` fix: nervur remove ${b}`)
414
+ process.exit(1)
415
+ }
416
+ if (status >= 400) {
417
+ console.error(`nervur: ${body.error}`)
418
+ process.exit(1)
419
+ }
420
+ print(body)
421
+ } else {
422
+ throw new Error('usage: nervur identity <add|list|remove> <name>')
423
+ }
424
+ break
425
+ }
426
+ // Client-side sticky selection (papers/host.md — switching is a client act; it
427
+ // selects which vault the verbs act on, stands no container, crosses no wall).
428
+ case 'use': {
429
+ const name = positional(args)[0]
430
+ if (!name) {
431
+ console.log(selectedIdentity)
432
+ break
433
+ }
434
+ mkdirSync(dirname(stickyPath), { recursive: true })
435
+ writeFileSync(stickyPath, name)
436
+ console.log(`using identity '${name}'`)
437
+ break
438
+ }
439
+ // ── the being family: scaffold (client-local) authors a species; deploy/species
440
+ // reach the carcass over its face, like every other verb (papers/host.md).
441
+ case 'scaffold': {
442
+ // Mint a species repo from a template kind into a target dir — the DNA a git
443
+ // repo from birth. Local, like up/down: it touches no carcass.
444
+ const name = positional(args)[0]
445
+ if (!name) throw new Error('usage: nervur scaffold <name> [--kind empty] [--dir <path>]')
446
+ const kind = flagValue(args, '--kind') ?? 'empty'
447
+ print(
448
+ scaffoldSpecies({
449
+ name,
450
+ template: resolveTemplate(kind, { from: join(process.cwd(), 'noop.js') }),
451
+ dir: flagValue(args, '--dir')
452
+ })
453
+ )
454
+ break
455
+ }
456
+ case 'deploy': {
457
+ // Grant this carcass a species repo at a pinned full-hash release, materialize
458
+ // and run it. Re-deploy with a new hash = upgrade or rollback, same verb.
459
+ const [name, repo, ref] = positional(args)
460
+ if (!name || !repo || !ref)
461
+ throw new Error('usage: nervur deploy <name> <repo> <full-commit-hash>')
462
+ print(await postJson(`${face()}/deploy`, { name, repo, ref }))
463
+ break
464
+ }
465
+ case 'species':
466
+ print(await getJson(`${face()}/deploy/list`))
467
+ break
468
+ case 'help':
469
+ case '--help':
470
+ case '-h':
471
+ console.log(
472
+ 'nervur — client of a running carcass\n\n' +
473
+ 'carcass family: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove\n' +
474
+ 'being family: scaffold, deploy, species\n\n' +
475
+ ' install Docker preflight, then stand a carcass at NERVUR_HOME (default ~/.nervur)\n' +
476
+ ' [NERVUR_HOME, NERVUR_IMAGE, NERVUR_PORT, NERVUR_FACES_PORT]\n' +
477
+ ' up [--no-door] (dev bench) run the bench preflight, then stand the ground\n' +
478
+ ' preflight [--no-door] run the bench preflight and report (exit code = verdict)\n' +
479
+ ' identity add <name> mint an identity on this machine (silent, idempotent)\n' +
480
+ ' identity list list identities on this machine · name + public id\n' +
481
+ ' identity remove <name> destroy an identity — refused (409) while beings remain\n' +
482
+ ' use [name] select which identity add/list act as (client-only); no args prints it\n' +
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'
489
+ )
490
+ break
491
+ default:
492
+ console.error(
493
+ `nervur: unknown verb: ${verb} (have: install, up, down, reset, preflight, whoami, status, identity, use, add, list, remove, scaffold, deploy, species)`
494
+ )
495
+ process.exit(1)
496
+ }
497
+ process.exit(0)
498
+ }
499
+
500
+ // Run only as the CLI; importing this module (e.g. to exercise knockDomain against
501
+ // the door) must not fire main(). Resolve the invoked path through symlinks — `npx
502
+ // nervur` / `node_modules/.bin/nervur` reach here via a symlink, and import.meta.url
503
+ // is already the realpath, so compare realpath to realpath or the door never opens.
504
+ function invokedAsCli() {
505
+ if (!process.argv[1]) return false
506
+ try {
507
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href
508
+ } catch {
509
+ return false
510
+ }
511
+ }
512
+ if (invokedAsCli()) {
513
+ main().catch((e) => {
514
+ console.error(`nervur: ${e.message}`)
515
+ process.exit(1)
516
+ })
517
+ }
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.1",
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": "^0.1.0"
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
+ }