@mcsherrylabs/evolver-mcp 0.0.0-stage → 0.8.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.
Files changed (70) hide show
  1. package/dist/amount.d.ts +20 -0
  2. package/dist/amount.d.ts.map +1 -0
  3. package/dist/amount.js +44 -0
  4. package/dist/amount.js.map +1 -0
  5. package/dist/bootstrap.d.ts +264 -0
  6. package/dist/bootstrap.d.ts.map +1 -0
  7. package/dist/bootstrap.js +901 -0
  8. package/dist/bootstrap.js.map +1 -0
  9. package/dist/channel.d.ts +78 -0
  10. package/dist/channel.d.ts.map +1 -0
  11. package/dist/channel.js +288 -0
  12. package/dist/channel.js.map +1 -0
  13. package/dist/claim.d.ts +48 -0
  14. package/dist/claim.d.ts.map +1 -0
  15. package/dist/claim.js +81 -0
  16. package/dist/claim.js.map +1 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +35 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/config.d.ts +39 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +77 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/content.d.ts +51 -0
  26. package/dist/content.d.ts.map +1 -0
  27. package/dist/content.js +60 -0
  28. package/dist/content.js.map +1 -0
  29. package/dist/group.d.ts +60 -0
  30. package/dist/group.d.ts.map +1 -0
  31. package/dist/group.js +204 -0
  32. package/dist/group.js.map +1 -0
  33. package/dist/identity.d.ts +87 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +230 -0
  36. package/dist/identity.js.map +1 -0
  37. package/dist/inbox.d.ts +30 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +95 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +10 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +206 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/mcp.d.ts +39 -0
  46. package/dist/mcp.d.ts.map +1 -0
  47. package/dist/mcp.js +83 -0
  48. package/dist/mcp.js.map +1 -0
  49. package/dist/read.d.ts +175 -0
  50. package/dist/read.d.ts.map +1 -0
  51. package/dist/read.js +219 -0
  52. package/dist/read.js.map +1 -0
  53. package/dist/result.d.ts +110 -0
  54. package/dist/result.d.ts.map +1 -0
  55. package/dist/result.js +97 -0
  56. package/dist/result.js.map +1 -0
  57. package/dist/signer.d.ts +37 -0
  58. package/dist/signer.d.ts.map +1 -0
  59. package/dist/signer.js +76 -0
  60. package/dist/signer.js.map +1 -0
  61. package/dist/tools.d.ts +44 -0
  62. package/dist/tools.d.ts.map +1 -0
  63. package/dist/tools.js +642 -0
  64. package/dist/tools.js.map +1 -0
  65. package/dist/write.d.ts +173 -0
  66. package/dist/write.d.ts.map +1 -0
  67. package/dist/write.js +322 -0
  68. package/dist/write.js.map +1 -0
  69. package/package.json +55 -4
  70. package/README.md +0 -3
@@ -0,0 +1,901 @@
1
+ // First-run identity bootstrap (BOOT-01..BOOT-07): mints a BLS+Ed25519 identity locally, PoP-signs
2
+ // the SDK faucet digest, writes the key (0600), seed (0600) and config.json into a 0700 state dir,
3
+ // claims through `NodeClient.claimIdentity`, and exits — WITHOUT ever starting the stdio server.
4
+ //
5
+ // 07-02 hardens this against crash, flaky devnet, squatted-name and re-run hazards. The state dir
6
+ // is a small, crash-safe state machine:
7
+ // claimed -> nothing happens (`already_bootstrapped`, zero chain calls);
8
+ // pending (seed present, no marker) -> RESUMES with the seed's exact keys, restores 0600/0700;
9
+ // fresh (no seed) -> refuses pre-existing state without `--force`, else mints+persists->claims;
10
+ // failed (a claim that did not commit) -> keys kept on disk, marker absent, non-zero exit.
11
+ //
12
+ // Pure planning (`planBootstrap`) beside thin effects (`executeBootstrap`) through structural
13
+ // `BootstrapDeps` seams, mirroring `resolveConfig`/`loadConfig`.
14
+ //
15
+ // SECURITY (T-07-01..T-07-18): private bytes never appear in an error message or output line —
16
+ // refusals name a PATH or a FIELD, never key material. No new dependency, no new MCP tool.
17
+ import { mkdirSync, chmodSync, lstatSync, writeFileSync, renameSync, readdirSync, readFileSync, realpathSync, } from "node:fs";
18
+ import { join, sep, dirname, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { randomBytes as nodeRandomBytes } from "node:crypto";
21
+ import { homedir as nodeHomedir } from "node:os";
22
+ import { newIdentity, faucetClaimDigest, faucetClaimDigestFor, blsSign, edSign, bytesToHex, hexToBytes, edSpkiToRaw, isValidNodeId, NodeClient, } from "@mcsherrylabs/evolver-client";
23
+ import { UNTRUSTED } from "./mcp.js";
24
+ // ── constants ────────────────────────────────────────────────────────────────
25
+ export const BOOTSTRAP_FLAG = "--bootstrap";
26
+ const DEVNET_DEFAULT_URL = "https://devnet.mcsherrylabs.com";
27
+ const LOCAL_DEFAULT_PORT = "8440";
28
+ export const RETRYABLE_CLAIM_ERRORS = ["node_not_ready", "queue_full", "confirmation_timeout", "internal"];
29
+ export const DEFAULT_RETRY_MAX = 3;
30
+ /** The one registration name the bootstrap emits (D-01, A9). */
31
+ export const DEFAULT_REGISTRATION_NAME = "evolver-mcp";
32
+ /** The built entry the registered launch runs, relative to the repo (A7). */
33
+ export const SERVER_ENTRY_REL = join("dist", "index.js");
34
+ export const BOOTSTRAP_USAGE = "usage: node dist/index.js --bootstrap [--target devnet|local] [--identity <name>] [--force]\n" +
35
+ "env: EVOLVER_TARGET, EVOLVER_FAUCET_URL, EVOLVER_DEVNET_URL, EVOLVER_NODE, LOCAL_CHAIN_PORT,\n" +
36
+ " EVOLVER_MCP_STATE_DIR, XDG_STATE_HOME, EVOLVER_CONTENT_URL, EVOLVER_BOOTSTRAP_RETRY_MAX,\n" +
37
+ " EVOLVER_BOOTSTRAP_FAUCET_URL (test-only)\n" +
38
+ "registration is DERIVED only — wave 3 emits it, never applies it.";
39
+ // ── small helpers ────────────────────────────────────────────────────────────
40
+ const optionalEnv = (env, key) => {
41
+ const v = env[key];
42
+ if (typeof v !== "string")
43
+ return undefined;
44
+ const t = v.trim();
45
+ return t.length > 0 ? t : undefined;
46
+ };
47
+ const isAbsolutePath = (p) => p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
48
+ const containsDotDot = (p) => p.split(sep).includes("..");
49
+ const UNKNOWN_TARGET_ERR = (name) => ({
50
+ kind: "partial",
51
+ what: "target",
52
+ cause: `unknown target "${name}" — the built-in targets are devnet, local`,
53
+ });
54
+ // ── resolveTarget (BOOT-01, T-07-05) ─────────────────────────────────────────
55
+ export function resolveTarget(env, argvTarget, chainName, cwd) {
56
+ const customChain = optionalEnv(env, "EVOLVER_CHAIN");
57
+ const customUrl = optionalEnv(env, "EVOLVER_CHAIN_URL");
58
+ if (customUrl !== undefined && customChain === undefined) {
59
+ return { ok: false, error: { kind: "partial", what: "EVOLVER_CHAIN_URL", cause: "EVOLVER_CHAIN_URL is set without EVOLVER_CHAIN — name the chain it belongs to" } };
60
+ }
61
+ if (customChain !== undefined) {
62
+ return { ok: false, error: { kind: "partial", what: "EVOLVER_CHAIN", cause: "bootstrap supports only the built-in devnet/local targets, not a custom chain" } };
63
+ }
64
+ let name = argvTarget;
65
+ if (name === undefined || name.trim().length === 0)
66
+ name = optionalEnv(env, "EVOLVER_TARGET");
67
+ if (name === undefined)
68
+ name = chainName ?? readTargetFile(cwd);
69
+ if (name === undefined || name.trim().length === 0)
70
+ name = "devnet";
71
+ name = name.trim();
72
+ if (name === "devnet") {
73
+ const chainUrl = optionalEnv(env, "EVOLVER_DEVNET_URL") ?? DEVNET_DEFAULT_URL;
74
+ const faucetUrl = optionalEnv(env, "EVOLVER_FAUCET_URL") ?? chainUrl;
75
+ return { ok: true, plan: { target: "devnet", chainUrl, faucetUrl } };
76
+ }
77
+ if (name === "local") {
78
+ const port = optionalEnv(env, "LOCAL_CHAIN_PORT");
79
+ const chainUrl = optionalEnv(env, "EVOLVER_NODE") ?? `http://localhost:${port ?? LOCAL_DEFAULT_PORT}`;
80
+ const faucetUrl = optionalEnv(env, "EVOLVER_FAUCET_URL") ?? chainUrl;
81
+ return { ok: true, plan: { target: "local", chainUrl, faucetUrl } };
82
+ }
83
+ return { ok: false, error: UNKNOWN_TARGET_ERR(name) };
84
+ }
85
+ function readTargetFile(cwd) {
86
+ try {
87
+ const t = readFileSync(join(cwd, ".target"), "utf8").trim();
88
+ return t.length > 0 ? t : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ // ── stateDirFor / statePaths ─────────────────────────────────────────────────
95
+ export function stateDirFor(env, target, homedir) {
96
+ const explicit = optionalEnv(env, "EVOLVER_MCP_STATE_DIR");
97
+ if (explicit !== undefined) {
98
+ if (!isAbsolutePath(explicit) || containsDotDot(explicit)) {
99
+ return { ok: false, reason: "EVOLVER_MCP_STATE_DIR must be an absolute path with no `..` segment" };
100
+ }
101
+ return { ok: true, dir: explicit };
102
+ }
103
+ const xdg = optionalEnv(env, "XDG_STATE_HOME");
104
+ const base = xdg ?? join(homedir, ".local", "state");
105
+ return { ok: true, dir: join(base, "evolver-mcp", target) };
106
+ }
107
+ /** Name the files. `identity` is the identity ACTUALLY in play (seed's name on resume, argv value on
108
+ * a fresh run, derived name otherwise). The dir is chosen by `stateDirFor`, never here. */
109
+ export function statePaths(stateDir, identity) {
110
+ return {
111
+ dir: stateDir,
112
+ config: join(stateDir, "config.json"),
113
+ key: join(stateDir, `${identity}.key`),
114
+ seed: join(stateDir, `${identity}.keys.json`),
115
+ registration: join(stateDir, "mcp.json"),
116
+ claimed: join(stateDir, `${identity}.claimed`),
117
+ };
118
+ }
119
+ export function parseBootstrapArgs(args) {
120
+ let target;
121
+ let identity;
122
+ let force = false;
123
+ for (let i = 2; i < args.length; i++) {
124
+ const a = args[i];
125
+ if (a === BOOTSTRAP_FLAG)
126
+ continue;
127
+ else if (a === "--target") {
128
+ target = args[++i];
129
+ if (target === undefined)
130
+ return { ok: false, error: "--target needs a value" };
131
+ }
132
+ else if (a.startsWith("--target="))
133
+ target = a.slice("--target=".length);
134
+ else if (a === "--identity") {
135
+ identity = args[++i];
136
+ if (identity === undefined)
137
+ return { ok: false, error: "--identity needs a value" };
138
+ }
139
+ else if (a.startsWith("--identity="))
140
+ identity = a.slice("--identity=".length);
141
+ else if (a === "--force")
142
+ force = true;
143
+ else
144
+ return { ok: false, error: `unknown argument "${a}" — ${BOOTSTRAP_USAGE}` };
145
+ }
146
+ return { ok: true, target, identity, force };
147
+ }
148
+ // ── generateBootstrapIdentity (BOOT-02, T-07-06) ────────────────────────────
149
+ export function generateBootstrapIdentity(name, mint = newIdentity) {
150
+ if (!isValidNodeId(name)) {
151
+ return { kind: "err", error: { kind: "partial", what: "identity", cause: `"${name}" is not a valid node id (letters and digits only, 1–50 chars)` } };
152
+ }
153
+ const id = mint(name);
154
+ const digest = faucetClaimDigestFor(id.nodeId, id.bls.pub, id.ed.pub);
155
+ if (bytesToHex(digest) !== bytesToHex(faucetClaimDigest(id))) {
156
+ return { kind: "err", error: { kind: "partial", what: "identity", cause: "the SDK's two digest entry points disagree — refusing to persist or sign before resolving the drift" } };
157
+ }
158
+ return { kind: "ok", value: { identity: id, digest, blsPop: blsSign(digest, id.bls.priv), edPop: edSign(digest, id.ed.priv) } };
159
+ }
160
+ // ── seed / key / config rendering ───────────────────────────────────────────
161
+ export function seedFor(seed) {
162
+ return JSON.stringify(seed);
163
+ }
164
+ export function keyFileFor(identity) {
165
+ return Buffer.from(identity.bls.priv).toString("base64");
166
+ }
167
+ export function configFor(opts) {
168
+ return JSON.stringify({ identity: opts.name, url: opts.chainUrl }, null, 2) + "\n";
169
+ }
170
+ export function identityFromSeed(seed) {
171
+ return {
172
+ nodeId: seed.identity,
173
+ bls: { priv: Buffer.from(seed.blsPrivBase64, "base64"), pub: Buffer.from(seed.blsPubBase64, "base64") },
174
+ ed: { priv: Buffer.from(seed.edPrivBase64, "base64"), pub: Buffer.from(seed.edPubBase64, "base64") },
175
+ };
176
+ }
177
+ const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
178
+ export function readSeed(fs, seedPath) {
179
+ let raw;
180
+ try {
181
+ raw = fs.readFile(seedPath, "utf8");
182
+ }
183
+ catch {
184
+ return { ok: false, reason: `${seedPath}: the seed file is missing or unreadable` };
185
+ }
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(raw);
189
+ }
190
+ catch {
191
+ return { ok: false, reason: `${seedPath}: the seed file is not valid JSON` };
192
+ }
193
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
194
+ return { ok: false, reason: `${seedPath}: the seed root must be a JSON object` };
195
+ }
196
+ const rec = parsed;
197
+ if (typeof rec.identity !== "string" || rec.identity.length === 0) {
198
+ return { ok: false, reason: `${seedPath}: the seed's "identity" field is not a string` };
199
+ }
200
+ const fields = [
201
+ { key: "blsPubBase64", len: 48 },
202
+ { key: "edPubBase64", len: 32 },
203
+ { key: "blsPrivBase64", len: 32 },
204
+ { key: "edPrivBase64", len: 32 },
205
+ ];
206
+ for (const { key, len } of fields) {
207
+ const v = rec[key];
208
+ if (typeof v !== "string" || v.length === 0) {
209
+ return { ok: false, reason: `${seedPath}: the seed's "${key}" field is missing or not a string` };
210
+ }
211
+ if (!B64_RE.test(v)) {
212
+ return { ok: false, reason: `${seedPath}: the seed's "${key}" field is not valid base64` };
213
+ }
214
+ if (Buffer.from(v, "base64").length !== len) {
215
+ return { ok: false, reason: `${seedPath}: the seed's "${key}" field decodes to ${Buffer.from(v, "base64").length} bytes, expected ${len}` };
216
+ }
217
+ }
218
+ return {
219
+ ok: true,
220
+ seed: {
221
+ identity: rec.identity,
222
+ blsPubBase64: rec.blsPubBase64,
223
+ edPubBase64: rec.edPubBase64,
224
+ blsPrivBase64: rec.blsPrivBase64,
225
+ edPrivBase64: rec.edPrivBase64,
226
+ createdAt: typeof rec.createdAt === "string" ? rec.createdAt : "",
227
+ },
228
+ };
229
+ }
230
+ // ── listClaimed / listSeeds ──────────────────────────────────────────────────
231
+ export function listClaimed(fs, dir) {
232
+ let names;
233
+ try {
234
+ names = fs.readdir(dir);
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ const claimed = names
240
+ .filter((f) => f.endsWith(".claimed") && !f.includes(".tmp"))
241
+ .map((f) => f.slice(0, -".claimed".length))
242
+ .filter((id) => id.length > 0);
243
+ // Only a claimed marker WITH a matching key file gates "done" (T-07-13).
244
+ return claimed.filter((id) => names.includes(`${id}.key`));
245
+ }
246
+ function listSeeds(fs, dir) {
247
+ let names;
248
+ try {
249
+ names = fs.readdir(dir);
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ return names
255
+ .filter((f) => f.endsWith(".keys.json") && !f.includes(".tmp"))
256
+ .map((f) => f.slice(0, -".keys.json".length));
257
+ }
258
+ // ── retryMax / backoff ───────────────────────────────────────────────────────
259
+ export function retryMax(env) {
260
+ const v = optionalEnv(env, "EVOLVER_BOOTSTRAP_RETRY_MAX");
261
+ if (v === undefined)
262
+ return DEFAULT_RETRY_MAX;
263
+ const n = Number.parseInt(v, 10);
264
+ return Number.isFinite(n) ? Math.min(Math.max(n, 1), 5) : DEFAULT_RETRY_MAX;
265
+ }
266
+ function backoffMs(attempt) {
267
+ return Math.min(250 * Math.pow(2, attempt - 1), 4000);
268
+ }
269
+ // ── claimWithRetries (T-07-17) ───────────────────────────────────────────────
270
+ export async function claimWithRetries(client, gen, max, sleep) {
271
+ let attempts = 0;
272
+ // eslint-disable-next-line no-constant-condition
273
+ while (true) {
274
+ attempts++;
275
+ const result = await client.claimIdentity(gen.identity);
276
+ if (result.kind !== "failed")
277
+ return { attempts, result };
278
+ if (RETRYABLE_CLAIM_ERRORS.includes(result.error) && attempts < max) {
279
+ await sleep(backoffMs(attempts));
280
+ continue;
281
+ }
282
+ return { attempts, result };
283
+ }
284
+ }
285
+ // ── exitCodeFor (T-07-16) ────────────────────────────────────────────────────
286
+ // The exit-2 class is "fix your arguments/transport and rerun" (argument/usage/identity/target/
287
+ // unreachable); the exit-1 class is "partial bootstrap, keys touched" (state/claim/claim-retry).
288
+ const EXIT_2_WHATS = new Set(["identity", "target", "usage"]);
289
+ export function exitCodeFor(outcome) {
290
+ switch (outcome.kind) {
291
+ case "committed":
292
+ case "exists":
293
+ case "already_bootstrapped":
294
+ return 0;
295
+ case "partial":
296
+ return EXIT_2_WHATS.has(outcome.what) ? 2 : 1;
297
+ case "unreachable":
298
+ return 2;
299
+ }
300
+ }
301
+ // ── writeState (BOOT-03) ─────────────────────────────────────────────────────
302
+ export function writeState(fs, paths, configContent, seedContent, keyContent) {
303
+ try {
304
+ fs.mkdir(paths.dir, { recursive: true, mode: 0o700 });
305
+ fs.chmod(paths.dir, 0o700);
306
+ }
307
+ catch (e) {
308
+ return { kind: "partial", what: paths.dir, cause: e instanceof Error ? e.message : String(e) };
309
+ }
310
+ const files = [
311
+ { dest: paths.key, content: keyContent },
312
+ { dest: paths.seed, content: seedContent },
313
+ { dest: paths.config, content: configContent },
314
+ ];
315
+ for (const { dest, content } of files) {
316
+ const err = writeOne(fs, dest, content);
317
+ if (err)
318
+ return err;
319
+ }
320
+ return null;
321
+ }
322
+ function writeOne(fs, dest, content) {
323
+ try {
324
+ if (fs.lstat(dest).isSymbolicLink()) {
325
+ return { kind: "partial", what: dest, cause: "refusing to write through a symlinked path" };
326
+ }
327
+ }
328
+ catch {
329
+ // not present — proceed
330
+ }
331
+ const tmp = `${dest}.tmp-${Math.random().toString(36).slice(2)}`;
332
+ try {
333
+ fs.writeFile(tmp, content, { mode: 0o600 });
334
+ fs.rename(tmp, dest);
335
+ }
336
+ catch (e) {
337
+ return { kind: "partial", what: dest, cause: e instanceof Error ? e.message : String(e) };
338
+ }
339
+ return null;
340
+ }
341
+ // ── writeClaimedMarker ───────────────────────────────────────────────────────
342
+ export function writeClaimedMarker(fs, claimedPath, marker) {
343
+ return writeOne(fs, claimedPath, JSON.stringify(marker));
344
+ }
345
+ export async function adoptExisting(client, name, identity) {
346
+ let keys;
347
+ try {
348
+ keys = await client.getIdentityKeys(name);
349
+ }
350
+ catch {
351
+ return { ok: false, cause: "could not read the name's on-chain keys", transport: true };
352
+ }
353
+ if (keys === null) {
354
+ return { ok: false, cause: "the chain reports the name exists but lists no keys for it" };
355
+ }
356
+ const found = new Set();
357
+ for (const key of keys) {
358
+ if (key.label !== "")
359
+ continue; // only label "" is compared
360
+ if (key.keyType === "BLS12-381-G1" && key.publicKeyHex.toLowerCase() === bytesToHex(identity.bls.pub).toLowerCase()) {
361
+ found.add("BLS12-381-G1");
362
+ }
363
+ else if (key.keyType === "Ed25519" && bytesToHex(edSpkiToRaw(hexToBytes(key.publicKeyHex))).toLowerCase() === bytesToHex(identity.ed.pub).toLowerCase()) {
364
+ found.add("Ed25519");
365
+ }
366
+ else {
367
+ // A row at label "" that is neither our BLS nor our Ed key (or a duplicate/extra) is foreign.
368
+ return { ok: false, cause: `the name's existing ${key.keyType} key at label "" is not ours` };
369
+ }
370
+ }
371
+ const haveBoth = found.has("BLS12-381-G1") && found.has("Ed25519");
372
+ if (!haveBoth) {
373
+ return { ok: false, cause: "the name's existing keys do not match this state dir's keys" };
374
+ }
375
+ // Any additional row at label "" beyond the one BLS + one Ed is a conflict (handled above: same
376
+ // type would hit the else). Safe to adopt: both required keys are ours and nothing foreign at "".
377
+ return { ok: true };
378
+ }
379
+ // ── planBootstrap (pure) ─────────────────────────────────────────────────────
380
+ export function planBootstrap(env, argv, cwd, deps) {
381
+ const parsed = parseBootstrapArgs(argv);
382
+ if (!parsed.ok)
383
+ return { ok: false, error: { kind: "partial", what: "usage", cause: parsed.error } };
384
+ const resolved = resolveTarget(env, parsed.target, undefined, cwd);
385
+ if (!resolved.ok)
386
+ return { ok: false, error: resolved.error };
387
+ const homedir = deps.homedir ?? (() => (process.env.HOME ?? "/tmp"));
388
+ const dir = stateDirFor(env, resolved.plan.target, homedir());
389
+ if (!dir.ok)
390
+ return { ok: false, error: { kind: "partial", what: "state dir", cause: dir.reason } };
391
+ const name = parsed.identity ?? derivedName(deps.randomBytes ?? nodeRandomBytes);
392
+ if (!isValidNodeId(name)) {
393
+ return { ok: false, error: { kind: "partial", what: "identity", cause: `"${name}" is not a valid node id` } };
394
+ }
395
+ const gen = generateBootstrapIdentity(name);
396
+ if (gen.kind === "err")
397
+ return { ok: false, error: gen.error };
398
+ const paths = statePaths(dir.dir, name);
399
+ const contentUrl = optionalEnv(env, "EVOLVER_CONTENT_URL") ?? resolved.plan.chainUrl;
400
+ // The server entry is canonicalised ONCE here (realpath of the possibly-symlinked built entry);
401
+ // the state paths are NOT canonicalised (D-04/G2: verbatim). `serverEntryPath`/`realpath` are
402
+ // injectable so tests never depend on where tsx is invoked from; G7b covers the non-injected path.
403
+ const entryFn = deps.serverEntryPath ?? defaultServerEntryPath;
404
+ const realpath = deps.realpath ?? ((p) => realpathSync(p));
405
+ const serverEntry = realpath(entryFn());
406
+ // Package-mode entry command (Phase 8): when `EVOLVER_MCP_ENTRY_COMMAND` is set, the registration
407
+ // names that verbatim command (e.g. `npx -y @mcsherrylabs/evolver-mcp`) instead of the checkout
408
+ // realpath. Default (unset) keeps Phase 7's `node <realpath dist/index.js>` checkout form.
409
+ const entryCommandValue = optionalEnv(env, "EVOLVER_MCP_ENTRY_COMMAND");
410
+ const entryCommand = entryCommandValue !== undefined ? splitEntryCommand(entryCommandValue) : checkoutEntryCommand(serverEntry);
411
+ return {
412
+ ok: true,
413
+ plan: {
414
+ target: resolved.plan.target,
415
+ chainUrl: resolved.plan.chainUrl,
416
+ faucetUrl: resolved.plan.faucetUrl,
417
+ name,
418
+ explicitName: parsed.identity ?? false,
419
+ identitySource: parsed.identity !== undefined ? "explicit" : "derived",
420
+ force: parsed.force,
421
+ retryMax: retryMax(env),
422
+ paths,
423
+ identity: gen.value,
424
+ contentUrl,
425
+ contentWarning: renderWarnings(resolved.plan.target, contentUrl).join("\n"),
426
+ cwd,
427
+ serverEntry,
428
+ entryCommand,
429
+ registrationEnv: {
430
+ EVOLVER_KEY_PATH: paths.key,
431
+ EVOLVER_KEY_TYPE: "bls",
432
+ EVOLVER_CONTENT_URL: contentUrl,
433
+ },
434
+ },
435
+ };
436
+ }
437
+ function derivedName(randomBytes) {
438
+ const bytes = randomBytes(6);
439
+ const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
440
+ let suffix = "";
441
+ for (let i = 0; i < 6; i++)
442
+ suffix += chars[bytes[i] % chars.length];
443
+ return `evolvermcp${suffix}`;
444
+ }
445
+ /** The content warning (D-03): stated on every state-writing run, never blocks. */
446
+ export function renderWarnings(target, contentUrl) {
447
+ const sentence = target === "devnet"
448
+ ? `warning: content tools may be unavailable on ${contentUrl} — devnet appears to serve no content listener, so READ-10..12 may answer unreachable even though the startup probe passes; EVOLVER_CONTENT_URL is still set in the registration.`
449
+ : `warning: content tools may be unavailable on ${contentUrl} — content is served by the local node's separate listener; EVOLVER_CONTENT_URL is still set in the registration.`;
450
+ return [`${sentence}${UNTRUSTED}`];
451
+ }
452
+ /** Split the `EVOLVER_MCP_ENTRY_COMMAND` value into `{command, args}` on whitespace, collapsing runs.
453
+ * Empty/whitespace-only yields `args: []` (an empty command is invalid and rejected by planBootstrap). */
454
+ export function splitEntryCommand(value) {
455
+ const parts = value.split(/\s+/).filter((s) => s.length > 0);
456
+ return { command: parts[0] ?? "", args: parts.slice(1) };
457
+ }
458
+ /** The launch shape for a checkout run: `node <realpath entry>`. */
459
+ export function checkoutEntryCommand(serverEntry) {
460
+ return { command: "node", args: [serverEntry] };
461
+ }
462
+ /** Derive the one harness registration as data (BOOT-06/D-01). The entry is the REALPATH'd server
463
+ * entry (default) OR a package-mode command (`npx -y @mcsherrylabs/evolver-mcp`) from
464
+ * `EVOLVER_MCP_ENTRY_COMMAND`. `plan.paths.config`/`key` and the content URL are serialised
465
+ * BYTE-FOR-BYTE as given (D-04/G2: verbatim state paths). Never key bytes, never the seed path,
466
+ * never the faucet. */
467
+ export function deriveRegistration(plan, entryCommand) {
468
+ return {
469
+ mcpServers: {
470
+ "evolver-mcp": {
471
+ type: "stdio",
472
+ command: entryCommand.command,
473
+ args: [...entryCommand.args, "--config", plan.paths.config],
474
+ env: { ...plan.registrationEnv },
475
+ },
476
+ },
477
+ };
478
+ }
479
+ /** The `claude mcp add` argv (scope user, transport stdio, env pairs, then the entry command). */
480
+ export function claudeAddCommand(plan, entryCommand) {
481
+ const env = plan.registrationEnv;
482
+ return [
483
+ "claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "evolver-mcp",
484
+ "--env", `EVOLVER_KEY_PATH=${env.EVOLVER_KEY_PATH}`,
485
+ "--env", `EVOLVER_KEY_TYPE=${env.EVOLVER_KEY_TYPE}`,
486
+ "--env", `EVOLVER_CONTENT_URL=${env.EVOLVER_CONTENT_URL}`,
487
+ "--", entryCommand.command, ...entryCommand.args, "--config", plan.paths.config,
488
+ ];
489
+ }
490
+ /** The `claude mcp add-json` argv carrying the whole entry object as its JSON payload. */
491
+ export function addJsonCommand(reg) {
492
+ return ["claude", "mcp", "add-json", "evolver-mcp", JSON.stringify(reg.mcpServers["evolver-mcp"]), "--scope", "user"];
493
+ }
494
+ /** Shell-quote one argv word for display: leave a safe token bare, else single-quote (escaping `'`
495
+ * as `'\''`). */
496
+ export function quoteArg(s) {
497
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
498
+ }
499
+ const quoteLine = (argv) => argv.map(quoteArg).join(" ");
500
+ /** The human-facing registration block: a header, the named artefacts, the two exact commands
501
+ * (via-flag and via-json), and the statement that the bootstrap prints them and never runs them. */
502
+ export function registrationLines(plan, entryCommand) {
503
+ const reg = deriveRegistration(plan, entryCommand);
504
+ return [
505
+ "registration (derived, not applied):",
506
+ ` config: ${plan.paths.config}`,
507
+ ` key: ${plan.paths.key}`,
508
+ ` mcp.json: ${plan.paths.registration}`,
509
+ "",
510
+ quoteLine(claudeAddCommand(plan, entryCommand)),
511
+ "",
512
+ "# generic clients (Codex, Pi-style runtimes, any stdio MCP client):",
513
+ quoteLine(addJsonCommand(reg)),
514
+ "",
515
+ "these commands are printed and never run by the bootstrap — the operator applies them.",
516
+ ];
517
+ }
518
+ /** The `--target local` refusal scan: a `.target` file in the state dir or the cwd, or a `.env*` file
519
+ * in the cwd, would be read into the child's process env by the local chain — refuse before any
520
+ * state write or claim. Returns the offending path, or null if clean. */
521
+ function localTargetRefusal(fs, dir, cwd) {
522
+ const existsAt = (p) => { try {
523
+ fs.lstat(p);
524
+ return true;
525
+ }
526
+ catch {
527
+ return false;
528
+ } };
529
+ const envRe = /^\.env(\..*)?$/;
530
+ const dirTarget = join(dir, ".target");
531
+ if (existsAt(dirTarget))
532
+ return dirTarget;
533
+ const cwdTarget = join(cwd, ".target");
534
+ if (existsAt(cwdTarget))
535
+ return cwdTarget;
536
+ try {
537
+ const hit = fs.readdir(cwd).find((f) => envRe.test(f));
538
+ if (hit !== undefined)
539
+ return join(cwd, hit);
540
+ }
541
+ catch { /* cwd unreadable — not a refusal */ }
542
+ return null;
543
+ }
544
+ // ── live gate (BOOT-09) ──────────────────────────────────────────────────────
545
+ /** The opt-in live devnet gate: enabled only when `EVOLVER_BOOTSTRAP_LIVE === "1"`; the base URL is
546
+ * `EVOLVER_DEVNET_URL` or the devnet default. Never on `npm test` (the var is unset there). */
547
+ export function liveGate(env) {
548
+ return {
549
+ enabled: env.EVOLVER_BOOTSTRAP_LIVE === "1",
550
+ baseUrl: optionalEnv(env, "EVOLVER_DEVNET_URL") ?? DEVNET_DEFAULT_URL,
551
+ };
552
+ }
553
+ // ── defaultDeps ──────────────────────────────────────────────────────────────
554
+ /** The built entry path, before canonicalisation. From `dist/` (the CLI) `import.meta.url` is
555
+ * `<repo>/dist/bootstrap.js`, so the entry is the sibling `index.js`; from `test/` (tsx) the module
556
+ * URL lives under `src/`, so the entry is `<cwd>/dist/index.js`. */
557
+ function defaultServerEntryPath() {
558
+ const p = fileURLToPath(import.meta.url);
559
+ const inBuild = /(?:^|[\\/])dist[\\/]/.test(p);
560
+ return inBuild ? join(dirname(p), "index.js") : resolve(process.cwd(), SERVER_ENTRY_REL);
561
+ }
562
+ export function defaultDeps() {
563
+ return {
564
+ fs: fullFs({}),
565
+ randomBytes: (n) => new Uint8Array(nodeRandomBytes(n)),
566
+ homedir: () => nodeHomedir(),
567
+ now: () => new Date(),
568
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
569
+ clientFactory: (baseUrl) => new NodeClient({ baseUrl }),
570
+ getIdentityBaseUrl: () => undefined,
571
+ mintIdentity: (name) => newIdentity(name),
572
+ serverEntryPath: defaultServerEntryPath,
573
+ realpath: (p) => realpathSync(p),
574
+ };
575
+ }
576
+ function fullFs(partial) {
577
+ return {
578
+ mkdir: partial.mkdir ?? ((p, o) => mkdirSync(p, o)),
579
+ chmod: partial.chmod ?? ((p, m) => chmodSync(p, m)),
580
+ lstat: partial.lstat ?? ((p) => lstatSync(p)),
581
+ writeFile: partial.writeFile ?? ((p, d, o) => writeFileSync(p, d, o)),
582
+ rename: partial.rename ?? ((f, t) => renameSync(f, t)),
583
+ readdir: partial.readdir ?? ((p) => readdirSync(p)),
584
+ readFile: partial.readFile ?? ((p, e) => readFileSync(p, e)),
585
+ exists: partial.exists ?? ((p) => { try {
586
+ lstatSync(p);
587
+ return true;
588
+ }
589
+ catch {
590
+ return false;
591
+ } }),
592
+ };
593
+ }
594
+ function mergeDeps(deps) {
595
+ const d = defaultDeps();
596
+ return {
597
+ fs: fullFs(deps.fs ?? {}),
598
+ randomBytes: deps.randomBytes ?? d.randomBytes,
599
+ homedir: deps.homedir ?? d.homedir,
600
+ now: deps.now ?? d.now,
601
+ sleep: deps.sleep ?? d.sleep,
602
+ clientFactory: deps.clientFactory ?? d.clientFactory,
603
+ getIdentityBaseUrl: deps.getIdentityBaseUrl ?? d.getIdentityBaseUrl,
604
+ mintIdentity: deps.mintIdentity ?? d.mintIdentity,
605
+ serverEntryPath: deps.serverEntryPath ?? d.serverEntryPath,
606
+ realpath: deps.realpath ?? d.realpath,
607
+ };
608
+ }
609
+ // ── executeBootstrap (the step 0–8 spine) ────────────────────────────────────
610
+ export async function executeBootstrap(plan, deps) {
611
+ const full = mergeDeps(deps);
612
+ const fs = full.fs;
613
+ const dir = plan.paths.dir;
614
+ // Step 0: identity resolution (seed FIRST) before any name-derived path or client. The argv
615
+ // identity (when one was given) is `plan.name` when `identitySource === "explicit"`; `explicitName`
616
+ // stays an invocation record (display only) and is never branched on here (T-07-19).
617
+ const argvIdentity = plan.identitySource === "explicit" ? plan.name : undefined;
618
+ const res = resolveIdentityStep0(fs, dir, argvIdentity, plan.name);
619
+ if (!res.ok)
620
+ return res.error;
621
+ const { identityName, identitySource } = res.value;
622
+ const paths = statePaths(dir, identityName);
623
+ // `--target local` refusal (before ANY state write or claim): the local chain's `fromEnvironment`
624
+ // reads `.target`/`.env*` into the process, so a child would join a node whose env the operator
625
+ // never chose. `--target devnet` is unaffected. Placed after identity resolution, before the
626
+ // claimed short-circuit, so a local resume is refused too.
627
+ if (plan.target === "local") {
628
+ const refused = localTargetRefusal(fs, dir, plan.cwd);
629
+ if (refused !== null) {
630
+ return {
631
+ kind: "partial",
632
+ what: "state",
633
+ cause: `${refused} exists; the chain reads it into the process env — remove it or bootstrap from a clean directory`,
634
+ };
635
+ }
636
+ }
637
+ // Step 1: claimed short-circuit (before any client construction).
638
+ if (identitySource === "explicit" || identitySource === "seed") {
639
+ if (fs.exists(paths.claimed))
640
+ return { kind: "already_bootstrapped", identityName };
641
+ }
642
+ else {
643
+ // derived — the ONLY derived case: resolve against the dir's claimed identities.
644
+ const claimed = listClaimed(fs, dir);
645
+ if (claimed.length === 1)
646
+ return { kind: "already_bootstrapped", identityName: claimed[0] };
647
+ if (claimed.length > 1) {
648
+ return { kind: "partial", what: "identity", cause: "several bootstrapped identities; pass --identity <name>" };
649
+ }
650
+ }
651
+ // Step 2: resume — a seed exists for <identityUsed>.
652
+ if (fs.exists(paths.seed)) {
653
+ return resumePath(full, plan, paths, identityName);
654
+ }
655
+ // Step 3: no seed; pre-existing state refusal (stated rule) checks the SAME dir.
656
+ const preKey = fs.exists(paths.key);
657
+ const preConfig = fs.exists(paths.config);
658
+ if (preKey || preConfig) {
659
+ if (!plan.force) {
660
+ const which = preKey ? paths.key : paths.config;
661
+ return { kind: "partial", what: "state", cause: `${which} exists; refusing to overwrite — pass --force to mint a new identity` };
662
+ }
663
+ // --force: fall through to fresh mint (the matching identity overwrites atomically; a
664
+ // different-name --force keeps the old <other>.* untouched).
665
+ }
666
+ // Step 4: fresh pre-check on the chain.
667
+ const preCheckBase = full.getIdentityBaseUrl ? full.getIdentityBaseUrl() : undefined;
668
+ const preCheckClient = makeClient(full, preCheckBase ?? plan.faucetUrl);
669
+ try {
670
+ const existing = await preCheckClient.getIdentity(identityName);
671
+ if (existing !== null) {
672
+ return { kind: "partial", what: "identity", cause: `${identityName} is already on this chain; a bootstrap never adopts keys it did not mint — pick another --identity` };
673
+ }
674
+ }
675
+ catch (e) {
676
+ return { kind: "unreachable", chain: preCheckBase ?? plan.faucetUrl, cause: e instanceof Error ? e.message : String(e) };
677
+ }
678
+ // Step 5: mint (via the injected hook — the resume path never reaches here, T-07-13).
679
+ const gen = generateBootstrapIdentity(identityName, full.mintIdentity);
680
+ if (gen.kind === "err")
681
+ return gen.error;
682
+ const seed = {
683
+ identity: identityName,
684
+ blsPubBase64: Buffer.from(gen.value.identity.bls.pub).toString("base64"),
685
+ edPubBase64: Buffer.from(gen.value.identity.ed.pub).toString("base64"),
686
+ blsPrivBase64: Buffer.from(gen.value.identity.bls.priv).toString("base64"),
687
+ edPrivBase64: Buffer.from(gen.value.identity.ed.priv).toString("base64"),
688
+ createdAt: full.now().toISOString(),
689
+ };
690
+ // Step 5b: persist BEFORE claim (T-07-08).
691
+ const writeErr = writeState(fs, paths, configFor({ name: identityName, chainUrl: plan.chainUrl }), seedFor(seed), keyFileFor(gen.value.identity));
692
+ if (writeErr)
693
+ return writeErr;
694
+ // Step 6: claim with bounded retries.
695
+ const client = makeClient(full, plan.faucetUrl);
696
+ let attempt;
697
+ try {
698
+ attempt = await claimWithRetries(client, gen.value, plan.retryMax, full.sleep);
699
+ }
700
+ catch (e) {
701
+ return { kind: "unreachable", chain: plan.faucetUrl, cause: e instanceof Error ? e.message : String(e) };
702
+ }
703
+ // Step 7: outcome mapping.
704
+ return mapClaimResult(full, plan, paths, identityName, gen.value.identity, attempt.result);
705
+ }
706
+ function resolveIdentityStep0(fs, dir, argvIdentity, provisionalName) {
707
+ const seeds = listSeeds(fs, dir);
708
+ if (seeds.length > 1) {
709
+ return { ok: false, error: { kind: "partial", what: "identity", cause: `several pending identities (${seeds.join(", ")}); pass --identity <name>` } };
710
+ }
711
+ if (seeds.length === 1) {
712
+ const seedName = seeds[0];
713
+ if (argvIdentity === undefined) {
714
+ return { ok: true, value: { identityName: seedName, identitySource: "seed", explicitName: false } };
715
+ }
716
+ const explicitName = argvIdentity === seedName ? true : argvIdentity;
717
+ return { ok: true, value: { identityName: seedName, identitySource: "seed", explicitName } };
718
+ }
719
+ // No seed.
720
+ if (argvIdentity !== undefined) {
721
+ return { ok: true, value: { identityName: argvIdentity, identitySource: "explicit", explicitName: true } };
722
+ }
723
+ return { ok: true, value: { identityName: provisionalName, identitySource: "derived", explicitName: false } };
724
+ }
725
+ /** Step 2: resume a seeded identity with its OWN keys (never mint; never a fresh path). */
726
+ async function resumePath(full, plan, paths, identityName) {
727
+ const r = readSeed(full.fs, paths.seed);
728
+ if (!r.ok)
729
+ return { kind: "partial", what: "state", cause: r.reason };
730
+ const identity = identityFromSeed(r.seed);
731
+ // Recompute PoP from the SAME seed bytes (no mint). The mint hook is NOT called here.
732
+ const gen = generateBootstrapIdentity(identity.nodeId, () => identity);
733
+ if (gen.kind === "err")
734
+ return gen.error;
735
+ // Rewrite identically (restores 0600/0700), persist-before-claim.
736
+ const writeErr = writeState(full.fs, paths, configFor({ name: identity.nodeId, chainUrl: plan.chainUrl }), seedFor(r.seed), keyFileFor(identity));
737
+ if (writeErr)
738
+ return writeErr;
739
+ const client = makeClient(full, plan.faucetUrl);
740
+ let attempt;
741
+ try {
742
+ attempt = await claimWithRetries(client, gen.value, plan.retryMax, full.sleep);
743
+ }
744
+ catch (e) {
745
+ return { kind: "unreachable", chain: plan.faucetUrl, cause: e instanceof Error ? e.message : String(e) };
746
+ }
747
+ return mapClaimResult(full, plan, paths, identity.nodeId, gen.value.identity, attempt.result);
748
+ }
749
+ /** Step 7: map a claim result to an outcome, and write the marker only on a verified success.
750
+ * `identity` is the identity ACTUALLY in play — the seed's identity on a resume, the minted
751
+ * identity on a fresh run — so the adoption comparison is always against OUR OWN keys. */
752
+ async function mapClaimResult(full, plan, paths, identityName, identity, result) {
753
+ if (result.kind === "committed") {
754
+ // chain-verify with getIdentity (throw -> unreachable, null -> partial committing not visible).
755
+ const verifyBase = full.getIdentityBaseUrl ? full.getIdentityBaseUrl() : undefined;
756
+ const verifyClient = makeClient(full, verifyBase ?? plan.faucetUrl);
757
+ let node;
758
+ try {
759
+ node = await verifyClient.getIdentity(identityName);
760
+ }
761
+ catch (e) {
762
+ return { kind: "unreachable", chain: verifyBase ?? plan.faucetUrl, cause: e instanceof Error ? e.message : String(e) };
763
+ }
764
+ if (node === null) {
765
+ return { kind: "partial", what: "claim", cause: "committed but the node does not list it yet" };
766
+ }
767
+ const markerErr = writeClaimedMarker(full.fs, paths.claimed, {
768
+ identity: identityName,
769
+ outcome: "committed",
770
+ blockHeight: result.blockHeight,
771
+ claimedAt: full.now().toISOString(),
772
+ });
773
+ if (markerErr)
774
+ return markerErr;
775
+ const regErr = writeRegistration(full.fs, paths, plan, identityName);
776
+ if (regErr)
777
+ return regErr;
778
+ return { kind: "committed", blockHeight: result.blockHeight, identityName };
779
+ }
780
+ if (result.kind === "exists") {
781
+ // Adoption comparison against OUR identity's own keys (the minted or seeded identity, never
782
+ // keys read from the chain to decide "ours").
783
+ const adoptClient = makeClient(full, plan.faucetUrl);
784
+ const adoption = await adoptExisting(adoptClient, identityName, identity);
785
+ if (!adoption.ok) {
786
+ if (adoption.transport) {
787
+ return { kind: "unreachable", chain: plan.faucetUrl, cause: adoption.cause };
788
+ }
789
+ // A foreign-name conflict is a partial-bootstrap (keys already persisted), so it exits 1 —
790
+ // distinct from the pre-check `partial("identity")` argument error which exits 2.
791
+ return { kind: "partial", what: "claim", cause: adoption.cause };
792
+ }
793
+ const markerErr = writeClaimedMarker(full.fs, paths.claimed, {
794
+ identity: identityName,
795
+ outcome: "exists",
796
+ claimedAt: full.now().toISOString(),
797
+ });
798
+ if (markerErr)
799
+ return markerErr;
800
+ const regErr = writeRegistration(full.fs, paths, plan, identityName);
801
+ if (regErr)
802
+ return regErr;
803
+ return { kind: "exists", outcome: "exists", identityName };
804
+ }
805
+ // failed: verbatim `${error}: ${message}` (digest_mismatch included, never reworded).
806
+ return { kind: "partial", what: "claim", cause: `${result.error}: ${result.message}` };
807
+ }
808
+ function makeClient(full, baseUrl) {
809
+ return full.clientFactory(baseUrl);
810
+ }
811
+ /** Write `<stateDir>/mcp.json` (0600, tmp+rename, symlink refused) from the RESOLVED paths — the
812
+ * `identityName` actually in play, so a resume under a seed `<Y>` names `<Y>.key`, never the argv
813
+ * `<X>`. Shares `deriveRegistration` with the printed lines so the file and the command cannot
814
+ * diverge (T-07-22). */
815
+ function writeRegistration(fs, paths, plan, identityName) {
816
+ const resolved = {
817
+ paths: { ...paths, key: join(paths.dir, `${identityName}.key`) },
818
+ registrationEnv: {
819
+ EVOLVER_KEY_PATH: join(paths.dir, `${identityName}.key`),
820
+ EVOLVER_KEY_TYPE: "bls",
821
+ EVOLVER_CONTENT_URL: plan.contentUrl,
822
+ },
823
+ };
824
+ const content = JSON.stringify(deriveRegistration(resolved, plan.entryCommand), null, 2) + "\n";
825
+ return writeOne(fs, paths.registration, content);
826
+ }
827
+ // ── runBootstrap (BOOT-07): the CLI shell — performs NO termination and NO cwd read ─
828
+ export async function runBootstrap(argv, env, deps = {}) {
829
+ const cwd = cwdFor(env);
830
+ // Re-derive the force flag + identity resolution from the state dir in executeBootstrap; plan
831
+ // carries the argv record. The faucet seam redirects BOTH the pre-check and the claim.
832
+ const planned = planBootstrap(env, argv, cwd, deps);
833
+ if (!planned.ok) {
834
+ return { exitCode: exitCodeFor(planned.error), output: renderOutcome(planned.error) + "\n", toStderr: true };
835
+ }
836
+ const plan = planned.plan;
837
+ const faucetEnv = optionalEnv(env, "EVOLVER_BOOTSTRAP_FAUCET_URL");
838
+ const execDeps = faucetEnv !== undefined ? { ...deps, getIdentityBaseUrl: () => faucetEnv } : deps;
839
+ const outcome = await executeBootstrap(plan, execDeps);
840
+ const code = exitCodeFor(outcome);
841
+ const lines = [];
842
+ if (outcome.kind === "committed" || outcome.kind === "exists" || outcome.kind === "already_bootstrapped") {
843
+ // Render from the RESOLVED identity (.key actually on disk), not the argv-level plan, so the
844
+ // printed `key:`/`identity:`/`claude mcp add` lines cannot diverge from the written mcp.json
845
+ // under a different-name resume (--identity X resolving to seed <Y>) (T-07-22 / CR-01).
846
+ const resolvedPaths = statePaths(plan.paths.dir, outcome.identityName);
847
+ const resolvedPlan = {
848
+ name: outcome.identityName,
849
+ paths: resolvedPaths,
850
+ registrationEnv: {
851
+ EVOLVER_KEY_PATH: resolvedPaths.key,
852
+ EVOLVER_KEY_TYPE: "bls",
853
+ EVOLVER_CONTENT_URL: plan.contentUrl,
854
+ },
855
+ };
856
+ if (outcome.kind === "committed") {
857
+ lines.push(`bootstrap committed (block ${outcome.blockHeight})`);
858
+ }
859
+ else if (outcome.kind === "exists") {
860
+ lines.push("bootstrap exists");
861
+ }
862
+ else {
863
+ lines.push("bootstrap already_bootstrapped");
864
+ }
865
+ lines.push(` state dir: ${resolvedPaths.dir}`);
866
+ lines.push(` config: ${resolvedPaths.config}`);
867
+ lines.push(` key: ${resolvedPaths.key}`);
868
+ lines.push(` identity: ${resolvedPlan.name}`);
869
+ lines.push(plan.contentWarning);
870
+ lines.push(...registrationLines(resolvedPlan, plan.entryCommand));
871
+ return { exitCode: 0, output: lines.join("\n") + "\n", toStderr: false };
872
+ }
873
+ return { exitCode: code, output: renderOutcome(outcome) + "\n", toStderr: true };
874
+ }
875
+ function cwdFor(env) {
876
+ const v = env.EVOLVER_BOOTSTRAP_CWD;
877
+ return typeof v === "string" && v.length > 0 ? v : "/";
878
+ }
879
+ function renderOutcome(o) {
880
+ switch (o.kind) {
881
+ case "partial":
882
+ return `bootstrap refused: ${o.what}: ${o.cause}`;
883
+ case "unreachable":
884
+ return `bootstrap unreachable: ${o.chain}: ${o.cause}`;
885
+ case "committed":
886
+ return `bootstrap committed (block ${o.blockHeight})`;
887
+ case "exists":
888
+ return "bootstrap exists";
889
+ case "already_bootstrapped":
890
+ return "bootstrap already_bootstrapped";
891
+ }
892
+ }
893
+ // ── hasBootstrapFlag (used by src/index.ts) ──────────────────────────────────
894
+ export function hasBootstrapFlag(args) {
895
+ for (let i = 2; i < args.length; i++) {
896
+ if (args[i] === BOOTSTRAP_FLAG)
897
+ return true;
898
+ }
899
+ return false;
900
+ }
901
+ //# sourceMappingURL=bootstrap.js.map