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 +202 -0
- package/README.md +46 -1
- package/nervur.js +517 -0
- package/package.json +15 -4
- package/preflight.js +161 -0
- package/scaffold.js +50 -0
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
|
-
|
|
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.
|
|
4
|
-
"description": "nervur —
|
|
5
|
-
"
|
|
6
|
-
"
|
|
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
|
+
}
|