@toon-protocol/hub 0.34.3

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/dist/cli.js ADDED
@@ -0,0 +1,4809 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'module'; const require = createRequire(import.meta.url);
3
+ import {
4
+ BootReconciler,
5
+ ConnectorAdminClient,
6
+ DEFAULT_ATOR_PROXY,
7
+ DockerOrchestrator,
8
+ LOG_SERVICES,
9
+ OrchestratorError,
10
+ PeerTypeResolver,
11
+ TransportProbe,
12
+ WalletManager,
13
+ aggregateEarnings,
14
+ assembleNodeEnv,
15
+ createApiServer,
16
+ createDeltaComputer,
17
+ createWizardApiServer,
18
+ decryptWallet,
19
+ detectExistingHsConfig,
20
+ encryptWallet,
21
+ getDefaultConfig,
22
+ isSyntheticDigest,
23
+ listSupportedSettlementAssets,
24
+ loadConfig,
25
+ loadWallet,
26
+ materializeComposeTemplate,
27
+ readImageManifest,
28
+ readNodesYaml,
29
+ resolveMillRelays,
30
+ resolvePublicBtpUrl,
31
+ resolveRelayUrl,
32
+ saveConfig,
33
+ saveWallet,
34
+ serviceFromContainerName,
35
+ tailContainerLogs,
36
+ writeDirectConnectorConfig,
37
+ writeHsConnectorConfig,
38
+ writeHsNodeEnvFile
39
+ } from "./chunk-L2U4G4OK.js";
40
+ import {
41
+ bytesToHex
42
+ } from "./chunk-5O4SBV5O.js";
43
+ import {
44
+ CONTAINER_PREFIX
45
+ } from "./chunk-MNVIN5XK.js";
46
+ import {
47
+ formatRelativeTime,
48
+ formatUsdc
49
+ } from "./chunk-JCOFMUPL.js";
50
+ import "./chunk-I2R4CRUX.js";
51
+
52
+ // src/cli.ts
53
+ import { parseArgs } from "util";
54
+ import {
55
+ mkdirSync,
56
+ writeFileSync,
57
+ readFileSync,
58
+ existsSync,
59
+ renameSync,
60
+ rmSync,
61
+ statSync,
62
+ realpathSync
63
+ } from "fs";
64
+ import { join, resolve, dirname } from "path";
65
+ import { homedir } from "os";
66
+ import { pathToFileURL } from "url";
67
+ import { spawn as spawn2 } from "child_process";
68
+ import { stringify } from "yaml";
69
+ import Docker2 from "dockerode";
70
+ import { nip19 } from "nostr-tools";
71
+
72
+ // src/rebind.ts
73
+ async function rebindChildContainers(deps) {
74
+ const log = deps.log ?? (() => void 0);
75
+ const summary = { started: [], skipped: [], failed: [] };
76
+ const yaml = await readNodesYaml(deps.nodesYamlPath);
77
+ if (yaml.entries.length === 0) return summary;
78
+ const mnemonic = deps.wallet.getMnemonic();
79
+ if (mnemonic === null) {
80
+ for (const entry of yaml.entries) {
81
+ summary.skipped.push({ id: entry.id, reason: "wallet locked" });
82
+ }
83
+ log(
84
+ `wallet locked \u2014 skipped rebinding ${yaml.entries.length} child node(s)`
85
+ );
86
+ return summary;
87
+ }
88
+ const apexEvmAddress = deps.wallet.getNodeKeys("town").evmAddress;
89
+ for (const entry of yaml.entries) {
90
+ const type = entry.type;
91
+ try {
92
+ if (type === "mill" && resolveMillRelays(void 0, deps.config).length === 0) {
93
+ summary.skipped.push({
94
+ id: entry.id,
95
+ reason: "no relays in config.yaml or MILL_RELAYS \u2014 rerun `townhouse node add mill --relays \u2026`"
96
+ });
97
+ continue;
98
+ }
99
+ const keys = await deps.wallet.deriveNodeKey(type, entry.derivationIndex);
100
+ const env = assembleNodeEnv({
101
+ type,
102
+ nostrSecretKeyHex: bytesToHex(keys.nostrSecretKey),
103
+ nostrPubkey: keys.nostrPubkey,
104
+ evmPrivateKeyHex: bytesToHex(keys.evmPrivateKey),
105
+ mnemonic,
106
+ apexEvmAddress,
107
+ config: deps.config,
108
+ publicBtpUrl: deps.publicBtpUrl,
109
+ relayUrl: deps.relayUrl
110
+ });
111
+ await deps.orchestrator.startNodeViaCompose(type, env);
112
+ summary.started.push(entry.id);
113
+ } catch (err) {
114
+ summary.failed.push({
115
+ id: entry.id,
116
+ err: err instanceof Error ? err.message : String(err)
117
+ });
118
+ }
119
+ }
120
+ if (summary.started.length > 0) {
121
+ log(`rebound ${summary.started.length} child container(s) from nodes.yaml`);
122
+ }
123
+ return summary;
124
+ }
125
+
126
+ // src/cli/browser-opener.ts
127
+ import { spawn } from "child_process";
128
+ var RealBrowserOpener = class {
129
+ async open(url) {
130
+ let cmd;
131
+ let args;
132
+ switch (process.platform) {
133
+ case "darwin":
134
+ cmd = "open";
135
+ args = [url];
136
+ break;
137
+ case "win32":
138
+ cmd = "cmd";
139
+ args = ["/c", "start", "", url];
140
+ break;
141
+ default:
142
+ cmd = "xdg-open";
143
+ args = [url];
144
+ break;
145
+ }
146
+ return new Promise((resolve2) => {
147
+ let settled = false;
148
+ const settle = () => {
149
+ if (settled) return;
150
+ settled = true;
151
+ resolve2();
152
+ };
153
+ try {
154
+ const child = spawn(cmd, args, {
155
+ stdio: ["ignore", "ignore", "ignore"],
156
+ detached: true
157
+ });
158
+ child.once("error", (err) => {
159
+ console.warn(
160
+ `[Townhouse] Could not open browser via ${cmd}: ${err.message}`
161
+ );
162
+ settle();
163
+ });
164
+ child.once("spawn", () => {
165
+ child.unref();
166
+ settle();
167
+ });
168
+ } catch (err) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ console.warn(`[Townhouse] Could not open browser: ${msg}`);
171
+ settle();
172
+ }
173
+ });
174
+ }
175
+ };
176
+
177
+ // src/cli/onboarding-ribbon.ts
178
+ var PHASES = {
179
+ pull: "Pulling apex image\u2026",
180
+ bootstrap: "Bootstrapping hidden service (this takes 30\u201390s)\u2026"
181
+ };
182
+ var SPINNER_FRAMES = ["|", "/", "-", "\\"];
183
+ function isTty() {
184
+ return process.stdout.isTTY === true;
185
+ }
186
+ function supportsUnicode() {
187
+ const term = process.env["TERM"] ?? "";
188
+ if (term === "dumb") return false;
189
+ if (/xterm|screen|tmux/i.test(term)) return true;
190
+ if (process.env["COLORTERM"] !== void 0) return true;
191
+ return false;
192
+ }
193
+ function isAnimationDisabled() {
194
+ if (process.env["NO_COLOR"] !== void 0 && process.env["NO_COLOR"] !== "")
195
+ return true;
196
+ if (process.env["CI"] === "true") return true;
197
+ return false;
198
+ }
199
+ function useAnsiRewrite() {
200
+ return isTty() && supportsUnicode() && !isAnimationDisabled();
201
+ }
202
+ var OnboardingRibbon = class {
203
+ currentPhase = null;
204
+ spinnerTimer = null;
205
+ spinnerFrame = 0;
206
+ hasWrittenLine = false;
207
+ start(phase, detail) {
208
+ this._stopSpinner();
209
+ if (phase === "live") {
210
+ const line = detail ? `Apex live at ${detail}` : "Apex live.";
211
+ this._writeLine(line);
212
+ this.currentPhase = "live";
213
+ return;
214
+ }
215
+ const text = PHASES[phase];
216
+ if (useAnsiRewrite() && this.hasWrittenLine) {
217
+ process.stdout.write("\x1B[1A\x1B[2K");
218
+ }
219
+ if (isAnimationDisabled() || !isTty()) {
220
+ this._writeLine(text);
221
+ } else {
222
+ this._writeLine(`${text} ${SPINNER_FRAMES[0]}`);
223
+ this.spinnerFrame = 1;
224
+ this.spinnerTimer = setInterval(() => {
225
+ const idx = this.spinnerFrame % SPINNER_FRAMES.length;
226
+ const frame = SPINNER_FRAMES[idx] ?? "|";
227
+ this.spinnerFrame++;
228
+ if (useAnsiRewrite()) {
229
+ process.stdout.write("\x1B[1A\x1B[2K");
230
+ process.stdout.write(`${text} ${frame}
231
+ `);
232
+ } else {
233
+ process.stdout.write(`${text} ${frame}
234
+ `);
235
+ }
236
+ }, 100);
237
+ }
238
+ this.currentPhase = phase;
239
+ }
240
+ stop() {
241
+ this._stopSpinner();
242
+ }
243
+ _stopSpinner() {
244
+ if (this.spinnerTimer !== null) {
245
+ clearInterval(this.spinnerTimer);
246
+ this.spinnerTimer = null;
247
+ }
248
+ }
249
+ _writeLine(text) {
250
+ process.stdout.write(`${text}
251
+ `);
252
+ this.hasWrittenLine = true;
253
+ }
254
+ };
255
+
256
+ // src/cli/failure-copy.ts
257
+ var FAILURE_COPY = {
258
+ "anon-timeout": {
259
+ headline: "Hidden service didn't publish in time.",
260
+ explanation: "The .anyone descriptor did not publish within the allotted time.",
261
+ nextStep: "Re-run with DEBUG=townhouse:* for verbose anon logs."
262
+ },
263
+ "anon-disabled": {
264
+ headline: "Connector is anon-disabled.",
265
+ explanation: "The connector config has anon.enabled: false.",
266
+ nextStep: "Edit ~/.townhouse/connector.yaml and set anon.enabled: true."
267
+ },
268
+ "image-pull-failure": {
269
+ headline: "Image pull failed.",
270
+ explanation: "Docker could not pull the required townhouse images.",
271
+ nextStep: "Check your network and try again."
272
+ },
273
+ "port-collision": {
274
+ headline: "Port already in use.",
275
+ explanation: "A required host port is already bound by another process.",
276
+ nextStep: "Stop the conflicting service or override the port via --connector-admin-port."
277
+ },
278
+ "missing-docker-sock": {
279
+ headline: "Docker daemon unreachable.",
280
+ explanation: "The Docker socket is not accessible or Docker is not running.",
281
+ nextStep: "Start Docker and re-run `townhouse hs up`."
282
+ },
283
+ generic: {
284
+ headline: "Apex boot failed.",
285
+ explanation: "",
286
+ nextStep: "Run with DEBUG=townhouse:* for verbose logs."
287
+ }
288
+ };
289
+ function supportsUnicode2() {
290
+ const term = process.env["TERM"] ?? "";
291
+ if (term === "dumb") return false;
292
+ if (/xterm|screen|tmux/i.test(term)) return true;
293
+ if (process.env["COLORTERM"] !== void 0) return true;
294
+ return false;
295
+ }
296
+ function useAscii() {
297
+ if (process.env["NO_COLOR"] !== void 0 && process.env["NO_COLOR"] !== "")
298
+ return true;
299
+ return !supportsUnicode2();
300
+ }
301
+ function classify(error) {
302
+ const msg = error instanceof Error ? error.message : String(error);
303
+ const isOrchError = error instanceof OrchestratorError;
304
+ const stderr = isOrchError ? error.stderr ?? "" : "";
305
+ if (msg.includes("HS hostname publication timeout")) {
306
+ return { key: "anon-timeout", explanation: msg };
307
+ }
308
+ if (isOrchError && msg.includes("anon-disabled")) {
309
+ return { key: "anon-timeout", explanation: msg };
310
+ }
311
+ if (!isOrchError && msg.includes("anon-disabled")) {
312
+ return { key: "anon-disabled", explanation: msg };
313
+ }
314
+ if (stderr.includes("failed to pull") || stderr.includes("pull access denied") || msg.includes("failed to pull") || msg.includes("pull access denied")) {
315
+ return { key: "image-pull-failure", explanation: msg };
316
+ }
317
+ if (stderr.includes("address already in use") || stderr.includes("port is already allocated") || msg.includes("address already in use") || msg.includes("port is already allocated")) {
318
+ return { key: "port-collision", explanation: msg };
319
+ }
320
+ if (stderr.includes("Cannot connect to the Docker daemon") || msg.includes("Cannot connect to the Docker daemon") || msg.includes("docker CLI not found on PATH")) {
321
+ return { key: "missing-docker-sock", explanation: msg };
322
+ }
323
+ return { key: "generic", explanation: msg };
324
+ }
325
+ function renderFailure(error) {
326
+ const ascii = useAscii();
327
+ const { key, explanation } = classify(error);
328
+ const entry = FAILURE_COPY[key];
329
+ if (!entry) {
330
+ const xMark2 = ascii ? "[X]" : "\u2715";
331
+ const arrow2 = ascii ? "->" : "\u2192";
332
+ process.stderr.write(`${xMark2} Apex boot failed.
333
+ `);
334
+ process.stderr.write(` ${explanation}
335
+ `);
336
+ process.stderr.write(
337
+ ` ${arrow2} Run with DEBUG=townhouse:* for verbose logs.
338
+ `
339
+ );
340
+ return { exitCode: 1 };
341
+ }
342
+ const xMark = ascii ? "[X]" : "\u2715";
343
+ const arrow = ascii ? "->" : "\u2192";
344
+ const explanationText = key === "generic" ? explanation : entry.explanation;
345
+ process.stderr.write(`${xMark} ${entry.headline}
346
+ `);
347
+ process.stderr.write(` ${explanationText}
348
+ `);
349
+ process.stderr.write(` ${arrow} ${entry.nextStep}
350
+ `);
351
+ return { exitCode: 1 };
352
+ }
353
+
354
+ // src/cli/password-prompt.ts
355
+ import { createInterface } from "readline";
356
+ function promptPassword(prompt = "Wallet password: ") {
357
+ return new Promise((resolve2, reject) => {
358
+ const rl = createInterface({
359
+ input: process.stdin,
360
+ output: process.stdout,
361
+ terminal: true
362
+ });
363
+ const iface = rl;
364
+ const origWrite = iface._writeToOutput.bind(iface);
365
+ iface._writeToOutput = (str) => {
366
+ if (str === "\r\n" || str === "\n" || str === "\r") {
367
+ origWrite(str);
368
+ } else if (/^[\x20-\x7e€-￿]/.test(str)) {
369
+ origWrite("*".repeat(str.length));
370
+ } else {
371
+ origWrite(str);
372
+ }
373
+ };
374
+ rl.question(prompt, (answer) => {
375
+ iface._writeToOutput = origWrite;
376
+ process.stdout.write("\n");
377
+ rl.close();
378
+ resolve2(answer);
379
+ });
380
+ rl.once("error", (err) => {
381
+ iface._writeToOutput = origWrite;
382
+ rl.close();
383
+ reject(err);
384
+ });
385
+ rl.once("close", () => {
386
+ });
387
+ });
388
+ }
389
+
390
+ // src/cli/preflight-ports.ts
391
+ import { createServer } from "net";
392
+ var HS_CANONICAL_PORTS = [
393
+ 9401,
394
+ 28090,
395
+ 7100,
396
+ 3100,
397
+ 3200,
398
+ 3400
399
+ ];
400
+ var DIRECT_CANONICAL_PORTS = [
401
+ 3e3,
402
+ 9401,
403
+ 28090,
404
+ 7100,
405
+ 3100,
406
+ 3200,
407
+ 3400
408
+ ];
409
+ async function isPortInUse(port) {
410
+ return new Promise((resolve2, reject) => {
411
+ const server = createServer();
412
+ let settled = false;
413
+ const finalize = (result) => {
414
+ if (settled) return;
415
+ settled = true;
416
+ server.removeAllListeners("error");
417
+ server.removeAllListeners("listening");
418
+ try {
419
+ server.close();
420
+ } catch {
421
+ }
422
+ if (result instanceof Error) reject(result);
423
+ else resolve2(result);
424
+ };
425
+ server.once("error", (err) => {
426
+ if (err.code === "EADDRINUSE") {
427
+ finalize(true);
428
+ } else {
429
+ finalize(err);
430
+ }
431
+ });
432
+ server.once("listening", () => {
433
+ const addr = server.address();
434
+ void addr;
435
+ finalize(false);
436
+ });
437
+ try {
438
+ server.listen({ port, host: "127.0.0.1", exclusive: true });
439
+ } catch (err) {
440
+ finalize(err);
441
+ }
442
+ });
443
+ }
444
+ function findDockerCulprit(containers, port) {
445
+ for (const c of containers) {
446
+ const ports = c.Ports ?? [];
447
+ for (const p of ports) {
448
+ if (p.PublicPort === port) {
449
+ const rawName = c.Names?.[0] ?? "";
450
+ const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
451
+ const project = c.Labels?.["com.docker.compose.project"];
452
+ return {
453
+ containerName: name || void 0,
454
+ composeProject: project,
455
+ status: c.Status
456
+ };
457
+ }
458
+ }
459
+ }
460
+ return void 0;
461
+ }
462
+ async function checkHsPortCollisions(docker, ports = HS_CANONICAL_PORTS) {
463
+ const probes = await Promise.all(
464
+ ports.map(async (port) => {
465
+ try {
466
+ const inUse = await isPortInUse(port);
467
+ return { port, inUse, probeError: void 0 };
468
+ } catch (err) {
469
+ return {
470
+ port,
471
+ inUse: true,
472
+ probeError: err instanceof Error ? err : new Error(String(err))
473
+ };
474
+ }
475
+ })
476
+ );
477
+ const taken = probes.filter((p) => p.inUse);
478
+ if (taken.length === 0) return [];
479
+ let containers = [];
480
+ if (docker) {
481
+ try {
482
+ containers = await docker.listContainers({ all: false });
483
+ } catch {
484
+ containers = [];
485
+ }
486
+ }
487
+ return taken.map((t) => {
488
+ const culprit = findDockerCulprit(containers, t.port);
489
+ return {
490
+ port: t.port,
491
+ ...culprit ?? {}
492
+ };
493
+ });
494
+ }
495
+ async function checkDirectPortCollisions(docker, ports = DIRECT_CANONICAL_PORTS) {
496
+ return checkHsPortCollisions(docker, ports);
497
+ }
498
+ function formatCollisionMessage(collisions) {
499
+ if (collisions.length === 0) return "";
500
+ const lines = [];
501
+ lines.push("townhouse hs up: cannot start \u2014 host ports already in use:");
502
+ lines.push("");
503
+ for (const c of collisions) {
504
+ const portLabel = `127.0.0.1:${c.port}`.padEnd(18);
505
+ if (c.containerName) {
506
+ lines.push(` ${portLabel}in use by container '${c.containerName}'`);
507
+ const project = c.composeProject ?? "<no compose project>";
508
+ const status = c.status ? `, ${c.status}` : "";
509
+ lines.push(` ${" ".repeat(18)}(compose project '${project}'${status})`);
510
+ } else {
511
+ lines.push(
512
+ ` ${portLabel}port in use (no Docker container found \u2014 try \`sudo lsof -iTCP:${c.port} -sTCP:LISTEN\`)`
513
+ );
514
+ }
515
+ }
516
+ lines.push("");
517
+ lines.push("The HS template needs canonical ports \u2014 it cannot remap.");
518
+ const projects = /* @__PURE__ */ new Set();
519
+ for (const c of collisions) {
520
+ if (c.composeProject) projects.add(c.composeProject);
521
+ }
522
+ if (projects.size > 0) {
523
+ lines.push("Stop the conflicting project to free them:");
524
+ lines.push("");
525
+ for (const project of projects) {
526
+ lines.push(` docker compose -p ${project} down`);
527
+ }
528
+ lines.push("");
529
+ lines.push(
530
+ "Or, if the conflicting process is NOT a townhouse stack, identify it with:"
531
+ );
532
+ } else {
533
+ lines.push("Identify the conflicting processes with:");
534
+ }
535
+ lines.push("");
536
+ const examplePort = collisions[0]?.port ?? 9401;
537
+ lines.push(` sudo lsof -iTCP:${examplePort} -sTCP:LISTEN`);
538
+ lines.push("");
539
+ lines.push("Re-run with --skip-preflight to bypass this check.");
540
+ return lines.join("\n") + "\n";
541
+ }
542
+
543
+ // src/cli/pull-narrator.ts
544
+ var THROTTLED_STATUSES = /* @__PURE__ */ new Set(["Downloading", "Extracting"]);
545
+ var PullNarrator = class {
546
+ now;
547
+ throttleMs;
548
+ perImage = /* @__PURE__ */ new Map();
549
+ constructor(options = {}) {
550
+ this.now = options.now ?? Date.now;
551
+ this.throttleMs = options.throttleMs ?? 1e3;
552
+ }
553
+ /**
554
+ * Render an event to a stdout-ready line, or `null` if it should be
555
+ * suppressed by the throttle.
556
+ */
557
+ format(event) {
558
+ const status = event.status;
559
+ if (!status) {
560
+ return null;
561
+ }
562
+ const state = this.perImage.get(event.image) ?? {
563
+ lastStatus: void 0,
564
+ lastThrottledAtMs: 0
565
+ };
566
+ const isThrottled = THROTTLED_STATUSES.has(status);
567
+ const isTransition = state.lastStatus !== status;
568
+ if (isThrottled && !isTransition) {
569
+ const elapsed = this.now() - state.lastThrottledAtMs;
570
+ if (elapsed < this.throttleMs) {
571
+ return null;
572
+ }
573
+ }
574
+ state.lastStatus = status;
575
+ if (isThrottled) {
576
+ state.lastThrottledAtMs = this.now();
577
+ }
578
+ this.perImage.set(event.image, state);
579
+ const progress = event.progress ? ` ${event.progress}` : "";
580
+ return ` [pull] ${event.image}: ${status}${progress}`;
581
+ }
582
+ /**
583
+ * Reset the narrator's per-image state. Useful between separate pull
584
+ * batches in the same process.
585
+ */
586
+ reset() {
587
+ this.perImage.clear();
588
+ }
589
+ };
590
+
591
+ // src/cli/node-commands.ts
592
+ import * as readline from "readline";
593
+ var DEFAULT_HS_API_URL = "http://127.0.0.1:28090";
594
+ var STEP_TO_STAGE = {
595
+ preflight: "Preflight",
596
+ "derive-key": "Deriving wallet",
597
+ "pull-image": "Pulling image",
598
+ "write-yaml": "Deriving wallet",
599
+ // same disk-class bucket from operator POV
600
+ "write-mill-config": "Deriving wallet",
601
+ // same disk-class bucket as write-yaml
602
+ "start-container": "Registering with apex",
603
+ healthcheck: "Registering with apex",
604
+ "register-peer": "Live"
605
+ };
606
+ var STAGE_LABELS = [
607
+ "Pulling image",
608
+ "Deriving wallet",
609
+ "Registering with apex",
610
+ "Live"
611
+ ];
612
+ var NODE_ADD_HELP = `townhouse node add \u2014 Provision a child node
613
+
614
+ Usage:
615
+ townhouse node add [<type>] [--relays <urls>] [--turbo-token <jwk>] [--json] [-c <path>]
616
+
617
+ Arguments:
618
+ <type> Node type to provision: town, mill, dvm (default: town)
619
+
620
+ Flags:
621
+ --relays mill only: comma-separated Nostr relay URLs (required for mill
622
+ unless set in config.yaml or the MILL_RELAYS env var)
623
+ --turbo-token dvm only: Arweave Turbo credential (JWK string) enabling
624
+ larger/paid kind:5094 uploads (free-tier <100KB works without)
625
+ --settlement-chain town only: settlement chain advertised in kind:10032; must be
626
+ a supported chain (see 'townhouse chains supported')
627
+ --asset town only: settlement token on that chain \u2014 USDC | ETH | SOL |
628
+ MINA (default USDC where supported, else native)
629
+ --json Machine-readable JSON output
630
+ -c Path to config file
631
+
632
+ Examples:
633
+ townhouse node add # provision a Town relay (default)
634
+ townhouse node add town --settlement-chain evm:base:8453 --asset USDC # price publishes in USDC on Base
635
+ townhouse node add mill --relays wss://relay.damus.io,wss://nos.lol # chain-swap node
636
+ townhouse node add dvm --turbo-token "$(cat arweave.json)" # DVM compute / Arweave node`;
637
+ var NODE_REMOVE_HELP = `townhouse node remove \u2014 Deprovision a child node
638
+
639
+ Usage:
640
+ townhouse node remove <id> [--yes] [--json] [-c <path>]
641
+
642
+ Arguments:
643
+ <id> Node ID to remove (use 'townhouse node list' to find IDs)
644
+
645
+ Flags:
646
+ --yes Skip confirmation prompt (required in non-interactive mode)
647
+ --json Machine-readable JSON output; implies non-interactive (no prompt)
648
+ -c Path to config file`;
649
+ var NODE_LIST_HELP = `townhouse node list \u2014 List provisioned nodes
650
+
651
+ Usage:
652
+ townhouse node list [--json] [-c <path>]
653
+
654
+ Flags:
655
+ --json Machine-readable JSON output (emits API response verbatim)
656
+ -c Path to config file`;
657
+ var NODE_HELP = `townhouse node \u2014 Manage child nodes
658
+
659
+ Usage:
660
+ townhouse node add [<type>] [--relays <urls>] [--turbo-token <jwk>] [--json] [-c <path>] Provision a child node (default: town)
661
+ townhouse node remove <id> [--yes] [--json] [-c <path>] Deprovision a child node
662
+ townhouse node list [--json] [-c <path>] List provisioned nodes
663
+
664
+ Run 'townhouse node <verb> --help' for details on each verb.
665
+
666
+ Tip:
667
+ townhouse node add mill # earn from chain swaps (5x earnings unlock)`;
668
+ function resolveApiUrl(apiUrl) {
669
+ return apiUrl ?? DEFAULT_HS_API_URL;
670
+ }
671
+ function formatRelativeTime2(iso) {
672
+ const ts = new Date(iso).getTime();
673
+ if (Number.isNaN(ts)) return "\u2014";
674
+ const diffMs = Date.now() - ts;
675
+ if (diffMs < 0) return "just now";
676
+ const secs = Math.floor(diffMs / 1e3);
677
+ if (secs < 60) return `${secs}s ago`;
678
+ const mins = Math.floor(secs / 60);
679
+ if (mins < 60) return `${mins}m ago`;
680
+ const hours = Math.floor(mins / 60);
681
+ if (hours < 24) return `${hours}h ago`;
682
+ return `${Math.floor(hours / 24)}d ago`;
683
+ }
684
+ async function confirmInteractive(question) {
685
+ const rl = readline.createInterface({
686
+ input: process.stdin,
687
+ output: process.stdout
688
+ });
689
+ try {
690
+ const answer = await new Promise(
691
+ (resolve2) => rl.question(question, resolve2)
692
+ );
693
+ return /^y(es)?$/i.test(answer.trim());
694
+ } finally {
695
+ rl.close();
696
+ }
697
+ }
698
+ function emitJsonError(obj, exitCode = 1) {
699
+ process.stdout.write(JSON.stringify(obj) + "\n");
700
+ process.exitCode = exitCode;
701
+ }
702
+ async function handleNodeAdd(type, options) {
703
+ const ascii = useAscii();
704
+ const check = ascii ? "[OK]" : "\u2713";
705
+ const xMark = ascii ? "[X]" : "\u2715";
706
+ const dot = ascii ? "." : "\xB7";
707
+ if (type !== "town" && type !== "mill" && type !== "dvm") {
708
+ const msg = `Unknown type: '${type}'. Supported: town, mill, dvm`;
709
+ if (options.json) {
710
+ emitJsonError({ ok: false, error: "invalid_type", message: msg });
711
+ } else {
712
+ process.stderr.write(`${xMark} ${msg}
713
+ `);
714
+ process.exitCode = 1;
715
+ }
716
+ return;
717
+ }
718
+ const url = resolveApiUrl(options.apiUrl);
719
+ const fetchImpl = options.fetch ?? fetch;
720
+ const requestBody = {
721
+ type
722
+ };
723
+ if (type === "mill" && options.relays !== void 0) {
724
+ const relays = options.relays.split(",").map((r) => r.trim()).filter(Boolean);
725
+ if (relays.length > 0) requestBody.relays = relays;
726
+ }
727
+ if (type === "dvm" && options.turboToken) {
728
+ requestBody.turboToken = options.turboToken;
729
+ }
730
+ if (type === "town") {
731
+ if (options.settlementChain) {
732
+ requestBody.settlementChainId = options.settlementChain.trim();
733
+ }
734
+ if (options.asset) requestBody.assetCode = options.asset.trim();
735
+ }
736
+ if (!options.json) {
737
+ process.stdout.write(
738
+ ` ${STAGE_LABELS.map((s) => `${dot} ${s}`).join(" \xB7 ")}
739
+ `
740
+ );
741
+ }
742
+ const controller = new AbortController();
743
+ const timer = setTimeout(() => controller.abort(), 12e4);
744
+ let response;
745
+ try {
746
+ response = await fetchImpl(`${url}/api/nodes`, {
747
+ method: "POST",
748
+ headers: { "Content-Type": "application/json" },
749
+ body: JSON.stringify(requestBody),
750
+ signal: controller.signal
751
+ });
752
+ } catch (err) {
753
+ clearTimeout(timer);
754
+ const isAborted = err instanceof Error && err.name === "AbortError";
755
+ const errMsg = isAborted ? "Request timed out after 120 seconds." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
756
+ if (options.json) {
757
+ emitJsonError({
758
+ ok: false,
759
+ error: isAborted ? "timeout" : "econnrefused",
760
+ message: errMsg
761
+ });
762
+ } else {
763
+ process.stderr.write(`${xMark} ${errMsg}
764
+ `);
765
+ process.exitCode = 1;
766
+ }
767
+ return;
768
+ }
769
+ clearTimeout(timer);
770
+ if (response.status === 201) {
771
+ const body2 = await response.json().catch(() => ({}));
772
+ if (options.json) {
773
+ process.stdout.write(JSON.stringify({ ok: true, ...body2 }) + "\n");
774
+ } else {
775
+ process.stdout.write(
776
+ ` ${STAGE_LABELS.map((s) => `${check} ${s}`).join(" \xB7 ")}
777
+ `
778
+ );
779
+ const addedId = body2.id ?? type;
780
+ const addedPeer = body2.peerId ? ` (${body2.peerId})` : "";
781
+ const addedAddr = body2.ilpAddress ? ` at ${body2.ilpAddress}` : "";
782
+ process.stdout.write(` Added ${addedId}${addedPeer}${addedAddr}
783
+ `);
784
+ }
785
+ return;
786
+ }
787
+ const body = await response.json().catch(() => ({}));
788
+ if (options.json) {
789
+ emitJsonError({ ok: false, ...body });
790
+ return;
791
+ }
792
+ if (response.status === 409 && body.error === "node_type_in_use") {
793
+ const existingId = body.existingId ?? body.type ?? type;
794
+ process.stderr.write(
795
+ `${xMark} A '${body.type}' node already exists (id '${existingId}'). Only one node per type is supported.
796
+ See your nodes: townhouse node list
797
+ Recreate it: townhouse node remove ${existingId} && townhouse node add ${body.type}
798
+ `
799
+ );
800
+ process.exitCode = 1;
801
+ return;
802
+ }
803
+ if (response.status === 409 && body.error === "node_lifecycle_in_flight") {
804
+ process.stderr.write(
805
+ `${xMark} Another node operation is in flight. Try again in a moment.
806
+ `
807
+ );
808
+ process.exitCode = 1;
809
+ return;
810
+ }
811
+ const step = body.step ?? "unknown";
812
+ const errText = body.err ?? "";
813
+ if (step === "pull-image") {
814
+ const syntheticErr = new Error(`failed to pull: ${errText}`);
815
+ renderFailure(syntheticErr);
816
+ } else if (step === "start-container" && (errText.includes("port is already allocated") || errText.includes("Cannot connect to the Docker daemon"))) {
817
+ renderFailure(new Error(errText));
818
+ } else if (step === "preflight") {
819
+ const arrow = ascii ? "->" : "\u2192";
820
+ process.stderr.write(`${xMark} ${errText}
821
+ `);
822
+ process.stderr.write(
823
+ ` ${arrow} Fix the configuration above, then retry 'townhouse node add'.
824
+ `
825
+ );
826
+ } else {
827
+ const stageName = STEP_TO_STAGE[step] ?? step;
828
+ const arrow = ascii ? "->" : "\u2192";
829
+ process.stderr.write(
830
+ `${xMark} Step ${step} failed (stage: ${stageName}): ${errText}
831
+ `
832
+ );
833
+ process.stderr.write(
834
+ ` ${arrow} Run 'townhouse hs down && townhouse hs up' to reset state, then retry.
835
+ `
836
+ );
837
+ }
838
+ if (body.rollbackError) {
839
+ process.stderr.write(` Rollback error: ${body.rollbackError}
840
+ `);
841
+ }
842
+ process.exitCode = 1;
843
+ }
844
+ var NODE_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
845
+ async function handleNodeRemove(id, options) {
846
+ const ascii = useAscii();
847
+ const check = ascii ? "[OK]" : "\u2713";
848
+ const xMark = ascii ? "[X]" : "\u2715";
849
+ if (!id) {
850
+ const msg = "Usage: townhouse node remove <id> [--yes] [--json]";
851
+ if (options.json) {
852
+ emitJsonError({ ok: false, error: "missing_id", message: msg });
853
+ } else {
854
+ process.stderr.write(`${msg}
855
+ `);
856
+ process.exitCode = 1;
857
+ }
858
+ return;
859
+ }
860
+ if (!NODE_ID_PATTERN.test(id)) {
861
+ const msg = `Invalid node id '${id}'. IDs must match ^[a-z][a-z0-9-]*$ (lowercase, no leading hyphens or underscores).`;
862
+ if (options.json) {
863
+ emitJsonError({ ok: false, error: "invalid_id", message: msg });
864
+ } else {
865
+ process.stderr.write(`${xMark} ${msg}
866
+ `);
867
+ process.exitCode = 1;
868
+ }
869
+ return;
870
+ }
871
+ const skipPrompt = options.yes || options.json;
872
+ if (!skipPrompt) {
873
+ if (!process.stdin.isTTY) {
874
+ const msg = "--yes required when stdin is not a TTY (use --yes for non-interactive removal).";
875
+ process.stderr.write(`${xMark} ${msg}
876
+ `);
877
+ process.exitCode = 1;
878
+ return;
879
+ }
880
+ const confirmFn = options.confirm ?? confirmInteractive;
881
+ const confirmed = await confirmFn(
882
+ `Remove node '${id}'? This deprovisions the container and deregisters the peer. [y/N] `
883
+ );
884
+ if (!confirmed) {
885
+ process.stdout.write("Cancelled.\n");
886
+ return;
887
+ }
888
+ }
889
+ const url = resolveApiUrl(options.apiUrl);
890
+ const fetchImpl = options.fetch ?? fetch;
891
+ const controller = new AbortController();
892
+ const timer = setTimeout(() => controller.abort(), 6e4);
893
+ let response;
894
+ try {
895
+ response = await fetchImpl(`${url}/api/nodes/${encodeURIComponent(id)}`, {
896
+ method: "DELETE",
897
+ signal: controller.signal
898
+ });
899
+ } catch (err) {
900
+ clearTimeout(timer);
901
+ const isAborted = err instanceof Error && err.name === "AbortError";
902
+ const errMsg = isAborted ? "Request timed out." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
903
+ if (options.json) {
904
+ emitJsonError({
905
+ ok: false,
906
+ error: isAborted ? "timeout" : "econnrefused",
907
+ message: errMsg
908
+ });
909
+ } else {
910
+ process.stderr.write(`${xMark} ${errMsg}
911
+ `);
912
+ process.exitCode = 1;
913
+ }
914
+ return;
915
+ }
916
+ clearTimeout(timer);
917
+ if (response.status === 200) {
918
+ const body2 = await response.json().catch(() => ({}));
919
+ const removedId = body2.id ?? id;
920
+ if (options.json) {
921
+ process.stdout.write(
922
+ JSON.stringify({ ok: true, id: removedId, type: body2.type }) + "\n"
923
+ );
924
+ } else {
925
+ process.stdout.write(`${check} Removed ${removedId}
926
+ `);
927
+ }
928
+ return;
929
+ }
930
+ const body = await response.json().catch(() => ({}));
931
+ if (options.json) {
932
+ emitJsonError({ ok: false, ...body });
933
+ return;
934
+ }
935
+ if (response.status === 404) {
936
+ process.stderr.write(`${xMark} No node with id '${id}'
937
+ `);
938
+ } else if (response.status === 409 && body.error === "node_lifecycle_in_flight") {
939
+ process.stderr.write(
940
+ `${xMark} Another node operation is in flight. Try again in a moment.
941
+ `
942
+ );
943
+ } else {
944
+ const step = body.step ?? "unknown";
945
+ process.stderr.write(`${xMark} Step ${step} failed: ${body.err ?? ""}
946
+ `);
947
+ }
948
+ process.exitCode = 1;
949
+ }
950
+ async function handleNodeList(options) {
951
+ const ascii = useAscii();
952
+ const xMark = ascii ? "[X]" : "\u2715";
953
+ const emDash = ascii ? "-" : "\u2014";
954
+ const url = resolveApiUrl(options.apiUrl);
955
+ const fetchImpl = options.fetch ?? fetch;
956
+ const controller = new AbortController();
957
+ const timer = setTimeout(() => controller.abort(), 3e4);
958
+ let response;
959
+ try {
960
+ response = await fetchImpl(`${url}/api/nodes`, {
961
+ method: "GET",
962
+ signal: controller.signal
963
+ });
964
+ } catch (err) {
965
+ clearTimeout(timer);
966
+ const isAborted = err instanceof Error && err.name === "AbortError";
967
+ const errMsg = isAborted ? "Request timed out." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
968
+ if (options.json) {
969
+ emitJsonError({
970
+ ok: false,
971
+ error: isAborted ? "timeout" : "econnrefused",
972
+ message: errMsg
973
+ });
974
+ } else {
975
+ process.stderr.write(`${xMark} ${errMsg}
976
+ `);
977
+ process.exitCode = 1;
978
+ }
979
+ return;
980
+ }
981
+ clearTimeout(timer);
982
+ if (response.status !== 200) {
983
+ const body2 = await response.json().catch(() => ({}));
984
+ if (options.json) {
985
+ emitJsonError({ ok: false, ...body2 });
986
+ } else {
987
+ process.stderr.write(
988
+ `${xMark} Failed to fetch nodes (HTTP ${response.status})
989
+ `
990
+ );
991
+ process.exitCode = 1;
992
+ }
993
+ return;
994
+ }
995
+ const body = await response.json().catch(() => ({ nodes: [] }));
996
+ const nodes = body.nodes ?? [];
997
+ if (options.json) {
998
+ process.stdout.write(JSON.stringify({ nodes }) + "\n");
999
+ return;
1000
+ }
1001
+ if (nodes.length === 0) {
1002
+ process.stdout.write(
1003
+ "No nodes provisioned. Run 'townhouse node add town' to add one.\n"
1004
+ );
1005
+ return;
1006
+ }
1007
+ const rows = nodes.map((node) => ({
1008
+ peer: node.id,
1009
+ type: node.type,
1010
+ status: node.status,
1011
+ lastClaim: node.lastSeenAt !== null ? formatRelativeTime2(node.lastSeenAt) : emDash
1012
+ }));
1013
+ const HEADERS = {
1014
+ peer: "peer",
1015
+ type: "type",
1016
+ status: "status",
1017
+ lastClaim: "last claim"
1018
+ };
1019
+ const widths = {
1020
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
1021
+ type: Math.max(HEADERS.type.length, ...rows.map((r) => r.type.length)),
1022
+ status: Math.max(
1023
+ HEADERS.status.length,
1024
+ ...rows.map((r) => r.status.length)
1025
+ ),
1026
+ lastClaim: Math.max(
1027
+ HEADERS.lastClaim.length,
1028
+ ...rows.map((r) => r.lastClaim.length)
1029
+ )
1030
+ };
1031
+ function pad(s, width) {
1032
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
1033
+ }
1034
+ const divider = ascii ? "-" : "\u2500";
1035
+ process.stdout.write(
1036
+ `${pad(HEADERS.peer, widths.peer)} ${pad(HEADERS.type, widths.type)} ${pad(HEADERS.status, widths.status)} ${HEADERS.lastClaim}
1037
+ `
1038
+ );
1039
+ process.stdout.write(
1040
+ `${divider.repeat(widths.peer)} ${divider.repeat(widths.type)} ${divider.repeat(widths.status)} ${divider.repeat(widths.lastClaim)}
1041
+ `
1042
+ );
1043
+ for (const row of rows) {
1044
+ process.stdout.write(
1045
+ `${pad(row.peer, widths.peer)} ${pad(row.type, widths.type)} ${pad(row.status, widths.status)} ${row.lastClaim}
1046
+ `
1047
+ );
1048
+ }
1049
+ }
1050
+
1051
+ // src/cli/drill-commands.ts
1052
+ import Docker from "dockerode";
1053
+ function truncate16(s) {
1054
+ return s.length > 16 ? s.slice(0, 16) + "\u2026" : s;
1055
+ }
1056
+ function emitJson(payload, opts) {
1057
+ process.stdout.write(
1058
+ JSON.stringify(payload, null, opts.compact ? 0 : 2) + "\n"
1059
+ );
1060
+ }
1061
+ function emitJsonError2(message, code, opts) {
1062
+ process.stdout.write(
1063
+ JSON.stringify({ error: message, code }, null, opts.compact ? 0 : 2) + "\n"
1064
+ );
1065
+ process.exitCode = 1;
1066
+ }
1067
+ async function handleChannels(adminClient, opts) {
1068
+ let channels;
1069
+ try {
1070
+ channels = await adminClient.getChannels();
1071
+ } catch (error) {
1072
+ const msg = error instanceof Error ? error.message : String(error);
1073
+ if (opts.json) {
1074
+ emitJsonError2(
1075
+ `Failed to fetch connector channels: ${msg}`,
1076
+ "unreachable",
1077
+ opts
1078
+ );
1079
+ } else {
1080
+ console.error(`Failed to fetch connector channels: ${msg}`);
1081
+ process.exitCode = 1;
1082
+ }
1083
+ return;
1084
+ }
1085
+ if (opts.json) {
1086
+ emitJson(channels, opts);
1087
+ return;
1088
+ }
1089
+ if (channels.length === 0) {
1090
+ console.log("No channels open");
1091
+ return;
1092
+ }
1093
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1094
+ const HEADERS = {
1095
+ channel: "CHANNEL",
1096
+ peer: "PEER",
1097
+ chain: "CHAIN",
1098
+ status: "STATUS",
1099
+ deposit: "DEPOSIT",
1100
+ lastActivity: "LAST ACTIVITY"
1101
+ };
1102
+ const rows = channels.map((c) => ({
1103
+ channel: truncate16(c.channelId),
1104
+ peer: truncate16(c.peerId),
1105
+ chain: c.chain,
1106
+ status: c.status,
1107
+ deposit: c.deposit,
1108
+ lastActivity: formatRelativeTime(c.lastActivity, now)
1109
+ }));
1110
+ const widths = {
1111
+ channel: Math.max(
1112
+ HEADERS.channel.length,
1113
+ ...rows.map((r) => r.channel.length)
1114
+ ),
1115
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
1116
+ chain: Math.max(HEADERS.chain.length, ...rows.map((r) => r.chain.length)),
1117
+ status: Math.max(
1118
+ HEADERS.status.length,
1119
+ ...rows.map((r) => r.status.length)
1120
+ ),
1121
+ deposit: Math.max(
1122
+ HEADERS.deposit.length,
1123
+ ...rows.map((r) => r.deposit.length)
1124
+ ),
1125
+ lastActivity: Math.max(
1126
+ HEADERS.lastActivity.length,
1127
+ ...rows.map((r) => r.lastActivity.length)
1128
+ )
1129
+ };
1130
+ const header = HEADERS.channel.padEnd(widths.channel) + " " + HEADERS.peer.padEnd(widths.peer) + " " + HEADERS.chain.padEnd(widths.chain) + " " + HEADERS.status.padEnd(widths.status) + " " + HEADERS.deposit.padEnd(widths.deposit) + " " + HEADERS.lastActivity;
1131
+ console.log(header);
1132
+ console.log("-".repeat(header.length));
1133
+ for (const row of rows) {
1134
+ console.log(
1135
+ row.channel.padEnd(widths.channel) + " " + row.peer.padEnd(widths.peer) + " " + row.chain.padEnd(widths.chain) + " " + row.status.padEnd(widths.status) + " " + row.deposit.padEnd(widths.deposit) + " " + row.lastActivity
1136
+ );
1137
+ }
1138
+ }
1139
+ async function handleMetrics(adminClient, opts) {
1140
+ try {
1141
+ const metrics = await adminClient.getMetrics();
1142
+ const peers = await adminClient.getPeers();
1143
+ const peerMetrics = new Map(metrics.peers.map((p) => [p.peerId, p]));
1144
+ if (opts.json) {
1145
+ emitJson(
1146
+ {
1147
+ aggregate: metrics.aggregate,
1148
+ peers: metrics.peers,
1149
+ peersDetail: peers,
1150
+ uptimeSeconds: metrics.uptimeSeconds,
1151
+ timestamp: metrics.timestamp
1152
+ },
1153
+ opts
1154
+ );
1155
+ return;
1156
+ }
1157
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1158
+ console.log("Connector Metrics:");
1159
+ console.log("------------------");
1160
+ console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);
1161
+ console.log(` Packets rejected: ${metrics.aggregate.packetsRejected}`);
1162
+ console.log(` Bytes sent: ${metrics.aggregate.bytesSent}`);
1163
+ console.log("");
1164
+ console.log("Peers:");
1165
+ console.log("------");
1166
+ if (peers.length === 0) {
1167
+ console.log(" No peers connected");
1168
+ } else {
1169
+ const HEADERS = {
1170
+ peer: "PEER",
1171
+ connected: "STATUS",
1172
+ packetsForwarded: "PACKETS FWD",
1173
+ packetsRejected: "PACKETS REJ",
1174
+ bytesSent: "BYTES SENT",
1175
+ lastPacket: "LAST PACKET"
1176
+ };
1177
+ const rows = peers.map((peer) => {
1178
+ const pm = peerMetrics.get(peer.id);
1179
+ return {
1180
+ peer: peer.id,
1181
+ connected: peer.connected ? "connected" : "disconnected",
1182
+ packetsForwarded: String(pm?.packetsForwarded ?? 0),
1183
+ packetsRejected: String(pm?.packetsRejected ?? 0),
1184
+ bytesSent: String(pm?.bytesSent ?? 0),
1185
+ lastPacket: pm?.lastPacketAt != null ? formatRelativeTime(pm.lastPacketAt, now) : "\u2014"
1186
+ };
1187
+ });
1188
+ const widths = {
1189
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
1190
+ connected: Math.max(
1191
+ HEADERS.connected.length,
1192
+ ...rows.map((r) => r.connected.length)
1193
+ ),
1194
+ packetsForwarded: Math.max(
1195
+ HEADERS.packetsForwarded.length,
1196
+ ...rows.map((r) => r.packetsForwarded.length)
1197
+ ),
1198
+ packetsRejected: Math.max(
1199
+ HEADERS.packetsRejected.length,
1200
+ ...rows.map((r) => r.packetsRejected.length)
1201
+ ),
1202
+ bytesSent: Math.max(
1203
+ HEADERS.bytesSent.length,
1204
+ ...rows.map((r) => r.bytesSent.length)
1205
+ ),
1206
+ lastPacket: Math.max(
1207
+ HEADERS.lastPacket.length,
1208
+ ...rows.map((r) => r.lastPacket.length)
1209
+ )
1210
+ };
1211
+ const headerLine = ` ${HEADERS.peer.padEnd(widths.peer)} ${HEADERS.connected.padEnd(widths.connected)} ${HEADERS.packetsForwarded.padEnd(widths.packetsForwarded)} ${HEADERS.packetsRejected.padEnd(widths.packetsRejected)} ${HEADERS.bytesSent.padEnd(widths.bytesSent)} ` + HEADERS.lastPacket;
1212
+ console.log(headerLine);
1213
+ console.log(` ${"-".repeat(headerLine.trim().length)}`);
1214
+ for (const row of rows) {
1215
+ console.log(
1216
+ ` ${row.peer.padEnd(widths.peer)} ${row.connected.padEnd(widths.connected)} ${row.packetsForwarded.padEnd(widths.packetsForwarded)} ${row.packetsRejected.padEnd(widths.packetsRejected)} ${row.bytesSent.padEnd(widths.bytesSent)} ` + row.lastPacket
1217
+ );
1218
+ }
1219
+ }
1220
+ } catch (error) {
1221
+ const msg = error instanceof Error ? error.message : String(error);
1222
+ if (opts.json) {
1223
+ emitJsonError2(
1224
+ `Failed to fetch connector metrics: ${msg}`,
1225
+ "unreachable",
1226
+ opts
1227
+ );
1228
+ } else {
1229
+ console.error(`Failed to fetch connector metrics: ${msg}`);
1230
+ process.exitCode = 1;
1231
+ }
1232
+ }
1233
+ }
1234
+ async function resolveContainerName(docker, nodeId) {
1235
+ let containers;
1236
+ try {
1237
+ containers = await docker.listContainers({ all: false });
1238
+ } catch (error) {
1239
+ const msg = error instanceof Error ? error.message : String(error);
1240
+ return {
1241
+ error: `Cannot connect to docker daemon: ${msg}. Is docker running?`,
1242
+ code: "docker-unavailable"
1243
+ };
1244
+ }
1245
+ const allNames = containers.flatMap(
1246
+ (c) => c.Names.map((n) => n.replace(/^\//, ""))
1247
+ );
1248
+ if (nodeId.startsWith(CONTAINER_PREFIX)) {
1249
+ if (!allNames.includes(nodeId)) {
1250
+ return {
1251
+ error: `Node "${nodeId}" is not running (no container named "${nodeId}").`,
1252
+ code: "unknown-node"
1253
+ };
1254
+ }
1255
+ const svc = serviceFromContainerName(nodeId) ?? "town";
1256
+ return { name: nodeId, service: svc };
1257
+ }
1258
+ const candidates = [];
1259
+ const exactName = `${CONTAINER_PREFIX}${nodeId}`;
1260
+ if (allNames.includes(exactName)) {
1261
+ const svc = serviceFromContainerName(exactName) ?? "town";
1262
+ candidates.push({ name: exactName, service: svc });
1263
+ }
1264
+ const isService = LOG_SERVICES.includes(nodeId);
1265
+ if (isService) {
1266
+ for (const name of allNames) {
1267
+ if (name === exactName) continue;
1268
+ const svc = serviceFromContainerName(name);
1269
+ if (svc === nodeId) {
1270
+ candidates.push({ name, service: svc });
1271
+ }
1272
+ }
1273
+ }
1274
+ const unique = candidates.filter(
1275
+ (c, i) => candidates.findIndex((x) => x.name === c.name) === i
1276
+ );
1277
+ if (unique.length === 0) {
1278
+ const resolvedName = `${CONTAINER_PREFIX}${nodeId}`;
1279
+ return {
1280
+ error: `Node "${nodeId}" is not running (no container named "${resolvedName}").`,
1281
+ code: "unknown-node"
1282
+ };
1283
+ }
1284
+ if (unique.length > 1) {
1285
+ const names = unique.map((c) => c.name).join(", ");
1286
+ return {
1287
+ error: `Ambiguous node-id "${nodeId}" \u2014 matches multiple containers: ${names}. Use the full container name.`,
1288
+ code: "ambiguous-node"
1289
+ };
1290
+ }
1291
+ const first = unique[0];
1292
+ if (first === void 0) {
1293
+ return {
1294
+ error: `Internal error resolving container name for "${nodeId}"`,
1295
+ code: "internal"
1296
+ };
1297
+ }
1298
+ return first;
1299
+ }
1300
+ async function handleLogs(docker, nodeId, opts) {
1301
+ const resolved = await resolveContainerName(docker, nodeId);
1302
+ if ("error" in resolved) {
1303
+ if (opts.json) {
1304
+ emitJsonError2(resolved.error, resolved.code, opts);
1305
+ } else {
1306
+ process.stderr.write(resolved.error + "\n");
1307
+ process.exitCode = 1;
1308
+ }
1309
+ return;
1310
+ }
1311
+ const { name: containerName, service } = resolved;
1312
+ const controller = new AbortController();
1313
+ const sigintHandler = () => {
1314
+ controller.abort();
1315
+ process.stdout.write("", () => {
1316
+ process.exit(process.exitCode ?? 0);
1317
+ });
1318
+ };
1319
+ process.once("SIGINT", sigintHandler);
1320
+ try {
1321
+ const gen = tailContainerLogs(docker, containerName, service, {
1322
+ tail: opts.lines,
1323
+ signal: controller.signal
1324
+ });
1325
+ for await (const evt of gen) {
1326
+ if (opts.json) {
1327
+ process.stdout.write(JSON.stringify(evt) + "\n");
1328
+ } else {
1329
+ process.stdout.write(
1330
+ `${evt.ts} [${evt.service}] ${evt.level}: ${evt.msg}
1331
+ `
1332
+ );
1333
+ }
1334
+ }
1335
+ } catch (error) {
1336
+ const msg = error instanceof Error ? error.message : String(error);
1337
+ const isDockerError = msg.includes("ENOENT") && msg.includes("/var/run/docker.sock") || msg.includes("connect ENOENT") || msg.includes("Cannot connect to the Docker daemon") || msg.includes("ECONNREFUSED") && msg.includes("docker");
1338
+ if (isDockerError) {
1339
+ const errMsg = `Cannot connect to docker daemon: ${msg}. Is docker running?`;
1340
+ if (opts.json) {
1341
+ emitJsonError2(errMsg, "docker-unavailable", opts);
1342
+ } else {
1343
+ process.stderr.write(errMsg + "\n");
1344
+ process.exitCode = 1;
1345
+ }
1346
+ } else {
1347
+ const errMsg = `Log stream error for "${nodeId}": ${msg}`;
1348
+ if (opts.json) {
1349
+ emitJsonError2(errMsg, "internal", opts);
1350
+ } else {
1351
+ process.stderr.write(errMsg + "\n");
1352
+ process.exitCode = 1;
1353
+ }
1354
+ }
1355
+ } finally {
1356
+ process.off("SIGINT", sigintHandler);
1357
+ }
1358
+ }
1359
+ async function handlePeerDetail(adminClient, peerId, opts) {
1360
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1361
+ let peers;
1362
+ try {
1363
+ peers = await adminClient.getPeers();
1364
+ } catch (error) {
1365
+ const msg = error instanceof Error ? error.message : String(error);
1366
+ if (opts.json) {
1367
+ emitJsonError2(msg, "unreachable", opts);
1368
+ } else {
1369
+ process.stderr.write(`Failed to fetch peers: ${msg}
1370
+ `);
1371
+ process.exitCode = 1;
1372
+ }
1373
+ return;
1374
+ }
1375
+ const peer = peers.find((p) => p.id === peerId);
1376
+ if (peer === void 0) {
1377
+ const errMsg = `Unknown peer "${peerId}". Use \`townhouse metrics\` to see registered peers.`;
1378
+ if (opts.json) {
1379
+ emitJsonError2(errMsg, "unknown-peer", opts);
1380
+ } else {
1381
+ process.stderr.write(errMsg + "\n");
1382
+ process.exitCode = 1;
1383
+ }
1384
+ return;
1385
+ }
1386
+ const [earningsRaw, channelsRaw] = await Promise.all([
1387
+ adminClient.getEarnings().catch(() => null),
1388
+ adminClient.getChannels().catch(() => null)
1389
+ ]);
1390
+ const peerEarnings = earningsRaw?.peers.find((p) => p.peerId === peerId) ?? null;
1391
+ const peerChannels = channelsRaw?.filter((c) => c.peerId === peerId) ?? [];
1392
+ if (opts.json) {
1393
+ const earningsForJson = peerEarnings && peerEarnings.byAsset.length > 0 ? peerEarnings : null;
1394
+ emitJson(
1395
+ {
1396
+ peer,
1397
+ earnings: earningsForJson,
1398
+ channels: peerChannels
1399
+ },
1400
+ opts
1401
+ );
1402
+ return;
1403
+ }
1404
+ console.log(`Peer: ${peerId}`);
1405
+ console.log("");
1406
+ if (peer.ilpAddresses.length === 0) {
1407
+ console.log(" (no ILP addresses registered)");
1408
+ } else {
1409
+ for (const addr of peer.ilpAddresses) {
1410
+ console.log(` ${addr}`);
1411
+ }
1412
+ }
1413
+ console.log(` Routes: ${peer.routeCount}`);
1414
+ console.log("");
1415
+ console.log(`Connected: ${peer.connected ? "yes" : "no"}`);
1416
+ console.log("");
1417
+ if (earningsRaw === null) {
1418
+ console.log("Earnings:");
1419
+ console.log(
1420
+ " (earnings endpoint unavailable: connector is not settlement-configured)"
1421
+ );
1422
+ } else if (peerEarnings === null || peerEarnings.byAsset.length === 0) {
1423
+ console.log("Earnings:");
1424
+ console.log(" (no settlement activity yet)");
1425
+ } else {
1426
+ console.log("Earnings:");
1427
+ for (const asset of peerEarnings.byAsset) {
1428
+ const lastClaim = asset.lastClaimAt ? formatRelativeTime(asset.lastClaimAt, now) : "never";
1429
+ console.log(
1430
+ ` ${asset.assetCode} \xB7 received ${asset.claimsReceivedTotal} \xB7 sent ${asset.claimsSentTotal} \xB7 net ${asset.netBalance} \xB7 last claim ${lastClaim}`
1431
+ );
1432
+ }
1433
+ }
1434
+ console.log("");
1435
+ if (channelsRaw === null) {
1436
+ console.log("Channels:");
1437
+ console.log(
1438
+ " (channels endpoint unavailable: connector is not settlement-configured)"
1439
+ );
1440
+ } else if (peerChannels.length === 0) {
1441
+ console.log("Channels:");
1442
+ console.log(" (no channels open)");
1443
+ } else {
1444
+ console.log("Channels:");
1445
+ for (const ch of peerChannels) {
1446
+ console.log(
1447
+ ` ${truncate16(ch.channelId)} \xB7 ${ch.chain} \xB7 ${ch.status} \xB7 deposit ${ch.deposit} \xB7 ${formatRelativeTime(ch.lastActivity, now)}`
1448
+ );
1449
+ }
1450
+ }
1451
+ }
1452
+ var PROBE_TIMEOUT_MS = 3e3;
1453
+ async function probeConnector(adminClient) {
1454
+ try {
1455
+ await adminClient.pingAdminLive();
1456
+ return { source: "connector", status: "healthy" };
1457
+ } catch (error) {
1458
+ const msg = error instanceof Error ? error.message : String(error);
1459
+ return { source: "connector", status: "unreachable", error: msg };
1460
+ }
1461
+ }
1462
+ async function probeHostApi(apiUrl, fetchImpl) {
1463
+ try {
1464
+ const response = await fetchImpl(`${apiUrl}/health`, {
1465
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1466
+ });
1467
+ if (!response.ok) {
1468
+ return {
1469
+ source: "api",
1470
+ status: "unhealthy",
1471
+ error: `HTTP ${response.status}`
1472
+ };
1473
+ }
1474
+ const body = await response.json();
1475
+ return {
1476
+ source: "api",
1477
+ status: "healthy",
1478
+ uptime: body.uptime,
1479
+ startedAt: body.startedAt,
1480
+ version: body.version
1481
+ };
1482
+ } catch (error) {
1483
+ const msg = error instanceof Error ? error.message : String(error);
1484
+ return { source: "api", status: "unreachable", error: msg };
1485
+ }
1486
+ }
1487
+ async function probeNodes(apiUrl, fetchImpl) {
1488
+ let nodes;
1489
+ try {
1490
+ const resp = await fetchImpl(`${apiUrl}/api/nodes`, {
1491
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1492
+ });
1493
+ if (!resp.ok) {
1494
+ return [
1495
+ {
1496
+ source: "nodes",
1497
+ status: "unknown",
1498
+ error: `failed to enumerate nodes: HTTP ${resp.status}`
1499
+ }
1500
+ ];
1501
+ }
1502
+ const body = await resp.json();
1503
+ nodes = body.nodes ?? [];
1504
+ } catch (error) {
1505
+ const msg = error instanceof Error ? error.message : String(error);
1506
+ return [
1507
+ {
1508
+ source: "nodes",
1509
+ status: "unknown",
1510
+ error: `failed to enumerate nodes: ${msg}`
1511
+ }
1512
+ ];
1513
+ }
1514
+ return Promise.all(
1515
+ nodes.map(async (node) => {
1516
+ try {
1517
+ const resp = await fetchImpl(
1518
+ `${apiUrl}/api/nodes/${encodeURIComponent(node.id)}/health`,
1519
+ {
1520
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1521
+ }
1522
+ );
1523
+ if (!resp.ok) {
1524
+ return {
1525
+ source: `node:${node.id}`,
1526
+ status: "unhealthy",
1527
+ error: `HTTP ${resp.status}`
1528
+ };
1529
+ }
1530
+ const body = await resp.json();
1531
+ const s = body.status;
1532
+ const status = s === "healthy" ? "healthy" : s === "unhealthy" ? "unhealthy" : s === "starting" ? "starting" : s === "degraded" ? "degraded" : "unknown";
1533
+ return { source: `node:${node.id}`, status };
1534
+ } catch (error) {
1535
+ const msg = error instanceof Error ? error.message : String(error);
1536
+ return {
1537
+ source: `node:${node.id}`,
1538
+ status: "unreachable",
1539
+ error: msg
1540
+ };
1541
+ }
1542
+ })
1543
+ );
1544
+ }
1545
+ async function probeAnyone(adminClient) {
1546
+ try {
1547
+ const result = await adminClient.getHsHostname();
1548
+ if (result.hostname !== null) {
1549
+ return {
1550
+ source: "anyone-hostname",
1551
+ status: "healthy",
1552
+ hostname: result.hostname,
1553
+ publishedAt: result.publishedAt ?? void 0
1554
+ };
1555
+ }
1556
+ return {
1557
+ source: "anyone-hostname",
1558
+ status: "starting",
1559
+ message: "anon publish pending"
1560
+ };
1561
+ } catch (error) {
1562
+ const msg = error instanceof Error ? error.message : String(error);
1563
+ if (msg.startsWith("connector is anon-disabled") || /(?:^|:\s)503\b/.test(msg)) {
1564
+ return {
1565
+ source: "anyone-hostname",
1566
+ status: "n/a",
1567
+ message: "anon disabled in config"
1568
+ };
1569
+ }
1570
+ return { source: "anyone-hostname", status: "unreachable", error: msg };
1571
+ }
1572
+ }
1573
+ function computeOverall(probes) {
1574
+ const statuses = probes.map((p) => p.status);
1575
+ if (statuses.some(
1576
+ (s) => s === "unhealthy" || s === "unreachable" || s === "unknown"
1577
+ )) {
1578
+ return "unhealthy";
1579
+ }
1580
+ if (statuses.some((s) => s === "starting" || s === "degraded")) {
1581
+ return "degraded";
1582
+ }
1583
+ return "healthy";
1584
+ }
1585
+ async function handleHealth(adminClient, opts) {
1586
+ const apiUrl = opts.apiUrl ?? "http://127.0.0.1:28090";
1587
+ const fetchImpl = opts.fetch ?? fetch;
1588
+ const healthClient = opts.adminClient ?? new ConnectorAdminClient(adminClient.getBaseUrl(), PROBE_TIMEOUT_MS);
1589
+ const [connectorProbe, apiProbe, nodeProbes, anyoneProbe] = await Promise.all(
1590
+ [
1591
+ probeConnector(healthClient),
1592
+ probeHostApi(apiUrl, fetchImpl),
1593
+ probeNodes(apiUrl, fetchImpl),
1594
+ probeAnyone(healthClient)
1595
+ ]
1596
+ );
1597
+ const probes = [
1598
+ connectorProbe,
1599
+ apiProbe,
1600
+ ...nodeProbes,
1601
+ anyoneProbe
1602
+ ];
1603
+ const overall = computeOverall(probes);
1604
+ if (opts.json) {
1605
+ emitJson({ overall, probes }, opts);
1606
+ } else {
1607
+ for (const probe of probes) {
1608
+ console.log(`${probe.source}: ${probe.status}`);
1609
+ if (probe.error) console.log(` error: ${probe.error}`);
1610
+ if (probe.uptime !== void 0) console.log(` uptime: ${probe.uptime}s`);
1611
+ if (probe.peersConnected !== void 0)
1612
+ console.log(
1613
+ ` peers: ${probe.peersConnected}/${probe.totalPeers ?? "?"} connected`
1614
+ );
1615
+ if (probe.startedAt) console.log(` startedAt: ${probe.startedAt}`);
1616
+ if (probe.version) console.log(` version: ${probe.version}`);
1617
+ if (probe.hostname) console.log(` hostname: ${probe.hostname}`);
1618
+ if (probe.publishedAt) console.log(` publishedAt: ${probe.publishedAt}`);
1619
+ if (probe.message) console.log(` ${probe.message}`);
1620
+ }
1621
+ console.log(`Overall: ${overall}`);
1622
+ }
1623
+ if (overall === "unhealthy") {
1624
+ process.exitCode = 1;
1625
+ }
1626
+ }
1627
+ async function dispatchDrillCommand(command, deps) {
1628
+ const { values, positionals, adminUrl, apiUrl } = deps;
1629
+ const json = values["json"] === true;
1630
+ const jsonCompact = values["json-compact"] === true;
1631
+ const baseOpts = { json, jsonCompact };
1632
+ const usageError = (msg, code) => {
1633
+ if (json) emitJsonError2(msg, code, baseOpts);
1634
+ else {
1635
+ console.error(msg);
1636
+ process.exitCode = 1;
1637
+ }
1638
+ };
1639
+ switch (command) {
1640
+ case "channels": {
1641
+ await handleChannels(new ConnectorAdminClient(adminUrl), baseOpts);
1642
+ return true;
1643
+ }
1644
+ case "metrics": {
1645
+ await handleMetrics(new ConnectorAdminClient(adminUrl), baseOpts);
1646
+ return true;
1647
+ }
1648
+ case "logs": {
1649
+ const nodeId = positionals[1];
1650
+ if (!nodeId) {
1651
+ usageError(
1652
+ "Usage: townhouse logs <node-id> [--lines N] [-f|--follow] [--json]",
1653
+ "usage"
1654
+ );
1655
+ return true;
1656
+ }
1657
+ const linesRaw = values["lines"];
1658
+ let lines = 50;
1659
+ if (linesRaw !== void 0) {
1660
+ if (!/^\d+$/.test(linesRaw)) {
1661
+ usageError(
1662
+ "--lines must be an integer between 0 and 10000",
1663
+ "bad-flag"
1664
+ );
1665
+ return true;
1666
+ }
1667
+ lines = Number(linesRaw);
1668
+ if (lines < 0 || lines > 1e4) {
1669
+ usageError(
1670
+ "--lines must be an integer between 0 and 10000",
1671
+ "bad-flag"
1672
+ );
1673
+ return true;
1674
+ }
1675
+ }
1676
+ const docker = deps.docker ?? new Docker();
1677
+ await handleLogs(docker, nodeId, { ...baseOpts, lines });
1678
+ return true;
1679
+ }
1680
+ case "peer": {
1681
+ const peerId = positionals[1];
1682
+ if (!peerId) {
1683
+ usageError("Usage: townhouse peer <id> [--json]", "usage");
1684
+ return true;
1685
+ }
1686
+ await handlePeerDetail(
1687
+ new ConnectorAdminClient(adminUrl),
1688
+ peerId,
1689
+ baseOpts
1690
+ );
1691
+ return true;
1692
+ }
1693
+ case "health": {
1694
+ await handleHealth(new ConnectorAdminClient(adminUrl, PROBE_TIMEOUT_MS), {
1695
+ ...baseOpts,
1696
+ apiUrl
1697
+ });
1698
+ return true;
1699
+ }
1700
+ default:
1701
+ return false;
1702
+ }
1703
+ }
1704
+
1705
+ // src/cli/status-earnings.ts
1706
+ var USDC_SCALE = 6;
1707
+ var USDC_ASSET = "USDC";
1708
+ var DECIMAL_RE = /^-?\d+$/;
1709
+ var POSITIVE_INT_RE = /^[1-9]\d*$/;
1710
+ function addDecimalStrings(a, b) {
1711
+ if (!DECIMAL_RE.test(b)) return a;
1712
+ try {
1713
+ return (BigInt(a) + BigInt(b)).toString();
1714
+ } catch {
1715
+ return a;
1716
+ }
1717
+ }
1718
+ function computeUsdcScalars(earnings) {
1719
+ let today = "0";
1720
+ let month = "0";
1721
+ let year = "0";
1722
+ let lifetime = "0";
1723
+ const apexUsdc = earnings.apex.routingFees[USDC_ASSET];
1724
+ if (apexUsdc !== void 0) {
1725
+ today = addDecimalStrings(today, apexUsdc.today);
1726
+ month = addDecimalStrings(month, apexUsdc.month);
1727
+ year = addDecimalStrings(year, apexUsdc.year);
1728
+ lifetime = addDecimalStrings(lifetime, apexUsdc.lifetime);
1729
+ }
1730
+ for (const peer of earnings.peers) {
1731
+ const peerUsdc = peer.byAsset[USDC_ASSET];
1732
+ if (peerUsdc !== void 0) {
1733
+ today = addDecimalStrings(today, peerUsdc.today);
1734
+ month = addDecimalStrings(month, peerUsdc.month);
1735
+ year = addDecimalStrings(year, peerUsdc.year);
1736
+ lifetime = addDecimalStrings(lifetime, peerUsdc.lifetime);
1737
+ }
1738
+ }
1739
+ return { today, month, year, lifetime };
1740
+ }
1741
+ function usdcMicroToSats(decimalString, satsPerUsdc) {
1742
+ if (!DECIMAL_RE.test(decimalString)) return "0";
1743
+ if (!Number.isInteger(satsPerUsdc) || satsPerUsdc <= 0) {
1744
+ throw new Error("satsPerUsdc must be a positive integer");
1745
+ }
1746
+ const negative = decimalString.startsWith("-");
1747
+ const absolute = negative ? decimalString.slice(1) : decimalString;
1748
+ const sats = BigInt(absolute) * BigInt(satsPerUsdc) / 10n ** BigInt(USDC_SCALE);
1749
+ return (negative && sats !== 0n ? "-" : "") + sats.toString();
1750
+ }
1751
+ function formatSatsRow(value) {
1752
+ if (!value || !DECIMAL_RE.test(value)) return "0 sats";
1753
+ const negative = value.startsWith("-");
1754
+ const abs = negative ? value.slice(1) : value;
1755
+ if (!abs || abs === "0") return "0 sats";
1756
+ let formatted;
1757
+ const absN = BigInt(abs);
1758
+ if (absN < BigInt(Number.MAX_SAFE_INTEGER)) {
1759
+ formatted = Number(abs).toLocaleString("en-US");
1760
+ } else {
1761
+ formatted = abs.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1762
+ }
1763
+ return (negative ? "-" : "") + formatted + " sats";
1764
+ }
1765
+ function renderEarningsSection(opts) {
1766
+ if (opts.earnings.status === "connector_unavailable") {
1767
+ return ["", "Earnings (USDC): unavailable"];
1768
+ }
1769
+ const scalars = computeUsdcScalars(opts.earnings);
1770
+ if (opts.units === "usdc") {
1771
+ return [
1772
+ "",
1773
+ "Earnings (USDC):",
1774
+ "----------------",
1775
+ ` TODAY ${formatUsdc(scalars.today, USDC_SCALE)}`,
1776
+ ` MONTH ${formatUsdc(scalars.month, USDC_SCALE)}`,
1777
+ ` YEAR ${formatUsdc(scalars.year, USDC_SCALE)}`,
1778
+ ` LIFETIME ${formatUsdc(scalars.lifetime, USDC_SCALE)}`
1779
+ ];
1780
+ }
1781
+ if (opts.satsPerUsdc === void 0 || !Number.isInteger(opts.satsPerUsdc) || opts.satsPerUsdc <= 0) {
1782
+ throw new Error(
1783
+ "renderEarningsSection: units='sats' requires a positive-integer satsPerUsdc"
1784
+ );
1785
+ }
1786
+ const rate = opts.satsPerUsdc;
1787
+ const header = `Earnings (sats @ ${rate}/USDC):`;
1788
+ return [
1789
+ "",
1790
+ header,
1791
+ "-".repeat(header.length),
1792
+ ` TODAY ${formatSatsRow(usdcMicroToSats(scalars.today, rate))}`,
1793
+ ` MONTH ${formatSatsRow(usdcMicroToSats(scalars.month, rate))}`,
1794
+ ` YEAR ${formatSatsRow(usdcMicroToSats(scalars.year, rate))}`,
1795
+ ` LIFETIME ${formatSatsRow(usdcMicroToSats(scalars.lifetime, rate))}`
1796
+ ];
1797
+ }
1798
+ function resolveSatsRate(values, env) {
1799
+ const cliRaw = typeof values["rate"] === "string" ? values["rate"] : void 0;
1800
+ const cliRate = cliRaw !== void 0 && cliRaw !== "" ? cliRaw : void 0;
1801
+ const envRate = env["TOWNHOUSE_SATS_PER_USDC"];
1802
+ const raw = cliRate ?? envRate;
1803
+ const source = cliRate !== void 0 ? "--rate" : "TOWNHOUSE_SATS_PER_USDC env var";
1804
+ if (raw === void 0) {
1805
+ return {
1806
+ error: "--units=sats requires --rate <sats-per-usdc> or TOWNHOUSE_SATS_PER_USDC env var (e.g. --rate 1500 for 1500 sats per 1 USDC)"
1807
+ };
1808
+ }
1809
+ if (!POSITIVE_INT_RE.test(raw)) {
1810
+ return {
1811
+ error: `${source} must be a positive integer (sats per 1 USDC); got: ${JSON.stringify(raw)}`
1812
+ };
1813
+ }
1814
+ const rate = Number(raw);
1815
+ if (!Number.isSafeInteger(rate) || rate <= 0) {
1816
+ return { error: `${source} is out of range` };
1817
+ }
1818
+ return { rate };
1819
+ }
1820
+
1821
+ // src/credits/buy.ts
1822
+ import { TurboFactory } from "@ardrive/turbo-sdk/node";
1823
+
1824
+ // src/wallet/turbo-signer.ts
1825
+ import {
1826
+ ArweaveSigner,
1827
+ EthereumSigner,
1828
+ HexSolanaSigner
1829
+ } from "@ardrive/turbo-sdk/node";
1830
+ import bs58 from "bs58";
1831
+ function solanaSecretKeyBase58(privateKeyHex, publicKeyBase58) {
1832
+ const priv = Buffer.from(privateKeyHex, "hex");
1833
+ if (priv.length !== 32) {
1834
+ throw new Error(
1835
+ `Solana private key seed must be 32 bytes, got ${priv.length}`
1836
+ );
1837
+ }
1838
+ const pub = bs58.decode(publicKeyBase58);
1839
+ if (pub.length !== 32) {
1840
+ throw new Error(`Solana public key must be 32 bytes, got ${pub.length}`);
1841
+ }
1842
+ const secret = new Uint8Array(64);
1843
+ secret.set(priv, 0);
1844
+ secret.set(pub, 32);
1845
+ return bs58.encode(secret);
1846
+ }
1847
+ var TURBO_TOKEN_MAP = {
1848
+ eth: "ethereum",
1849
+ pol: "pol",
1850
+ "base-eth": "base-eth",
1851
+ "base-usdc": "base-usdc",
1852
+ "usdc-eth": "usdc",
1853
+ "usdc-pol": "polygon-usdc",
1854
+ sol: "solana",
1855
+ ar: "arweave"
1856
+ };
1857
+ var EVM_TOKENS = /* @__PURE__ */ new Set([
1858
+ "eth",
1859
+ "pol",
1860
+ "base-eth",
1861
+ "base-usdc",
1862
+ "usdc-eth",
1863
+ "usdc-pol"
1864
+ ]);
1865
+ function canonicalTurboToken(token) {
1866
+ const canonical = TURBO_TOKEN_MAP[token];
1867
+ if (!canonical) {
1868
+ throw new Error(
1869
+ `Unknown TurboTokenId '${String(token)}'. Supported: ${Object.keys(TURBO_TOKEN_MAP).join(", ")}`
1870
+ );
1871
+ }
1872
+ return canonical;
1873
+ }
1874
+ async function buildTurboSigner(wallet, nodeType, token) {
1875
+ const canonical = canonicalTurboToken(token);
1876
+ if (EVM_TOKENS.has(token)) {
1877
+ const privateKeyHex = wallet.getEvmPrivateKeyHex(nodeType);
1878
+ const signer = new EthereumSigner(privateKeyHex);
1879
+ const keys = wallet.getNodeKeys(nodeType);
1880
+ return { signer, token: canonical, address: keys.evmAddress };
1881
+ }
1882
+ if (token === "sol") {
1883
+ const privateKeyHex = wallet.getSolanaPrivateKeyHex(nodeType);
1884
+ const keys = wallet.getNodeKeys(nodeType);
1885
+ if (!keys.solanaAddress) {
1886
+ throw new Error(`Solana address not available for node '${nodeType}'`);
1887
+ }
1888
+ const secretBase58 = solanaSecretKeyBase58(
1889
+ privateKeyHex,
1890
+ keys.solanaAddress
1891
+ );
1892
+ const signer = new HexSolanaSigner(secretBase58);
1893
+ return { signer, token: canonical, address: keys.solanaAddress };
1894
+ }
1895
+ if (token === "ar") {
1896
+ await wallet.ensureArweaveKey(nodeType);
1897
+ const jwk = wallet.getArweaveJwk(nodeType);
1898
+ const signer = new ArweaveSigner(jwk);
1899
+ const keys = wallet.getNodeKeys(nodeType);
1900
+ if (!keys.arweaveAddress) {
1901
+ throw new Error(
1902
+ `Arweave address not populated for node '${nodeType}' after ensureArweaveKey`
1903
+ );
1904
+ }
1905
+ return { signer, token: canonical, address: keys.arweaveAddress };
1906
+ }
1907
+ throw new Error(`Unsupported TurboTokenId: ${String(token)}`);
1908
+ }
1909
+
1910
+ // src/credits/units.ts
1911
+ var WINC_PER_BYTE_APPROX = 610000n;
1912
+ function wincToBytes(winc) {
1913
+ if (winc < 0n) return 0n;
1914
+ return winc / WINC_PER_BYTE_APPROX;
1915
+ }
1916
+ function formatWincAsBytes(winc) {
1917
+ const bytes = wincToBytes(winc);
1918
+ if (bytes < 1000n) return `~${bytes.toString()} B`;
1919
+ if (bytes < 1000000n) {
1920
+ return `~${(bytes / 1000n).toString()} KB`;
1921
+ }
1922
+ if (bytes < 1000000000n) {
1923
+ return `~${(bytes / 1000000n).toString()} MB`;
1924
+ }
1925
+ if (bytes < 1000000000000n) {
1926
+ return `~${(bytes / 1000000000n).toString()} GB`;
1927
+ }
1928
+ return `~${(bytes / 1000000000000n).toString()} TB`;
1929
+ }
1930
+ var TOKEN_DECIMALS = {
1931
+ ar: 12,
1932
+ sol: 9,
1933
+ eth: 18,
1934
+ pol: 18,
1935
+ "base-eth": 18,
1936
+ "base-usdc": 6,
1937
+ "usdc-eth": 6,
1938
+ "usdc-pol": 6
1939
+ };
1940
+ var TOKEN_SYMBOL = {
1941
+ ar: "AR",
1942
+ sol: "SOL",
1943
+ eth: "ETH",
1944
+ pol: "POL",
1945
+ "base-eth": "ETH (Base)",
1946
+ "base-usdc": "USDC (Base)",
1947
+ "usdc-eth": "USDC (Ethereum)",
1948
+ "usdc-pol": "USDC (Polygon)"
1949
+ };
1950
+ function formatTokenAmount(token, baseAmount) {
1951
+ const decimals = TOKEN_DECIMALS[token];
1952
+ const symbol = TOKEN_SYMBOL[token];
1953
+ if (decimals === void 0 || symbol === void 0) {
1954
+ throw new Error(`Unknown TurboTokenId for formatting: ${String(token)}`);
1955
+ }
1956
+ const scale = 10n ** BigInt(decimals);
1957
+ const isNegative = baseAmount < 0n;
1958
+ const abs = isNegative ? -baseAmount : baseAmount;
1959
+ const whole = abs / scale;
1960
+ const frac = abs % scale;
1961
+ const fracStr = frac.toString().padStart(decimals, "0");
1962
+ const sign = isNegative ? "-" : "";
1963
+ return `${sign}${whole.toString()}.${fracStr} ${symbol}`;
1964
+ }
1965
+ function parseTokenAmount(token, decimal) {
1966
+ const decimals = TOKEN_DECIMALS[token];
1967
+ if (decimals === void 0) {
1968
+ throw new Error(`Unknown TurboTokenId: ${String(token)}`);
1969
+ }
1970
+ const trimmed = decimal.trim();
1971
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
1972
+ throw new Error(
1973
+ `Invalid decimal amount '${decimal}' for token '${token}'. Use plain decimal notation (e.g. "0.05").`
1974
+ );
1975
+ }
1976
+ const [wholeStr, fracStr = ""] = trimmed.split(".");
1977
+ if (fracStr.length > decimals) {
1978
+ throw new Error(
1979
+ `Amount '${decimal}' has ${fracStr.length} decimal places, but '${token}' supports at most ${decimals}.`
1980
+ );
1981
+ }
1982
+ const fracPadded = fracStr.padEnd(decimals, "0");
1983
+ const whole = BigInt(wholeStr);
1984
+ const frac = fracPadded.length > 0 ? BigInt(fracPadded) : 0n;
1985
+ return whole * 10n ** BigInt(decimals) + frac;
1986
+ }
1987
+
1988
+ // src/credits/buy.ts
1989
+ async function buyCredits(opts) {
1990
+ const {
1991
+ wallet,
1992
+ nodeType,
1993
+ token,
1994
+ amount,
1995
+ feeMultiplier,
1996
+ quoteOnly,
1997
+ destinationAddress
1998
+ } = opts;
1999
+ const baseAmount = parseTokenAmount(token, amount);
2000
+ const {
2001
+ signer,
2002
+ token: canonicalToken,
2003
+ address: fromAddress
2004
+ } = await buildTurboSigner(wallet, nodeType, token);
2005
+ const creditAddress = destinationAddress ?? fromAddress;
2006
+ const turbo = TurboFactory.authenticated({
2007
+ signer,
2008
+ token: canonicalToken
2009
+ });
2010
+ const quote = await turbo.getWincForToken({
2011
+ tokenAmount: baseAmount.toString()
2012
+ });
2013
+ const quotedWinc = BigInt(quote.winc);
2014
+ if (quoteOnly) {
2015
+ return {
2016
+ kind: "quote",
2017
+ fromAddress,
2018
+ creditAddress,
2019
+ baseAmount,
2020
+ winc: quotedWinc,
2021
+ raw: {
2022
+ winc: quote.winc,
2023
+ actualTokenAmount: quote.actualTokenAmount,
2024
+ equivalentWincTokenAmount: quote.equivalentWincTokenAmount
2025
+ }
2026
+ };
2027
+ }
2028
+ const topUpParams = {
2029
+ tokenAmount: baseAmount.toString()
2030
+ };
2031
+ if (feeMultiplier !== void 0) topUpParams.feeMultiplier = feeMultiplier;
2032
+ if (destinationAddress !== void 0) {
2033
+ topUpParams.turboCreditDestinationAddress = destinationAddress;
2034
+ }
2035
+ const submitted = await turbo.topUpWithTokens(topUpParams);
2036
+ return {
2037
+ kind: "submit",
2038
+ fromAddress,
2039
+ creditAddress,
2040
+ baseAmount,
2041
+ winc: BigInt(submitted.winc),
2042
+ id: submitted.id,
2043
+ status: submitted.status,
2044
+ token: submitted.token,
2045
+ ...submitted.block !== void 0 ? { block: submitted.block } : {}
2046
+ };
2047
+ }
2048
+
2049
+ // src/credits/balance.ts
2050
+ import { TurboFactory as TurboFactory2 } from "@ardrive/turbo-sdk/node";
2051
+ async function getCreditBalance(opts) {
2052
+ const { wallet, nodeType, token, address: explicitAddress } = opts;
2053
+ const {
2054
+ signer,
2055
+ token: canonicalToken,
2056
+ address: signerAddress
2057
+ } = await buildTurboSigner(wallet, nodeType, token);
2058
+ const turbo = TurboFactory2.authenticated({
2059
+ signer,
2060
+ token: canonicalToken
2061
+ });
2062
+ const balance = explicitAddress ? await turbo.getBalance(explicitAddress) : await turbo.getBalance();
2063
+ return {
2064
+ winc: BigInt(balance.winc),
2065
+ controlledWinc: BigInt(balance.controlledWinc),
2066
+ effectiveBalance: BigInt(balance.effectiveBalance),
2067
+ address: explicitAddress ?? signerAddress
2068
+ };
2069
+ }
2070
+
2071
+ // src/tui/tty-detect.ts
2072
+ function isOptOut(value) {
2073
+ if (value === void 0) return false;
2074
+ if (value === "" || value === "0" || value.toLowerCase() === "false")
2075
+ return false;
2076
+ return true;
2077
+ }
2078
+ function shouldRenderInk() {
2079
+ if (process.stdout.isTTY !== true) return false;
2080
+ if (process.env["CI"] === "true") return false;
2081
+ if (isOptOut(process.env["NO_TUI"])) return false;
2082
+ if ((process.env["TERM"] ?? "") === "dumb") return false;
2083
+ return true;
2084
+ }
2085
+
2086
+ // src/cli.ts
2087
+ var CliHelpRequested = class extends Error {
2088
+ constructor() {
2089
+ super(HELP_TEXT);
2090
+ this.name = "CliHelpRequested";
2091
+ }
2092
+ };
2093
+ function readCliVersion() {
2094
+ try {
2095
+ const pkgUrl = new URL("../package.json", import.meta.url);
2096
+ const pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
2097
+ return pkg.version;
2098
+ } catch {
2099
+ return "0.0.0";
2100
+ }
2101
+ }
2102
+ var HELP_TEXT = `townhouse \u2014 TOON node orchestrator
2103
+
2104
+ Usage:
2105
+ townhouse --version [--json] Print the package version (--json: { "version" })
2106
+ townhouse setup [--no-browser] [--port <n>] [--config-dir <dir>] Run the first-run setup wizard
2107
+ townhouse init [--force] [--config-dir <dir>] [--password <pw>] [--preset <name>] [--network <mode>] [--yes] [--json] Initialize config + wallet (set TOWNHOUSE_MNEMONIC + no password = config-only, no encrypted wallet)
2108
+ townhouse up [--transport direct|hs] [--dev] [--town] [--mill] [--dvm] [-c <path>] [--password <pw>]
2109
+ Boot a direct-BTP apex + children (default; clients dial ws://host:3000/btp). --transport hs = HS path; --dev = contributor children-only dev stack
2110
+ townhouse down [-c <path>] [--json] Stop all nodes
2111
+ townhouse status [-c <path>] [--json] Show node status
2112
+ townhouse urls [-c <path>] [--json] Print BTP (write) + relay (read) URLs to share with clients
2113
+ townhouse metrics [-c <path>] Show connector metrics
2114
+ townhouse wallet show [--json] [--hex] [--paths] [-c <path>] [--password <pw>] Show derived addresses
2115
+ townhouse wallet seed --confirm [-c <path>] [--password <pw>] [--json] Print the BIP-39 seed phrase (password-gated, requires --confirm)
2116
+ townhouse credits buy --token <id> --amount <decimal> [--fee-multiplier <n>] [--quote-only] [--yes] [-c <path>] [--password <pw>]
2117
+ Buy Arweave upload credits (token: eth|sol|pol|base-eth|base-usdc|usdc-eth|usdc-pol)
2118
+ townhouse credits balance --token <id> [-c <path>] [--password <pw>] Show Turbo credit balance for the funding address
2119
+ townhouse hs up [--password <pw>] [--skip-preflight] [-c <path>] Boot/enable hidden-service mode (opt-in, anonymous .anon apex) (launches dashboard TUI in TTY mode)
2120
+ townhouse hs enable [--password <pw>] [-c <path>] [--json] Switch a running direct apex to hidden-service mode (down direct \u2192 up HS; --json emits NDJSON boot steps)
2121
+ townhouse hs down [--rotate-keys] [-c <path>] Stop apex (--rotate-keys deletes .anyone keypair)
2122
+ townhouse node add [<type>] [--json] [-c <path>] Provision a child node (default: town)
2123
+ townhouse node remove <id> [--yes] [--json] [-c <path>] Deprovision a child node
2124
+ townhouse node list [--json] [-c <path>] List provisioned nodes
2125
+ townhouse chains list [--json] [-c <path>] List configured settlement chains (EVM/Solana/Mina)
2126
+ townhouse chains add --chain-type <evm|solana|mina> --chain-id <id> [fields] [-c <path>] Add/update a settlement chain
2127
+ townhouse chains remove <chainId> [-c <path>] Remove a settlement chain
2128
+ townhouse channels [--json] Show open payment channels
2129
+ townhouse logs <node-id> [-f|--follow] [--lines N] [--json] Tail logs for a node (Ctrl-C to stop)
2130
+ townhouse peer <id> [--json] Show per-peer detail card
2131
+ townhouse health [--json] Probe apex/api/nodes/.anyone health
2132
+ townhouse --help Show this help
2133
+
2134
+ Flags:
2135
+ --transport up transport: direct (default; plain ws://host:3000/btp apex) | hs (hidden-service apex, == \`hs up\`)
2136
+ --dev up: boot the contributor children-only dev stack (profile:'dev') instead of the direct apex
2137
+ --town Start Town (Nostr relay) node
2138
+ --mill Start Mill (swap) node
2139
+ --dvm Start DVM (compute) node
2140
+ --password Wallet password (non-interactive mode)
2141
+ --rotate-keys Delete the .anyone keypair volume on hs down (produces a new address on next hs up)
2142
+ --skip-preflight Skip the port-collision preflight check on hs up (escape hatch)
2143
+ --no-browser Skip opening the browser automatically (setup command)
2144
+ --port Override the API port (setup command, default 9400)
2145
+ --preset Init from a named preset (init only). Supported: demo
2146
+ --network Chain network for apex + nodes (init only): testnet (default), devnet, mainnet, custom
2147
+ (mainnet has no deployed TOON settlement contracts \u2192 relay-only)
2148
+ --evm-url / --sol-url RPC URLs for --network custom (the project's dev chains; or EVM_URL/SOL_URL env)
2149
+ --yes Non-interactive (init only); with --preset=demo uses demo password if --password absent
2150
+ --json Machine-readable JSON output (node commands; NDJSON for \`logs\`)
2151
+ --lines Number of historical log lines to fetch on attach (logs command, default 50)
2152
+ -f|--follow Accepted for \`tail -f\` muscle memory on \`logs\` (no-op \u2014 follow is default)
2153
+ With no flags, \`up\` boots a direct-BTP apex + the enabled children from config.`;
2154
+ var DEFAULT_CONFIG_DIR = join(homedir(), ".townhouse");
2155
+ var DEFAULT_CONFIG_PATH = join(DEFAULT_CONFIG_DIR, "config.yaml");
2156
+ function printInitNextStep(dir) {
2157
+ const isDefaultDir = dir === resolve(DEFAULT_CONFIG_DIR);
2158
+ const cmd = isDefaultDir ? "npx @toon-protocol/hub hs up" : `npx @toon-protocol/hub hs up -c ${join(dir, "config.yaml")}`;
2159
+ console.log("");
2160
+ console.log("Next \u2014 start your node:");
2161
+ console.log(` ${cmd}`);
2162
+ console.log("");
2163
+ console.log(
2164
+ "First run pulls container images and bootstraps a hidden service."
2165
+ );
2166
+ console.log("It can take a few minutes; progress is shown throughout.");
2167
+ }
2168
+ async function handleInit(force, configDir, password, preset, yes, network, endpoints, json = false) {
2169
+ const dir = resolve(configDir ?? DEFAULT_CONFIG_DIR);
2170
+ const configPath = join(dir, "config.yaml");
2171
+ if (existsSync(configPath) && !force) {
2172
+ console.error(
2173
+ `Config already exists at ${configPath}. Use --force to overwrite.`
2174
+ );
2175
+ process.exitCode = 1;
2176
+ return;
2177
+ }
2178
+ mkdirSync(dir, { recursive: true, mode: 448 });
2179
+ let configToWrite;
2180
+ if (preset === "demo") {
2181
+ const { buildDemoConfig, DEMO_DETERMINISTIC_PASSWORD } = await import("./demo-UJ37MLCG.js");
2182
+ configToWrite = buildDemoConfig({ walletPath: join(dir, "wallet.enc") });
2183
+ if (yes && !password) {
2184
+ password = DEMO_DETERMINISTIC_PASSWORD;
2185
+ if (!json) {
2186
+ console.log(
2187
+ "[demo preset] Using deterministic demo password (insecure \u2014 demo only)."
2188
+ );
2189
+ }
2190
+ }
2191
+ } else {
2192
+ configToWrite = getDefaultConfig();
2193
+ configToWrite.wallet.encrypted_path = join(dir, "wallet.enc");
2194
+ }
2195
+ if (network !== void 0) {
2196
+ configToWrite.network = network;
2197
+ if (network === "mainnet" && !json) {
2198
+ console.warn(
2199
+ "\u26A0\uFE0F network=mainnet: TOON settlement contracts are not deployed on mainnet yet \u2014\n nodes will run RELAY-ONLY (no on-chain settlement). Use --network testnet\n (or devnet) for a settlement-ready deployment."
2200
+ );
2201
+ }
2202
+ }
2203
+ if (endpoints && (endpoints.evmUrl || endpoints.solUrl)) {
2204
+ configToWrite.endpoints = endpoints;
2205
+ }
2206
+ const yamlContent = stringify(configToWrite);
2207
+ writeFileSync(configPath, yamlContent, {
2208
+ encoding: "utf-8",
2209
+ mode: 384
2210
+ });
2211
+ if (!json) console.log(`Config created at ${configPath}`);
2212
+ const walletPath = join(dir, "wallet.enc");
2213
+ const envMnemonic = process.env["TOWNHOUSE_MNEMONIC"]?.trim();
2214
+ const suppliedPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2215
+ if (envMnemonic && !suppliedPassword) {
2216
+ const walletManager2 = new WalletManager({ encryptedPath: walletPath });
2217
+ await walletManager2.fromMnemonic(envMnemonic);
2218
+ const addresses2 = walletManager2.getAllKeys().map((info) => ({
2219
+ nodeType: info.nodeType,
2220
+ nostrPubkey: info.nostrPubkey,
2221
+ evmAddress: info.evmAddress
2222
+ }));
2223
+ walletManager2.lock();
2224
+ if (json) {
2225
+ console.log(
2226
+ JSON.stringify({
2227
+ created: true,
2228
+ configPath,
2229
+ walletMode: "mnemonic",
2230
+ addresses: addresses2
2231
+ })
2232
+ );
2233
+ return;
2234
+ }
2235
+ console.log("");
2236
+ console.log(
2237
+ "Mnemonic mode \u2014 using TOWNHOUSE_MNEMONIC (no encrypted wallet written)."
2238
+ );
2239
+ console.log("");
2240
+ console.log("Derived Node Addresses:");
2241
+ console.log("-----------------------");
2242
+ for (const info of addresses2) {
2243
+ console.log(` ${info.nodeType.padEnd(6)} Nostr: ${info.nostrPubkey}`);
2244
+ console.log(` ${"".padEnd(6)} EVM: ${info.evmAddress}`);
2245
+ }
2246
+ printInitNextStep(dir);
2247
+ return;
2248
+ }
2249
+ if (existsSync(walletPath) && !force) {
2250
+ if (json) {
2251
+ console.log(JSON.stringify({ created: false, configPath, walletPath }));
2252
+ return;
2253
+ }
2254
+ console.log("");
2255
+ console.log(
2256
+ `Wallet already exists at ${walletPath} \u2014 keeping your existing keys.`
2257
+ );
2258
+ console.log(
2259
+ "Your seed phrase from the first run is still valid; nothing changed."
2260
+ );
2261
+ console.log(
2262
+ "(Re-run with --force to regenerate, which REPLACES your keys.)"
2263
+ );
2264
+ printInitNextStep(dir);
2265
+ return;
2266
+ }
2267
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2268
+ if (!walletPassword) {
2269
+ console.error(
2270
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
2271
+ );
2272
+ process.exitCode = 1;
2273
+ return;
2274
+ }
2275
+ const walletManager = new WalletManager({ encryptedPath: walletPath });
2276
+ const { mnemonic } = await walletManager.generate();
2277
+ if (!json) {
2278
+ console.log("");
2279
+ console.log("=== IMPORTANT: Back up your seed phrase ===");
2280
+ console.log("");
2281
+ console.log(` ${mnemonic}`);
2282
+ console.log("");
2283
+ console.log("This is the ONLY time your seed phrase will be shown.");
2284
+ console.log("Store it safely. You will need it to recover your node keys.");
2285
+ console.log("============================================");
2286
+ console.log("");
2287
+ }
2288
+ const encrypted = encryptWallet(mnemonic, walletPassword);
2289
+ await saveWallet(walletPath, encrypted);
2290
+ if (!json) console.log(`Wallet saved to ${walletPath}`);
2291
+ const allKeys = walletManager.getAllKeys();
2292
+ const addresses = allKeys.map((info) => ({
2293
+ nodeType: info.nodeType,
2294
+ nostrPubkey: info.nostrPubkey,
2295
+ evmAddress: info.evmAddress
2296
+ }));
2297
+ if (json) {
2298
+ console.log(
2299
+ JSON.stringify({
2300
+ created: true,
2301
+ configPath,
2302
+ walletPath,
2303
+ mnemonic,
2304
+ addresses
2305
+ })
2306
+ );
2307
+ walletManager.lock();
2308
+ return;
2309
+ }
2310
+ console.log("");
2311
+ console.log("Derived Node Addresses:");
2312
+ console.log("-----------------------");
2313
+ for (const info of addresses) {
2314
+ console.log(` ${info.nodeType.padEnd(6)} Nostr: ${info.nostrPubkey}`);
2315
+ console.log(` ${"".padEnd(6)} EVM: ${info.evmAddress}`);
2316
+ }
2317
+ walletManager.lock();
2318
+ printInitNextStep(dir);
2319
+ }
2320
+ async function handleSetup(configDir, port, noBrowser, dockerInstance, browserOpener) {
2321
+ const dir = resolve(configDir ?? DEFAULT_CONFIG_DIR);
2322
+ const configPath = join(dir, "config.yaml");
2323
+ const walletPath = join(dir, "wallet.enc");
2324
+ if (existsSync(configPath) && existsSync(walletPath)) {
2325
+ console.log("Already initialized \u2014 run `townhouse up` to start your nodes");
2326
+ return;
2327
+ }
2328
+ if (existsSync(configPath) && !existsSync(walletPath)) {
2329
+ console.error(
2330
+ `Found ${configPath} but no wallet at ${walletPath}.
2331
+ Delete the orphan config and re-run \`townhouse setup\`, or restore the wallet from backup.`
2332
+ );
2333
+ process.exitCode = 1;
2334
+ return;
2335
+ }
2336
+ const docker = dockerInstance ?? new Docker2();
2337
+ const opener = browserOpener ?? new RealBrowserOpener();
2338
+ const wizardServer = await createWizardApiServer({
2339
+ configDir: dir,
2340
+ configPath,
2341
+ walletPath,
2342
+ port,
2343
+ docker
2344
+ });
2345
+ const url = `http://127.0.0.1:${port}/wizard`;
2346
+ try {
2347
+ await wizardServer.app.listen({ host: "127.0.0.1", port });
2348
+ } catch (err) {
2349
+ const e = err;
2350
+ if (e.code === "EADDRINUSE") {
2351
+ console.error(
2352
+ `Port ${port} is already in use. Pass \`--port <n>\` to choose a different port.`
2353
+ );
2354
+ process.exitCode = 1;
2355
+ try {
2356
+ await wizardServer.close();
2357
+ } catch {
2358
+ }
2359
+ return;
2360
+ }
2361
+ throw err;
2362
+ }
2363
+ console.log(`Wizard ready at ${url}`);
2364
+ if (!noBrowser) {
2365
+ await opener.open(url);
2366
+ }
2367
+ let shuttingDown = false;
2368
+ const shutdown = async (sig) => {
2369
+ if (shuttingDown) return;
2370
+ shuttingDown = true;
2371
+ console.log(`
2372
+ Received ${sig}, shutting down...`);
2373
+ try {
2374
+ await wizardServer.close();
2375
+ } catch {
2376
+ }
2377
+ process.exit(0);
2378
+ };
2379
+ process.once("SIGINT", () => {
2380
+ void shutdown("SIGINT");
2381
+ });
2382
+ process.once("SIGTERM", () => {
2383
+ void shutdown("SIGTERM");
2384
+ });
2385
+ }
2386
+ var NODE_ROLE_DESCRIPTIONS = {
2387
+ town: "Nostr relay \u2014 earns ILP fees per event relayed.",
2388
+ mill: "Multi-chain swap peer \u2014 settles cross-chain swaps for fees.",
2389
+ dvm: "Compute / DVM worker \u2014 collects job payments, signs Arweave uploads."
2390
+ };
2391
+ function buildNodeRows(info, options) {
2392
+ const rows = [];
2393
+ const npub = nip19.npubEncode(info.nostrPubkey);
2394
+ const nostrPurposeByNode = {
2395
+ town: "share this to be found",
2396
+ mill: "announces swap quotes",
2397
+ dvm: "offers DVM services"
2398
+ };
2399
+ rows.push({
2400
+ label: "Nostr",
2401
+ value: npub,
2402
+ purpose: nostrPurposeByNode[info.nodeType],
2403
+ hex: options.hex ? info.nostrPubkey : void 0,
2404
+ path: options.paths ? info.nostrDerivationPath : void 0
2405
+ });
2406
+ const evmPurposeByNode = {
2407
+ town: "receives ILP earnings",
2408
+ mill: "settles EVM swaps",
2409
+ dvm: "collects job payments"
2410
+ };
2411
+ rows.push({
2412
+ label: "EVM",
2413
+ value: info.evmAddress,
2414
+ purpose: evmPurposeByNode[info.nodeType],
2415
+ path: options.paths ? info.evmDerivationPath : void 0
2416
+ });
2417
+ const solPurposeByNode = {
2418
+ town: "receives swap fills",
2419
+ mill: "settles SOL swaps",
2420
+ dvm: "spends Arweave credits"
2421
+ };
2422
+ rows.push({
2423
+ label: "SOL",
2424
+ value: info.solanaAddress ?? "\u2014",
2425
+ purpose: solPurposeByNode[info.nodeType],
2426
+ path: options.paths ? info.solanaDerivationPath : void 0
2427
+ });
2428
+ if (info.nodeType === "mill") {
2429
+ rows.push({
2430
+ label: "Mina",
2431
+ value: info.minaAddress ?? "\u2014",
2432
+ purpose: "settles Mina swaps"
2433
+ // Mina derivation path is not currently surfaced through NodeKeyInfo.
2434
+ });
2435
+ }
2436
+ if (info.nodeType === "dvm") {
2437
+ rows.push({
2438
+ label: "AR",
2439
+ value: info.arweaveAddress ?? "\u2014",
2440
+ purpose: "signs Arweave uploads",
2441
+ path: options.paths ? info.arweaveDerivationPath : void 0
2442
+ });
2443
+ }
2444
+ return rows;
2445
+ }
2446
+ function renderNodeCard(info, rows) {
2447
+ const role = NODE_ROLE_DESCRIPTIONS[info.nodeType];
2448
+ const labelWidth = Math.max(...rows.map((r) => r.label.length));
2449
+ const headerLine = `${info.nodeType.toUpperCase()} \u2014 ${role}`;
2450
+ const bodyLines = [];
2451
+ for (const row of rows) {
2452
+ bodyLines.push(`${row.label.padEnd(labelWidth)} ${row.value}`);
2453
+ bodyLines.push(`${" ".repeat(labelWidth)} (${row.purpose})`);
2454
+ if (row.hex) {
2455
+ bodyLines.push(`${" ".repeat(labelWidth)} hex: ${row.hex}`);
2456
+ }
2457
+ if (row.path) {
2458
+ bodyLines.push(`${" ".repeat(labelWidth)} path: ${row.path}`);
2459
+ }
2460
+ }
2461
+ const innerWidth = Math.max(
2462
+ headerLine.length,
2463
+ ...bodyLines.map((l) => l.length)
2464
+ );
2465
+ const totalInner = innerWidth + 2;
2466
+ const horizontal = "\u2500".repeat(totalInner);
2467
+ const top = `\u250C${horizontal}\u2510`;
2468
+ const bottom = `\u2514${horizontal}\u2518`;
2469
+ const lines = [];
2470
+ lines.push(top);
2471
+ lines.push(`\u2502 ${headerLine.padEnd(innerWidth)} \u2502`);
2472
+ lines.push(`\u251C${horizontal}\u2524`);
2473
+ for (const body of bodyLines) {
2474
+ lines.push(`\u2502 ${body.padEnd(innerWidth)} \u2502`);
2475
+ }
2476
+ lines.push(bottom);
2477
+ return lines.join("\n");
2478
+ }
2479
+ function buildWalletJson(allKeys) {
2480
+ const out = {};
2481
+ for (const info of allKeys) {
2482
+ const node = {
2483
+ nostr: {
2484
+ npub: nip19.npubEncode(info.nostrPubkey),
2485
+ hex: info.nostrPubkey,
2486
+ path: info.nostrDerivationPath
2487
+ },
2488
+ evm: { address: info.evmAddress, path: info.evmDerivationPath }
2489
+ };
2490
+ if (info.solanaAddress) {
2491
+ node["sol"] = {
2492
+ address: info.solanaAddress,
2493
+ path: info.solanaDerivationPath
2494
+ };
2495
+ }
2496
+ if (info.nodeType === "mill" && info.minaAddress) {
2497
+ node["mina"] = { address: info.minaAddress };
2498
+ }
2499
+ if (info.nodeType === "dvm" && info.arweaveAddress) {
2500
+ node["arweave"] = {
2501
+ address: info.arweaveAddress,
2502
+ path: info.arweaveDerivationPath
2503
+ };
2504
+ }
2505
+ out[info.nodeType] = node;
2506
+ }
2507
+ return out;
2508
+ }
2509
+ async function handleWalletShow(config, password, options = {}) {
2510
+ const walletPath = config.wallet.encrypted_path;
2511
+ let walletManager = await tryEnvMnemonicWallet(walletPath) ?? void 0;
2512
+ let walletPassword;
2513
+ if (!walletManager) {
2514
+ const result = await loadWallet(walletPath);
2515
+ if (!result) {
2516
+ console.error(
2517
+ "No wallet found. Run `townhouse init` first (or set TOWNHOUSE_MNEMONIC)."
2518
+ );
2519
+ process.exitCode = 1;
2520
+ return;
2521
+ }
2522
+ if (result.permissionsWarning) {
2523
+ console.error(result.permissionsWarning);
2524
+ }
2525
+ walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2526
+ if (!walletPassword) {
2527
+ console.error(
2528
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var (or set TOWNHOUSE_MNEMONIC)."
2529
+ );
2530
+ process.exitCode = 1;
2531
+ return;
2532
+ }
2533
+ walletManager = new WalletManager({ encryptedPath: walletPath });
2534
+ try {
2535
+ await walletManager.fromMnemonic(
2536
+ decryptWallet(result.wallet, walletPassword)
2537
+ );
2538
+ } catch (err) {
2539
+ const msg = err instanceof Error ? err.message : String(err);
2540
+ console.error(`Failed to decrypt wallet: ${msg}`);
2541
+ process.exitCode = 1;
2542
+ return;
2543
+ }
2544
+ }
2545
+ try {
2546
+ const arStartMs = Date.now();
2547
+ const arStatusTimer = setTimeout(() => {
2548
+ process.stderr.write("deriving Arweave key (first run, ~15s)...\n");
2549
+ }, 200);
2550
+ try {
2551
+ await walletManager.ensureArweaveKey("dvm", walletPassword);
2552
+ } catch (err) {
2553
+ const msg = err instanceof Error ? err.message : String(err);
2554
+ console.error(
2555
+ `Warning: Arweave key derivation failed (${msg}). AR address will display as '\u2014'.`
2556
+ );
2557
+ } finally {
2558
+ clearTimeout(arStatusTimer);
2559
+ void arStartMs;
2560
+ }
2561
+ const allKeys = walletManager.getAllKeys();
2562
+ if (options.json) {
2563
+ console.log(JSON.stringify(buildWalletJson(allKeys), null, 2));
2564
+ return;
2565
+ }
2566
+ const renderOpts = {
2567
+ hex: options.hex === true,
2568
+ paths: options.paths === true
2569
+ };
2570
+ for (const info of allKeys) {
2571
+ const rows = buildNodeRows(info, renderOpts);
2572
+ console.log(renderNodeCard(info, rows));
2573
+ console.log("");
2574
+ }
2575
+ console.log("Tip: townhouse wallet show --json for scripting");
2576
+ console.log(" townhouse wallet show --hex to see raw hex pubkeys");
2577
+ console.log(" townhouse wallet show --paths to see derivation paths");
2578
+ console.log(
2579
+ " townhouse credits buy --token sol --amount <n> to fund Arweave uploads"
2580
+ );
2581
+ } finally {
2582
+ walletManager.lock();
2583
+ }
2584
+ }
2585
+ async function handleWalletSeed(config, password, confirm, json = false) {
2586
+ if (!confirm) {
2587
+ console.error(
2588
+ "This command will print your seed phrase to your terminal. Re-run with --confirm to acknowledge."
2589
+ );
2590
+ process.exitCode = 1;
2591
+ return;
2592
+ }
2593
+ const walletPath = config.wallet.encrypted_path;
2594
+ let walletManager = await tryEnvMnemonicWallet(walletPath) ?? void 0;
2595
+ if (!walletManager) {
2596
+ const result = await loadWallet(walletPath);
2597
+ if (!result) {
2598
+ console.error(
2599
+ "No wallet found. Run `townhouse init` first (or set TOWNHOUSE_MNEMONIC)."
2600
+ );
2601
+ process.exitCode = 1;
2602
+ return;
2603
+ }
2604
+ if (result.permissionsWarning) {
2605
+ console.error(result.permissionsWarning);
2606
+ }
2607
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2608
+ if (!walletPassword) {
2609
+ console.error(
2610
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var (or set TOWNHOUSE_MNEMONIC)."
2611
+ );
2612
+ process.exitCode = 1;
2613
+ return;
2614
+ }
2615
+ walletManager = new WalletManager({ encryptedPath: walletPath });
2616
+ try {
2617
+ await walletManager.fromMnemonic(
2618
+ decryptWallet(result.wallet, walletPassword)
2619
+ );
2620
+ } catch (err) {
2621
+ const msg = err instanceof Error ? err.message : String(err);
2622
+ console.error(`Failed to decrypt wallet: ${msg}`);
2623
+ process.exitCode = 1;
2624
+ return;
2625
+ }
2626
+ }
2627
+ try {
2628
+ const mnemonic = walletManager.getMnemonic();
2629
+ if (!mnemonic) {
2630
+ console.error("Internal error: mnemonic unavailable after unlock.");
2631
+ process.exitCode = 1;
2632
+ return;
2633
+ }
2634
+ if (json) {
2635
+ console.log(JSON.stringify({ mnemonic }));
2636
+ return;
2637
+ }
2638
+ console.log(
2639
+ "============================================================="
2640
+ );
2641
+ console.log(" [!] Anyone who sees this seed owns your townhouse identity.");
2642
+ console.log(" [!] Anyone who records this terminal owns your earnings.");
2643
+ console.log(
2644
+ " [!] Shoulder-surf, screen-shares, and tmux logs are vectors."
2645
+ );
2646
+ console.log(
2647
+ "============================================================="
2648
+ );
2649
+ console.log("");
2650
+ console.log("");
2651
+ console.log(` ${mnemonic}`);
2652
+ console.log("");
2653
+ console.log("");
2654
+ console.log(
2655
+ "This is the same 12 words shown at `townhouse init`. Storing them elsewhere is your responsibility."
2656
+ );
2657
+ } finally {
2658
+ walletManager.lock();
2659
+ }
2660
+ }
2661
+ var VALID_TURBO_TOKENS = /* @__PURE__ */ new Set([
2662
+ "eth",
2663
+ "pol",
2664
+ "base-eth",
2665
+ "base-usdc",
2666
+ "usdc-eth",
2667
+ "usdc-pol",
2668
+ "sol",
2669
+ "ar"
2670
+ ]);
2671
+ function isTurboTokenId(value) {
2672
+ return VALID_TURBO_TOKENS.has(value);
2673
+ }
2674
+ async function resolveWalletPassword(flagPassword) {
2675
+ const envPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
2676
+ if (flagPassword) return flagPassword;
2677
+ if (envPassword) return envPassword;
2678
+ if (process.stdin.isTTY) {
2679
+ return await promptPassword("Wallet password: ");
2680
+ }
2681
+ return null;
2682
+ }
2683
+ async function tryEnvMnemonicWallet(walletPath) {
2684
+ const mnemonic = process.env["TOWNHOUSE_MNEMONIC"]?.trim();
2685
+ if (!mnemonic) return null;
2686
+ const walletManager = new WalletManager({ encryptedPath: walletPath });
2687
+ await walletManager.fromMnemonic(mnemonic);
2688
+ return walletManager;
2689
+ }
2690
+ async function promptYesNo(question) {
2691
+ const { createInterface: createInterface3 } = await import("readline");
2692
+ const answer = await new Promise((resolve2) => {
2693
+ const rl = createInterface3({
2694
+ input: process.stdin,
2695
+ output: process.stdout
2696
+ });
2697
+ rl.question(question, (ans) => {
2698
+ rl.close();
2699
+ resolve2(ans);
2700
+ });
2701
+ });
2702
+ return ["y", "yes"].includes(answer.trim().toLowerCase());
2703
+ }
2704
+ async function handleCreditsBuy(config, values, nodeType = "dvm") {
2705
+ const tokenRaw = values["token"];
2706
+ const amountRaw = values["amount"];
2707
+ if (!tokenRaw || !amountRaw) {
2708
+ console.error(
2709
+ "Usage: townhouse credits buy --token <id> --amount <decimal> [--fee-multiplier <n>] [--credit-destination <addr>] [--quote-only] [--yes]"
2710
+ );
2711
+ process.exitCode = 1;
2712
+ return;
2713
+ }
2714
+ if (!isTurboTokenId(tokenRaw)) {
2715
+ console.error(
2716
+ `Unknown token '${tokenRaw}'. Supported: ${Array.from(VALID_TURBO_TOKENS).join(", ")}`
2717
+ );
2718
+ process.exitCode = 1;
2719
+ return;
2720
+ }
2721
+ const token = tokenRaw;
2722
+ let feeMultiplier;
2723
+ const feeRaw = values["fee-multiplier"];
2724
+ if (feeRaw !== void 0) {
2725
+ const parsed = Number(feeRaw);
2726
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2727
+ console.error(
2728
+ `--fee-multiplier must be a positive number, got '${feeRaw}'`
2729
+ );
2730
+ process.exitCode = 1;
2731
+ return;
2732
+ }
2733
+ feeMultiplier = parsed;
2734
+ }
2735
+ const quoteOnly = values["quote-only"] === true;
2736
+ const skipConfirm = values["yes"] === true;
2737
+ const destinationOverride = values["credit-destination"];
2738
+ const json = values["json"] === true;
2739
+ if (json && !quoteOnly && !skipConfirm) {
2740
+ console.error("credits buy --json requires --yes (non-interactive).");
2741
+ process.exitCode = 1;
2742
+ return;
2743
+ }
2744
+ const walletPath = config.wallet.encrypted_path;
2745
+ let wallet = await tryEnvMnemonicWallet(walletPath) ?? void 0;
2746
+ let resolvedPassword;
2747
+ if (!wallet) {
2748
+ const loaded = await loadWallet(walletPath);
2749
+ if (!loaded) {
2750
+ console.error(
2751
+ `No wallet found at ${walletPath}. Run \`townhouse init\` first (or set TOWNHOUSE_MNEMONIC).`
2752
+ );
2753
+ process.exitCode = 1;
2754
+ return;
2755
+ }
2756
+ if (loaded.permissionsWarning) console.error(loaded.permissionsWarning);
2757
+ resolvedPassword = await resolveWalletPassword(values["password"]) ?? void 0;
2758
+ if (!resolvedPassword) {
2759
+ console.error(
2760
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var (or set TOWNHOUSE_MNEMONIC)."
2761
+ );
2762
+ process.exitCode = 1;
2763
+ return;
2764
+ }
2765
+ wallet = new WalletManager({ encryptedPath: walletPath });
2766
+ try {
2767
+ await wallet.fromMnemonic(decryptWallet(loaded.wallet, resolvedPassword));
2768
+ } catch (err) {
2769
+ const msg = err instanceof Error ? err.message : String(err);
2770
+ console.error(`Failed to decrypt wallet: ${msg}`);
2771
+ process.exitCode = 1;
2772
+ return;
2773
+ }
2774
+ }
2775
+ try {
2776
+ let destinationAddress;
2777
+ if (destinationOverride) {
2778
+ destinationAddress = destinationOverride;
2779
+ } else if (token !== "ar" && nodeType === "dvm") {
2780
+ if (!json) {
2781
+ process.stdout.write(
2782
+ `Resolving DVM Arweave credit address (first run, ~10s)...
2783
+ `
2784
+ );
2785
+ }
2786
+ await wallet.ensureArweaveKey("dvm", resolvedPassword);
2787
+ const dvmKeys = wallet.getNodeKeys("dvm");
2788
+ if (!dvmKeys.arweaveAddress) {
2789
+ throw new Error(
2790
+ "DVM Arweave address not populated after ensureArweaveKey"
2791
+ );
2792
+ }
2793
+ destinationAddress = dvmKeys.arweaveAddress;
2794
+ }
2795
+ if (!json) {
2796
+ process.stdout.write(
2797
+ `Quoting ${amountRaw} ${token} for ${nodeType}'s credit address...
2798
+ `
2799
+ );
2800
+ }
2801
+ const quote = await buyCredits({
2802
+ wallet,
2803
+ nodeType,
2804
+ token,
2805
+ amount: amountRaw,
2806
+ quoteOnly: true,
2807
+ ...destinationAddress ? { destinationAddress } : {}
2808
+ });
2809
+ if (quote.kind !== "quote") {
2810
+ throw new Error("Internal error: quoteOnly returned non-quote result");
2811
+ }
2812
+ if (!json) {
2813
+ const quotedDisplay = `${quote.winc.toString()} winc (${formatWincAsBytes(quote.winc)})`;
2814
+ process.stdout.write(
2815
+ `Quote: ${formatTokenAmount(token, quote.baseAmount)} \u2192 ${quotedDisplay}
2816
+ `
2817
+ );
2818
+ process.stdout.write(`Source address (${token}): ${quote.fromAddress}
2819
+ `);
2820
+ process.stdout.write(`Credit recipient: ${quote.creditAddress}
2821
+ `);
2822
+ }
2823
+ if (quoteOnly) {
2824
+ if (json) {
2825
+ console.log(
2826
+ JSON.stringify({
2827
+ kind: "quote",
2828
+ token,
2829
+ baseAmount: quote.baseAmount.toString(),
2830
+ winc: quote.winc.toString(),
2831
+ bytes: formatWincAsBytes(quote.winc),
2832
+ fromAddress: quote.fromAddress,
2833
+ creditAddress: quote.creditAddress
2834
+ })
2835
+ );
2836
+ } else {
2837
+ process.stdout.write(
2838
+ "Quote-only; no on-chain transaction submitted.\n"
2839
+ );
2840
+ }
2841
+ return;
2842
+ }
2843
+ if (!skipConfirm) {
2844
+ const ok = await promptYesNo("Proceed? [y/N] ");
2845
+ if (!ok) {
2846
+ process.stdout.write("Aborted. No transaction submitted.\n");
2847
+ process.exitCode = 1;
2848
+ return;
2849
+ }
2850
+ }
2851
+ if (!json) process.stdout.write("Submitting on-chain transaction...\n");
2852
+ const result = await buyCredits({
2853
+ wallet,
2854
+ nodeType,
2855
+ token,
2856
+ amount: amountRaw,
2857
+ ...feeMultiplier !== void 0 ? { feeMultiplier } : {},
2858
+ ...destinationAddress ? { destinationAddress } : {}
2859
+ });
2860
+ if (result.kind !== "submit") {
2861
+ throw new Error("Internal error: submit path returned non-submit result");
2862
+ }
2863
+ if (json) {
2864
+ console.log(
2865
+ JSON.stringify({
2866
+ kind: "submit",
2867
+ token,
2868
+ id: result.id,
2869
+ status: result.status,
2870
+ winc: result.winc.toString(),
2871
+ bytes: formatWincAsBytes(result.winc),
2872
+ ...result.block !== void 0 ? { block: result.block } : {}
2873
+ })
2874
+ );
2875
+ } else {
2876
+ process.stdout.write(`Transaction submitted: ${result.id}
2877
+ `);
2878
+ process.stdout.write(`Status: ${result.status}
2879
+ `);
2880
+ process.stdout.write(
2881
+ `Credited: ${result.winc.toString()} winc (${formatWincAsBytes(result.winc)})
2882
+ `
2883
+ );
2884
+ if (result.block !== void 0) {
2885
+ process.stdout.write(`Block: ${result.block}
2886
+ `);
2887
+ }
2888
+ process.stdout.write("Done.\n");
2889
+ }
2890
+ } catch (err) {
2891
+ const msg = err instanceof Error ? err.message : String(err);
2892
+ console.error(`credits buy failed: ${msg}`);
2893
+ process.exitCode = 1;
2894
+ } finally {
2895
+ wallet.lock();
2896
+ }
2897
+ }
2898
+ async function handleCreditsBalance(config, values, nodeType = "dvm") {
2899
+ const tokenRaw = values["token"];
2900
+ if (!tokenRaw) {
2901
+ console.error(
2902
+ "Usage: townhouse credits balance --token <id> [-c <path>] [--password <pw>]"
2903
+ );
2904
+ process.exitCode = 1;
2905
+ return;
2906
+ }
2907
+ if (!isTurboTokenId(tokenRaw)) {
2908
+ console.error(
2909
+ `Unknown token '${tokenRaw}'. Supported: ${Array.from(VALID_TURBO_TOKENS).join(", ")}`
2910
+ );
2911
+ process.exitCode = 1;
2912
+ return;
2913
+ }
2914
+ const token = tokenRaw;
2915
+ const walletPath = config.wallet.encrypted_path;
2916
+ let wallet = await tryEnvMnemonicWallet(walletPath) ?? void 0;
2917
+ if (!wallet) {
2918
+ const loaded = await loadWallet(walletPath);
2919
+ if (!loaded) {
2920
+ console.error(
2921
+ `No wallet found at ${walletPath}. Run \`townhouse init\` first (or set TOWNHOUSE_MNEMONIC).`
2922
+ );
2923
+ process.exitCode = 1;
2924
+ return;
2925
+ }
2926
+ if (loaded.permissionsWarning) console.error(loaded.permissionsWarning);
2927
+ const resolvedPassword = await resolveWalletPassword(
2928
+ values["password"]
2929
+ );
2930
+ if (!resolvedPassword) {
2931
+ console.error(
2932
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var (or set TOWNHOUSE_MNEMONIC)."
2933
+ );
2934
+ process.exitCode = 1;
2935
+ return;
2936
+ }
2937
+ wallet = new WalletManager({ encryptedPath: walletPath });
2938
+ try {
2939
+ await wallet.fromMnemonic(decryptWallet(loaded.wallet, resolvedPassword));
2940
+ } catch (err) {
2941
+ const msg = err instanceof Error ? err.message : String(err);
2942
+ console.error(`Failed to decrypt wallet: ${msg}`);
2943
+ process.exitCode = 1;
2944
+ return;
2945
+ }
2946
+ }
2947
+ const json = values["json"] === true;
2948
+ try {
2949
+ const balance = await getCreditBalance({ wallet, nodeType, token });
2950
+ if (json) {
2951
+ console.log(
2952
+ JSON.stringify({
2953
+ token,
2954
+ address: balance.address,
2955
+ winc: balance.winc.toString(),
2956
+ effectiveBalance: balance.effectiveBalance.toString(),
2957
+ bytes: formatWincAsBytes(balance.winc)
2958
+ })
2959
+ );
2960
+ } else {
2961
+ process.stdout.write(`Address (${token}): ${balance.address}
2962
+ `);
2963
+ process.stdout.write(
2964
+ `Balance: ${balance.winc.toString()} winc (${formatWincAsBytes(balance.winc)})
2965
+ `
2966
+ );
2967
+ if (balance.effectiveBalance !== balance.winc) {
2968
+ process.stdout.write(
2969
+ `Effective (incl. received approvals): ${balance.effectiveBalance.toString()} winc (${formatWincAsBytes(balance.effectiveBalance)})
2970
+ `
2971
+ );
2972
+ }
2973
+ }
2974
+ } catch (err) {
2975
+ const msg = err instanceof Error ? err.message : String(err);
2976
+ console.error(`credits balance failed: ${msg}`);
2977
+ process.exitCode = 1;
2978
+ } finally {
2979
+ wallet.lock();
2980
+ }
2981
+ }
2982
+ async function resolveEarnings(adminUrl, configPath) {
2983
+ const base = dirname(configPath);
2984
+ try {
2985
+ const yaml = await readNodesYaml(join(base, "nodes.yaml"));
2986
+ return await aggregateEarnings({
2987
+ connectorAdmin: new ConnectorAdminClient(adminUrl),
2988
+ peerTypeResolver: new PeerTypeResolver(yaml),
2989
+ deltaComputer: createDeltaComputer({
2990
+ snapshotPath: join(base, "earnings-snapshots.jsonl")
2991
+ })
2992
+ });
2993
+ } catch (err) {
2994
+ console.error(`Earnings unavailable: ${formatLocalEarningsError(err)}`);
2995
+ return {
2996
+ status: "connector_unavailable",
2997
+ apex: { routingFees: {} },
2998
+ peers: [],
2999
+ recentClaims: [],
3000
+ eventsRelayed: 0,
3001
+ uptimeSeconds: 0
3002
+ };
3003
+ }
3004
+ }
3005
+ function formatLocalEarningsError(err) {
3006
+ if (err !== null && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
3007
+ const issues = err.issues;
3008
+ const parts = issues.map((i) => {
3009
+ const path = Array.isArray(i.path) && i.path.length > 0 ? i.path.join(".") : "<root>";
3010
+ const msg = typeof i.message === "string" ? i.message : "invalid";
3011
+ return `${path}: ${msg}`;
3012
+ }).join("; ");
3013
+ if (parts) return parts;
3014
+ }
3015
+ return err instanceof Error ? err.message : String(err);
3016
+ }
3017
+ async function handleStatus(docker, config, opts = {
3018
+ units: "usdc",
3019
+ configPath: DEFAULT_CONFIG_PATH
3020
+ }) {
3021
+ const json = opts.json === true;
3022
+ const orchestrator = new DockerOrchestrator(docker, config, void 0, {
3023
+ profile: "dev"
3024
+ });
3025
+ const statuses = await orchestrator.status();
3026
+ const payload = {
3027
+ nodes: statuses.map((s) => ({
3028
+ name: s.name,
3029
+ state: s.state,
3030
+ ...s.health ? { health: s.health } : {}
3031
+ })),
3032
+ connector: { available: false }
3033
+ };
3034
+ if (!json) {
3035
+ console.log("Node Status:");
3036
+ console.log("------------");
3037
+ for (const s of statuses) {
3038
+ const health = s.health ? ` (${s.health})` : "";
3039
+ console.log(` ${s.name.padEnd(12)} ${s.state}${health}`);
3040
+ }
3041
+ }
3042
+ const connectorHs = config.transport.hiddenService;
3043
+ const relayHs = config.transport.relayHiddenService;
3044
+ const connectorUrl = connectorHs?.externalUrl ?? config.transport.externalUrl;
3045
+ if (config.transport.mode === "hs" || connectorHs?.externalUrl || relayHs?.externalUrl || config.transport.externalUrl) {
3046
+ payload.hiddenServices = {
3047
+ ...connectorUrl ? { connector: connectorUrl } : {},
3048
+ ...relayHs?.externalUrl ? { relay: relayHs.externalUrl } : {}
3049
+ };
3050
+ if (!json) {
3051
+ console.log("");
3052
+ console.log("Hidden Services:");
3053
+ console.log("----------------");
3054
+ if (connectorUrl) {
3055
+ console.log(` Connector (BTP): ${connectorUrl}`);
3056
+ }
3057
+ if (relayHs?.externalUrl) {
3058
+ console.log(` Relay (Nostr): ${relayHs.externalUrl}`);
3059
+ }
3060
+ if (!connectorUrl && !relayHs?.externalUrl) {
3061
+ console.log(" (hs mode set but no externalUrl configured)");
3062
+ }
3063
+ }
3064
+ }
3065
+ try {
3066
+ const adminClient = new ConnectorAdminClient(
3067
+ `http://127.0.0.1:${config.connector.adminPort}`
3068
+ );
3069
+ const metrics = await adminClient.getMetrics();
3070
+ const peers = await adminClient.getPeers();
3071
+ const activePeers = peers.filter((p) => p.connected).length;
3072
+ payload.connector = {
3073
+ available: true,
3074
+ packetsForwarded: metrics.aggregate.packetsForwarded,
3075
+ activePeers,
3076
+ totalPeers: peers.length
3077
+ };
3078
+ if (!json) {
3079
+ console.log("");
3080
+ console.log("Connector Metrics:");
3081
+ console.log("------------------");
3082
+ console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);
3083
+ console.log(` Active peers: ${activePeers}/${peers.length}`);
3084
+ }
3085
+ } catch {
3086
+ if (!json) {
3087
+ console.log("");
3088
+ console.log("Connector Metrics: unavailable");
3089
+ }
3090
+ }
3091
+ if (opts.units === "sats" && opts.satsPerUsdc === void 0) {
3092
+ if (json) console.log(JSON.stringify(payload));
3093
+ return;
3094
+ }
3095
+ const earnings = await resolveEarnings(
3096
+ `http://127.0.0.1:${config.connector.adminPort}`,
3097
+ opts.configPath
3098
+ );
3099
+ payload.earnings = earnings;
3100
+ if (json) {
3101
+ console.log(JSON.stringify(payload));
3102
+ return;
3103
+ }
3104
+ for (const line of renderEarningsSection({
3105
+ earnings,
3106
+ units: opts.units,
3107
+ satsPerUsdc: opts.satsPerUsdc
3108
+ }))
3109
+ console.log(line);
3110
+ }
3111
+ function resolveProfiles(values, config) {
3112
+ const explicitFlags = [];
3113
+ if (values["town"]) explicitFlags.push("town");
3114
+ if (values["mill"]) explicitFlags.push("mill");
3115
+ if (values["dvm"]) explicitFlags.push("dvm");
3116
+ if (explicitFlags.length > 0) {
3117
+ return explicitFlags;
3118
+ }
3119
+ const enabled = [];
3120
+ if (config.nodes.town.enabled) enabled.push("town");
3121
+ if (config.nodes.mill.enabled) enabled.push("mill");
3122
+ if (config.nodes.dvm.enabled) enabled.push("dvm");
3123
+ return enabled;
3124
+ }
3125
+ async function handleUp(configPath, config, profiles, docker, password, dryRun = false) {
3126
+ if (profiles.length === 0) {
3127
+ console.log(
3128
+ "No nodes enabled in config. Enable nodes in config.yaml first."
3129
+ );
3130
+ return;
3131
+ }
3132
+ const walletPath = config.wallet.encrypted_path;
3133
+ let walletManager = await tryEnvMnemonicWallet(walletPath) ?? void 0;
3134
+ let walletPassword;
3135
+ if (!walletManager) {
3136
+ if (!existsSync(walletPath)) {
3137
+ console.error(
3138
+ `Wallet not found at ${walletPath}. Run \`townhouse setup\` first (or restore your wallet backup), or set TOWNHOUSE_MNEMONIC.`
3139
+ );
3140
+ process.exitCode = 1;
3141
+ return;
3142
+ }
3143
+ walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
3144
+ if (!walletPassword) {
3145
+ throw new Error(
3146
+ "Wallet password required to start the API. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var (or set TOWNHOUSE_MNEMONIC)."
3147
+ );
3148
+ }
3149
+ const loaded = await loadWallet(walletPath);
3150
+ if (!loaded) {
3151
+ throw new Error(`Wallet at ${walletPath} could not be read.`);
3152
+ }
3153
+ if (loaded.permissionsWarning) {
3154
+ console.error(loaded.permissionsWarning);
3155
+ }
3156
+ walletManager = new WalletManager({ encryptedPath: walletPath });
3157
+ try {
3158
+ await walletManager.fromMnemonic(
3159
+ decryptWallet(loaded.wallet, walletPassword)
3160
+ );
3161
+ } catch (err) {
3162
+ const msg = err instanceof Error ? err.message : String(err);
3163
+ throw new Error(`Failed to decrypt wallet: ${msg}`);
3164
+ }
3165
+ }
3166
+ if (profiles.includes("dvm")) {
3167
+ try {
3168
+ await walletManager.ensureArweaveKey("dvm", walletPassword);
3169
+ } catch (err) {
3170
+ const msg = err instanceof Error ? err.message : String(err);
3171
+ console.warn(
3172
+ `[townhouse up] AR pre-warm failed (non-fatal, orchestrator will retry): ${msg}`
3173
+ );
3174
+ }
3175
+ }
3176
+ const orchestrator = new DockerOrchestrator(docker, config, walletManager, {
3177
+ profile: "dev"
3178
+ });
3179
+ orchestrator.on(
3180
+ "containerState",
3181
+ (event) => {
3182
+ console.log(` ${event.name}: ${event.state}`);
3183
+ }
3184
+ );
3185
+ orchestrator.on(
3186
+ "pullProgress",
3187
+ (event) => {
3188
+ const progress = event.progress ? ` ${event.progress}` : "";
3189
+ console.log(` [pull] ${event.image}: ${event.status}${progress}`);
3190
+ }
3191
+ );
3192
+ let apiServer;
3193
+ const sigintHandler = async () => {
3194
+ console.log("\nReceived SIGINT, shutting down gracefully...");
3195
+ if (apiServer) {
3196
+ try {
3197
+ await apiServer.close();
3198
+ } catch {
3199
+ }
3200
+ }
3201
+ try {
3202
+ await orchestrator.down();
3203
+ } catch {
3204
+ }
3205
+ process.exit(0);
3206
+ };
3207
+ process.on("SIGINT", sigintHandler);
3208
+ const sigtermHandler = async () => {
3209
+ console.log("\nReceived SIGTERM, shutting down gracefully...");
3210
+ if (apiServer) {
3211
+ try {
3212
+ await apiServer.close();
3213
+ } catch {
3214
+ }
3215
+ }
3216
+ try {
3217
+ await orchestrator.down();
3218
+ } catch {
3219
+ }
3220
+ process.exit(0);
3221
+ };
3222
+ process.on("SIGTERM", sigtermHandler);
3223
+ let serverStarted = false;
3224
+ if (profiles.includes("dvm") && config.nodes.dvm.enabled && !process.env["TURBO_TOKEN"]) {
3225
+ console.warn(
3226
+ "[townhouse] WARN: TURBO_TOKEN is not set \u2014 Arweave DVM (kind:5094) free-tier (<100KB) uploads still work, but larger/paid uploads will fail."
3227
+ );
3228
+ console.warn(
3229
+ "[townhouse] Pass `townhouse node add dvm --turbo-token <arweave-jwk-json>` (HS mode) or export TURBO_TOKEN before `townhouse up` to enable full uploads."
3230
+ );
3231
+ }
3232
+ try {
3233
+ console.log(`Starting nodes: ${profiles.join(", ")}...`);
3234
+ if (!dryRun) {
3235
+ await orchestrator.up(profiles);
3236
+ console.log("All nodes started successfully.");
3237
+ } else {
3238
+ console.log("[dry-run] Skipped orchestrator.up()");
3239
+ }
3240
+ if (walletManager) {
3241
+ const connectorAdmin = new ConnectorAdminClient(
3242
+ `http://127.0.0.1:${config.connector.adminPort}`
3243
+ );
3244
+ const transportProbe = new TransportProbe({
3245
+ proxyUrl: config.transport.mode === "hs" ? config.transport.socksProxy ?? DEFAULT_ATOR_PROXY : ""
3246
+ });
3247
+ if (config.transport.mode === "hs") {
3248
+ transportProbe.start();
3249
+ }
3250
+ const apiDeps = {
3251
+ configPath,
3252
+ config,
3253
+ orchestrator,
3254
+ wallet: walletManager,
3255
+ connectorAdmin,
3256
+ transportProbe
3257
+ };
3258
+ apiServer = await createApiServer(apiDeps);
3259
+ const { host, port } = config.api;
3260
+ if (!dryRun) {
3261
+ await apiServer.app.listen({
3262
+ host: host ?? "127.0.0.1",
3263
+ port: port ?? 9400
3264
+ });
3265
+ serverStarted = true;
3266
+ console.log(
3267
+ `
3268
+ [Townhouse API] listening on http://${host ?? "127.0.0.1"}:${port ?? 9400}`
3269
+ );
3270
+ console.log(
3271
+ " GET /nodes, GET /nodes/:type, PATCH /nodes/:type/config, GET /wallet, WS /metrics"
3272
+ );
3273
+ } else {
3274
+ console.log(
3275
+ `[dry-run] API factory invoked: configPath=${configPath} host=${host ?? "127.0.0.1"} port=${port ?? 9400} connectorAdmin=http://127.0.0.1:${config.connector.adminPort} wallet=WalletManager`
3276
+ );
3277
+ await apiServer.close();
3278
+ apiServer = void 0;
3279
+ }
3280
+ }
3281
+ } catch (error) {
3282
+ const msg = error instanceof Error ? error.message : String(error);
3283
+ if (msg.includes("Docker is not running") || msg.includes("ENOENT") || msg.includes("ECONNREFUSED") || msg.includes("socket")) {
3284
+ throw new Error(
3285
+ `Docker is not available. Please ensure Docker is running and try again. (${msg})`
3286
+ );
3287
+ }
3288
+ throw error;
3289
+ } finally {
3290
+ if (!serverStarted) {
3291
+ process.removeListener("SIGINT", sigintHandler);
3292
+ process.removeListener("SIGTERM", sigtermHandler);
3293
+ }
3294
+ }
3295
+ }
3296
+ async function handleDown(config, docker, json = false) {
3297
+ const orchestrator = new DockerOrchestrator(docker, config, void 0, {
3298
+ profile: "dev"
3299
+ });
3300
+ const nodes = [];
3301
+ orchestrator.on(
3302
+ "containerState",
3303
+ (event) => {
3304
+ nodes.push(event);
3305
+ if (!json) console.log(` ${event.name}: ${event.state}`);
3306
+ }
3307
+ );
3308
+ if (!json) console.log("Stopping nodes...");
3309
+ await orchestrator.down();
3310
+ if (json) {
3311
+ console.log(JSON.stringify({ stopped: true, nodes }));
3312
+ } else {
3313
+ console.log("All nodes stopped.");
3314
+ }
3315
+ }
3316
+ var HS_CONNECTOR_ADMIN_URL = "http://127.0.0.1:9401";
3317
+ var HS_TOWNHOUSE_API_URL = "http://127.0.0.1:28090";
3318
+ var DIRECT_BTP_DIAL_URL = "ws://127.0.0.1:3000/btp";
3319
+ async function reconcileWithBriefRetry(reconciler, budgetMs) {
3320
+ const deadline = Date.now() + budgetMs;
3321
+ for (; ; ) {
3322
+ try {
3323
+ await reconciler.reconcile();
3324
+ return;
3325
+ } catch (err) {
3326
+ const msg = err instanceof Error ? err.message : String(err);
3327
+ const transient = msg.includes("ECONNREFUSED") || msg.includes("connection refused") || msg.includes("request timeout");
3328
+ if (!transient || Date.now() >= deadline) {
3329
+ throw err;
3330
+ }
3331
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
3332
+ }
3333
+ }
3334
+ }
3335
+ async function rebindAndReconcileChildren(opts) {
3336
+ const { configDir, walletManager, orch, config, logPrefix, hsOverrides } = opts;
3337
+ const nodesYamlPath = join(configDir, "nodes.yaml");
3338
+ let publicBtpUrl;
3339
+ let relayUrl;
3340
+ try {
3341
+ let hostname;
3342
+ let relayHostname;
3343
+ try {
3344
+ const raw = readFileSync(join(configDir, "host.json"), "utf-8");
3345
+ const parsed = JSON.parse(raw);
3346
+ if (typeof parsed.hostname === "string") hostname = parsed.hostname;
3347
+ if (typeof parsed.relayHostname === "string")
3348
+ relayHostname = parsed.relayHostname;
3349
+ } catch {
3350
+ }
3351
+ publicBtpUrl = resolvePublicBtpUrl(config, hostname);
3352
+ relayUrl = resolveRelayUrl(config, relayHostname);
3353
+ } catch {
3354
+ publicBtpUrl = void 0;
3355
+ relayUrl = void 0;
3356
+ }
3357
+ const composeEnvPrev = {
3358
+ TOWNHOUSE_HOME: process.env["TOWNHOUSE_HOME"],
3359
+ TOWNHOUSE_WALLET_DIR: process.env["TOWNHOUSE_WALLET_DIR"],
3360
+ TOWNHOUSE_UID: process.env["TOWNHOUSE_UID"],
3361
+ TOWNHOUSE_DOCKER_GID: process.env["TOWNHOUSE_DOCKER_GID"]
3362
+ };
3363
+ process.env["TOWNHOUSE_HOME"] = configDir;
3364
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
3365
+ resolve(config.wallet.encrypted_path)
3366
+ );
3367
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
3368
+ try {
3369
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(
3370
+ statSync("/var/run/docker.sock").gid
3371
+ );
3372
+ } catch {
3373
+ process.env["TOWNHOUSE_DOCKER_GID"] = "0";
3374
+ }
3375
+ try {
3376
+ const rebindFn = hsOverrides?.rebindChildren ?? rebindChildContainers;
3377
+ if (walletManager && typeof orch.startNodeViaCompose === "function") {
3378
+ const startNodeViaCompose = orch.startNodeViaCompose.bind(orch);
3379
+ try {
3380
+ const summary = await rebindFn({
3381
+ nodesYamlPath,
3382
+ wallet: walletManager,
3383
+ orchestrator: { startNodeViaCompose },
3384
+ config,
3385
+ publicBtpUrl,
3386
+ relayUrl,
3387
+ log: (line) => console.error(`${logPrefix} ${line}`)
3388
+ });
3389
+ for (const s of summary.skipped) {
3390
+ console.error(`${logPrefix} node ${s.id} not rebound: ${s.reason}`);
3391
+ }
3392
+ for (const f of summary.failed) {
3393
+ console.error(
3394
+ `${logPrefix} node ${f.id} rebind failed (non-fatal): ${f.err}`
3395
+ );
3396
+ }
3397
+ } catch (rebindErr) {
3398
+ const detail = rebindErr instanceof Error ? rebindErr.stack ?? rebindErr.message : String(rebindErr);
3399
+ console.error(`${logPrefix} child rebind error (non-fatal): ${detail}`);
3400
+ }
3401
+ }
3402
+ const reconcilerLogPath = join(configDir, "reconciler.log");
3403
+ const reconcilerFactory = hsOverrides?.createReconciler ?? ((nodesPath, logPath) => {
3404
+ const reconcilerAdminClient = new ConnectorAdminClient(
3405
+ HS_CONNECTOR_ADMIN_URL,
3406
+ 5e3
3407
+ );
3408
+ return new BootReconciler(reconcilerAdminClient, nodesPath, logPath);
3409
+ });
3410
+ const reconciler = reconcilerFactory(nodesYamlPath, reconcilerLogPath);
3411
+ try {
3412
+ await reconcileWithBriefRetry(reconciler, 5e3);
3413
+ } catch (reconcilerErr) {
3414
+ const detail = reconcilerErr instanceof Error ? reconcilerErr.stack ?? reconcilerErr.message : String(reconcilerErr);
3415
+ console.error(`${logPrefix} reconciler error (non-fatal): ${detail}`);
3416
+ }
3417
+ } finally {
3418
+ for (const [k, v] of Object.entries(composeEnvPrev)) {
3419
+ if (v === void 0) Reflect.deleteProperty(process.env, k);
3420
+ else process.env[k] = v;
3421
+ }
3422
+ }
3423
+ }
3424
+ async function collectApexImageRefs(configDir) {
3425
+ const manifestPath = join(configDir, "image-manifest.json");
3426
+ if (!existsSync(manifestPath)) return [];
3427
+ try {
3428
+ const manifest = await readImageManifest(manifestPath);
3429
+ if (isSyntheticDigest(manifest.images.connector.digest) || isSyntheticDigest(manifest.images["townhouse-api"].digest)) {
3430
+ return [];
3431
+ }
3432
+ return [
3433
+ `${manifest.images.connector.name}@${manifest.images.connector.digest}`,
3434
+ `${manifest.images["townhouse-api"].name}@${manifest.images["townhouse-api"].digest}`
3435
+ ];
3436
+ } catch {
3437
+ return [];
3438
+ }
3439
+ }
3440
+ function isAnonBootstrapTimeout(err) {
3441
+ if (!(err instanceof OrchestratorError)) return false;
3442
+ const text = `${err.message}
3443
+ ${err.stderr ?? ""}`;
3444
+ return /connector.*unhealthy|dependency.*connector.*fail/i.test(text);
3445
+ }
3446
+ async function attachDashboard(hostname) {
3447
+ if (!shouldRenderInk()) return;
3448
+ try {
3449
+ const { mountTui } = await import("./tui-QE3ZRZO3.js");
3450
+ const apiUrlOverride = process.env["HS_TOWNHOUSE_API_URL"];
3451
+ const mountOpts = apiUrlOverride !== void 0 ? { apiUrl: apiUrlOverride } : {};
3452
+ const instance = mountTui(mountOpts);
3453
+ await instance.waitUntilExit();
3454
+ } catch (tuiErr) {
3455
+ const detail = tuiErr instanceof Error ? tuiErr.message : String(tuiErr);
3456
+ console.error("");
3457
+ console.error(`Your node is live at ${hostname}.`);
3458
+ console.error(
3459
+ `The live dashboard could not open (${detail}) \u2014 this is a display issue, not a node issue. Your node keeps running.`
3460
+ );
3461
+ console.error(
3462
+ "Stop it anytime with: npx @toon-protocol/hub hs down"
3463
+ );
3464
+ }
3465
+ }
3466
+ function emitUpStep(json, step, extra = {}) {
3467
+ if (json) console.log(JSON.stringify({ step, ...extra }));
3468
+ }
3469
+ async function handleHsUp(_configPath, configDir, config, docker, options) {
3470
+ const { password, force, skipPreflight, hsOverrides } = options;
3471
+ const json = options.json === true;
3472
+ emitUpStep(json, "starting", { transport: "hs" });
3473
+ if (!force) {
3474
+ const adminClientFactory = hsOverrides?.createAdminClient ?? ((url, t) => new ConnectorAdminClient(url, t));
3475
+ const probe = adminClientFactory(HS_CONNECTOR_ADMIN_URL, 3e3);
3476
+ try {
3477
+ const existing = await probe.getHsHostname();
3478
+ if (existing.hostname !== null) {
3479
+ console.log(`Apex live at ${existing.hostname}`);
3480
+ emitUpStep(json, "done", {
3481
+ transport: "hs",
3482
+ hostname: existing.hostname,
3483
+ alreadyLive: true
3484
+ });
3485
+ _writeHostJson(configDir, {
3486
+ hostname: existing.hostname,
3487
+ publishedAt: existing.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3488
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
3489
+ });
3490
+ await attachDashboard(existing.hostname);
3491
+ return;
3492
+ }
3493
+ } catch (probeErr) {
3494
+ const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
3495
+ if (msg.includes("anon-disabled")) {
3496
+ const { exitCode } = renderFailure(probeErr);
3497
+ process.exitCode = exitCode;
3498
+ return;
3499
+ }
3500
+ }
3501
+ }
3502
+ if (!skipPreflight) {
3503
+ const preflight = hsOverrides?.checkPortCollisions ?? ((d) => checkHsPortCollisions(d));
3504
+ try {
3505
+ const collisions = await preflight(docker);
3506
+ if (collisions.length > 0) {
3507
+ const msg = formatCollisionMessage(collisions);
3508
+ process.stderr.write(msg);
3509
+ process.exitCode = 1;
3510
+ return;
3511
+ }
3512
+ } catch (preflightErr) {
3513
+ const detail = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
3514
+ console.error(
3515
+ `[townhouse hs up] port preflight skipped (non-fatal): ${detail}`
3516
+ );
3517
+ }
3518
+ }
3519
+ const walletPath = config.wallet.encrypted_path;
3520
+ let walletManager = await tryEnvMnemonicWallet(walletPath) ?? void 0;
3521
+ let resolvedPassword;
3522
+ if (!walletManager) {
3523
+ if (!existsSync(walletPath)) {
3524
+ console.error(
3525
+ `Wallet not found at ${walletPath}. Run \`townhouse init\` first (or set TOWNHOUSE_MNEMONIC).`
3526
+ );
3527
+ process.exitCode = 1;
3528
+ return;
3529
+ }
3530
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
3531
+ if (walletPassword) {
3532
+ resolvedPassword = walletPassword;
3533
+ } else if (process.stdin.isTTY) {
3534
+ resolvedPassword = await promptPassword("Wallet password: ");
3535
+ } else {
3536
+ console.error(
3537
+ "Wallet password required, but no interactive terminal is available to prompt.\nPass --password <pw>, set TOWNHOUSE_WALLET_PASSWORD, or set TOWNHOUSE_MNEMONIC."
3538
+ );
3539
+ process.exitCode = 1;
3540
+ return;
3541
+ }
3542
+ const loaded = await loadWallet(walletPath);
3543
+ if (!loaded) {
3544
+ console.error(`Wallet at ${walletPath} could not be read.`);
3545
+ process.exitCode = 1;
3546
+ return;
3547
+ }
3548
+ try {
3549
+ walletManager = new WalletManager({ encryptedPath: walletPath });
3550
+ await walletManager.fromMnemonic(
3551
+ decryptWallet(loaded.wallet, resolvedPassword)
3552
+ );
3553
+ } catch (err) {
3554
+ const msg = err instanceof Error ? err.message : String(err);
3555
+ console.error(`Failed to decrypt wallet: ${msg}`);
3556
+ process.exitCode = 1;
3557
+ return;
3558
+ }
3559
+ }
3560
+ const ribbon = new OnboardingRibbon();
3561
+ try {
3562
+ const apexSettlementKeys = await walletManager.getApexSettlementKeys();
3563
+ writeHsConnectorConfig(configDir, config, { force, apexSettlementKeys });
3564
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
3565
+ const { composePath } = materialize("hs", { townhouseHome: configDir });
3566
+ writeHsNodeEnvFile(configDir, config);
3567
+ ribbon.start("pull");
3568
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
3569
+ const orch = orchestratorFactory(docker, config, walletManager, {
3570
+ profile: "hs",
3571
+ composePath
3572
+ });
3573
+ const narrator = new PullNarrator();
3574
+ orch.on("pullProgress", (event) => {
3575
+ const ev = event;
3576
+ if (!ev.image || !ev.status) return;
3577
+ const line = narrator.format({
3578
+ image: ev.image,
3579
+ status: ev.status,
3580
+ id: ev.id,
3581
+ progress: ev.progress
3582
+ });
3583
+ if (line !== null) console.log(line);
3584
+ });
3585
+ let bootstrapStarted = false;
3586
+ orch.on("containerState", (event) => {
3587
+ const ev = event;
3588
+ if (!bootstrapStarted && (ev.state === "creating" || ev.state === "starting")) {
3589
+ bootstrapStarted = true;
3590
+ ribbon.start("bootstrap");
3591
+ }
3592
+ });
3593
+ ribbon.stop();
3594
+ if (typeof orch.pullImage === "function") {
3595
+ try {
3596
+ const apexImages = await collectApexImageRefs(configDir);
3597
+ if (apexImages.length > 0) {
3598
+ console.log(
3599
+ `Pulling ${apexImages.length} apex ${apexImages.length === 1 ? "image" : "images"}...`
3600
+ );
3601
+ let pulled = 0;
3602
+ for (const ref of apexImages) {
3603
+ pulled++;
3604
+ console.log(` [${pulled}/${apexImages.length}] ${ref}`);
3605
+ await orch.pullImage(ref);
3606
+ }
3607
+ } else {
3608
+ console.log(
3609
+ "No pinned image manifest found \u2014 Docker will pull images on demand."
3610
+ );
3611
+ console.log(
3612
+ "First start can take several minutes with limited progress output."
3613
+ );
3614
+ }
3615
+ } catch (pullErr) {
3616
+ const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
3617
+ console.log(
3618
+ `Could not pre-pull images (${detail}). Docker will pull them during startup \u2014 this is normal and may take a few minutes.`
3619
+ );
3620
+ }
3621
+ }
3622
+ let dockerSockGid = 0;
3623
+ try {
3624
+ dockerSockGid = statSync("/var/run/docker.sock").gid;
3625
+ } catch {
3626
+ }
3627
+ const prevTownhouseHome = process.env["TOWNHOUSE_HOME"];
3628
+ const prevWalletPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
3629
+ const prevTownhouseUid = process.env["TOWNHOUSE_UID"];
3630
+ const prevWalletDir = process.env["TOWNHOUSE_WALLET_DIR"];
3631
+ const prevDockerGid = process.env["TOWNHOUSE_DOCKER_GID"];
3632
+ process.env["TOWNHOUSE_HOME"] = configDir;
3633
+ if (resolvedPassword !== void 0) {
3634
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = resolvedPassword;
3635
+ }
3636
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
3637
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
3638
+ resolve(config.wallet.encrypted_path)
3639
+ );
3640
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(dockerSockGid);
3641
+ if (!bootstrapStarted) {
3642
+ bootstrapStarted = true;
3643
+ ribbon.start("bootstrap");
3644
+ }
3645
+ const MAX_ANON_RETRIES = 3;
3646
+ try {
3647
+ for (let attempt = 1; attempt <= MAX_ANON_RETRIES; attempt++) {
3648
+ try {
3649
+ await orch.up([]);
3650
+ break;
3651
+ } catch (err) {
3652
+ if (isAnonBootstrapTimeout(err) && attempt < MAX_ANON_RETRIES) {
3653
+ console.error(
3654
+ `[townhouse hs up] ATOR bootstrap timed out (attempt ${attempt}/${MAX_ANON_RETRIES}) \u2014 retrying...`
3655
+ );
3656
+ await orch.down().catch(() => void 0);
3657
+ continue;
3658
+ }
3659
+ throw err;
3660
+ }
3661
+ }
3662
+ } finally {
3663
+ if (prevTownhouseHome === void 0) {
3664
+ delete process.env["TOWNHOUSE_HOME"];
3665
+ } else {
3666
+ process.env["TOWNHOUSE_HOME"] = prevTownhouseHome;
3667
+ }
3668
+ if (prevWalletPassword === void 0) {
3669
+ delete process.env["TOWNHOUSE_WALLET_PASSWORD"];
3670
+ } else {
3671
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = prevWalletPassword;
3672
+ }
3673
+ if (prevTownhouseUid === void 0) {
3674
+ delete process.env["TOWNHOUSE_UID"];
3675
+ } else {
3676
+ process.env["TOWNHOUSE_UID"] = prevTownhouseUid;
3677
+ }
3678
+ if (prevWalletDir === void 0) {
3679
+ delete process.env["TOWNHOUSE_WALLET_DIR"];
3680
+ } else {
3681
+ process.env["TOWNHOUSE_WALLET_DIR"] = prevWalletDir;
3682
+ }
3683
+ if (prevDockerGid === void 0) {
3684
+ delete process.env["TOWNHOUSE_DOCKER_GID"];
3685
+ } else {
3686
+ process.env["TOWNHOUSE_DOCKER_GID"] = prevDockerGid;
3687
+ }
3688
+ }
3689
+ await rebindAndReconcileChildren({
3690
+ configDir,
3691
+ walletManager,
3692
+ orch,
3693
+ config,
3694
+ logPrefix: "[townhouse hs up]",
3695
+ hsOverrides
3696
+ });
3697
+ let relayHostname;
3698
+ if (nodesYamlHasTown(configDir) && typeof orch.ensureRelaySidecar === "function" && typeof orch.getRelayHsHostname === "function") {
3699
+ try {
3700
+ await orch.ensureRelaySidecar();
3701
+ relayHostname = await orch.getRelayHsHostname() ?? void 0;
3702
+ if (relayHostname) {
3703
+ console.error(
3704
+ `[townhouse hs up] relay hidden service published: wss://${relayHostname}/`
3705
+ );
3706
+ } else {
3707
+ console.error(
3708
+ "[townhouse hs up] relay hidden service started; hostname not resolved yet (will appear on next `hs up` / `townhouse urls`)"
3709
+ );
3710
+ }
3711
+ } catch (relayErr) {
3712
+ const detail = relayErr instanceof Error ? relayErr.stack ?? relayErr.message : String(relayErr);
3713
+ console.error(
3714
+ `[townhouse hs up] relay hidden service error (non-fatal): ${detail}`
3715
+ );
3716
+ }
3717
+ }
3718
+ const adminClientFactory2 = hsOverrides?.createAdminClient ?? ((url, t) => new ConnectorAdminClient(url, t));
3719
+ const adminClient = adminClientFactory2(HS_CONNECTOR_ADMIN_URL, 5e3);
3720
+ const hsInfo = await adminClient.getHsHostname();
3721
+ const hostname = hsInfo.hostname ?? "";
3722
+ const publishedAt = hsInfo.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3723
+ _writeHostJson(configDir, {
3724
+ hostname,
3725
+ publishedAt,
3726
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString(),
3727
+ ...relayHostname ? { relayHostname } : {}
3728
+ });
3729
+ ribbon.start("live", hostname);
3730
+ emitUpStep(json, "done", { transport: "hs", hostname });
3731
+ await attachDashboard(hostname);
3732
+ } catch (err) {
3733
+ emitUpStep(json, "error", {
3734
+ transport: "hs",
3735
+ message: err instanceof Error ? err.message : String(err)
3736
+ });
3737
+ const { exitCode } = renderFailure(err);
3738
+ process.exitCode = exitCode;
3739
+ } finally {
3740
+ ribbon.stop();
3741
+ if (walletManager) {
3742
+ walletManager.lock();
3743
+ }
3744
+ }
3745
+ }
3746
+ async function handleDirectUp(_configPath, configDir, config, docker, options) {
3747
+ const { password, force, skipPreflight, hsOverrides } = options;
3748
+ const json = options.json === true;
3749
+ emitUpStep(json, "starting", { transport: "direct" });
3750
+ const adminClientFactory = hsOverrides?.createAdminClient ?? ((url, t) => new ConnectorAdminClient(url, t));
3751
+ if (detectExistingHsConfig(configDir)) {
3752
+ console.error(
3753
+ "Existing hidden-service apex detected (connector.yaml has anon.enabled: true).\n`townhouse up` boots a direct-BTP apex and would downgrade your HS deployment.\n \u2022 To keep hidden-service mode: townhouse hs up\n \u2022 To switch to direct BTP: townhouse hs down --rotate-keys && townhouse up"
3754
+ );
3755
+ process.exitCode = 1;
3756
+ return;
3757
+ }
3758
+ const walletPath = config.wallet.encrypted_path;
3759
+ if (!process.env["TOWNHOUSE_MNEMONIC"] && !existsSync(walletPath)) {
3760
+ console.error(
3761
+ `Wallet not found at ${walletPath}. Run \`townhouse init\` first (or set TOWNHOUSE_MNEMONIC).`
3762
+ );
3763
+ process.exitCode = 1;
3764
+ return;
3765
+ }
3766
+ if (!force) {
3767
+ const probe = adminClientFactory(HS_CONNECTOR_ADMIN_URL, 3e3);
3768
+ const ping = probe.pingAdminLive?.bind(probe);
3769
+ if (ping) {
3770
+ try {
3771
+ await ping();
3772
+ console.log(`Apex live (direct BTP) at ${DIRECT_BTP_DIAL_URL}`);
3773
+ emitUpStep(json, "done", { transport: "direct", alreadyLive: true });
3774
+ return;
3775
+ } catch {
3776
+ }
3777
+ }
3778
+ }
3779
+ if (!skipPreflight) {
3780
+ const preflight = hsOverrides?.checkPortCollisions ?? ((d) => checkDirectPortCollisions(d));
3781
+ try {
3782
+ const collisions = await preflight(docker);
3783
+ if (collisions.length > 0) {
3784
+ process.stderr.write(formatCollisionMessage(collisions));
3785
+ process.exitCode = 1;
3786
+ return;
3787
+ }
3788
+ } catch (preflightErr) {
3789
+ const detail = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
3790
+ console.error(
3791
+ `[townhouse up --transport direct] port preflight skipped (non-fatal): ${detail}`
3792
+ );
3793
+ }
3794
+ }
3795
+ let walletManager = await tryEnvMnemonicWallet(walletPath) ?? void 0;
3796
+ let resolvedPassword;
3797
+ if (!walletManager) {
3798
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
3799
+ if (walletPassword) {
3800
+ resolvedPassword = walletPassword;
3801
+ } else if (process.stdin.isTTY) {
3802
+ resolvedPassword = await promptPassword("Wallet password: ");
3803
+ } else {
3804
+ console.error(
3805
+ "Wallet password required, but no interactive terminal is available to prompt.\nPass --password <pw>, set TOWNHOUSE_WALLET_PASSWORD, or set TOWNHOUSE_MNEMONIC."
3806
+ );
3807
+ process.exitCode = 1;
3808
+ return;
3809
+ }
3810
+ const loaded = await loadWallet(walletPath);
3811
+ if (!loaded) {
3812
+ console.error(`Wallet at ${walletPath} could not be read.`);
3813
+ process.exitCode = 1;
3814
+ return;
3815
+ }
3816
+ try {
3817
+ walletManager = new WalletManager({ encryptedPath: walletPath });
3818
+ await walletManager.fromMnemonic(
3819
+ decryptWallet(loaded.wallet, resolvedPassword)
3820
+ );
3821
+ } catch (err) {
3822
+ const msg = err instanceof Error ? err.message : String(err);
3823
+ console.error(`Failed to decrypt wallet: ${msg}`);
3824
+ process.exitCode = 1;
3825
+ return;
3826
+ }
3827
+ }
3828
+ const ribbon = new OnboardingRibbon();
3829
+ try {
3830
+ const apexSettlementKeys = await walletManager.getApexSettlementKeys();
3831
+ writeDirectConnectorConfig(configDir, config, {
3832
+ force,
3833
+ apexSettlementKeys
3834
+ });
3835
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
3836
+ const { composePath } = materialize("direct", { townhouseHome: configDir });
3837
+ writeHsNodeEnvFile(configDir, config);
3838
+ ribbon.start("pull");
3839
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
3840
+ const orch = orchestratorFactory(docker, config, walletManager, {
3841
+ profile: "direct",
3842
+ composePath
3843
+ });
3844
+ const narrator = new PullNarrator();
3845
+ orch.on("pullProgress", (event) => {
3846
+ const ev = event;
3847
+ if (!ev.image || !ev.status) return;
3848
+ const line = narrator.format({
3849
+ image: ev.image,
3850
+ status: ev.status,
3851
+ id: ev.id,
3852
+ progress: ev.progress
3853
+ });
3854
+ if (line !== null) console.log(line);
3855
+ });
3856
+ let bootstrapStarted = false;
3857
+ orch.on("containerState", (event) => {
3858
+ const ev = event;
3859
+ if (!bootstrapStarted && (ev.state === "creating" || ev.state === "starting")) {
3860
+ bootstrapStarted = true;
3861
+ ribbon.start("bootstrap");
3862
+ }
3863
+ });
3864
+ ribbon.stop();
3865
+ if (typeof orch.pullImage === "function") {
3866
+ try {
3867
+ const apexImages = await collectApexImageRefs(configDir);
3868
+ if (apexImages.length > 0) {
3869
+ console.log(
3870
+ `Pulling ${apexImages.length} apex ${apexImages.length === 1 ? "image" : "images"}...`
3871
+ );
3872
+ let pulled = 0;
3873
+ for (const ref of apexImages) {
3874
+ pulled++;
3875
+ console.log(` [${pulled}/${apexImages.length}] ${ref}`);
3876
+ await orch.pullImage(ref);
3877
+ }
3878
+ } else {
3879
+ console.log(
3880
+ "No pinned image manifest found \u2014 Docker will pull images on demand."
3881
+ );
3882
+ }
3883
+ } catch (pullErr) {
3884
+ const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
3885
+ console.log(
3886
+ `Could not pre-pull images (${detail}). Docker will pull them during startup \u2014 this is normal and may take a few minutes.`
3887
+ );
3888
+ }
3889
+ }
3890
+ let dockerSockGid = 0;
3891
+ try {
3892
+ dockerSockGid = statSync("/var/run/docker.sock").gid;
3893
+ } catch {
3894
+ }
3895
+ const prevTownhouseHome = process.env["TOWNHOUSE_HOME"];
3896
+ const prevWalletPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
3897
+ const prevTownhouseUid = process.env["TOWNHOUSE_UID"];
3898
+ const prevWalletDir = process.env["TOWNHOUSE_WALLET_DIR"];
3899
+ const prevDockerGid = process.env["TOWNHOUSE_DOCKER_GID"];
3900
+ process.env["TOWNHOUSE_HOME"] = configDir;
3901
+ if (resolvedPassword !== void 0) {
3902
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = resolvedPassword;
3903
+ }
3904
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
3905
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
3906
+ resolve(config.wallet.encrypted_path)
3907
+ );
3908
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(dockerSockGid);
3909
+ if (!bootstrapStarted) {
3910
+ bootstrapStarted = true;
3911
+ ribbon.start("bootstrap");
3912
+ }
3913
+ try {
3914
+ await orch.up([]);
3915
+ } finally {
3916
+ if (prevTownhouseHome === void 0) {
3917
+ delete process.env["TOWNHOUSE_HOME"];
3918
+ } else {
3919
+ process.env["TOWNHOUSE_HOME"] = prevTownhouseHome;
3920
+ }
3921
+ if (prevWalletPassword === void 0) {
3922
+ delete process.env["TOWNHOUSE_WALLET_PASSWORD"];
3923
+ } else {
3924
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = prevWalletPassword;
3925
+ }
3926
+ if (prevTownhouseUid === void 0) {
3927
+ delete process.env["TOWNHOUSE_UID"];
3928
+ } else {
3929
+ process.env["TOWNHOUSE_UID"] = prevTownhouseUid;
3930
+ }
3931
+ if (prevWalletDir === void 0) {
3932
+ delete process.env["TOWNHOUSE_WALLET_DIR"];
3933
+ } else {
3934
+ process.env["TOWNHOUSE_WALLET_DIR"] = prevWalletDir;
3935
+ }
3936
+ if (prevDockerGid === void 0) {
3937
+ delete process.env["TOWNHOUSE_DOCKER_GID"];
3938
+ } else {
3939
+ process.env["TOWNHOUSE_DOCKER_GID"] = prevDockerGid;
3940
+ }
3941
+ }
3942
+ await rebindAndReconcileChildren({
3943
+ configDir,
3944
+ walletManager,
3945
+ orch,
3946
+ config,
3947
+ logPrefix: "[townhouse up]",
3948
+ hsOverrides
3949
+ });
3950
+ ribbon.stop();
3951
+ console.log(`Apex live (direct BTP) at ${DIRECT_BTP_DIAL_URL}`);
3952
+ emitUpStep(json, "done", { transport: "direct" });
3953
+ } catch (err) {
3954
+ emitUpStep(json, "error", {
3955
+ transport: "direct",
3956
+ message: err instanceof Error ? err.message : String(err)
3957
+ });
3958
+ const { exitCode } = renderFailure(err);
3959
+ process.exitCode = exitCode;
3960
+ } finally {
3961
+ ribbon.stop();
3962
+ if (walletManager) {
3963
+ walletManager.lock();
3964
+ }
3965
+ }
3966
+ }
3967
+ async function handleHsEnable(configPath, configDir, config, docker, options) {
3968
+ const { hsOverrides } = options;
3969
+ const json = options.json === true;
3970
+ emitUpStep(json, "starting", { transport: "hs", action: "enable" });
3971
+ if (detectExistingHsConfig(configDir)) {
3972
+ if (json) {
3973
+ emitUpStep(json, "done", {
3974
+ transport: "hs",
3975
+ action: "enable",
3976
+ alreadyHs: true
3977
+ });
3978
+ } else {
3979
+ console.log(
3980
+ "Hidden-service apex already configured. Use `townhouse hs up` to (re)attach."
3981
+ );
3982
+ }
3983
+ return;
3984
+ }
3985
+ if (!json) console.log("Switching direct apex \u2192 hidden-service mode...");
3986
+ try {
3987
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
3988
+ const { composePath } = materialize("direct", {
3989
+ townhouseHome: configDir
3990
+ });
3991
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
3992
+ const orch = orchestratorFactory(docker, config, void 0, {
3993
+ profile: "direct",
3994
+ composePath
3995
+ });
3996
+ await orch.down();
3997
+ } catch (err) {
3998
+ const detail = err instanceof Error ? err.message : String(err);
3999
+ if (json) {
4000
+ emitUpStep(json, "teardown-skipped", { detail });
4001
+ } else {
4002
+ console.warn(
4003
+ `[townhouse hs enable] direct stack teardown skipped (non-fatal): ${detail}`
4004
+ );
4005
+ }
4006
+ }
4007
+ await handleHsUp(configPath, configDir, config, docker, {
4008
+ password: options.password,
4009
+ force: true,
4010
+ skipPreflight: options.skipPreflight,
4011
+ hsOverrides,
4012
+ json
4013
+ });
4014
+ }
4015
+ function nodesYamlHasTown(configDir) {
4016
+ try {
4017
+ return /type:\s*town/.test(
4018
+ readFileSync(join(configDir, "nodes.yaml"), "utf-8")
4019
+ );
4020
+ } catch {
4021
+ return false;
4022
+ }
4023
+ }
4024
+ function _writeHostJson(configDir, data) {
4025
+ const hostJsonPath = join(configDir, "host.json");
4026
+ const tmpPath = `${hostJsonPath}.tmp`;
4027
+ const content = JSON.stringify(
4028
+ {
4029
+ hostname: data.hostname,
4030
+ publishedAt: data.publishedAt,
4031
+ // Relay hidden-service .anyone hostname (free client reads), when published.
4032
+ ...data.relayHostname ? { relayHostname: data.relayHostname } : {},
4033
+ connectorAdminUrl: HS_CONNECTOR_ADMIN_URL,
4034
+ townhouseApiUrl: HS_TOWNHOUSE_API_URL,
4035
+ writtenAt: data.writtenAt
4036
+ },
4037
+ null,
4038
+ 2
4039
+ );
4040
+ writeFileSync(tmpPath, content, { mode: 384, encoding: "utf-8" });
4041
+ renameSync(tmpPath, hostJsonPath);
4042
+ }
4043
+ async function handleHsDown(configDir, config, docker, options) {
4044
+ const { rotateKeys, hsOverrides } = options;
4045
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
4046
+ const { composePath } = materialize("hs", { townhouseHome: configDir });
4047
+ const prevTownhouseHome = process.env["TOWNHOUSE_HOME"];
4048
+ const prevTownhouseUid = process.env["TOWNHOUSE_UID"];
4049
+ const prevWalletDir = process.env["TOWNHOUSE_WALLET_DIR"];
4050
+ const prevDockerGid = process.env["TOWNHOUSE_DOCKER_GID"];
4051
+ const prevWalletPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
4052
+ process.env["TOWNHOUSE_HOME"] = configDir;
4053
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
4054
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
4055
+ resolve(config.wallet.encrypted_path)
4056
+ );
4057
+ let dockerSockGid = 0;
4058
+ try {
4059
+ dockerSockGid = statSync("/var/run/docker.sock").gid;
4060
+ } catch {
4061
+ }
4062
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(dockerSockGid);
4063
+ if (prevWalletPassword === void 0) {
4064
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = "";
4065
+ }
4066
+ const restoreTownhouseHome = () => {
4067
+ if (prevTownhouseHome === void 0) {
4068
+ delete process.env["TOWNHOUSE_HOME"];
4069
+ } else {
4070
+ process.env["TOWNHOUSE_HOME"] = prevTownhouseHome;
4071
+ }
4072
+ if (prevTownhouseUid === void 0) {
4073
+ delete process.env["TOWNHOUSE_UID"];
4074
+ } else {
4075
+ process.env["TOWNHOUSE_UID"] = prevTownhouseUid;
4076
+ }
4077
+ if (prevWalletDir === void 0) {
4078
+ delete process.env["TOWNHOUSE_WALLET_DIR"];
4079
+ } else {
4080
+ process.env["TOWNHOUSE_WALLET_DIR"] = prevWalletDir;
4081
+ }
4082
+ if (prevDockerGid === void 0) {
4083
+ delete process.env["TOWNHOUSE_DOCKER_GID"];
4084
+ } else {
4085
+ process.env["TOWNHOUSE_DOCKER_GID"] = prevDockerGid;
4086
+ }
4087
+ if (prevWalletPassword === void 0) {
4088
+ delete process.env["TOWNHOUSE_WALLET_PASSWORD"];
4089
+ }
4090
+ };
4091
+ if (rotateKeys) {
4092
+ if (process.stdin.isTTY) {
4093
+ let existingHostname = "(unknown)";
4094
+ const hostJsonPath = join(configDir, "host.json");
4095
+ if (existsSync(hostJsonPath)) {
4096
+ try {
4097
+ const { readFileSync: readFileSync2 } = await import("fs");
4098
+ const json = JSON.parse(readFileSync2(hostJsonPath, "utf-8"));
4099
+ existingHostname = json.hostname ?? existingHostname;
4100
+ } catch {
4101
+ }
4102
+ }
4103
+ const { createInterface: createInterface3 } = await import("readline");
4104
+ const answer = await new Promise((resolve2) => {
4105
+ const rl = createInterface3({
4106
+ input: process.stdin,
4107
+ output: process.stdout
4108
+ });
4109
+ rl.question(
4110
+ `WARNING: --rotate-keys will permanently delete your current .anyone address (${existingHostname}). The next 'hs up' will publish a new address. Continue? [y/N] `,
4111
+ (ans) => {
4112
+ rl.close();
4113
+ resolve2(ans);
4114
+ }
4115
+ );
4116
+ });
4117
+ if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
4118
+ console.log("Cancelled.");
4119
+ return;
4120
+ }
4121
+ }
4122
+ const runDown = hsOverrides?.runComposeDown ?? _runDockerComposeDown;
4123
+ try {
4124
+ await runDown(composePath, true);
4125
+ } catch (err) {
4126
+ const { exitCode } = renderFailure(err);
4127
+ process.exitCode = exitCode;
4128
+ restoreTownhouseHome();
4129
+ return;
4130
+ }
4131
+ rmSync(join(configDir, "host.json"), { force: true });
4132
+ console.log(
4133
+ "Apex stopped. Volumes deleted \u2014 your next 'hs up' will publish a NEW .anyone address."
4134
+ );
4135
+ restoreTownhouseHome();
4136
+ return;
4137
+ }
4138
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
4139
+ const orch = orchestratorFactory(docker, config, void 0, {
4140
+ profile: "hs",
4141
+ composePath
4142
+ });
4143
+ try {
4144
+ await orch.down();
4145
+ } catch (err) {
4146
+ const { exitCode } = renderFailure(err);
4147
+ process.exitCode = exitCode;
4148
+ restoreTownhouseHome();
4149
+ return;
4150
+ }
4151
+ restoreTownhouseHome();
4152
+ console.log(
4153
+ "Apex stopped. Volumes preserved \u2014 your .anyone address is stable."
4154
+ );
4155
+ }
4156
+ function _runDockerComposeDown(composePath, withVolumes) {
4157
+ return new Promise((resolve2, reject) => {
4158
+ const args = ["compose", "-f", composePath, "down"];
4159
+ if (withVolumes) args.push("-v");
4160
+ const child = spawn2("docker", args, {
4161
+ stdio: ["ignore", "inherit", "inherit"]
4162
+ });
4163
+ child.on("error", reject);
4164
+ child.on("close", (code) => {
4165
+ if (code === 0) {
4166
+ resolve2();
4167
+ } else {
4168
+ reject(new Error(`docker compose down exited with code ${code}`));
4169
+ }
4170
+ });
4171
+ });
4172
+ }
4173
+ var CHAINS_HELP = `townhouse chains \u2014 configure settlement chains (connector chainProviders)
4174
+
4175
+ The connector settles ILP payment claims on these chains. Changes take effect
4176
+ on the next 'townhouse hs down && townhouse hs up'.
4177
+
4178
+ Usage:
4179
+ townhouse chains list [--json] [-c <path>]
4180
+ townhouse chains add --chain-type <evm|solana|mina> --chain-id <id> [fields] [-c <path>]
4181
+ townhouse chains remove <chainId> [-c <path>]
4182
+
4183
+ Fields by chain type ([--key-id] is OPTIONAL \u2014 defaults to the operator's
4184
+ mnemonic-derived apex settlement key; pass it only for an external/hardware key):
4185
+ evm: --rpc-url <url> --registry <0x..> --token-address <0x..> [--key-id <0x..>]
4186
+ solana: --rpc-url <url> --program-id <addr> [--key-id <id>] [--ws-url <url>] [--token-mint <addr>]
4187
+ mina: --graphql-url <url> --zkapp <addr> [--key-id <id>]`;
4188
+ function buildChainProviderFromFlags(f) {
4189
+ const { chainType, chainId } = f;
4190
+ if (chainType !== "evm" && chainType !== "solana" && chainType !== "mina") {
4191
+ throw new Error("--chain-type must be one of: evm, solana, mina");
4192
+ }
4193
+ if (!chainId) throw new Error("--chain-id is required");
4194
+ const require2 = (flag, val) => {
4195
+ if (!val) throw new Error(`${flag} is required for ${chainType} chains`);
4196
+ return val;
4197
+ };
4198
+ if (chainType === "evm") {
4199
+ return {
4200
+ chainType: "evm",
4201
+ chainId,
4202
+ rpcUrl: require2("--rpc-url", f.rpcUrl),
4203
+ registryAddress: require2("--registry", f.registry),
4204
+ tokenAddress: require2("--token-address", f.tokenAddress),
4205
+ ...f.keyId ? { keyId: f.keyId } : {}
4206
+ };
4207
+ }
4208
+ if (chainType === "solana") {
4209
+ return {
4210
+ chainType: "solana",
4211
+ chainId,
4212
+ rpcUrl: require2("--rpc-url", f.rpcUrl),
4213
+ ...f.wsUrl ? { wsUrl: f.wsUrl } : {},
4214
+ programId: require2("--program-id", f.programId),
4215
+ ...f.tokenMint ? { tokenMint: f.tokenMint } : {},
4216
+ ...f.keyId ? { keyId: f.keyId } : {}
4217
+ };
4218
+ }
4219
+ return {
4220
+ chainType: "mina",
4221
+ chainId,
4222
+ graphqlUrl: require2("--graphql-url", f.graphqlUrl),
4223
+ zkAppAddress: require2("--zkapp", f.zkapp),
4224
+ ...f.keyId ? { keyId: f.keyId } : {}
4225
+ };
4226
+ }
4227
+ async function handleChains(action, chainIdArg, flags, configPath, jsonMode) {
4228
+ if (!action) {
4229
+ console.log(CHAINS_HELP);
4230
+ throw new CliHelpRequested();
4231
+ }
4232
+ const config = loadConfig(configPath);
4233
+ const providers = config.chainProviders ?? [];
4234
+ switch (action) {
4235
+ case "list": {
4236
+ if (jsonMode) {
4237
+ console.log(JSON.stringify(providers, null, 2));
4238
+ return;
4239
+ }
4240
+ if (providers.length === 0) {
4241
+ console.log(
4242
+ "No settlement chains configured \u2014 the connector uses a built-in dev-Anvil EVM placeholder."
4243
+ );
4244
+ console.log(
4245
+ "Add one with: townhouse chains add --chain-type evm --chain-id evm:base:8453 ..."
4246
+ );
4247
+ return;
4248
+ }
4249
+ console.log("Configured settlement chains:");
4250
+ for (const p of providers) {
4251
+ console.log(` ${p.chainType.padEnd(6)} ${p.chainId}`);
4252
+ }
4253
+ return;
4254
+ }
4255
+ case "add": {
4256
+ let entry;
4257
+ try {
4258
+ entry = buildChainProviderFromFlags(flags);
4259
+ } catch (err) {
4260
+ console.error(err instanceof Error ? err.message : String(err));
4261
+ process.exitCode = 1;
4262
+ return;
4263
+ }
4264
+ const next = providers.filter((p) => p.chainId !== entry.chainId);
4265
+ next.push(entry);
4266
+ try {
4267
+ saveConfig(configPath, { ...config, chainProviders: next });
4268
+ } catch (err) {
4269
+ console.error(
4270
+ `Invalid chain config: ${err instanceof Error ? err.message : String(err)}`
4271
+ );
4272
+ process.exitCode = 1;
4273
+ return;
4274
+ }
4275
+ if (jsonMode) {
4276
+ console.log(
4277
+ JSON.stringify({
4278
+ added: true,
4279
+ chainType: entry.chainType,
4280
+ chainId: entry.chainId
4281
+ })
4282
+ );
4283
+ return;
4284
+ }
4285
+ console.log(
4286
+ `Added ${entry.chainType} settlement chain '${entry.chainId}'.`
4287
+ );
4288
+ console.log("Apply with: townhouse hs down && townhouse hs up");
4289
+ return;
4290
+ }
4291
+ case "remove": {
4292
+ if (!chainIdArg) {
4293
+ console.error("Usage: townhouse chains remove <chainId>");
4294
+ process.exitCode = 1;
4295
+ return;
4296
+ }
4297
+ const next = providers.filter((p) => p.chainId !== chainIdArg);
4298
+ if (next.length === providers.length) {
4299
+ console.error(
4300
+ `No settlement chain with chainId '${chainIdArg}' found.`
4301
+ );
4302
+ process.exitCode = 1;
4303
+ return;
4304
+ }
4305
+ saveConfig(configPath, {
4306
+ ...config,
4307
+ chainProviders: next.length > 0 ? next : void 0
4308
+ });
4309
+ if (jsonMode) {
4310
+ console.log(JSON.stringify({ removed: true, chainId: chainIdArg }));
4311
+ return;
4312
+ }
4313
+ console.log(`Removed settlement chain '${chainIdArg}'.`);
4314
+ console.log("Apply with: townhouse hs down && townhouse hs up");
4315
+ return;
4316
+ }
4317
+ default: {
4318
+ const safe = action.replace(/[\x00-\x1f\x7f]/g, "");
4319
+ console.error(`Unknown chains subcommand: ${safe}`);
4320
+ console.log(CHAINS_HELP);
4321
+ process.exitCode = 1;
4322
+ }
4323
+ }
4324
+ }
4325
+ async function main(argv, dockerInstance, browserOpener, hsOverrides, nodeCommandOverrides) {
4326
+ const { values, positionals } = parseArgs({
4327
+ args: argv,
4328
+ options: {
4329
+ help: { type: "boolean" },
4330
+ version: { type: "boolean" },
4331
+ force: { type: "boolean" },
4332
+ config: { type: "string", short: "c" },
4333
+ "config-dir": { type: "string" },
4334
+ town: { type: "boolean" },
4335
+ mill: { type: "boolean" },
4336
+ dvm: { type: "boolean" },
4337
+ password: { type: "string" },
4338
+ "dry-run": { type: "boolean" },
4339
+ "no-browser": { type: "boolean" },
4340
+ port: { type: "string" },
4341
+ preset: { type: "string" },
4342
+ network: { type: "string" },
4343
+ "evm-url": { type: "string" },
4344
+ "sol-url": { type: "string" },
4345
+ yes: { type: "boolean" },
4346
+ "rotate-keys": { type: "boolean" },
4347
+ "skip-preflight": { type: "boolean" },
4348
+ // Phase 3: `townhouse up` defaults to a direct-BTP apex + children.
4349
+ // --transport direct (default) | --transport hs (synonym for `hs up`).
4350
+ // --dev selects the contributor children-only dev stack (profile:'dev').
4351
+ transport: { type: "string" },
4352
+ dev: { type: "boolean" },
4353
+ json: { type: "boolean" },
4354
+ "json-compact": { type: "boolean" },
4355
+ lines: { type: "string" },
4356
+ follow: { type: "boolean", short: "f" },
4357
+ units: { type: "string" },
4358
+ rate: { type: "string" },
4359
+ // credits buy / credits balance (epic-49, Phase 2)
4360
+ token: { type: "string" },
4361
+ amount: { type: "string" },
4362
+ "fee-multiplier": { type: "string" },
4363
+ "quote-only": { type: "boolean" },
4364
+ "credit-destination": { type: "string" },
4365
+ // wallet show / wallet seed (epic-49, Phase 3)
4366
+ hex: { type: "boolean" },
4367
+ paths: { type: "boolean" },
4368
+ confirm: { type: "boolean" },
4369
+ // chains add (multi-chain settlement config)
4370
+ "chain-type": { type: "string" },
4371
+ "chain-id": { type: "string" },
4372
+ "rpc-url": { type: "string" },
4373
+ "ws-url": { type: "string" },
4374
+ registry: { type: "string" },
4375
+ "token-address": { type: "string" },
4376
+ "token-mint": { type: "string" },
4377
+ "program-id": { type: "string" },
4378
+ "graphql-url": { type: "string" },
4379
+ zkapp: { type: "string" },
4380
+ "key-id": { type: "string" },
4381
+ // node add operator inputs (mill relays / dvm Arweave Turbo credential /
4382
+ // town settlement chain + token)
4383
+ relays: { type: "string" },
4384
+ "turbo-token": { type: "string" },
4385
+ "settlement-chain": { type: "string" },
4386
+ asset: { type: "string" }
4387
+ },
4388
+ strict: false,
4389
+ allowPositionals: true
4390
+ });
4391
+ const command = positionals[0];
4392
+ if (values.version === true || command === "version") {
4393
+ const version = readCliVersion();
4394
+ console.log(values.json === true ? JSON.stringify({ version }) : version);
4395
+ throw new CliHelpRequested();
4396
+ }
4397
+ if (command === "node" && values.help) {
4398
+ const action = positionals[1];
4399
+ const subHelp = action === "add" ? NODE_ADD_HELP : action === "remove" ? NODE_REMOVE_HELP : action === "list" ? NODE_LIST_HELP : NODE_HELP;
4400
+ console.log(subHelp);
4401
+ throw new CliHelpRequested();
4402
+ }
4403
+ if (values.help) {
4404
+ console.log(HELP_TEXT);
4405
+ throw new CliHelpRequested();
4406
+ }
4407
+ if (!command) {
4408
+ console.log(HELP_TEXT);
4409
+ throw new CliHelpRequested();
4410
+ }
4411
+ switch (command) {
4412
+ case "setup": {
4413
+ const portStr = values["port"];
4414
+ const port = portStr ? Number(portStr) : 9400;
4415
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
4416
+ console.error("--port must be an integer between 1 and 65535");
4417
+ process.exitCode = 1;
4418
+ break;
4419
+ }
4420
+ await handleSetup(
4421
+ values["config-dir"],
4422
+ port,
4423
+ values["no-browser"] === true,
4424
+ dockerInstance,
4425
+ browserOpener
4426
+ );
4427
+ break;
4428
+ }
4429
+ case "init": {
4430
+ const presetVal = values.preset;
4431
+ if (presetVal !== void 0 && presetVal !== "demo") {
4432
+ console.error(`Unknown preset: ${presetVal}. Supported: demo`);
4433
+ process.exitCode = 1;
4434
+ break;
4435
+ }
4436
+ const networkVal = values.network;
4437
+ if (networkVal !== void 0 && !["mainnet", "testnet", "devnet", "custom"].includes(networkVal)) {
4438
+ console.error(
4439
+ `Unknown network: ${networkVal}. Supported: mainnet, testnet, devnet, custom`
4440
+ );
4441
+ process.exitCode = 1;
4442
+ break;
4443
+ }
4444
+ const evmUrl = values["evm-url"] ?? process.env["EVM_URL"];
4445
+ const solUrl = values["sol-url"] ?? process.env["SOL_URL"];
4446
+ const endpoints = evmUrl || solUrl ? {
4447
+ ...evmUrl ? { evmUrl } : {},
4448
+ ...solUrl ? { solUrl } : {}
4449
+ } : void 0;
4450
+ await handleInit(
4451
+ values.force === true,
4452
+ values["config-dir"],
4453
+ values.password,
4454
+ presetVal,
4455
+ values.yes === true,
4456
+ networkVal,
4457
+ endpoints,
4458
+ values.json === true
4459
+ );
4460
+ break;
4461
+ }
4462
+ case "wallet": {
4463
+ const subCommand = positionals[1];
4464
+ if (subCommand === "show") {
4465
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4466
+ const config = loadConfig(configPath);
4467
+ await handleWalletShow(config, values.password, {
4468
+ json: values.json === true,
4469
+ hex: values.hex === true,
4470
+ paths: values.paths === true
4471
+ });
4472
+ } else if (subCommand === "seed") {
4473
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4474
+ const config = loadConfig(configPath);
4475
+ await handleWalletSeed(
4476
+ config,
4477
+ values.password,
4478
+ values.confirm === true,
4479
+ values.json === true
4480
+ );
4481
+ } else {
4482
+ console.error(
4483
+ "Usage:\n townhouse wallet show [--json] [--hex] [--paths] [-c <path>] [--password <pw>]\n townhouse wallet seed --confirm [-c <path>] [--password <pw>]"
4484
+ );
4485
+ process.exitCode = 1;
4486
+ }
4487
+ break;
4488
+ }
4489
+ case "credits": {
4490
+ const subCommand = positionals[1];
4491
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4492
+ const config = loadConfig(configPath);
4493
+ if (subCommand === "buy") {
4494
+ await handleCreditsBuy(config, values);
4495
+ } else if (subCommand === "balance") {
4496
+ await handleCreditsBalance(config, values);
4497
+ } else {
4498
+ console.error(
4499
+ "Usage:\n townhouse credits buy --token <id> --amount <decimal> [--fee-multiplier <n>] [--quote-only] [--yes] [-c <path>] [--password <pw>]\n townhouse credits balance --token <id> [-c <path>] [--password <pw>]"
4500
+ );
4501
+ process.exitCode = 1;
4502
+ }
4503
+ break;
4504
+ }
4505
+ case "urls": {
4506
+ const configPath = values["config"] ?? DEFAULT_CONFIG_PATH;
4507
+ const cfg = loadConfig(configPath);
4508
+ const dir = dirname(configPath);
4509
+ let hostname;
4510
+ let relayHostname;
4511
+ try {
4512
+ const raw = readFileSync(join(dir, "host.json"), "utf-8");
4513
+ const parsed = JSON.parse(raw);
4514
+ if (typeof parsed.hostname === "string") hostname = parsed.hostname;
4515
+ if (typeof parsed.relayHostname === "string")
4516
+ relayHostname = parsed.relayHostname;
4517
+ } catch {
4518
+ }
4519
+ const btpUrl = resolvePublicBtpUrl(cfg, hostname);
4520
+ const relayUrl = resolveRelayUrl(cfg, relayHostname);
4521
+ let ilpDestination;
4522
+ try {
4523
+ const ny = readFileSync(join(dir, "nodes.yaml"), "utf-8");
4524
+ if (/type:\s*town/.test(ny)) ilpDestination = "g.townhouse.town";
4525
+ } catch {
4526
+ }
4527
+ if (values["json"] === true) {
4528
+ console.log(
4529
+ JSON.stringify({
4530
+ write: btpUrl ?? null,
4531
+ ilpDestination: ilpDestination ?? null,
4532
+ read: relayUrl ?? null
4533
+ })
4534
+ );
4535
+ } else {
4536
+ console.log("Share these with clients (out-of-band):");
4537
+ console.log("");
4538
+ console.log(
4539
+ ` Write (pay-to-publish, BTP): ${btpUrl ?? "(apex not running \u2014 run `townhouse up` / `hs up`)"}`
4540
+ );
4541
+ if (ilpDestination) {
4542
+ console.log(` ILP destination: ${ilpDestination}`);
4543
+ }
4544
+ console.log(
4545
+ ` Read (free Nostr reads): ${relayUrl ?? "(relay not publicly exposed \u2014 set transport.relayExternalUrl or enable the relay hidden service)"}`
4546
+ );
4547
+ }
4548
+ break;
4549
+ }
4550
+ case "status": {
4551
+ const configPath = values["config"] ?? DEFAULT_CONFIG_PATH;
4552
+ const rawUnits = values["units"] ?? "usdc";
4553
+ if (rawUnits !== "usdc" && rawUnits !== "sats") {
4554
+ console.error(`--units must be 'usdc' or 'sats'`);
4555
+ process.exitCode = 1;
4556
+ break;
4557
+ }
4558
+ let satsPerUsdc;
4559
+ if (rawUnits === "sats") {
4560
+ const r = resolveSatsRate(
4561
+ values,
4562
+ process.env
4563
+ );
4564
+ if ("error" in r) {
4565
+ console.error(r.error);
4566
+ process.exitCode = 1;
4567
+ } else {
4568
+ satsPerUsdc = r.rate;
4569
+ }
4570
+ }
4571
+ const units = rawUnits;
4572
+ await handleStatus(
4573
+ dockerInstance ?? new Docker2(),
4574
+ loadConfig(configPath),
4575
+ { units, satsPerUsdc, configPath, json: values.json === true }
4576
+ );
4577
+ break;
4578
+ }
4579
+ case "up": {
4580
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4581
+ const config = loadConfig(configPath);
4582
+ const docker = dockerInstance ?? new Docker2();
4583
+ const configDir = dirname(configPath);
4584
+ const transport = values.transport;
4585
+ if (transport !== void 0 && transport !== "direct" && transport !== "hs") {
4586
+ console.error(
4587
+ `Unknown --transport value: ${transport}. Supported: direct (default), hs`
4588
+ );
4589
+ process.exitCode = 1;
4590
+ break;
4591
+ }
4592
+ if (values.dev === true) {
4593
+ const profiles = resolveProfiles(values, config);
4594
+ await handleUp(
4595
+ configPath,
4596
+ config,
4597
+ profiles,
4598
+ docker,
4599
+ values.password,
4600
+ values["dry-run"] === true
4601
+ );
4602
+ break;
4603
+ }
4604
+ if (transport === "hs") {
4605
+ await handleHsUp(configPath, configDir, config, docker, {
4606
+ password: values.password,
4607
+ force: values.force === true,
4608
+ skipPreflight: values["skip-preflight"] === true,
4609
+ hsOverrides,
4610
+ json: values.json === true
4611
+ });
4612
+ break;
4613
+ }
4614
+ await handleDirectUp(configPath, configDir, config, docker, {
4615
+ password: values.password,
4616
+ force: values.force === true,
4617
+ skipPreflight: values["skip-preflight"] === true,
4618
+ hsOverrides,
4619
+ json: values.json === true
4620
+ });
4621
+ break;
4622
+ }
4623
+ case "down": {
4624
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4625
+ const config = loadConfig(configPath);
4626
+ const docker = dockerInstance ?? new Docker2();
4627
+ await handleDown(config, docker, values.json === true);
4628
+ break;
4629
+ }
4630
+ case "channels":
4631
+ case "metrics":
4632
+ case "logs":
4633
+ case "peer":
4634
+ case "health": {
4635
+ await dispatchDrillCommand(command, {
4636
+ adminUrl: HS_CONNECTOR_ADMIN_URL,
4637
+ apiUrl: HS_TOWNHOUSE_API_URL,
4638
+ values,
4639
+ positionals,
4640
+ docker: dockerInstance
4641
+ });
4642
+ break;
4643
+ }
4644
+ case "hs": {
4645
+ const action = positionals[1];
4646
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4647
+ const config = loadConfig(configPath);
4648
+ const docker = dockerInstance ?? new Docker2();
4649
+ const configDir = dirname(configPath);
4650
+ if (action === "up") {
4651
+ await handleHsUp(configPath, configDir, config, docker, {
4652
+ password: values.password,
4653
+ force: values.force === true,
4654
+ skipPreflight: values["skip-preflight"] === true,
4655
+ hsOverrides,
4656
+ json: values.json === true
4657
+ });
4658
+ } else if (action === "enable") {
4659
+ await handleHsEnable(configPath, configDir, config, docker, {
4660
+ password: values.password,
4661
+ force: values.force === true,
4662
+ skipPreflight: values["skip-preflight"] === true,
4663
+ hsOverrides,
4664
+ json: values.json === true
4665
+ });
4666
+ } else if (action === "down") {
4667
+ await handleHsDown(configDir, config, docker, {
4668
+ rotateKeys: values["rotate-keys"] === true,
4669
+ hsOverrides
4670
+ });
4671
+ } else {
4672
+ console.error(
4673
+ "Usage: townhouse hs <up|enable|down> [--rotate-keys] [--password <pw>] [-c <path>]"
4674
+ );
4675
+ process.exitCode = 1;
4676
+ }
4677
+ break;
4678
+ }
4679
+ case "node": {
4680
+ const action = positionals[1];
4681
+ const jsonMode = values.json === true;
4682
+ const yesMode = values.yes === true;
4683
+ const nodeApiUrl = nodeCommandOverrides?.apiUrl ?? HS_TOWNHOUSE_API_URL;
4684
+ if (!action) {
4685
+ console.log(NODE_HELP);
4686
+ throw new CliHelpRequested();
4687
+ }
4688
+ switch (action) {
4689
+ case "add": {
4690
+ const typeArg = positionals[2] ?? "town";
4691
+ await handleNodeAdd(typeArg, {
4692
+ json: jsonMode,
4693
+ apiUrl: nodeApiUrl,
4694
+ fetch: nodeCommandOverrides?.fetch,
4695
+ confirm: nodeCommandOverrides?.confirm,
4696
+ relays: values["relays"],
4697
+ turboToken: values["turbo-token"],
4698
+ settlementChain: values["settlement-chain"],
4699
+ asset: values["asset"]
4700
+ });
4701
+ break;
4702
+ }
4703
+ case "remove": {
4704
+ const idArg = positionals[2] ?? "";
4705
+ await handleNodeRemove(idArg, {
4706
+ yes: yesMode,
4707
+ json: jsonMode,
4708
+ apiUrl: nodeApiUrl,
4709
+ fetch: nodeCommandOverrides?.fetch,
4710
+ confirm: nodeCommandOverrides?.confirm
4711
+ });
4712
+ break;
4713
+ }
4714
+ case "list": {
4715
+ await handleNodeList({
4716
+ json: jsonMode,
4717
+ apiUrl: nodeApiUrl,
4718
+ fetch: nodeCommandOverrides?.fetch
4719
+ });
4720
+ break;
4721
+ }
4722
+ default: {
4723
+ const safeAction = action.replace(/[\x00-\x1f\x7f]/g, "");
4724
+ console.error(`Unknown node subcommand: ${safeAction}`);
4725
+ console.log(NODE_HELP);
4726
+ process.exitCode = 1;
4727
+ }
4728
+ }
4729
+ break;
4730
+ }
4731
+ case "chains": {
4732
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
4733
+ const action = positionals[1];
4734
+ const chainIdArg = positionals[2];
4735
+ if (action === "supported") {
4736
+ const cfg = loadConfig(configPath);
4737
+ const assets = listSupportedSettlementAssets(cfg);
4738
+ if (values.json === true) {
4739
+ console.log(JSON.stringify({ chains: assets }));
4740
+ } else if (assets.length === 0) {
4741
+ console.log(
4742
+ "No supported settlement chains. Set `network` (mainnet/testnet/devnet) or run `townhouse chains add`."
4743
+ );
4744
+ } else {
4745
+ console.log(
4746
+ "Supported settlement chains/tokens \u2014 use with `node add town --settlement-chain <id> --asset <code>`:"
4747
+ );
4748
+ for (const a of assets) {
4749
+ console.log(
4750
+ ` ${a.chainId} ${a.assetCode} (scale ${a.assetScale})${a.native ? " [native]" : ""}`
4751
+ );
4752
+ }
4753
+ }
4754
+ break;
4755
+ }
4756
+ const flags = {
4757
+ chainType: values["chain-type"],
4758
+ chainId: values["chain-id"],
4759
+ rpcUrl: values["rpc-url"],
4760
+ wsUrl: values["ws-url"],
4761
+ registry: values["registry"],
4762
+ tokenAddress: values["token-address"],
4763
+ tokenMint: values["token-mint"],
4764
+ programId: values["program-id"],
4765
+ graphqlUrl: values["graphql-url"],
4766
+ zkapp: values["zkapp"],
4767
+ keyId: values["key-id"]
4768
+ };
4769
+ await handleChains(
4770
+ action,
4771
+ chainIdArg,
4772
+ flags,
4773
+ configPath,
4774
+ values.json === true
4775
+ );
4776
+ break;
4777
+ }
4778
+ default: {
4779
+ const sanitized = command.replace(/[\x00-\x1f\x7f]/g, "");
4780
+ console.error(`Unknown command: ${sanitized}`);
4781
+ console.log(HELP_TEXT);
4782
+ process.exitCode = 1;
4783
+ }
4784
+ }
4785
+ }
4786
+ var invokedFile = process.argv[1];
4787
+ var invokedDirectly = false;
4788
+ if (typeof invokedFile === "string") {
4789
+ try {
4790
+ invokedDirectly = import.meta.url === pathToFileURL(realpathSync(invokedFile)).href;
4791
+ } catch {
4792
+ invokedDirectly = import.meta.url === pathToFileURL(invokedFile).href;
4793
+ }
4794
+ }
4795
+ if (invokedDirectly) {
4796
+ main(process.argv.slice(2)).catch((error) => {
4797
+ if (error instanceof CliHelpRequested) {
4798
+ process.exit(0);
4799
+ }
4800
+ console.error("[Townhouse] Error:", error);
4801
+ process.exit(1);
4802
+ });
4803
+ }
4804
+ export {
4805
+ CliHelpRequested,
4806
+ main,
4807
+ readCliVersion
4808
+ };
4809
+ //# sourceMappingURL=cli.js.map