@toon-protocol/townhouse 0.1.0-rc5 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,30 +1,60 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'module'; const require = createRequire(import.meta.url);
3
3
  import {
4
+ BootReconciler,
4
5
  ConnectorAdminClient,
5
6
  DEFAULT_ATOR_PROXY,
6
7
  DockerOrchestrator,
8
+ LOG_SERVICES,
9
+ OrchestratorError,
10
+ PeerTypeResolver,
7
11
  TransportProbe,
8
12
  WalletManager,
13
+ aggregateEarnings,
9
14
  createApiServer,
15
+ createDeltaComputer,
10
16
  createWizardApiServer,
11
17
  decryptWallet,
12
18
  encryptWallet,
13
19
  getDefaultConfig,
20
+ isSyntheticDigest,
14
21
  loadConfig,
15
22
  loadWallet,
16
- saveWallet
17
- } from "./chunk-IB6TNCUQ.js";
18
- import "./chunk-UTFWPLTB.js";
23
+ materializeComposeTemplate,
24
+ readImageManifest,
25
+ readNodesYaml,
26
+ saveWallet,
27
+ serviceFromContainerName,
28
+ tailContainerLogs,
29
+ writeHsConnectorConfig
30
+ } from "./chunk-W33MEOPM.js";
31
+ import "./chunk-5O4SBV5O.js";
32
+ import {
33
+ CONTAINER_PREFIX
34
+ } from "./chunk-GQNBZJ6F.js";
35
+ import {
36
+ formatRelativeTime,
37
+ formatUsdc
38
+ } from "./chunk-JCOFMUPL.js";
39
+ import "./chunk-I2R4CRUX.js";
19
40
 
20
41
  // src/cli.ts
21
42
  import { parseArgs } from "util";
22
- import { mkdirSync, writeFileSync, existsSync } from "fs";
23
- import { join, resolve } from "path";
43
+ import {
44
+ mkdirSync,
45
+ writeFileSync,
46
+ existsSync,
47
+ renameSync,
48
+ rmSync,
49
+ statSync
50
+ } from "fs";
51
+ import { join, resolve, dirname } from "path";
24
52
  import { homedir } from "os";
25
53
  import { pathToFileURL } from "url";
54
+ import { spawn as spawn2 } from "child_process";
26
55
  import { stringify } from "yaml";
27
- import Docker from "dockerode";
56
+ import Docker2 from "dockerode";
57
+ import { nip19 } from "nostr-tools";
28
58
 
29
59
  // src/cli/browser-opener.ts
30
60
  import { spawn } from "child_process";
@@ -46,36 +76,1906 @@ var RealBrowserOpener = class {
46
76
  args = [url];
47
77
  break;
48
78
  }
49
- return new Promise((resolve2) => {
50
- let settled = false;
51
- const settle = () => {
52
- if (settled) return;
53
- settled = true;
54
- resolve2();
55
- };
56
- try {
57
- const child = spawn(cmd, args, {
58
- stdio: ["ignore", "ignore", "ignore"],
59
- detached: true
60
- });
61
- child.once("error", (err) => {
62
- console.warn(
63
- `[Townhouse] Could not open browser via ${cmd}: ${err.message}`
64
- );
65
- settle();
66
- });
67
- child.once("spawn", () => {
68
- child.unref();
69
- settle();
70
- });
71
- } catch (err) {
72
- const msg = err instanceof Error ? err.message : String(err);
73
- console.warn(`[Townhouse] Could not open browser: ${msg}`);
74
- settle();
75
- }
76
- });
79
+ return new Promise((resolve2) => {
80
+ let settled = false;
81
+ const settle = () => {
82
+ if (settled) return;
83
+ settled = true;
84
+ resolve2();
85
+ };
86
+ try {
87
+ const child = spawn(cmd, args, {
88
+ stdio: ["ignore", "ignore", "ignore"],
89
+ detached: true
90
+ });
91
+ child.once("error", (err) => {
92
+ console.warn(
93
+ `[Townhouse] Could not open browser via ${cmd}: ${err.message}`
94
+ );
95
+ settle();
96
+ });
97
+ child.once("spawn", () => {
98
+ child.unref();
99
+ settle();
100
+ });
101
+ } catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ console.warn(`[Townhouse] Could not open browser: ${msg}`);
104
+ settle();
105
+ }
106
+ });
107
+ }
108
+ };
109
+
110
+ // src/cli/onboarding-ribbon.ts
111
+ var PHASES = {
112
+ pull: "Pulling apex image\u2026",
113
+ bootstrap: "Bootstrapping hidden service (this takes 30\u201390s)\u2026"
114
+ };
115
+ var SPINNER_FRAMES = ["|", "/", "-", "\\"];
116
+ function isTty() {
117
+ return process.stdout.isTTY === true;
118
+ }
119
+ function supportsUnicode() {
120
+ const term = process.env["TERM"] ?? "";
121
+ if (term === "dumb") return false;
122
+ if (/xterm|screen|tmux/i.test(term)) return true;
123
+ if (process.env["COLORTERM"] !== void 0) return true;
124
+ return false;
125
+ }
126
+ function isAnimationDisabled() {
127
+ if (process.env["NO_COLOR"] !== void 0 && process.env["NO_COLOR"] !== "")
128
+ return true;
129
+ if (process.env["CI"] === "true") return true;
130
+ return false;
131
+ }
132
+ function useAnsiRewrite() {
133
+ return isTty() && supportsUnicode() && !isAnimationDisabled();
134
+ }
135
+ var OnboardingRibbon = class {
136
+ currentPhase = null;
137
+ spinnerTimer = null;
138
+ spinnerFrame = 0;
139
+ hasWrittenLine = false;
140
+ start(phase, detail) {
141
+ this._stopSpinner();
142
+ if (phase === "live") {
143
+ const line = detail ? `Apex live at ${detail}` : "Apex live.";
144
+ this._writeLine(line);
145
+ this.currentPhase = "live";
146
+ return;
147
+ }
148
+ const text = PHASES[phase];
149
+ if (useAnsiRewrite() && this.hasWrittenLine) {
150
+ process.stdout.write("\x1B[1A\x1B[2K");
151
+ }
152
+ if (isAnimationDisabled() || !isTty()) {
153
+ this._writeLine(text);
154
+ } else {
155
+ this._writeLine(`${text} ${SPINNER_FRAMES[0]}`);
156
+ this.spinnerFrame = 1;
157
+ this.spinnerTimer = setInterval(() => {
158
+ const idx = this.spinnerFrame % SPINNER_FRAMES.length;
159
+ const frame = SPINNER_FRAMES[idx] ?? "|";
160
+ this.spinnerFrame++;
161
+ if (useAnsiRewrite()) {
162
+ process.stdout.write("\x1B[1A\x1B[2K");
163
+ process.stdout.write(`${text} ${frame}
164
+ `);
165
+ } else {
166
+ process.stdout.write(`${text} ${frame}
167
+ `);
168
+ }
169
+ }, 100);
170
+ }
171
+ this.currentPhase = phase;
172
+ }
173
+ stop() {
174
+ this._stopSpinner();
175
+ }
176
+ _stopSpinner() {
177
+ if (this.spinnerTimer !== null) {
178
+ clearInterval(this.spinnerTimer);
179
+ this.spinnerTimer = null;
180
+ }
181
+ }
182
+ _writeLine(text) {
183
+ process.stdout.write(`${text}
184
+ `);
185
+ this.hasWrittenLine = true;
186
+ }
187
+ };
188
+
189
+ // src/cli/failure-copy.ts
190
+ var FAILURE_COPY = {
191
+ "anon-timeout": {
192
+ headline: "Hidden service didn't publish in time.",
193
+ explanation: "The .anyone descriptor did not publish within the allotted time.",
194
+ nextStep: "Re-run with DEBUG=townhouse:* for verbose anon logs."
195
+ },
196
+ "anon-disabled": {
197
+ headline: "Connector is anon-disabled.",
198
+ explanation: "The connector config has anon.enabled: false.",
199
+ nextStep: "Edit ~/.townhouse/connector.yaml and set anon.enabled: true."
200
+ },
201
+ "image-pull-failure": {
202
+ headline: "Image pull failed.",
203
+ explanation: "Docker could not pull the required townhouse images.",
204
+ nextStep: "Check your network and try again."
205
+ },
206
+ "port-collision": {
207
+ headline: "Port already in use.",
208
+ explanation: "A required host port is already bound by another process.",
209
+ nextStep: "Stop the conflicting service or override the port via --connector-admin-port."
210
+ },
211
+ "missing-docker-sock": {
212
+ headline: "Docker daemon unreachable.",
213
+ explanation: "The Docker socket is not accessible or Docker is not running.",
214
+ nextStep: "Start Docker and re-run `townhouse hs up`."
215
+ },
216
+ generic: {
217
+ headline: "Apex boot failed.",
218
+ explanation: "",
219
+ nextStep: "Run with DEBUG=townhouse:* for verbose logs."
220
+ }
221
+ };
222
+ function supportsUnicode2() {
223
+ const term = process.env["TERM"] ?? "";
224
+ if (term === "dumb") return false;
225
+ if (/xterm|screen|tmux/i.test(term)) return true;
226
+ if (process.env["COLORTERM"] !== void 0) return true;
227
+ return false;
228
+ }
229
+ function useAscii() {
230
+ if (process.env["NO_COLOR"] !== void 0 && process.env["NO_COLOR"] !== "")
231
+ return true;
232
+ return !supportsUnicode2();
233
+ }
234
+ function classify(error) {
235
+ const msg = error instanceof Error ? error.message : String(error);
236
+ const isOrchError = error instanceof OrchestratorError;
237
+ const stderr = isOrchError ? error.stderr ?? "" : "";
238
+ if (msg.includes("HS hostname publication timeout")) {
239
+ return { key: "anon-timeout", explanation: msg };
240
+ }
241
+ if (isOrchError && msg.includes("anon-disabled")) {
242
+ return { key: "anon-timeout", explanation: msg };
243
+ }
244
+ if (!isOrchError && msg.includes("anon-disabled")) {
245
+ return { key: "anon-disabled", explanation: msg };
246
+ }
247
+ if (stderr.includes("failed to pull") || stderr.includes("pull access denied") || msg.includes("failed to pull") || msg.includes("pull access denied")) {
248
+ return { key: "image-pull-failure", explanation: msg };
249
+ }
250
+ 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")) {
251
+ return { key: "port-collision", explanation: msg };
252
+ }
253
+ 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")) {
254
+ return { key: "missing-docker-sock", explanation: msg };
255
+ }
256
+ return { key: "generic", explanation: msg };
257
+ }
258
+ function renderFailure(error) {
259
+ const ascii = useAscii();
260
+ const { key, explanation } = classify(error);
261
+ const entry = FAILURE_COPY[key];
262
+ if (!entry) {
263
+ const xMark2 = ascii ? "[X]" : "\u2715";
264
+ const arrow2 = ascii ? "->" : "\u2192";
265
+ process.stderr.write(`${xMark2} Apex boot failed.
266
+ `);
267
+ process.stderr.write(` ${explanation}
268
+ `);
269
+ process.stderr.write(
270
+ ` ${arrow2} Run with DEBUG=townhouse:* for verbose logs.
271
+ `
272
+ );
273
+ return { exitCode: 1 };
274
+ }
275
+ const xMark = ascii ? "[X]" : "\u2715";
276
+ const arrow = ascii ? "->" : "\u2192";
277
+ const explanationText = key === "generic" ? explanation : entry.explanation;
278
+ process.stderr.write(`${xMark} ${entry.headline}
279
+ `);
280
+ process.stderr.write(` ${explanationText}
281
+ `);
282
+ process.stderr.write(` ${arrow} ${entry.nextStep}
283
+ `);
284
+ return { exitCode: 1 };
285
+ }
286
+
287
+ // src/cli/password-prompt.ts
288
+ import { createInterface } from "readline";
289
+ function promptPassword(prompt = "Wallet password: ") {
290
+ return new Promise((resolve2, reject) => {
291
+ const rl = createInterface({
292
+ input: process.stdin,
293
+ output: process.stdout,
294
+ terminal: true
295
+ });
296
+ const iface = rl;
297
+ const origWrite = iface._writeToOutput.bind(iface);
298
+ iface._writeToOutput = (str) => {
299
+ if (str === "\r\n" || str === "\n" || str === "\r") {
300
+ origWrite(str);
301
+ } else if (/^[\x20-\x7e€-￿]/.test(str)) {
302
+ origWrite("*".repeat(str.length));
303
+ } else {
304
+ origWrite(str);
305
+ }
306
+ };
307
+ rl.question(prompt, (answer) => {
308
+ iface._writeToOutput = origWrite;
309
+ process.stdout.write("\n");
310
+ rl.close();
311
+ resolve2(answer);
312
+ });
313
+ rl.once("error", (err) => {
314
+ iface._writeToOutput = origWrite;
315
+ rl.close();
316
+ reject(err);
317
+ });
318
+ rl.once("close", () => {
319
+ });
320
+ });
321
+ }
322
+
323
+ // src/cli/preflight-ports.ts
324
+ import { createServer } from "net";
325
+ var HS_CANONICAL_PORTS = [
326
+ 9401,
327
+ 28090,
328
+ 7100,
329
+ 3100,
330
+ 3200,
331
+ 3400
332
+ ];
333
+ async function isPortInUse(port) {
334
+ return new Promise((resolve2, reject) => {
335
+ const server = createServer();
336
+ let settled = false;
337
+ const finalize = (result) => {
338
+ if (settled) return;
339
+ settled = true;
340
+ server.removeAllListeners("error");
341
+ server.removeAllListeners("listening");
342
+ try {
343
+ server.close();
344
+ } catch {
345
+ }
346
+ if (result instanceof Error) reject(result);
347
+ else resolve2(result);
348
+ };
349
+ server.once("error", (err) => {
350
+ if (err.code === "EADDRINUSE") {
351
+ finalize(true);
352
+ } else {
353
+ finalize(err);
354
+ }
355
+ });
356
+ server.once("listening", () => {
357
+ const addr = server.address();
358
+ void addr;
359
+ finalize(false);
360
+ });
361
+ try {
362
+ server.listen({ port, host: "127.0.0.1", exclusive: true });
363
+ } catch (err) {
364
+ finalize(err);
365
+ }
366
+ });
367
+ }
368
+ function findDockerCulprit(containers, port) {
369
+ for (const c of containers) {
370
+ const ports = c.Ports ?? [];
371
+ for (const p of ports) {
372
+ if (p.PublicPort === port) {
373
+ const rawName = c.Names?.[0] ?? "";
374
+ const name = rawName.startsWith("/") ? rawName.slice(1) : rawName;
375
+ const project = c.Labels?.["com.docker.compose.project"];
376
+ return {
377
+ containerName: name || void 0,
378
+ composeProject: project,
379
+ status: c.Status
380
+ };
381
+ }
382
+ }
383
+ }
384
+ return void 0;
385
+ }
386
+ async function checkHsPortCollisions(docker, ports = HS_CANONICAL_PORTS) {
387
+ const probes = await Promise.all(
388
+ ports.map(async (port) => {
389
+ try {
390
+ const inUse = await isPortInUse(port);
391
+ return { port, inUse, probeError: void 0 };
392
+ } catch (err) {
393
+ return {
394
+ port,
395
+ inUse: true,
396
+ probeError: err instanceof Error ? err : new Error(String(err))
397
+ };
398
+ }
399
+ })
400
+ );
401
+ const taken = probes.filter((p) => p.inUse);
402
+ if (taken.length === 0) return [];
403
+ let containers = [];
404
+ if (docker) {
405
+ try {
406
+ containers = await docker.listContainers({ all: false });
407
+ } catch {
408
+ containers = [];
409
+ }
410
+ }
411
+ return taken.map((t) => {
412
+ const culprit = findDockerCulprit(containers, t.port);
413
+ return {
414
+ port: t.port,
415
+ ...culprit ?? {}
416
+ };
417
+ });
418
+ }
419
+ function formatCollisionMessage(collisions) {
420
+ if (collisions.length === 0) return "";
421
+ const lines = [];
422
+ lines.push("townhouse hs up: cannot start \u2014 host ports already in use:");
423
+ lines.push("");
424
+ for (const c of collisions) {
425
+ const portLabel = `127.0.0.1:${c.port}`.padEnd(18);
426
+ if (c.containerName) {
427
+ lines.push(` ${portLabel}in use by container '${c.containerName}'`);
428
+ const project = c.composeProject ?? "<no compose project>";
429
+ const status = c.status ? `, ${c.status}` : "";
430
+ lines.push(` ${" ".repeat(18)}(compose project '${project}'${status})`);
431
+ } else {
432
+ lines.push(
433
+ ` ${portLabel}port in use (no Docker container found \u2014 try \`sudo lsof -iTCP:${c.port} -sTCP:LISTEN\`)`
434
+ );
435
+ }
436
+ }
437
+ lines.push("");
438
+ lines.push("The HS template needs canonical ports \u2014 it cannot remap.");
439
+ const projects = /* @__PURE__ */ new Set();
440
+ for (const c of collisions) {
441
+ if (c.composeProject) projects.add(c.composeProject);
442
+ }
443
+ if (projects.size > 0) {
444
+ lines.push("Stop the conflicting project to free them:");
445
+ lines.push("");
446
+ for (const project of projects) {
447
+ lines.push(` docker compose -p ${project} down`);
448
+ }
449
+ lines.push("");
450
+ lines.push(
451
+ "Or, if the conflicting process is NOT a townhouse stack, identify it with:"
452
+ );
453
+ } else {
454
+ lines.push("Identify the conflicting processes with:");
455
+ }
456
+ lines.push("");
457
+ const examplePort = collisions[0]?.port ?? 9401;
458
+ lines.push(` sudo lsof -iTCP:${examplePort} -sTCP:LISTEN`);
459
+ lines.push("");
460
+ lines.push("Re-run with --skip-preflight to bypass this check.");
461
+ return lines.join("\n") + "\n";
462
+ }
463
+
464
+ // src/cli/pull-narrator.ts
465
+ var THROTTLED_STATUSES = /* @__PURE__ */ new Set(["Downloading", "Extracting"]);
466
+ var PullNarrator = class {
467
+ now;
468
+ throttleMs;
469
+ perImage = /* @__PURE__ */ new Map();
470
+ constructor(options = {}) {
471
+ this.now = options.now ?? Date.now;
472
+ this.throttleMs = options.throttleMs ?? 1e3;
473
+ }
474
+ /**
475
+ * Render an event to a stdout-ready line, or `null` if it should be
476
+ * suppressed by the throttle.
477
+ */
478
+ format(event) {
479
+ const status = event.status;
480
+ if (!status) {
481
+ return null;
482
+ }
483
+ const state = this.perImage.get(event.image) ?? {
484
+ lastStatus: void 0,
485
+ lastThrottledAtMs: 0
486
+ };
487
+ const isThrottled = THROTTLED_STATUSES.has(status);
488
+ const isTransition = state.lastStatus !== status;
489
+ if (isThrottled && !isTransition) {
490
+ const elapsed = this.now() - state.lastThrottledAtMs;
491
+ if (elapsed < this.throttleMs) {
492
+ return null;
493
+ }
494
+ }
495
+ state.lastStatus = status;
496
+ if (isThrottled) {
497
+ state.lastThrottledAtMs = this.now();
498
+ }
499
+ this.perImage.set(event.image, state);
500
+ const progress = event.progress ? ` ${event.progress}` : "";
501
+ return ` [pull] ${event.image}: ${status}${progress}`;
502
+ }
503
+ /**
504
+ * Reset the narrator's per-image state. Useful between separate pull
505
+ * batches in the same process.
506
+ */
507
+ reset() {
508
+ this.perImage.clear();
509
+ }
510
+ };
511
+
512
+ // src/cli/node-commands.ts
513
+ import * as readline from "readline";
514
+ var DEFAULT_HS_API_URL = "http://127.0.0.1:28090";
515
+ var STEP_TO_STAGE = {
516
+ preflight: "Preflight",
517
+ "derive-key": "Deriving wallet",
518
+ "pull-image": "Pulling image",
519
+ "write-yaml": "Deriving wallet",
520
+ // same disk-class bucket from operator POV
521
+ "write-mill-config": "Deriving wallet",
522
+ // same disk-class bucket as write-yaml
523
+ "start-container": "Registering with apex",
524
+ healthcheck: "Registering with apex",
525
+ "register-peer": "Live"
526
+ };
527
+ var STAGE_LABELS = [
528
+ "Pulling image",
529
+ "Deriving wallet",
530
+ "Registering with apex",
531
+ "Live"
532
+ ];
533
+ var NODE_ADD_HELP = `townhouse node add \u2014 Provision a child node
534
+
535
+ Usage:
536
+ townhouse node add [<type>] [--json] [-c <path>]
537
+
538
+ Arguments:
539
+ <type> Node type to provision: town, mill, dvm (default: town)
540
+
541
+ Flags:
542
+ --json Machine-readable JSON output
543
+ -c Path to config file
544
+
545
+ Examples:
546
+ townhouse node add # provision a Town relay (default)
547
+ townhouse node add town # same as above
548
+ townhouse node add mill # earn from chain swaps (5x earnings unlock)
549
+ townhouse node add dvm # add a DVM compute node`;
550
+ var NODE_REMOVE_HELP = `townhouse node remove \u2014 Deprovision a child node
551
+
552
+ Usage:
553
+ townhouse node remove <id> [--yes] [--json] [-c <path>]
554
+
555
+ Arguments:
556
+ <id> Node ID to remove (use 'townhouse node list' to find IDs)
557
+
558
+ Flags:
559
+ --yes Skip confirmation prompt (required in non-interactive mode)
560
+ --json Machine-readable JSON output; implies non-interactive (no prompt)
561
+ -c Path to config file`;
562
+ var NODE_LIST_HELP = `townhouse node list \u2014 List provisioned nodes
563
+
564
+ Usage:
565
+ townhouse node list [--json] [-c <path>]
566
+
567
+ Flags:
568
+ --json Machine-readable JSON output (emits API response verbatim)
569
+ -c Path to config file`;
570
+ var NODE_HELP = `townhouse node \u2014 Manage child nodes
571
+
572
+ Usage:
573
+ townhouse node add [<type>] [--json] [-c <path>] Provision a child node (default: town)
574
+ townhouse node remove <id> [--yes] [--json] [-c <path>] Deprovision a child node
575
+ townhouse node list [--json] [-c <path>] List provisioned nodes
576
+
577
+ Run 'townhouse node <verb> --help' for details on each verb.
578
+
579
+ Tip:
580
+ townhouse node add mill # earn from chain swaps (5x earnings unlock)`;
581
+ function resolveApiUrl(apiUrl) {
582
+ return apiUrl ?? DEFAULT_HS_API_URL;
583
+ }
584
+ function formatRelativeTime2(iso) {
585
+ const ts = new Date(iso).getTime();
586
+ if (Number.isNaN(ts)) return "\u2014";
587
+ const diffMs = Date.now() - ts;
588
+ if (diffMs < 0) return "just now";
589
+ const secs = Math.floor(diffMs / 1e3);
590
+ if (secs < 60) return `${secs}s ago`;
591
+ const mins = Math.floor(secs / 60);
592
+ if (mins < 60) return `${mins}m ago`;
593
+ const hours = Math.floor(mins / 60);
594
+ if (hours < 24) return `${hours}h ago`;
595
+ return `${Math.floor(hours / 24)}d ago`;
596
+ }
597
+ async function confirmInteractive(question) {
598
+ const rl = readline.createInterface({
599
+ input: process.stdin,
600
+ output: process.stdout
601
+ });
602
+ try {
603
+ const answer = await new Promise(
604
+ (resolve2) => rl.question(question, resolve2)
605
+ );
606
+ return /^y(es)?$/i.test(answer.trim());
607
+ } finally {
608
+ rl.close();
609
+ }
610
+ }
611
+ function emitJsonError(obj, exitCode = 1) {
612
+ process.stdout.write(JSON.stringify(obj) + "\n");
613
+ process.exitCode = exitCode;
614
+ }
615
+ async function handleNodeAdd(type, options) {
616
+ const ascii = useAscii();
617
+ const check = ascii ? "[OK]" : "\u2713";
618
+ const xMark = ascii ? "[X]" : "\u2715";
619
+ const dot = ascii ? "." : "\xB7";
620
+ if (type !== "town" && type !== "mill" && type !== "dvm") {
621
+ const msg = `Unknown type: '${type}'. Supported: town, mill, dvm`;
622
+ if (options.json) {
623
+ emitJsonError({ ok: false, error: "invalid_type", message: msg });
624
+ } else {
625
+ process.stderr.write(`${xMark} ${msg}
626
+ `);
627
+ process.exitCode = 1;
628
+ }
629
+ return;
630
+ }
631
+ const url = resolveApiUrl(options.apiUrl);
632
+ const fetchImpl = options.fetch ?? fetch;
633
+ if (!options.json) {
634
+ process.stdout.write(
635
+ ` ${STAGE_LABELS.map((s) => `${dot} ${s}`).join(" \xB7 ")}
636
+ `
637
+ );
638
+ }
639
+ const controller = new AbortController();
640
+ const timer = setTimeout(() => controller.abort(), 12e4);
641
+ let response;
642
+ try {
643
+ response = await fetchImpl(`${url}/api/nodes`, {
644
+ method: "POST",
645
+ headers: { "Content-Type": "application/json" },
646
+ body: JSON.stringify({ type }),
647
+ signal: controller.signal
648
+ });
649
+ } catch (err) {
650
+ clearTimeout(timer);
651
+ const isAborted = err instanceof Error && err.name === "AbortError";
652
+ const errMsg = isAborted ? "Request timed out after 120 seconds." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
653
+ if (options.json) {
654
+ emitJsonError({
655
+ ok: false,
656
+ error: isAborted ? "timeout" : "econnrefused",
657
+ message: errMsg
658
+ });
659
+ } else {
660
+ process.stderr.write(`${xMark} ${errMsg}
661
+ `);
662
+ process.exitCode = 1;
663
+ }
664
+ return;
665
+ }
666
+ clearTimeout(timer);
667
+ if (response.status === 201) {
668
+ const body2 = await response.json().catch(() => ({}));
669
+ if (options.json) {
670
+ process.stdout.write(JSON.stringify({ ok: true, ...body2 }) + "\n");
671
+ } else {
672
+ process.stdout.write(
673
+ ` ${STAGE_LABELS.map((s) => `${check} ${s}`).join(" \xB7 ")}
674
+ `
675
+ );
676
+ const addedId = body2.id ?? type;
677
+ const addedPeer = body2.peerId ? ` (${body2.peerId})` : "";
678
+ const addedAddr = body2.ilpAddress ? ` at ${body2.ilpAddress}` : "";
679
+ process.stdout.write(` Added ${addedId}${addedPeer}${addedAddr}
680
+ `);
681
+ }
682
+ return;
683
+ }
684
+ const body = await response.json().catch(() => ({}));
685
+ if (options.json) {
686
+ emitJsonError({ ok: false, ...body });
687
+ return;
688
+ }
689
+ if (response.status === 409 && body.error === "node_type_in_use") {
690
+ process.stderr.write(
691
+ `${xMark} Node of type '${body.type}' already exists with id '${body.existingId}'. Remove it first or use a different type.
692
+ `
693
+ );
694
+ process.exitCode = 1;
695
+ return;
696
+ }
697
+ if (response.status === 409 && body.error === "node_lifecycle_in_flight") {
698
+ process.stderr.write(
699
+ `${xMark} Another node operation is in flight. Try again in a moment.
700
+ `
701
+ );
702
+ process.exitCode = 1;
703
+ return;
704
+ }
705
+ const step = body.step ?? "unknown";
706
+ const errText = body.err ?? "";
707
+ if (step === "pull-image") {
708
+ const syntheticErr = new Error(`failed to pull: ${errText}`);
709
+ renderFailure(syntheticErr);
710
+ } else if (step === "start-container" && (errText.includes("port is already allocated") || errText.includes("Cannot connect to the Docker daemon"))) {
711
+ renderFailure(new Error(errText));
712
+ } else if (step === "preflight") {
713
+ const arrow = ascii ? "->" : "\u2192";
714
+ process.stderr.write(`${xMark} ${errText}
715
+ `);
716
+ process.stderr.write(
717
+ ` ${arrow} Fix the configuration above, then retry 'townhouse node add'.
718
+ `
719
+ );
720
+ } else {
721
+ const stageName = STEP_TO_STAGE[step] ?? step;
722
+ const arrow = ascii ? "->" : "\u2192";
723
+ process.stderr.write(
724
+ `${xMark} Step ${step} failed (stage: ${stageName}): ${errText}
725
+ `
726
+ );
727
+ process.stderr.write(
728
+ ` ${arrow} Run 'townhouse hs down && townhouse hs up' to reset state, then retry.
729
+ `
730
+ );
731
+ }
732
+ if (body.rollbackError) {
733
+ process.stderr.write(` Rollback error: ${body.rollbackError}
734
+ `);
735
+ }
736
+ process.exitCode = 1;
737
+ }
738
+ var NODE_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
739
+ async function handleNodeRemove(id, options) {
740
+ const ascii = useAscii();
741
+ const check = ascii ? "[OK]" : "\u2713";
742
+ const xMark = ascii ? "[X]" : "\u2715";
743
+ if (!id) {
744
+ const msg = "Usage: townhouse node remove <id> [--yes] [--json]";
745
+ if (options.json) {
746
+ emitJsonError({ ok: false, error: "missing_id", message: msg });
747
+ } else {
748
+ process.stderr.write(`${msg}
749
+ `);
750
+ process.exitCode = 1;
751
+ }
752
+ return;
753
+ }
754
+ if (!NODE_ID_PATTERN.test(id)) {
755
+ const msg = `Invalid node id '${id}'. IDs must match ^[a-z][a-z0-9-]*$ (lowercase, no leading hyphens or underscores).`;
756
+ if (options.json) {
757
+ emitJsonError({ ok: false, error: "invalid_id", message: msg });
758
+ } else {
759
+ process.stderr.write(`${xMark} ${msg}
760
+ `);
761
+ process.exitCode = 1;
762
+ }
763
+ return;
764
+ }
765
+ const skipPrompt = options.yes || options.json;
766
+ if (!skipPrompt) {
767
+ if (!process.stdin.isTTY) {
768
+ const msg = "--yes required when stdin is not a TTY (use --yes for non-interactive removal).";
769
+ process.stderr.write(`${xMark} ${msg}
770
+ `);
771
+ process.exitCode = 1;
772
+ return;
773
+ }
774
+ const confirmFn = options.confirm ?? confirmInteractive;
775
+ const confirmed = await confirmFn(
776
+ `Remove node '${id}'? This deprovisions the container and deregisters the peer. [y/N] `
777
+ );
778
+ if (!confirmed) {
779
+ process.stdout.write("Cancelled.\n");
780
+ return;
781
+ }
782
+ }
783
+ const url = resolveApiUrl(options.apiUrl);
784
+ const fetchImpl = options.fetch ?? fetch;
785
+ const controller = new AbortController();
786
+ const timer = setTimeout(() => controller.abort(), 6e4);
787
+ let response;
788
+ try {
789
+ response = await fetchImpl(`${url}/api/nodes/${encodeURIComponent(id)}`, {
790
+ method: "DELETE",
791
+ signal: controller.signal
792
+ });
793
+ } catch (err) {
794
+ clearTimeout(timer);
795
+ const isAborted = err instanceof Error && err.name === "AbortError";
796
+ const errMsg = isAborted ? "Request timed out." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
797
+ if (options.json) {
798
+ emitJsonError({
799
+ ok: false,
800
+ error: isAborted ? "timeout" : "econnrefused",
801
+ message: errMsg
802
+ });
803
+ } else {
804
+ process.stderr.write(`${xMark} ${errMsg}
805
+ `);
806
+ process.exitCode = 1;
807
+ }
808
+ return;
809
+ }
810
+ clearTimeout(timer);
811
+ if (response.status === 200) {
812
+ const body2 = await response.json().catch(() => ({}));
813
+ const removedId = body2.id ?? id;
814
+ if (options.json) {
815
+ process.stdout.write(
816
+ JSON.stringify({ ok: true, id: removedId, type: body2.type }) + "\n"
817
+ );
818
+ } else {
819
+ process.stdout.write(`${check} Removed ${removedId}
820
+ `);
821
+ }
822
+ return;
823
+ }
824
+ const body = await response.json().catch(() => ({}));
825
+ if (options.json) {
826
+ emitJsonError({ ok: false, ...body });
827
+ return;
828
+ }
829
+ if (response.status === 404) {
830
+ process.stderr.write(`${xMark} No node with id '${id}'
831
+ `);
832
+ } else if (response.status === 409 && body.error === "node_lifecycle_in_flight") {
833
+ process.stderr.write(
834
+ `${xMark} Another node operation is in flight. Try again in a moment.
835
+ `
836
+ );
837
+ } else {
838
+ const step = body.step ?? "unknown";
839
+ process.stderr.write(`${xMark} Step ${step} failed: ${body.err ?? ""}
840
+ `);
841
+ }
842
+ process.exitCode = 1;
843
+ }
844
+ async function handleNodeList(options) {
845
+ const ascii = useAscii();
846
+ const xMark = ascii ? "[X]" : "\u2715";
847
+ const emDash = ascii ? "-" : "\u2014";
848
+ const url = resolveApiUrl(options.apiUrl);
849
+ const fetchImpl = options.fetch ?? fetch;
850
+ const controller = new AbortController();
851
+ const timer = setTimeout(() => controller.abort(), 3e4);
852
+ let response;
853
+ try {
854
+ response = await fetchImpl(`${url}/api/nodes`, {
855
+ method: "GET",
856
+ signal: controller.signal
857
+ });
858
+ } catch (err) {
859
+ clearTimeout(timer);
860
+ const isAborted = err instanceof Error && err.name === "AbortError";
861
+ const errMsg = isAborted ? "Request timed out." : "townhouse hs up isn't running. Run 'townhouse hs up' first.";
862
+ if (options.json) {
863
+ emitJsonError({
864
+ ok: false,
865
+ error: isAborted ? "timeout" : "econnrefused",
866
+ message: errMsg
867
+ });
868
+ } else {
869
+ process.stderr.write(`${xMark} ${errMsg}
870
+ `);
871
+ process.exitCode = 1;
872
+ }
873
+ return;
874
+ }
875
+ clearTimeout(timer);
876
+ if (response.status !== 200) {
877
+ const body2 = await response.json().catch(() => ({}));
878
+ if (options.json) {
879
+ emitJsonError({ ok: false, ...body2 });
880
+ } else {
881
+ process.stderr.write(
882
+ `${xMark} Failed to fetch nodes (HTTP ${response.status})
883
+ `
884
+ );
885
+ process.exitCode = 1;
886
+ }
887
+ return;
888
+ }
889
+ const body = await response.json().catch(() => ({ nodes: [] }));
890
+ const nodes = body.nodes ?? [];
891
+ if (options.json) {
892
+ process.stdout.write(JSON.stringify({ nodes }) + "\n");
893
+ return;
894
+ }
895
+ if (nodes.length === 0) {
896
+ process.stdout.write(
897
+ "No nodes provisioned. Run 'townhouse node add town' to add one.\n"
898
+ );
899
+ return;
900
+ }
901
+ const rows = nodes.map((node) => ({
902
+ peer: node.id,
903
+ type: node.type,
904
+ status: node.status,
905
+ lastClaim: node.lastSeenAt !== null ? formatRelativeTime2(node.lastSeenAt) : emDash
906
+ }));
907
+ const HEADERS = {
908
+ peer: "peer",
909
+ type: "type",
910
+ status: "status",
911
+ lastClaim: "last claim"
912
+ };
913
+ const widths = {
914
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
915
+ type: Math.max(HEADERS.type.length, ...rows.map((r) => r.type.length)),
916
+ status: Math.max(
917
+ HEADERS.status.length,
918
+ ...rows.map((r) => r.status.length)
919
+ ),
920
+ lastClaim: Math.max(
921
+ HEADERS.lastClaim.length,
922
+ ...rows.map((r) => r.lastClaim.length)
923
+ )
924
+ };
925
+ function pad(s, width) {
926
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
927
+ }
928
+ const divider = ascii ? "-" : "\u2500";
929
+ process.stdout.write(
930
+ `${pad(HEADERS.peer, widths.peer)} ${pad(HEADERS.type, widths.type)} ${pad(HEADERS.status, widths.status)} ${HEADERS.lastClaim}
931
+ `
932
+ );
933
+ process.stdout.write(
934
+ `${divider.repeat(widths.peer)} ${divider.repeat(widths.type)} ${divider.repeat(widths.status)} ${divider.repeat(widths.lastClaim)}
935
+ `
936
+ );
937
+ for (const row of rows) {
938
+ process.stdout.write(
939
+ `${pad(row.peer, widths.peer)} ${pad(row.type, widths.type)} ${pad(row.status, widths.status)} ${row.lastClaim}
940
+ `
941
+ );
942
+ }
943
+ }
944
+
945
+ // src/cli/drill-commands.ts
946
+ import Docker from "dockerode";
947
+ function truncate16(s) {
948
+ return s.length > 16 ? s.slice(0, 16) + "\u2026" : s;
949
+ }
950
+ function emitJson(payload, opts) {
951
+ process.stdout.write(
952
+ JSON.stringify(payload, null, opts.compact ? 0 : 2) + "\n"
953
+ );
954
+ }
955
+ function emitJsonError2(message, code, opts) {
956
+ process.stdout.write(
957
+ JSON.stringify({ error: message, code }, null, opts.compact ? 0 : 2) + "\n"
958
+ );
959
+ process.exitCode = 1;
960
+ }
961
+ async function handleChannels(adminClient, opts) {
962
+ let channels;
963
+ try {
964
+ channels = await adminClient.getChannels();
965
+ } catch (error) {
966
+ const msg = error instanceof Error ? error.message : String(error);
967
+ if (opts.json) {
968
+ emitJsonError2(
969
+ `Failed to fetch connector channels: ${msg}`,
970
+ "unreachable",
971
+ opts
972
+ );
973
+ } else {
974
+ console.error(`Failed to fetch connector channels: ${msg}`);
975
+ process.exitCode = 1;
976
+ }
977
+ return;
978
+ }
979
+ if (opts.json) {
980
+ emitJson(channels, opts);
981
+ return;
982
+ }
983
+ if (channels.length === 0) {
984
+ console.log("No channels open");
985
+ return;
986
+ }
987
+ const now = opts.now ?? /* @__PURE__ */ new Date();
988
+ const HEADERS = {
989
+ channel: "CHANNEL",
990
+ peer: "PEER",
991
+ chain: "CHAIN",
992
+ status: "STATUS",
993
+ deposit: "DEPOSIT",
994
+ lastActivity: "LAST ACTIVITY"
995
+ };
996
+ const rows = channels.map((c) => ({
997
+ channel: truncate16(c.channelId),
998
+ peer: truncate16(c.peerId),
999
+ chain: c.chain,
1000
+ status: c.status,
1001
+ deposit: c.deposit,
1002
+ lastActivity: formatRelativeTime(c.lastActivity, now)
1003
+ }));
1004
+ const widths = {
1005
+ channel: Math.max(
1006
+ HEADERS.channel.length,
1007
+ ...rows.map((r) => r.channel.length)
1008
+ ),
1009
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
1010
+ chain: Math.max(HEADERS.chain.length, ...rows.map((r) => r.chain.length)),
1011
+ status: Math.max(
1012
+ HEADERS.status.length,
1013
+ ...rows.map((r) => r.status.length)
1014
+ ),
1015
+ deposit: Math.max(
1016
+ HEADERS.deposit.length,
1017
+ ...rows.map((r) => r.deposit.length)
1018
+ ),
1019
+ lastActivity: Math.max(
1020
+ HEADERS.lastActivity.length,
1021
+ ...rows.map((r) => r.lastActivity.length)
1022
+ )
1023
+ };
1024
+ 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;
1025
+ console.log(header);
1026
+ console.log("-".repeat(header.length));
1027
+ for (const row of rows) {
1028
+ console.log(
1029
+ 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
1030
+ );
1031
+ }
1032
+ }
1033
+ async function handleMetrics(adminClient, opts) {
1034
+ try {
1035
+ const metrics = await adminClient.getMetrics();
1036
+ const peers = await adminClient.getPeers();
1037
+ const peerMetrics = new Map(metrics.peers.map((p) => [p.peerId, p]));
1038
+ if (opts.json) {
1039
+ emitJson(
1040
+ {
1041
+ aggregate: metrics.aggregate,
1042
+ peers: metrics.peers,
1043
+ peersDetail: peers,
1044
+ uptimeSeconds: metrics.uptimeSeconds,
1045
+ timestamp: metrics.timestamp
1046
+ },
1047
+ opts
1048
+ );
1049
+ return;
1050
+ }
1051
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1052
+ console.log("Connector Metrics:");
1053
+ console.log("------------------");
1054
+ console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);
1055
+ console.log(` Packets rejected: ${metrics.aggregate.packetsRejected}`);
1056
+ console.log(` Bytes sent: ${metrics.aggregate.bytesSent}`);
1057
+ console.log("");
1058
+ console.log("Peers:");
1059
+ console.log("------");
1060
+ if (peers.length === 0) {
1061
+ console.log(" No peers connected");
1062
+ } else {
1063
+ const HEADERS = {
1064
+ peer: "PEER",
1065
+ connected: "STATUS",
1066
+ packetsForwarded: "PACKETS FWD",
1067
+ packetsRejected: "PACKETS REJ",
1068
+ bytesSent: "BYTES SENT",
1069
+ lastPacket: "LAST PACKET"
1070
+ };
1071
+ const rows = peers.map((peer) => {
1072
+ const pm = peerMetrics.get(peer.id);
1073
+ return {
1074
+ peer: peer.id,
1075
+ connected: peer.connected ? "connected" : "disconnected",
1076
+ packetsForwarded: String(pm?.packetsForwarded ?? 0),
1077
+ packetsRejected: String(pm?.packetsRejected ?? 0),
1078
+ bytesSent: String(pm?.bytesSent ?? 0),
1079
+ lastPacket: pm?.lastPacketAt != null ? formatRelativeTime(pm.lastPacketAt, now) : "\u2014"
1080
+ };
1081
+ });
1082
+ const widths = {
1083
+ peer: Math.max(HEADERS.peer.length, ...rows.map((r) => r.peer.length)),
1084
+ connected: Math.max(
1085
+ HEADERS.connected.length,
1086
+ ...rows.map((r) => r.connected.length)
1087
+ ),
1088
+ packetsForwarded: Math.max(
1089
+ HEADERS.packetsForwarded.length,
1090
+ ...rows.map((r) => r.packetsForwarded.length)
1091
+ ),
1092
+ packetsRejected: Math.max(
1093
+ HEADERS.packetsRejected.length,
1094
+ ...rows.map((r) => r.packetsRejected.length)
1095
+ ),
1096
+ bytesSent: Math.max(
1097
+ HEADERS.bytesSent.length,
1098
+ ...rows.map((r) => r.bytesSent.length)
1099
+ ),
1100
+ lastPacket: Math.max(
1101
+ HEADERS.lastPacket.length,
1102
+ ...rows.map((r) => r.lastPacket.length)
1103
+ )
1104
+ };
1105
+ 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;
1106
+ console.log(headerLine);
1107
+ console.log(` ${"-".repeat(headerLine.trim().length)}`);
1108
+ for (const row of rows) {
1109
+ console.log(
1110
+ ` ${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
1111
+ );
1112
+ }
1113
+ }
1114
+ } catch (error) {
1115
+ const msg = error instanceof Error ? error.message : String(error);
1116
+ if (opts.json) {
1117
+ emitJsonError2(
1118
+ `Failed to fetch connector metrics: ${msg}`,
1119
+ "unreachable",
1120
+ opts
1121
+ );
1122
+ } else {
1123
+ console.error(`Failed to fetch connector metrics: ${msg}`);
1124
+ process.exitCode = 1;
1125
+ }
1126
+ }
1127
+ }
1128
+ async function resolveContainerName(docker, nodeId) {
1129
+ let containers;
1130
+ try {
1131
+ containers = await docker.listContainers({ all: false });
1132
+ } catch (error) {
1133
+ const msg = error instanceof Error ? error.message : String(error);
1134
+ return {
1135
+ error: `Cannot connect to docker daemon: ${msg}. Is docker running?`,
1136
+ code: "docker-unavailable"
1137
+ };
1138
+ }
1139
+ const allNames = containers.flatMap(
1140
+ (c) => c.Names.map((n) => n.replace(/^\//, ""))
1141
+ );
1142
+ if (nodeId.startsWith(CONTAINER_PREFIX)) {
1143
+ if (!allNames.includes(nodeId)) {
1144
+ return {
1145
+ error: `Node "${nodeId}" is not running (no container named "${nodeId}").`,
1146
+ code: "unknown-node"
1147
+ };
1148
+ }
1149
+ const svc = serviceFromContainerName(nodeId) ?? "town";
1150
+ return { name: nodeId, service: svc };
1151
+ }
1152
+ const candidates = [];
1153
+ const exactName = `${CONTAINER_PREFIX}${nodeId}`;
1154
+ if (allNames.includes(exactName)) {
1155
+ const svc = serviceFromContainerName(exactName) ?? "town";
1156
+ candidates.push({ name: exactName, service: svc });
1157
+ }
1158
+ const isService = LOG_SERVICES.includes(nodeId);
1159
+ if (isService) {
1160
+ for (const name of allNames) {
1161
+ if (name === exactName) continue;
1162
+ const svc = serviceFromContainerName(name);
1163
+ if (svc === nodeId) {
1164
+ candidates.push({ name, service: svc });
1165
+ }
1166
+ }
1167
+ }
1168
+ const unique = candidates.filter(
1169
+ (c, i) => candidates.findIndex((x) => x.name === c.name) === i
1170
+ );
1171
+ if (unique.length === 0) {
1172
+ const resolvedName = `${CONTAINER_PREFIX}${nodeId}`;
1173
+ return {
1174
+ error: `Node "${nodeId}" is not running (no container named "${resolvedName}").`,
1175
+ code: "unknown-node"
1176
+ };
1177
+ }
1178
+ if (unique.length > 1) {
1179
+ const names = unique.map((c) => c.name).join(", ");
1180
+ return {
1181
+ error: `Ambiguous node-id "${nodeId}" \u2014 matches multiple containers: ${names}. Use the full container name.`,
1182
+ code: "ambiguous-node"
1183
+ };
1184
+ }
1185
+ const first = unique[0];
1186
+ if (first === void 0) {
1187
+ return {
1188
+ error: `Internal error resolving container name for "${nodeId}"`,
1189
+ code: "internal"
1190
+ };
1191
+ }
1192
+ return first;
1193
+ }
1194
+ async function handleLogs(docker, nodeId, opts) {
1195
+ const resolved = await resolveContainerName(docker, nodeId);
1196
+ if ("error" in resolved) {
1197
+ if (opts.json) {
1198
+ emitJsonError2(resolved.error, resolved.code, opts);
1199
+ } else {
1200
+ process.stderr.write(resolved.error + "\n");
1201
+ process.exitCode = 1;
1202
+ }
1203
+ return;
1204
+ }
1205
+ const { name: containerName, service } = resolved;
1206
+ const controller = new AbortController();
1207
+ const sigintHandler = () => {
1208
+ controller.abort();
1209
+ process.stdout.write("", () => {
1210
+ process.exit(process.exitCode ?? 0);
1211
+ });
1212
+ };
1213
+ process.once("SIGINT", sigintHandler);
1214
+ try {
1215
+ const gen = tailContainerLogs(docker, containerName, service, {
1216
+ tail: opts.lines,
1217
+ signal: controller.signal
1218
+ });
1219
+ for await (const evt of gen) {
1220
+ if (opts.json) {
1221
+ process.stdout.write(JSON.stringify(evt) + "\n");
1222
+ } else {
1223
+ process.stdout.write(
1224
+ `${evt.ts} [${evt.service}] ${evt.level}: ${evt.msg}
1225
+ `
1226
+ );
1227
+ }
1228
+ }
1229
+ } catch (error) {
1230
+ const msg = error instanceof Error ? error.message : String(error);
1231
+ 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");
1232
+ if (isDockerError) {
1233
+ const errMsg = `Cannot connect to docker daemon: ${msg}. Is docker running?`;
1234
+ if (opts.json) {
1235
+ emitJsonError2(errMsg, "docker-unavailable", opts);
1236
+ } else {
1237
+ process.stderr.write(errMsg + "\n");
1238
+ process.exitCode = 1;
1239
+ }
1240
+ } else {
1241
+ const errMsg = `Log stream error for "${nodeId}": ${msg}`;
1242
+ if (opts.json) {
1243
+ emitJsonError2(errMsg, "internal", opts);
1244
+ } else {
1245
+ process.stderr.write(errMsg + "\n");
1246
+ process.exitCode = 1;
1247
+ }
1248
+ }
1249
+ } finally {
1250
+ process.off("SIGINT", sigintHandler);
1251
+ }
1252
+ }
1253
+ async function handlePeerDetail(adminClient, peerId, opts) {
1254
+ const now = opts.now ?? /* @__PURE__ */ new Date();
1255
+ let peers;
1256
+ try {
1257
+ peers = await adminClient.getPeers();
1258
+ } catch (error) {
1259
+ const msg = error instanceof Error ? error.message : String(error);
1260
+ if (opts.json) {
1261
+ emitJsonError2(msg, "unreachable", opts);
1262
+ } else {
1263
+ process.stderr.write(`Failed to fetch peers: ${msg}
1264
+ `);
1265
+ process.exitCode = 1;
1266
+ }
1267
+ return;
1268
+ }
1269
+ const peer = peers.find((p) => p.id === peerId);
1270
+ if (peer === void 0) {
1271
+ const errMsg = `Unknown peer "${peerId}". Use \`townhouse metrics\` to see registered peers.`;
1272
+ if (opts.json) {
1273
+ emitJsonError2(errMsg, "unknown-peer", opts);
1274
+ } else {
1275
+ process.stderr.write(errMsg + "\n");
1276
+ process.exitCode = 1;
1277
+ }
1278
+ return;
1279
+ }
1280
+ const [earningsRaw, channelsRaw] = await Promise.all([
1281
+ adminClient.getEarnings().catch(() => null),
1282
+ adminClient.getChannels().catch(() => null)
1283
+ ]);
1284
+ const peerEarnings = earningsRaw?.peers.find((p) => p.peerId === peerId) ?? null;
1285
+ const peerChannels = channelsRaw?.filter((c) => c.peerId === peerId) ?? [];
1286
+ if (opts.json) {
1287
+ const earningsForJson = peerEarnings && peerEarnings.byAsset.length > 0 ? peerEarnings : null;
1288
+ emitJson(
1289
+ {
1290
+ peer,
1291
+ earnings: earningsForJson,
1292
+ channels: peerChannels
1293
+ },
1294
+ opts
1295
+ );
1296
+ return;
1297
+ }
1298
+ console.log(`Peer: ${peerId}`);
1299
+ console.log("");
1300
+ if (peer.ilpAddresses.length === 0) {
1301
+ console.log(" (no ILP addresses registered)");
1302
+ } else {
1303
+ for (const addr of peer.ilpAddresses) {
1304
+ console.log(` ${addr}`);
1305
+ }
1306
+ }
1307
+ console.log(` Routes: ${peer.routeCount}`);
1308
+ console.log("");
1309
+ console.log(`Connected: ${peer.connected ? "yes" : "no"}`);
1310
+ console.log("");
1311
+ if (earningsRaw === null) {
1312
+ console.log("Earnings:");
1313
+ console.log(
1314
+ " (earnings endpoint unavailable: connector is not settlement-configured)"
1315
+ );
1316
+ } else if (peerEarnings === null || peerEarnings.byAsset.length === 0) {
1317
+ console.log("Earnings:");
1318
+ console.log(" (no settlement activity yet)");
1319
+ } else {
1320
+ console.log("Earnings:");
1321
+ for (const asset of peerEarnings.byAsset) {
1322
+ const lastClaim = asset.lastClaimAt ? formatRelativeTime(asset.lastClaimAt, now) : "never";
1323
+ console.log(
1324
+ ` ${asset.assetCode} \xB7 received ${asset.claimsReceivedTotal} \xB7 sent ${asset.claimsSentTotal} \xB7 net ${asset.netBalance} \xB7 last claim ${lastClaim}`
1325
+ );
1326
+ }
1327
+ }
1328
+ console.log("");
1329
+ if (channelsRaw === null) {
1330
+ console.log("Channels:");
1331
+ console.log(
1332
+ " (channels endpoint unavailable: connector is not settlement-configured)"
1333
+ );
1334
+ } else if (peerChannels.length === 0) {
1335
+ console.log("Channels:");
1336
+ console.log(" (no channels open)");
1337
+ } else {
1338
+ console.log("Channels:");
1339
+ for (const ch of peerChannels) {
1340
+ console.log(
1341
+ ` ${truncate16(ch.channelId)} \xB7 ${ch.chain} \xB7 ${ch.status} \xB7 deposit ${ch.deposit} \xB7 ${formatRelativeTime(ch.lastActivity, now)}`
1342
+ );
1343
+ }
1344
+ }
1345
+ }
1346
+ var PROBE_TIMEOUT_MS = 3e3;
1347
+ async function probeConnector(adminClient) {
1348
+ try {
1349
+ await adminClient.pingAdminLive();
1350
+ return { source: "connector", status: "healthy" };
1351
+ } catch (error) {
1352
+ const msg = error instanceof Error ? error.message : String(error);
1353
+ return { source: "connector", status: "unreachable", error: msg };
1354
+ }
1355
+ }
1356
+ async function probeHostApi(apiUrl, fetchImpl) {
1357
+ try {
1358
+ const response = await fetchImpl(`${apiUrl}/health`, {
1359
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1360
+ });
1361
+ if (!response.ok) {
1362
+ return {
1363
+ source: "api",
1364
+ status: "unhealthy",
1365
+ error: `HTTP ${response.status}`
1366
+ };
1367
+ }
1368
+ const body = await response.json();
1369
+ return {
1370
+ source: "api",
1371
+ status: "healthy",
1372
+ uptime: body.uptime,
1373
+ startedAt: body.startedAt,
1374
+ version: body.version
1375
+ };
1376
+ } catch (error) {
1377
+ const msg = error instanceof Error ? error.message : String(error);
1378
+ return { source: "api", status: "unreachable", error: msg };
1379
+ }
1380
+ }
1381
+ async function probeNodes(apiUrl, fetchImpl) {
1382
+ let nodes;
1383
+ try {
1384
+ const resp = await fetchImpl(`${apiUrl}/api/nodes`, {
1385
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1386
+ });
1387
+ if (!resp.ok) {
1388
+ return [
1389
+ {
1390
+ source: "nodes",
1391
+ status: "unknown",
1392
+ error: `failed to enumerate nodes: HTTP ${resp.status}`
1393
+ }
1394
+ ];
1395
+ }
1396
+ const body = await resp.json();
1397
+ nodes = body.nodes ?? [];
1398
+ } catch (error) {
1399
+ const msg = error instanceof Error ? error.message : String(error);
1400
+ return [
1401
+ {
1402
+ source: "nodes",
1403
+ status: "unknown",
1404
+ error: `failed to enumerate nodes: ${msg}`
1405
+ }
1406
+ ];
1407
+ }
1408
+ return Promise.all(
1409
+ nodes.map(async (node) => {
1410
+ try {
1411
+ const resp = await fetchImpl(
1412
+ `${apiUrl}/api/nodes/${encodeURIComponent(node.id)}/health`,
1413
+ {
1414
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
1415
+ }
1416
+ );
1417
+ if (!resp.ok) {
1418
+ return {
1419
+ source: `node:${node.id}`,
1420
+ status: "unhealthy",
1421
+ error: `HTTP ${resp.status}`
1422
+ };
1423
+ }
1424
+ const body = await resp.json();
1425
+ const s = body.status;
1426
+ const status = s === "healthy" ? "healthy" : s === "unhealthy" ? "unhealthy" : s === "starting" ? "starting" : s === "degraded" ? "degraded" : "unknown";
1427
+ return { source: `node:${node.id}`, status };
1428
+ } catch (error) {
1429
+ const msg = error instanceof Error ? error.message : String(error);
1430
+ return {
1431
+ source: `node:${node.id}`,
1432
+ status: "unreachable",
1433
+ error: msg
1434
+ };
1435
+ }
1436
+ })
1437
+ );
1438
+ }
1439
+ async function probeAnyone(adminClient) {
1440
+ try {
1441
+ const result = await adminClient.getHsHostname();
1442
+ if (result.hostname !== null) {
1443
+ return {
1444
+ source: "anyone-hostname",
1445
+ status: "healthy",
1446
+ hostname: result.hostname,
1447
+ publishedAt: result.publishedAt ?? void 0
1448
+ };
1449
+ }
1450
+ return {
1451
+ source: "anyone-hostname",
1452
+ status: "starting",
1453
+ message: "anon publish pending"
1454
+ };
1455
+ } catch (error) {
1456
+ const msg = error instanceof Error ? error.message : String(error);
1457
+ if (msg.startsWith("connector is anon-disabled") || /(?:^|:\s)503\b/.test(msg)) {
1458
+ return {
1459
+ source: "anyone-hostname",
1460
+ status: "n/a",
1461
+ message: "anon disabled in config"
1462
+ };
1463
+ }
1464
+ return { source: "anyone-hostname", status: "unreachable", error: msg };
1465
+ }
1466
+ }
1467
+ function computeOverall(probes) {
1468
+ const statuses = probes.map((p) => p.status);
1469
+ if (statuses.some(
1470
+ (s) => s === "unhealthy" || s === "unreachable" || s === "unknown"
1471
+ )) {
1472
+ return "unhealthy";
1473
+ }
1474
+ if (statuses.some((s) => s === "starting" || s === "degraded")) {
1475
+ return "degraded";
1476
+ }
1477
+ return "healthy";
1478
+ }
1479
+ async function handleHealth(adminClient, opts) {
1480
+ const apiUrl = opts.apiUrl ?? "http://127.0.0.1:28090";
1481
+ const fetchImpl = opts.fetch ?? fetch;
1482
+ const healthClient = opts.adminClient ?? new ConnectorAdminClient(adminClient.getBaseUrl(), PROBE_TIMEOUT_MS);
1483
+ const [connectorProbe, apiProbe, nodeProbes, anyoneProbe] = await Promise.all(
1484
+ [
1485
+ probeConnector(healthClient),
1486
+ probeHostApi(apiUrl, fetchImpl),
1487
+ probeNodes(apiUrl, fetchImpl),
1488
+ probeAnyone(healthClient)
1489
+ ]
1490
+ );
1491
+ const probes = [
1492
+ connectorProbe,
1493
+ apiProbe,
1494
+ ...nodeProbes,
1495
+ anyoneProbe
1496
+ ];
1497
+ const overall = computeOverall(probes);
1498
+ if (opts.json) {
1499
+ emitJson({ overall, probes }, opts);
1500
+ } else {
1501
+ for (const probe of probes) {
1502
+ console.log(`${probe.source}: ${probe.status}`);
1503
+ if (probe.error) console.log(` error: ${probe.error}`);
1504
+ if (probe.uptime !== void 0) console.log(` uptime: ${probe.uptime}s`);
1505
+ if (probe.peersConnected !== void 0)
1506
+ console.log(
1507
+ ` peers: ${probe.peersConnected}/${probe.totalPeers ?? "?"} connected`
1508
+ );
1509
+ if (probe.startedAt) console.log(` startedAt: ${probe.startedAt}`);
1510
+ if (probe.version) console.log(` version: ${probe.version}`);
1511
+ if (probe.hostname) console.log(` hostname: ${probe.hostname}`);
1512
+ if (probe.publishedAt) console.log(` publishedAt: ${probe.publishedAt}`);
1513
+ if (probe.message) console.log(` ${probe.message}`);
1514
+ }
1515
+ console.log(`Overall: ${overall}`);
1516
+ }
1517
+ if (overall === "unhealthy") {
1518
+ process.exitCode = 1;
1519
+ }
1520
+ }
1521
+ async function dispatchDrillCommand(command, deps) {
1522
+ const { values, positionals, adminUrl, apiUrl } = deps;
1523
+ const json = values["json"] === true;
1524
+ const jsonCompact = values["json-compact"] === true;
1525
+ const baseOpts = { json, jsonCompact };
1526
+ const usageError = (msg, code) => {
1527
+ if (json) emitJsonError2(msg, code, baseOpts);
1528
+ else {
1529
+ console.error(msg);
1530
+ process.exitCode = 1;
1531
+ }
1532
+ };
1533
+ switch (command) {
1534
+ case "channels": {
1535
+ await handleChannels(new ConnectorAdminClient(adminUrl), baseOpts);
1536
+ return true;
1537
+ }
1538
+ case "metrics": {
1539
+ await handleMetrics(new ConnectorAdminClient(adminUrl), baseOpts);
1540
+ return true;
1541
+ }
1542
+ case "logs": {
1543
+ const nodeId = positionals[1];
1544
+ if (!nodeId) {
1545
+ usageError(
1546
+ "Usage: townhouse logs <node-id> [--lines N] [-f|--follow] [--json]",
1547
+ "usage"
1548
+ );
1549
+ return true;
1550
+ }
1551
+ const linesRaw = values["lines"];
1552
+ let lines = 50;
1553
+ if (linesRaw !== void 0) {
1554
+ if (!/^\d+$/.test(linesRaw)) {
1555
+ usageError(
1556
+ "--lines must be an integer between 0 and 10000",
1557
+ "bad-flag"
1558
+ );
1559
+ return true;
1560
+ }
1561
+ lines = Number(linesRaw);
1562
+ if (lines < 0 || lines > 1e4) {
1563
+ usageError(
1564
+ "--lines must be an integer between 0 and 10000",
1565
+ "bad-flag"
1566
+ );
1567
+ return true;
1568
+ }
1569
+ }
1570
+ const docker = deps.docker ?? new Docker();
1571
+ await handleLogs(docker, nodeId, { ...baseOpts, lines });
1572
+ return true;
1573
+ }
1574
+ case "peer": {
1575
+ const peerId = positionals[1];
1576
+ if (!peerId) {
1577
+ usageError("Usage: townhouse peer <id> [--json]", "usage");
1578
+ return true;
1579
+ }
1580
+ await handlePeerDetail(
1581
+ new ConnectorAdminClient(adminUrl),
1582
+ peerId,
1583
+ baseOpts
1584
+ );
1585
+ return true;
1586
+ }
1587
+ case "health": {
1588
+ await handleHealth(new ConnectorAdminClient(adminUrl, PROBE_TIMEOUT_MS), {
1589
+ ...baseOpts,
1590
+ apiUrl
1591
+ });
1592
+ return true;
1593
+ }
1594
+ default:
1595
+ return false;
1596
+ }
1597
+ }
1598
+
1599
+ // src/cli/status-earnings.ts
1600
+ var USDC_SCALE = 6;
1601
+ var USDC_ASSET = "USDC";
1602
+ var DECIMAL_RE = /^-?\d+$/;
1603
+ var POSITIVE_INT_RE = /^[1-9]\d*$/;
1604
+ function addDecimalStrings(a, b) {
1605
+ if (!DECIMAL_RE.test(b)) return a;
1606
+ try {
1607
+ return (BigInt(a) + BigInt(b)).toString();
1608
+ } catch {
1609
+ return a;
1610
+ }
1611
+ }
1612
+ function computeUsdcScalars(earnings) {
1613
+ let today = "0";
1614
+ let month = "0";
1615
+ let year = "0";
1616
+ let lifetime = "0";
1617
+ const apexUsdc = earnings.apex.routingFees[USDC_ASSET];
1618
+ if (apexUsdc !== void 0) {
1619
+ today = addDecimalStrings(today, apexUsdc.today);
1620
+ month = addDecimalStrings(month, apexUsdc.month);
1621
+ year = addDecimalStrings(year, apexUsdc.year);
1622
+ lifetime = addDecimalStrings(lifetime, apexUsdc.lifetime);
1623
+ }
1624
+ for (const peer of earnings.peers) {
1625
+ const peerUsdc = peer.byAsset[USDC_ASSET];
1626
+ if (peerUsdc !== void 0) {
1627
+ today = addDecimalStrings(today, peerUsdc.today);
1628
+ month = addDecimalStrings(month, peerUsdc.month);
1629
+ year = addDecimalStrings(year, peerUsdc.year);
1630
+ lifetime = addDecimalStrings(lifetime, peerUsdc.lifetime);
1631
+ }
1632
+ }
1633
+ return { today, month, year, lifetime };
1634
+ }
1635
+ function usdcMicroToSats(decimalString, satsPerUsdc) {
1636
+ if (!DECIMAL_RE.test(decimalString)) return "0";
1637
+ if (!Number.isInteger(satsPerUsdc) || satsPerUsdc <= 0) {
1638
+ throw new Error("satsPerUsdc must be a positive integer");
1639
+ }
1640
+ const negative = decimalString.startsWith("-");
1641
+ const absolute = negative ? decimalString.slice(1) : decimalString;
1642
+ const sats = BigInt(absolute) * BigInt(satsPerUsdc) / 10n ** BigInt(USDC_SCALE);
1643
+ return (negative && sats !== 0n ? "-" : "") + sats.toString();
1644
+ }
1645
+ function formatSatsRow(value) {
1646
+ if (!value || !DECIMAL_RE.test(value)) return "0 sats";
1647
+ const negative = value.startsWith("-");
1648
+ const abs = negative ? value.slice(1) : value;
1649
+ if (!abs || abs === "0") return "0 sats";
1650
+ let formatted;
1651
+ const absN = BigInt(abs);
1652
+ if (absN < BigInt(Number.MAX_SAFE_INTEGER)) {
1653
+ formatted = Number(abs).toLocaleString("en-US");
1654
+ } else {
1655
+ formatted = abs.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1656
+ }
1657
+ return (negative ? "-" : "") + formatted + " sats";
1658
+ }
1659
+ function renderEarningsSection(opts) {
1660
+ if (opts.earnings.status === "connector_unavailable") {
1661
+ return ["", "Earnings (USDC): unavailable"];
1662
+ }
1663
+ const scalars = computeUsdcScalars(opts.earnings);
1664
+ if (opts.units === "usdc") {
1665
+ return [
1666
+ "",
1667
+ "Earnings (USDC):",
1668
+ "----------------",
1669
+ ` TODAY ${formatUsdc(scalars.today, USDC_SCALE)}`,
1670
+ ` MONTH ${formatUsdc(scalars.month, USDC_SCALE)}`,
1671
+ ` YEAR ${formatUsdc(scalars.year, USDC_SCALE)}`,
1672
+ ` LIFETIME ${formatUsdc(scalars.lifetime, USDC_SCALE)}`
1673
+ ];
1674
+ }
1675
+ if (opts.satsPerUsdc === void 0 || !Number.isInteger(opts.satsPerUsdc) || opts.satsPerUsdc <= 0) {
1676
+ throw new Error(
1677
+ "renderEarningsSection: units='sats' requires a positive-integer satsPerUsdc"
1678
+ );
1679
+ }
1680
+ const rate = opts.satsPerUsdc;
1681
+ const header = `Earnings (sats @ ${rate}/USDC):`;
1682
+ return [
1683
+ "",
1684
+ header,
1685
+ "-".repeat(header.length),
1686
+ ` TODAY ${formatSatsRow(usdcMicroToSats(scalars.today, rate))}`,
1687
+ ` MONTH ${formatSatsRow(usdcMicroToSats(scalars.month, rate))}`,
1688
+ ` YEAR ${formatSatsRow(usdcMicroToSats(scalars.year, rate))}`,
1689
+ ` LIFETIME ${formatSatsRow(usdcMicroToSats(scalars.lifetime, rate))}`
1690
+ ];
1691
+ }
1692
+ function resolveSatsRate(values, env) {
1693
+ const cliRaw = typeof values["rate"] === "string" ? values["rate"] : void 0;
1694
+ const cliRate = cliRaw !== void 0 && cliRaw !== "" ? cliRaw : void 0;
1695
+ const envRate = env["TOWNHOUSE_SATS_PER_USDC"];
1696
+ const raw = cliRate ?? envRate;
1697
+ const source = cliRate !== void 0 ? "--rate" : "TOWNHOUSE_SATS_PER_USDC env var";
1698
+ if (raw === void 0) {
1699
+ return {
1700
+ 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)"
1701
+ };
1702
+ }
1703
+ if (!POSITIVE_INT_RE.test(raw)) {
1704
+ return {
1705
+ error: `${source} must be a positive integer (sats per 1 USDC); got: ${JSON.stringify(raw)}`
1706
+ };
1707
+ }
1708
+ const rate = Number(raw);
1709
+ if (!Number.isSafeInteger(rate) || rate <= 0) {
1710
+ return { error: `${source} is out of range` };
1711
+ }
1712
+ return { rate };
1713
+ }
1714
+
1715
+ // src/credits/buy.ts
1716
+ import { TurboFactory } from "@ardrive/turbo-sdk/node";
1717
+
1718
+ // src/wallet/turbo-signer.ts
1719
+ import {
1720
+ ArweaveSigner,
1721
+ EthereumSigner,
1722
+ HexSolanaSigner
1723
+ } from "@ardrive/turbo-sdk/node";
1724
+ import bs58 from "bs58";
1725
+ function solanaSecretKeyBase58(privateKeyHex, publicKeyBase58) {
1726
+ const priv = Buffer.from(privateKeyHex, "hex");
1727
+ if (priv.length !== 32) {
1728
+ throw new Error(
1729
+ `Solana private key seed must be 32 bytes, got ${priv.length}`
1730
+ );
1731
+ }
1732
+ const pub = bs58.decode(publicKeyBase58);
1733
+ if (pub.length !== 32) {
1734
+ throw new Error(`Solana public key must be 32 bytes, got ${pub.length}`);
1735
+ }
1736
+ const secret = new Uint8Array(64);
1737
+ secret.set(priv, 0);
1738
+ secret.set(pub, 32);
1739
+ return bs58.encode(secret);
1740
+ }
1741
+ var TURBO_TOKEN_MAP = {
1742
+ eth: "ethereum",
1743
+ pol: "pol",
1744
+ "base-eth": "base-eth",
1745
+ "base-usdc": "base-usdc",
1746
+ "usdc-eth": "usdc",
1747
+ "usdc-pol": "polygon-usdc",
1748
+ sol: "solana",
1749
+ ar: "arweave"
1750
+ };
1751
+ var EVM_TOKENS = /* @__PURE__ */ new Set([
1752
+ "eth",
1753
+ "pol",
1754
+ "base-eth",
1755
+ "base-usdc",
1756
+ "usdc-eth",
1757
+ "usdc-pol"
1758
+ ]);
1759
+ function canonicalTurboToken(token) {
1760
+ const canonical = TURBO_TOKEN_MAP[token];
1761
+ if (!canonical) {
1762
+ throw new Error(
1763
+ `Unknown TurboTokenId '${String(token)}'. Supported: ${Object.keys(TURBO_TOKEN_MAP).join(", ")}`
1764
+ );
1765
+ }
1766
+ return canonical;
1767
+ }
1768
+ async function buildTurboSigner(wallet, nodeType, token) {
1769
+ const canonical = canonicalTurboToken(token);
1770
+ if (EVM_TOKENS.has(token)) {
1771
+ const privateKeyHex = wallet.getEvmPrivateKeyHex(nodeType);
1772
+ const signer = new EthereumSigner(privateKeyHex);
1773
+ const keys = wallet.getNodeKeys(nodeType);
1774
+ return { signer, token: canonical, address: keys.evmAddress };
1775
+ }
1776
+ if (token === "sol") {
1777
+ const privateKeyHex = wallet.getSolanaPrivateKeyHex(nodeType);
1778
+ const keys = wallet.getNodeKeys(nodeType);
1779
+ if (!keys.solanaAddress) {
1780
+ throw new Error(`Solana address not available for node '${nodeType}'`);
1781
+ }
1782
+ const secretBase58 = solanaSecretKeyBase58(
1783
+ privateKeyHex,
1784
+ keys.solanaAddress
1785
+ );
1786
+ const signer = new HexSolanaSigner(secretBase58);
1787
+ return { signer, token: canonical, address: keys.solanaAddress };
1788
+ }
1789
+ if (token === "ar") {
1790
+ await wallet.ensureArweaveKey(nodeType);
1791
+ const jwk = wallet.getArweaveJwk(nodeType);
1792
+ const signer = new ArweaveSigner(jwk);
1793
+ const keys = wallet.getNodeKeys(nodeType);
1794
+ if (!keys.arweaveAddress) {
1795
+ throw new Error(
1796
+ `Arweave address not populated for node '${nodeType}' after ensureArweaveKey`
1797
+ );
1798
+ }
1799
+ return { signer, token: canonical, address: keys.arweaveAddress };
1800
+ }
1801
+ throw new Error(`Unsupported TurboTokenId: ${String(token)}`);
1802
+ }
1803
+
1804
+ // src/credits/units.ts
1805
+ var WINC_PER_BYTE_APPROX = 610000n;
1806
+ function wincToBytes(winc) {
1807
+ if (winc < 0n) return 0n;
1808
+ return winc / WINC_PER_BYTE_APPROX;
1809
+ }
1810
+ function formatWincAsBytes(winc) {
1811
+ const bytes = wincToBytes(winc);
1812
+ if (bytes < 1000n) return `~${bytes.toString()} B`;
1813
+ if (bytes < 1000000n) {
1814
+ return `~${(bytes / 1000n).toString()} KB`;
1815
+ }
1816
+ if (bytes < 1000000000n) {
1817
+ return `~${(bytes / 1000000n).toString()} MB`;
1818
+ }
1819
+ if (bytes < 1000000000000n) {
1820
+ return `~${(bytes / 1000000000n).toString()} GB`;
77
1821
  }
1822
+ return `~${(bytes / 1000000000000n).toString()} TB`;
1823
+ }
1824
+ var TOKEN_DECIMALS = {
1825
+ ar: 12,
1826
+ sol: 9,
1827
+ eth: 18,
1828
+ pol: 18,
1829
+ "base-eth": 18,
1830
+ "base-usdc": 6,
1831
+ "usdc-eth": 6,
1832
+ "usdc-pol": 6
1833
+ };
1834
+ var TOKEN_SYMBOL = {
1835
+ ar: "AR",
1836
+ sol: "SOL",
1837
+ eth: "ETH",
1838
+ pol: "POL",
1839
+ "base-eth": "ETH (Base)",
1840
+ "base-usdc": "USDC (Base)",
1841
+ "usdc-eth": "USDC (Ethereum)",
1842
+ "usdc-pol": "USDC (Polygon)"
78
1843
  };
1844
+ function formatTokenAmount(token, baseAmount) {
1845
+ const decimals = TOKEN_DECIMALS[token];
1846
+ const symbol = TOKEN_SYMBOL[token];
1847
+ if (decimals === void 0 || symbol === void 0) {
1848
+ throw new Error(`Unknown TurboTokenId for formatting: ${String(token)}`);
1849
+ }
1850
+ const scale = 10n ** BigInt(decimals);
1851
+ const isNegative = baseAmount < 0n;
1852
+ const abs = isNegative ? -baseAmount : baseAmount;
1853
+ const whole = abs / scale;
1854
+ const frac = abs % scale;
1855
+ const fracStr = frac.toString().padStart(decimals, "0");
1856
+ const sign = isNegative ? "-" : "";
1857
+ return `${sign}${whole.toString()}.${fracStr} ${symbol}`;
1858
+ }
1859
+ function parseTokenAmount(token, decimal) {
1860
+ const decimals = TOKEN_DECIMALS[token];
1861
+ if (decimals === void 0) {
1862
+ throw new Error(`Unknown TurboTokenId: ${String(token)}`);
1863
+ }
1864
+ const trimmed = decimal.trim();
1865
+ if (!/^\d+(\.\d+)?$/.test(trimmed)) {
1866
+ throw new Error(
1867
+ `Invalid decimal amount '${decimal}' for token '${token}'. Use plain decimal notation (e.g. "0.05").`
1868
+ );
1869
+ }
1870
+ const [wholeStr, fracStr = ""] = trimmed.split(".");
1871
+ if (fracStr.length > decimals) {
1872
+ throw new Error(
1873
+ `Amount '${decimal}' has ${fracStr.length} decimal places, but '${token}' supports at most ${decimals}.`
1874
+ );
1875
+ }
1876
+ const fracPadded = fracStr.padEnd(decimals, "0");
1877
+ const whole = BigInt(wholeStr);
1878
+ const frac = fracPadded.length > 0 ? BigInt(fracPadded) : 0n;
1879
+ return whole * 10n ** BigInt(decimals) + frac;
1880
+ }
1881
+
1882
+ // src/credits/buy.ts
1883
+ async function buyCredits(opts) {
1884
+ const {
1885
+ wallet,
1886
+ nodeType,
1887
+ token,
1888
+ amount,
1889
+ feeMultiplier,
1890
+ quoteOnly,
1891
+ destinationAddress
1892
+ } = opts;
1893
+ const baseAmount = parseTokenAmount(token, amount);
1894
+ const {
1895
+ signer,
1896
+ token: canonicalToken,
1897
+ address: fromAddress
1898
+ } = await buildTurboSigner(wallet, nodeType, token);
1899
+ const creditAddress = destinationAddress ?? fromAddress;
1900
+ const turbo = TurboFactory.authenticated({
1901
+ signer,
1902
+ token: canonicalToken
1903
+ });
1904
+ const quote = await turbo.getWincForToken({
1905
+ tokenAmount: baseAmount.toString()
1906
+ });
1907
+ const quotedWinc = BigInt(quote.winc);
1908
+ if (quoteOnly) {
1909
+ return {
1910
+ kind: "quote",
1911
+ fromAddress,
1912
+ creditAddress,
1913
+ baseAmount,
1914
+ winc: quotedWinc,
1915
+ raw: {
1916
+ winc: quote.winc,
1917
+ actualTokenAmount: quote.actualTokenAmount,
1918
+ equivalentWincTokenAmount: quote.equivalentWincTokenAmount
1919
+ }
1920
+ };
1921
+ }
1922
+ const topUpParams = {
1923
+ tokenAmount: baseAmount.toString()
1924
+ };
1925
+ if (feeMultiplier !== void 0) topUpParams.feeMultiplier = feeMultiplier;
1926
+ if (destinationAddress !== void 0) {
1927
+ topUpParams.turboCreditDestinationAddress = destinationAddress;
1928
+ }
1929
+ const submitted = await turbo.topUpWithTokens(topUpParams);
1930
+ return {
1931
+ kind: "submit",
1932
+ fromAddress,
1933
+ creditAddress,
1934
+ baseAmount,
1935
+ winc: BigInt(submitted.winc),
1936
+ id: submitted.id,
1937
+ status: submitted.status,
1938
+ token: submitted.token,
1939
+ ...submitted.block !== void 0 ? { block: submitted.block } : {}
1940
+ };
1941
+ }
1942
+
1943
+ // src/credits/balance.ts
1944
+ import { TurboFactory as TurboFactory2 } from "@ardrive/turbo-sdk/node";
1945
+ async function getCreditBalance(opts) {
1946
+ const { wallet, nodeType, token, address: explicitAddress } = opts;
1947
+ const {
1948
+ signer,
1949
+ token: canonicalToken,
1950
+ address: signerAddress
1951
+ } = await buildTurboSigner(wallet, nodeType, token);
1952
+ const turbo = TurboFactory2.authenticated({
1953
+ signer,
1954
+ token: canonicalToken
1955
+ });
1956
+ const balance = explicitAddress ? await turbo.getBalance(explicitAddress) : await turbo.getBalance();
1957
+ return {
1958
+ winc: BigInt(balance.winc),
1959
+ controlledWinc: BigInt(balance.controlledWinc),
1960
+ effectiveBalance: BigInt(balance.effectiveBalance),
1961
+ address: explicitAddress ?? signerAddress
1962
+ };
1963
+ }
1964
+
1965
+ // src/tui/tty-detect.ts
1966
+ function isOptOut(value) {
1967
+ if (value === void 0) return false;
1968
+ if (value === "" || value === "0" || value.toLowerCase() === "false")
1969
+ return false;
1970
+ return true;
1971
+ }
1972
+ function shouldRenderInk() {
1973
+ if (process.stdout.isTTY !== true) return false;
1974
+ if (process.env["CI"] === "true") return false;
1975
+ if (isOptOut(process.env["NO_TUI"])) return false;
1976
+ if ((process.env["TERM"] ?? "") === "dumb") return false;
1977
+ return true;
1978
+ }
79
1979
 
80
1980
  // src/cli.ts
81
1981
  var CliHelpRequested = class extends Error {
@@ -93,18 +1993,36 @@ Usage:
93
1993
  townhouse down [-c <path>] Stop all nodes
94
1994
  townhouse status [-c <path>] Show node status
95
1995
  townhouse metrics [-c <path>] Show connector metrics
96
- townhouse wallet show [-c <path>] [--password <pw>] Show derived addresses
1996
+ townhouse wallet show [--json] [--hex] [--paths] [-c <path>] [--password <pw>] Show derived addresses
1997
+ townhouse wallet seed --confirm [-c <path>] [--password <pw>] Print the BIP-39 seed phrase (password-gated, requires --confirm)
1998
+ townhouse credits buy --token <id> --amount <decimal> [--fee-multiplier <n>] [--quote-only] [--yes] [-c <path>] [--password <pw>]
1999
+ Buy Arweave upload credits (token: eth|sol|pol|base-eth|base-usdc|usdc-eth|usdc-pol)
2000
+ townhouse credits balance --token <id> [-c <path>] [--password <pw>] Show Turbo credit balance for the funding address
2001
+ townhouse hs up [--password <pw>] [--skip-preflight] [-c <path>] Boot apex (connector + .anyone HS) (launches dashboard TUI in TTY mode)
2002
+ townhouse hs down [--rotate-keys] [-c <path>] Stop apex (--rotate-keys deletes .anyone keypair)
2003
+ townhouse node add [<type>] [--json] [-c <path>] Provision a child node (default: town)
2004
+ townhouse node remove <id> [--yes] [--json] [-c <path>] Deprovision a child node
2005
+ townhouse node list [--json] [-c <path>] List provisioned nodes
2006
+ townhouse channels [--json] Show open payment channels
2007
+ townhouse logs <node-id> [-f|--follow] [--lines N] [--json] Tail logs for a node (Ctrl-C to stop)
2008
+ townhouse peer <id> [--json] Show per-peer detail card
2009
+ townhouse health [--json] Probe apex/api/nodes/.anyone health
97
2010
  townhouse --help Show this help
98
2011
 
99
2012
  Flags:
100
- --town Start Town (Nostr relay) node
101
- --mill Start Mill (swap) node
102
- --dvm Start DVM (compute) node
103
- --password Wallet password (non-interactive mode)
104
- --no-browser Skip opening the browser automatically (setup command)
105
- --port Override the API port (setup command, default 9400)
106
- --preset Init from a named preset (init only). Supported: demo
107
- --yes Non-interactive (init only); with --preset=demo uses demo password if --password absent
2013
+ --town Start Town (Nostr relay) node
2014
+ --mill Start Mill (swap) node
2015
+ --dvm Start DVM (compute) node
2016
+ --password Wallet password (non-interactive mode)
2017
+ --rotate-keys Delete the .anyone keypair volume on hs down (produces a new address on next hs up)
2018
+ --skip-preflight Skip the port-collision preflight check on hs up (escape hatch)
2019
+ --no-browser Skip opening the browser automatically (setup command)
2020
+ --port Override the API port (setup command, default 9400)
2021
+ --preset Init from a named preset (init only). Supported: demo
2022
+ --yes Non-interactive (init only); with --preset=demo uses demo password if --password absent
2023
+ --json Machine-readable JSON output (node commands; NDJSON for \`logs\`)
2024
+ --lines Number of historical log lines to fetch on attach (logs command, default 50)
2025
+ -f|--follow Accepted for \`tail -f\` muscle memory on \`logs\` (no-op \u2014 follow is default)
108
2026
  If no flags given, starts all enabled nodes from config.`;
109
2027
  var DEFAULT_CONFIG_DIR = join(homedir(), ".townhouse");
110
2028
  var DEFAULT_CONFIG_PATH = join(DEFAULT_CONFIG_DIR, "config.yaml");
@@ -121,7 +2039,7 @@ async function handleInit(force, configDir, password, preset, yes) {
121
2039
  mkdirSync(dir, { recursive: true, mode: 448 });
122
2040
  let configToWrite;
123
2041
  if (preset === "demo") {
124
- const { buildDemoConfig, DEMO_DETERMINISTIC_PASSWORD } = await import("./demo-MJR47QHZ.js");
2042
+ const { buildDemoConfig, DEMO_DETERMINISTIC_PASSWORD } = await import("./demo-3DWRDMYY.js");
125
2043
  configToWrite = buildDemoConfig({ walletPath: join(dir, "wallet.enc") });
126
2044
  if (yes && !password) {
127
2045
  password = DEMO_DETERMINISTIC_PASSWORD;
@@ -194,7 +2112,7 @@ Delete the orphan config and re-run \`townhouse setup\`, or restore the wallet f
194
2112
  process.exitCode = 1;
195
2113
  return;
196
2114
  }
197
- const docker = dockerInstance ?? new Docker();
2115
+ const docker = dockerInstance ?? new Docker2();
198
2116
  const opener = browserOpener ?? new RealBrowserOpener();
199
2117
  const wizardServer = await createWizardApiServer({
200
2118
  configDir: dir,
@@ -244,7 +2162,207 @@ Received ${sig}, shutting down...`);
244
2162
  void shutdown("SIGTERM");
245
2163
  });
246
2164
  }
247
- async function handleWalletShow(config, password) {
2165
+ var NODE_ROLE_DESCRIPTIONS = {
2166
+ town: "Nostr relay \u2014 earns ILP fees per event relayed.",
2167
+ mill: "Multi-chain swap peer \u2014 settles cross-chain swaps for fees.",
2168
+ dvm: "Compute / DVM worker \u2014 collects job payments, signs Arweave uploads."
2169
+ };
2170
+ function buildNodeRows(info, options) {
2171
+ const rows = [];
2172
+ const npub = nip19.npubEncode(info.nostrPubkey);
2173
+ const nostrPurposeByNode = {
2174
+ town: "share this to be found",
2175
+ mill: "announces swap quotes",
2176
+ dvm: "offers DVM services"
2177
+ };
2178
+ rows.push({
2179
+ label: "Nostr",
2180
+ value: npub,
2181
+ purpose: nostrPurposeByNode[info.nodeType],
2182
+ hex: options.hex ? info.nostrPubkey : void 0,
2183
+ path: options.paths ? info.nostrDerivationPath : void 0
2184
+ });
2185
+ const evmPurposeByNode = {
2186
+ town: "receives ILP earnings",
2187
+ mill: "settles EVM swaps",
2188
+ dvm: "collects job payments"
2189
+ };
2190
+ rows.push({
2191
+ label: "EVM",
2192
+ value: info.evmAddress,
2193
+ purpose: evmPurposeByNode[info.nodeType],
2194
+ path: options.paths ? info.evmDerivationPath : void 0
2195
+ });
2196
+ const solPurposeByNode = {
2197
+ town: "receives swap fills",
2198
+ mill: "settles SOL swaps",
2199
+ dvm: "spends Arweave credits"
2200
+ };
2201
+ rows.push({
2202
+ label: "SOL",
2203
+ value: info.solanaAddress ?? "\u2014",
2204
+ purpose: solPurposeByNode[info.nodeType],
2205
+ path: options.paths ? info.solanaDerivationPath : void 0
2206
+ });
2207
+ if (info.nodeType === "mill") {
2208
+ rows.push({
2209
+ label: "Mina",
2210
+ value: info.minaAddress ?? "\u2014",
2211
+ purpose: "settles Mina swaps"
2212
+ // Mina derivation path is not currently surfaced through NodeKeyInfo.
2213
+ });
2214
+ }
2215
+ if (info.nodeType === "dvm") {
2216
+ rows.push({
2217
+ label: "AR",
2218
+ value: info.arweaveAddress ?? "\u2014",
2219
+ purpose: "signs Arweave uploads",
2220
+ path: options.paths ? info.arweaveDerivationPath : void 0
2221
+ });
2222
+ }
2223
+ return rows;
2224
+ }
2225
+ function renderNodeCard(info, rows) {
2226
+ const role = NODE_ROLE_DESCRIPTIONS[info.nodeType];
2227
+ const labelWidth = Math.max(...rows.map((r) => r.label.length));
2228
+ const headerLine = `${info.nodeType.toUpperCase()} \u2014 ${role}`;
2229
+ const bodyLines = [];
2230
+ for (const row of rows) {
2231
+ bodyLines.push(`${row.label.padEnd(labelWidth)} ${row.value}`);
2232
+ bodyLines.push(`${" ".repeat(labelWidth)} (${row.purpose})`);
2233
+ if (row.hex) {
2234
+ bodyLines.push(`${" ".repeat(labelWidth)} hex: ${row.hex}`);
2235
+ }
2236
+ if (row.path) {
2237
+ bodyLines.push(`${" ".repeat(labelWidth)} path: ${row.path}`);
2238
+ }
2239
+ }
2240
+ const innerWidth = Math.max(
2241
+ headerLine.length,
2242
+ ...bodyLines.map((l) => l.length)
2243
+ );
2244
+ const totalInner = innerWidth + 2;
2245
+ const horizontal = "\u2500".repeat(totalInner);
2246
+ const top = `\u250C${horizontal}\u2510`;
2247
+ const bottom = `\u2514${horizontal}\u2518`;
2248
+ const lines = [];
2249
+ lines.push(top);
2250
+ lines.push(`\u2502 ${headerLine.padEnd(innerWidth)} \u2502`);
2251
+ lines.push(`\u251C${horizontal}\u2524`);
2252
+ for (const body of bodyLines) {
2253
+ lines.push(`\u2502 ${body.padEnd(innerWidth)} \u2502`);
2254
+ }
2255
+ lines.push(bottom);
2256
+ return lines.join("\n");
2257
+ }
2258
+ function buildWalletJson(allKeys) {
2259
+ const out = {};
2260
+ for (const info of allKeys) {
2261
+ const node = {
2262
+ nostr: {
2263
+ npub: nip19.npubEncode(info.nostrPubkey),
2264
+ hex: info.nostrPubkey,
2265
+ path: info.nostrDerivationPath
2266
+ },
2267
+ evm: { address: info.evmAddress, path: info.evmDerivationPath }
2268
+ };
2269
+ if (info.solanaAddress) {
2270
+ node["sol"] = {
2271
+ address: info.solanaAddress,
2272
+ path: info.solanaDerivationPath
2273
+ };
2274
+ }
2275
+ if (info.nodeType === "mill" && info.minaAddress) {
2276
+ node["mina"] = { address: info.minaAddress };
2277
+ }
2278
+ if (info.nodeType === "dvm" && info.arweaveAddress) {
2279
+ node["arweave"] = {
2280
+ address: info.arweaveAddress,
2281
+ path: info.arweaveDerivationPath
2282
+ };
2283
+ }
2284
+ out[info.nodeType] = node;
2285
+ }
2286
+ return out;
2287
+ }
2288
+ async function handleWalletShow(config, password, options = {}) {
2289
+ const walletPath = config.wallet.encrypted_path;
2290
+ const result = await loadWallet(walletPath);
2291
+ if (!result) {
2292
+ console.error("No wallet found. Run `townhouse init` first.");
2293
+ process.exitCode = 1;
2294
+ return;
2295
+ }
2296
+ if (result.permissionsWarning) {
2297
+ console.error(result.permissionsWarning);
2298
+ }
2299
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2300
+ if (!walletPassword) {
2301
+ console.error(
2302
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
2303
+ );
2304
+ process.exitCode = 1;
2305
+ return;
2306
+ }
2307
+ const walletManager = new WalletManager({ encryptedPath: walletPath });
2308
+ try {
2309
+ await walletManager.fromMnemonic(
2310
+ decryptWallet(result.wallet, walletPassword)
2311
+ );
2312
+ } catch (err) {
2313
+ const msg = err instanceof Error ? err.message : String(err);
2314
+ console.error(`Failed to decrypt wallet: ${msg}`);
2315
+ process.exitCode = 1;
2316
+ return;
2317
+ }
2318
+ try {
2319
+ const arStartMs = Date.now();
2320
+ const arStatusTimer = setTimeout(() => {
2321
+ process.stderr.write("deriving Arweave key (first run, ~15s)...\n");
2322
+ }, 200);
2323
+ try {
2324
+ await walletManager.ensureArweaveKey("dvm", walletPassword);
2325
+ } catch (err) {
2326
+ const msg = err instanceof Error ? err.message : String(err);
2327
+ console.error(
2328
+ `Warning: Arweave key derivation failed (${msg}). AR address will display as '\u2014'.`
2329
+ );
2330
+ } finally {
2331
+ clearTimeout(arStatusTimer);
2332
+ void arStartMs;
2333
+ }
2334
+ const allKeys = walletManager.getAllKeys();
2335
+ if (options.json) {
2336
+ console.log(JSON.stringify(buildWalletJson(allKeys), null, 2));
2337
+ return;
2338
+ }
2339
+ const renderOpts = {
2340
+ hex: options.hex === true,
2341
+ paths: options.paths === true
2342
+ };
2343
+ for (const info of allKeys) {
2344
+ const rows = buildNodeRows(info, renderOpts);
2345
+ console.log(renderNodeCard(info, rows));
2346
+ console.log("");
2347
+ }
2348
+ console.log("Tip: townhouse wallet show --json for scripting");
2349
+ console.log(" townhouse wallet show --hex to see raw hex pubkeys");
2350
+ console.log(" townhouse wallet show --paths to see derivation paths");
2351
+ console.log(
2352
+ " townhouse credits buy --token sol --amount <n> to fund Arweave uploads"
2353
+ );
2354
+ } finally {
2355
+ walletManager.lock();
2356
+ }
2357
+ }
2358
+ async function handleWalletSeed(config, password, confirm) {
2359
+ if (!confirm) {
2360
+ console.error(
2361
+ "This command will print your seed phrase to your terminal. Re-run with --confirm to acknowledge."
2362
+ );
2363
+ process.exitCode = 1;
2364
+ return;
2365
+ }
248
2366
  const walletPath = config.wallet.encrypted_path;
249
2367
  const result = await loadWallet(walletPath);
250
2368
  if (!result) {
@@ -252,44 +2370,354 @@ async function handleWalletShow(config, password) {
252
2370
  process.exitCode = 1;
253
2371
  return;
254
2372
  }
255
- if (result.permissionsWarning) {
256
- console.error(result.permissionsWarning);
2373
+ if (result.permissionsWarning) {
2374
+ console.error(result.permissionsWarning);
2375
+ }
2376
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
2377
+ if (!walletPassword) {
2378
+ console.error(
2379
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
2380
+ );
2381
+ process.exitCode = 1;
2382
+ return;
2383
+ }
2384
+ const walletManager = new WalletManager({ encryptedPath: walletPath });
2385
+ try {
2386
+ await walletManager.fromMnemonic(
2387
+ decryptWallet(result.wallet, walletPassword)
2388
+ );
2389
+ } catch (err) {
2390
+ const msg = err instanceof Error ? err.message : String(err);
2391
+ console.error(`Failed to decrypt wallet: ${msg}`);
2392
+ process.exitCode = 1;
2393
+ return;
2394
+ }
2395
+ try {
2396
+ const mnemonic = walletManager.getMnemonic();
2397
+ if (!mnemonic) {
2398
+ console.error("Internal error: mnemonic unavailable after unlock.");
2399
+ process.exitCode = 1;
2400
+ return;
2401
+ }
2402
+ console.log(
2403
+ "============================================================="
2404
+ );
2405
+ console.log(" [!] Anyone who sees this seed owns your townhouse identity.");
2406
+ console.log(" [!] Anyone who records this terminal owns your earnings.");
2407
+ console.log(
2408
+ " [!] Shoulder-surf, screen-shares, and tmux logs are vectors."
2409
+ );
2410
+ console.log(
2411
+ "============================================================="
2412
+ );
2413
+ console.log("");
2414
+ console.log("");
2415
+ console.log(` ${mnemonic}`);
2416
+ console.log("");
2417
+ console.log("");
2418
+ console.log(
2419
+ "This is the same 12 words shown at `townhouse init`. Storing them elsewhere is your responsibility."
2420
+ );
2421
+ } finally {
2422
+ walletManager.lock();
2423
+ }
2424
+ }
2425
+ var VALID_TURBO_TOKENS = /* @__PURE__ */ new Set([
2426
+ "eth",
2427
+ "pol",
2428
+ "base-eth",
2429
+ "base-usdc",
2430
+ "usdc-eth",
2431
+ "usdc-pol",
2432
+ "sol",
2433
+ "ar"
2434
+ ]);
2435
+ function isTurboTokenId(value) {
2436
+ return VALID_TURBO_TOKENS.has(value);
2437
+ }
2438
+ async function resolveWalletPassword(flagPassword) {
2439
+ const envPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
2440
+ if (flagPassword) return flagPassword;
2441
+ if (envPassword) return envPassword;
2442
+ if (process.stdin.isTTY) {
2443
+ return await promptPassword("Wallet password: ");
2444
+ }
2445
+ return null;
2446
+ }
2447
+ async function promptYesNo(question) {
2448
+ const { createInterface: createInterface3 } = await import("readline");
2449
+ const answer = await new Promise((resolve2) => {
2450
+ const rl = createInterface3({
2451
+ input: process.stdin,
2452
+ output: process.stdout
2453
+ });
2454
+ rl.question(question, (ans) => {
2455
+ rl.close();
2456
+ resolve2(ans);
2457
+ });
2458
+ });
2459
+ return ["y", "yes"].includes(answer.trim().toLowerCase());
2460
+ }
2461
+ async function handleCreditsBuy(config, values, nodeType = "dvm") {
2462
+ const tokenRaw = values["token"];
2463
+ const amountRaw = values["amount"];
2464
+ if (!tokenRaw || !amountRaw) {
2465
+ console.error(
2466
+ "Usage: townhouse credits buy --token <id> --amount <decimal> [--fee-multiplier <n>] [--credit-destination <addr>] [--quote-only] [--yes]"
2467
+ );
2468
+ process.exitCode = 1;
2469
+ return;
2470
+ }
2471
+ if (!isTurboTokenId(tokenRaw)) {
2472
+ console.error(
2473
+ `Unknown token '${tokenRaw}'. Supported: ${Array.from(VALID_TURBO_TOKENS).join(", ")}`
2474
+ );
2475
+ process.exitCode = 1;
2476
+ return;
2477
+ }
2478
+ const token = tokenRaw;
2479
+ let feeMultiplier;
2480
+ const feeRaw = values["fee-multiplier"];
2481
+ if (feeRaw !== void 0) {
2482
+ const parsed = Number(feeRaw);
2483
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2484
+ console.error(
2485
+ `--fee-multiplier must be a positive number, got '${feeRaw}'`
2486
+ );
2487
+ process.exitCode = 1;
2488
+ return;
2489
+ }
2490
+ feeMultiplier = parsed;
2491
+ }
2492
+ const quoteOnly = values["quote-only"] === true;
2493
+ const skipConfirm = values["yes"] === true;
2494
+ const destinationOverride = values["credit-destination"];
2495
+ const walletPath = config.wallet.encrypted_path;
2496
+ const loaded = await loadWallet(walletPath);
2497
+ if (!loaded) {
2498
+ console.error(
2499
+ `No wallet found at ${walletPath}. Run \`townhouse init\` first.`
2500
+ );
2501
+ process.exitCode = 1;
2502
+ return;
2503
+ }
2504
+ if (loaded.permissionsWarning) console.error(loaded.permissionsWarning);
2505
+ const resolvedPassword = await resolveWalletPassword(
2506
+ values["password"]
2507
+ );
2508
+ if (!resolvedPassword) {
2509
+ console.error(
2510
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
2511
+ );
2512
+ process.exitCode = 1;
2513
+ return;
2514
+ }
2515
+ const wallet = new WalletManager({ encryptedPath: walletPath });
2516
+ try {
2517
+ await wallet.fromMnemonic(decryptWallet(loaded.wallet, resolvedPassword));
2518
+ } catch (err) {
2519
+ const msg = err instanceof Error ? err.message : String(err);
2520
+ console.error(`Failed to decrypt wallet: ${msg}`);
2521
+ process.exitCode = 1;
2522
+ return;
2523
+ }
2524
+ try {
2525
+ let destinationAddress;
2526
+ if (destinationOverride) {
2527
+ destinationAddress = destinationOverride;
2528
+ } else if (token !== "ar" && nodeType === "dvm") {
2529
+ process.stdout.write(
2530
+ `Resolving DVM Arweave credit address (first run, ~10s)...
2531
+ `
2532
+ );
2533
+ await wallet.ensureArweaveKey("dvm", resolvedPassword);
2534
+ const dvmKeys = wallet.getNodeKeys("dvm");
2535
+ if (!dvmKeys.arweaveAddress) {
2536
+ throw new Error(
2537
+ "DVM Arweave address not populated after ensureArweaveKey"
2538
+ );
2539
+ }
2540
+ destinationAddress = dvmKeys.arweaveAddress;
2541
+ }
2542
+ process.stdout.write(
2543
+ `Quoting ${amountRaw} ${token} for ${nodeType}'s credit address...
2544
+ `
2545
+ );
2546
+ const quote = await buyCredits({
2547
+ wallet,
2548
+ nodeType,
2549
+ token,
2550
+ amount: amountRaw,
2551
+ quoteOnly: true,
2552
+ ...destinationAddress ? { destinationAddress } : {}
2553
+ });
2554
+ if (quote.kind !== "quote") {
2555
+ throw new Error("Internal error: quoteOnly returned non-quote result");
2556
+ }
2557
+ const quotedDisplay = `${quote.winc.toString()} winc (${formatWincAsBytes(quote.winc)})`;
2558
+ process.stdout.write(
2559
+ `Quote: ${formatTokenAmount(token, quote.baseAmount)} \u2192 ${quotedDisplay}
2560
+ `
2561
+ );
2562
+ process.stdout.write(`Source address (${token}): ${quote.fromAddress}
2563
+ `);
2564
+ process.stdout.write(`Credit recipient: ${quote.creditAddress}
2565
+ `);
2566
+ if (quoteOnly) {
2567
+ process.stdout.write("Quote-only; no on-chain transaction submitted.\n");
2568
+ return;
2569
+ }
2570
+ if (!skipConfirm) {
2571
+ const ok = await promptYesNo("Proceed? [y/N] ");
2572
+ if (!ok) {
2573
+ process.stdout.write("Aborted. No transaction submitted.\n");
2574
+ process.exitCode = 1;
2575
+ return;
2576
+ }
2577
+ }
2578
+ process.stdout.write("Submitting on-chain transaction...\n");
2579
+ const result = await buyCredits({
2580
+ wallet,
2581
+ nodeType,
2582
+ token,
2583
+ amount: amountRaw,
2584
+ ...feeMultiplier !== void 0 ? { feeMultiplier } : {},
2585
+ ...destinationAddress ? { destinationAddress } : {}
2586
+ });
2587
+ if (result.kind !== "submit") {
2588
+ throw new Error("Internal error: submit path returned non-submit result");
2589
+ }
2590
+ process.stdout.write(`Transaction submitted: ${result.id}
2591
+ `);
2592
+ process.stdout.write(`Status: ${result.status}
2593
+ `);
2594
+ process.stdout.write(
2595
+ `Credited: ${result.winc.toString()} winc (${formatWincAsBytes(result.winc)})
2596
+ `
2597
+ );
2598
+ if (result.block !== void 0) {
2599
+ process.stdout.write(`Block: ${result.block}
2600
+ `);
2601
+ }
2602
+ process.stdout.write("Done.\n");
2603
+ } catch (err) {
2604
+ const msg = err instanceof Error ? err.message : String(err);
2605
+ console.error(`credits buy failed: ${msg}`);
2606
+ process.exitCode = 1;
2607
+ } finally {
2608
+ wallet.lock();
2609
+ }
2610
+ }
2611
+ async function handleCreditsBalance(config, values, nodeType = "dvm") {
2612
+ const tokenRaw = values["token"];
2613
+ if (!tokenRaw) {
2614
+ console.error(
2615
+ "Usage: townhouse credits balance --token <id> [-c <path>] [--password <pw>]"
2616
+ );
2617
+ process.exitCode = 1;
2618
+ return;
2619
+ }
2620
+ if (!isTurboTokenId(tokenRaw)) {
2621
+ console.error(
2622
+ `Unknown token '${tokenRaw}'. Supported: ${Array.from(VALID_TURBO_TOKENS).join(", ")}`
2623
+ );
2624
+ process.exitCode = 1;
2625
+ return;
257
2626
  }
258
- const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
259
- if (!walletPassword) {
2627
+ const token = tokenRaw;
2628
+ const walletPath = config.wallet.encrypted_path;
2629
+ const loaded = await loadWallet(walletPath);
2630
+ if (!loaded) {
2631
+ console.error(
2632
+ `No wallet found at ${walletPath}. Run \`townhouse init\` first.`
2633
+ );
2634
+ process.exitCode = 1;
2635
+ return;
2636
+ }
2637
+ if (loaded.permissionsWarning) console.error(loaded.permissionsWarning);
2638
+ const resolvedPassword = await resolveWalletPassword(
2639
+ values["password"]
2640
+ );
2641
+ if (!resolvedPassword) {
260
2642
  console.error(
261
2643
  "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
262
2644
  );
263
2645
  process.exitCode = 1;
264
2646
  return;
265
2647
  }
266
- const walletManager = new WalletManager({ encryptedPath: walletPath });
2648
+ const wallet = new WalletManager({ encryptedPath: walletPath });
267
2649
  try {
268
- await walletManager.fromMnemonic(
269
- decryptWallet(result.wallet, walletPassword)
270
- );
2650
+ await wallet.fromMnemonic(decryptWallet(loaded.wallet, resolvedPassword));
271
2651
  } catch (err) {
272
2652
  const msg = err instanceof Error ? err.message : String(err);
273
2653
  console.error(`Failed to decrypt wallet: ${msg}`);
274
2654
  process.exitCode = 1;
275
2655
  return;
276
2656
  }
277
- console.log(
278
- "Node Type | Nostr Pubkey | EVM Address | Derivation Path"
279
- );
280
- console.log(
281
- "-----------|------------------------------------------------------------------|--------------------------------------------|--------------------------"
282
- );
283
- const allKeys = walletManager.getAllKeys();
284
- for (const info of allKeys) {
285
- console.log(
286
- `${info.nodeType.padEnd(10)} | ${info.nostrPubkey} | ${info.evmAddress} | ${info.nostrDerivationPath}`
2657
+ try {
2658
+ const balance = await getCreditBalance({ wallet, nodeType, token });
2659
+ process.stdout.write(`Address (${token}): ${balance.address}
2660
+ `);
2661
+ process.stdout.write(
2662
+ `Balance: ${balance.winc.toString()} winc (${formatWincAsBytes(balance.winc)})
2663
+ `
287
2664
  );
2665
+ if (balance.effectiveBalance !== balance.winc) {
2666
+ process.stdout.write(
2667
+ `Effective (incl. received approvals): ${balance.effectiveBalance.toString()} winc (${formatWincAsBytes(balance.effectiveBalance)})
2668
+ `
2669
+ );
2670
+ }
2671
+ } catch (err) {
2672
+ const msg = err instanceof Error ? err.message : String(err);
2673
+ console.error(`credits balance failed: ${msg}`);
2674
+ process.exitCode = 1;
2675
+ } finally {
2676
+ wallet.lock();
288
2677
  }
289
- walletManager.lock();
290
2678
  }
291
- async function handleStatus(docker, config) {
292
- const orchestrator = new DockerOrchestrator(docker, config);
2679
+ async function resolveEarnings(adminUrl, configPath) {
2680
+ const base = dirname(configPath);
2681
+ try {
2682
+ const yaml = await readNodesYaml(join(base, "nodes.yaml"));
2683
+ return await aggregateEarnings({
2684
+ connectorAdmin: new ConnectorAdminClient(adminUrl),
2685
+ peerTypeResolver: new PeerTypeResolver(yaml),
2686
+ deltaComputer: createDeltaComputer({
2687
+ snapshotPath: join(base, "earnings-snapshots.jsonl")
2688
+ })
2689
+ });
2690
+ } catch (err) {
2691
+ console.error(`Earnings unavailable: ${formatLocalEarningsError(err)}`);
2692
+ return {
2693
+ status: "connector_unavailable",
2694
+ apex: { routingFees: {} },
2695
+ peers: [],
2696
+ recentClaims: [],
2697
+ eventsRelayed: 0,
2698
+ uptimeSeconds: 0
2699
+ };
2700
+ }
2701
+ }
2702
+ function formatLocalEarningsError(err) {
2703
+ if (err !== null && typeof err === "object" && "issues" in err && Array.isArray(err.issues)) {
2704
+ const issues = err.issues;
2705
+ const parts = issues.map((i) => {
2706
+ const path = Array.isArray(i.path) && i.path.length > 0 ? i.path.join(".") : "<root>";
2707
+ const msg = typeof i.message === "string" ? i.message : "invalid";
2708
+ return `${path}: ${msg}`;
2709
+ }).join("; ");
2710
+ if (parts) return parts;
2711
+ }
2712
+ return err instanceof Error ? err.message : String(err);
2713
+ }
2714
+ async function handleStatus(docker, config, opts = {
2715
+ units: "usdc",
2716
+ configPath: DEFAULT_CONFIG_PATH
2717
+ }) {
2718
+ const orchestrator = new DockerOrchestrator(docker, config, void 0, {
2719
+ profile: "dev"
2720
+ });
293
2721
  const statuses = await orchestrator.status();
294
2722
  console.log("Node Status:");
295
2723
  console.log("------------");
@@ -330,37 +2758,17 @@ async function handleStatus(docker, config) {
330
2758
  console.log("");
331
2759
  console.log("Connector Metrics: unavailable");
332
2760
  }
333
- }
334
- async function handleMetrics(config) {
335
- const adminClient = new ConnectorAdminClient(
336
- `http://127.0.0.1:${config.connector.adminPort}`
2761
+ if (opts.units === "sats" && opts.satsPerUsdc === void 0) return;
2762
+ const earnings = await resolveEarnings(
2763
+ `http://127.0.0.1:${config.connector.adminPort}`,
2764
+ opts.configPath
337
2765
  );
338
- try {
339
- const metrics = await adminClient.getMetrics();
340
- const peers = await adminClient.getPeers();
341
- const peerMetrics = new Map(metrics.peers.map((p) => [p.peerId, p]));
342
- console.log("Connector Metrics:");
343
- console.log("------------------");
344
- console.log(` Packets forwarded: ${metrics.aggregate.packetsForwarded}`);
345
- console.log(` Packets rejected: ${metrics.aggregate.packetsRejected}`);
346
- console.log(` Bytes sent: ${metrics.aggregate.bytesSent}`);
347
- console.log("");
348
- console.log("Peers:");
349
- console.log("------");
350
- if (peers.length === 0) {
351
- console.log(" No peers connected");
352
- } else {
353
- for (const peer of peers) {
354
- const status = peer.connected ? "connected" : "disconnected";
355
- const packets = peerMetrics.get(peer.id)?.packetsForwarded ?? 0;
356
- console.log(` ${peer.id.padEnd(12)} ${status} (${packets} packets)`);
357
- }
358
- }
359
- } catch (error) {
360
- const msg = error instanceof Error ? error.message : String(error);
361
- console.error(`Failed to fetch connector metrics: ${msg}`);
362
- process.exitCode = 1;
363
- }
2766
+ for (const line of renderEarningsSection({
2767
+ earnings,
2768
+ units: opts.units,
2769
+ satsPerUsdc: opts.satsPerUsdc
2770
+ }))
2771
+ console.log(line);
364
2772
  }
365
2773
  function resolveProfiles(values, config) {
366
2774
  const explicitFlags = [];
@@ -414,8 +2822,20 @@ async function handleUp(configPath, config, profiles, docker, password, dryRun =
414
2822
  const msg = err instanceof Error ? err.message : String(err);
415
2823
  throw new Error(`Failed to decrypt wallet: ${msg}`);
416
2824
  }
2825
+ if (profiles.includes("dvm")) {
2826
+ try {
2827
+ await walletManager.ensureArweaveKey("dvm", walletPassword);
2828
+ } catch (err) {
2829
+ const msg = err instanceof Error ? err.message : String(err);
2830
+ console.warn(
2831
+ `[townhouse up] AR pre-warm failed (non-fatal, orchestrator will retry): ${msg}`
2832
+ );
2833
+ }
2834
+ }
417
2835
  }
418
- const orchestrator = new DockerOrchestrator(docker, config, walletManager);
2836
+ const orchestrator = new DockerOrchestrator(docker, config, walletManager, {
2837
+ profile: "dev"
2838
+ });
419
2839
  orchestrator.on(
420
2840
  "containerState",
421
2841
  (event) => {
@@ -534,7 +2954,9 @@ async function handleUp(configPath, config, profiles, docker, password, dryRun =
534
2954
  }
535
2955
  }
536
2956
  async function handleDown(config, docker) {
537
- const orchestrator = new DockerOrchestrator(docker, config);
2957
+ const orchestrator = new DockerOrchestrator(docker, config, void 0, {
2958
+ profile: "dev"
2959
+ });
538
2960
  orchestrator.on(
539
2961
  "containerState",
540
2962
  (event) => {
@@ -545,7 +2967,434 @@ async function handleDown(config, docker) {
545
2967
  await orchestrator.down();
546
2968
  console.log("All nodes stopped.");
547
2969
  }
548
- async function main(argv, dockerInstance, browserOpener) {
2970
+ var HS_CONNECTOR_ADMIN_URL = "http://127.0.0.1:9401";
2971
+ var HS_TOWNHOUSE_API_URL = "http://127.0.0.1:28090";
2972
+ async function reconcileWithBriefRetry(reconciler, budgetMs) {
2973
+ const deadline = Date.now() + budgetMs;
2974
+ for (; ; ) {
2975
+ try {
2976
+ await reconciler.reconcile();
2977
+ return;
2978
+ } catch (err) {
2979
+ const msg = err instanceof Error ? err.message : String(err);
2980
+ const transient = msg.includes("ECONNREFUSED") || msg.includes("connection refused") || msg.includes("request timeout");
2981
+ if (!transient || Date.now() >= deadline) {
2982
+ throw err;
2983
+ }
2984
+ await new Promise((resolve2) => setTimeout(resolve2, 250));
2985
+ }
2986
+ }
2987
+ }
2988
+ async function collectApexImageRefs(configDir) {
2989
+ const manifestPath = join(configDir, "image-manifest.json");
2990
+ if (!existsSync(manifestPath)) return [];
2991
+ try {
2992
+ const manifest = await readImageManifest(manifestPath);
2993
+ if (isSyntheticDigest(manifest.images.connector.digest) || isSyntheticDigest(manifest.images["townhouse-api"].digest)) {
2994
+ return [];
2995
+ }
2996
+ return [
2997
+ `${manifest.images.connector.name}@${manifest.images.connector.digest}`,
2998
+ `${manifest.images["townhouse-api"].name}@${manifest.images["townhouse-api"].digest}`
2999
+ ];
3000
+ } catch {
3001
+ return [];
3002
+ }
3003
+ }
3004
+ function isAnonBootstrapTimeout(err) {
3005
+ if (!(err instanceof OrchestratorError)) return false;
3006
+ const text = `${err.message}
3007
+ ${err.stderr ?? ""}`;
3008
+ return /connector.*unhealthy|dependency.*connector.*fail/i.test(text);
3009
+ }
3010
+ async function handleHsUp(_configPath, configDir, config, docker, options) {
3011
+ const { password, force, skipPreflight, hsOverrides } = options;
3012
+ if (!skipPreflight) {
3013
+ const preflight = hsOverrides?.checkPortCollisions ?? ((d) => checkHsPortCollisions(d));
3014
+ try {
3015
+ const collisions = await preflight(docker);
3016
+ if (collisions.length > 0) {
3017
+ const msg = formatCollisionMessage(collisions);
3018
+ process.stderr.write(msg);
3019
+ process.exitCode = 1;
3020
+ return;
3021
+ }
3022
+ } catch (preflightErr) {
3023
+ const detail = preflightErr instanceof Error ? preflightErr.message : String(preflightErr);
3024
+ console.error(
3025
+ `[townhouse hs up] port preflight skipped (non-fatal): ${detail}`
3026
+ );
3027
+ }
3028
+ }
3029
+ const walletPath = config.wallet.encrypted_path;
3030
+ if (!existsSync(walletPath)) {
3031
+ console.error(
3032
+ `Wallet not found at ${walletPath}. Run \`townhouse init\` first.`
3033
+ );
3034
+ process.exitCode = 1;
3035
+ return;
3036
+ }
3037
+ const walletPassword = password ?? process.env["TOWNHOUSE_WALLET_PASSWORD"];
3038
+ let resolvedPassword;
3039
+ if (walletPassword) {
3040
+ resolvedPassword = walletPassword;
3041
+ } else if (process.stdin.isTTY) {
3042
+ resolvedPassword = await promptPassword("Wallet password: ");
3043
+ } else {
3044
+ console.error(
3045
+ "Wallet password required. Use --password flag or TOWNHOUSE_WALLET_PASSWORD env var."
3046
+ );
3047
+ process.exitCode = 1;
3048
+ return;
3049
+ }
3050
+ const loaded = await loadWallet(walletPath);
3051
+ if (!loaded) {
3052
+ console.error(`Wallet at ${walletPath} could not be read.`);
3053
+ process.exitCode = 1;
3054
+ return;
3055
+ }
3056
+ let walletManager;
3057
+ try {
3058
+ walletManager = new WalletManager({ encryptedPath: walletPath });
3059
+ await walletManager.fromMnemonic(
3060
+ decryptWallet(loaded.wallet, resolvedPassword)
3061
+ );
3062
+ } catch (err) {
3063
+ const msg = err instanceof Error ? err.message : String(err);
3064
+ console.error(`Failed to decrypt wallet: ${msg}`);
3065
+ process.exitCode = 1;
3066
+ return;
3067
+ }
3068
+ const ribbon = new OnboardingRibbon();
3069
+ try {
3070
+ if (!force) {
3071
+ const adminClientFactory = hsOverrides?.createAdminClient ?? ((url, t) => new ConnectorAdminClient(url, t));
3072
+ const probe = adminClientFactory(HS_CONNECTOR_ADMIN_URL, 3e3);
3073
+ try {
3074
+ const existing = await probe.getHsHostname();
3075
+ if (existing.hostname !== null) {
3076
+ console.log(`Apex live at ${existing.hostname}`);
3077
+ _writeHostJson(configDir, {
3078
+ hostname: existing.hostname,
3079
+ publishedAt: existing.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3080
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
3081
+ });
3082
+ return;
3083
+ }
3084
+ } catch (probeErr) {
3085
+ const msg = probeErr instanceof Error ? probeErr.message : String(probeErr);
3086
+ if (msg.includes("anon-disabled")) {
3087
+ const { exitCode } = renderFailure(probeErr);
3088
+ process.exitCode = exitCode;
3089
+ return;
3090
+ }
3091
+ }
3092
+ }
3093
+ writeHsConnectorConfig(configDir, config, { force });
3094
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
3095
+ const { composePath } = materialize("hs", { townhouseHome: configDir });
3096
+ ribbon.start("pull");
3097
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
3098
+ const orch = orchestratorFactory(docker, config, walletManager, {
3099
+ profile: "hs",
3100
+ composePath
3101
+ });
3102
+ const narrator = new PullNarrator();
3103
+ orch.on("pullProgress", (event) => {
3104
+ const ev = event;
3105
+ if (!ev.image || !ev.status) return;
3106
+ const line = narrator.format({
3107
+ image: ev.image,
3108
+ status: ev.status,
3109
+ id: ev.id,
3110
+ progress: ev.progress
3111
+ });
3112
+ if (line !== null) console.log(line);
3113
+ });
3114
+ let bootstrapStarted = false;
3115
+ orch.on("containerState", (event) => {
3116
+ const ev = event;
3117
+ if (!bootstrapStarted && (ev.state === "creating" || ev.state === "starting")) {
3118
+ bootstrapStarted = true;
3119
+ ribbon.start("bootstrap");
3120
+ }
3121
+ });
3122
+ if (typeof orch.pullImage === "function") {
3123
+ try {
3124
+ const apexImages = await collectApexImageRefs(configDir);
3125
+ if (apexImages.length > 0) {
3126
+ console.log(
3127
+ `Pulling ${apexImages.length} apex ${apexImages.length === 1 ? "image" : "images"}...`
3128
+ );
3129
+ let pulled = 0;
3130
+ for (const ref of apexImages) {
3131
+ pulled++;
3132
+ console.log(` [${pulled}/${apexImages.length}] ${ref}`);
3133
+ await orch.pullImage(ref);
3134
+ }
3135
+ }
3136
+ } catch (pullErr) {
3137
+ const detail = pullErr instanceof Error ? pullErr.message : String(pullErr);
3138
+ console.error(
3139
+ `[townhouse hs up] pre-pull skipped (non-fatal, compose will retry): ${detail}`
3140
+ );
3141
+ }
3142
+ }
3143
+ let dockerSockGid = 0;
3144
+ try {
3145
+ dockerSockGid = statSync("/var/run/docker.sock").gid;
3146
+ } catch {
3147
+ }
3148
+ const prevTownhouseHome = process.env["TOWNHOUSE_HOME"];
3149
+ const prevWalletPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
3150
+ const prevTownhouseUid = process.env["TOWNHOUSE_UID"];
3151
+ const prevWalletDir = process.env["TOWNHOUSE_WALLET_DIR"];
3152
+ const prevDockerGid = process.env["TOWNHOUSE_DOCKER_GID"];
3153
+ process.env["TOWNHOUSE_HOME"] = configDir;
3154
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = resolvedPassword;
3155
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
3156
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
3157
+ resolve(config.wallet.encrypted_path)
3158
+ );
3159
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(dockerSockGid);
3160
+ const MAX_ANON_RETRIES = 3;
3161
+ try {
3162
+ for (let attempt = 1; attempt <= MAX_ANON_RETRIES; attempt++) {
3163
+ try {
3164
+ await orch.up([]);
3165
+ break;
3166
+ } catch (err) {
3167
+ if (isAnonBootstrapTimeout(err) && attempt < MAX_ANON_RETRIES) {
3168
+ console.error(
3169
+ `[townhouse hs up] ATOR bootstrap timed out (attempt ${attempt}/${MAX_ANON_RETRIES}) \u2014 retrying...`
3170
+ );
3171
+ await orch.down().catch(() => void 0);
3172
+ continue;
3173
+ }
3174
+ throw err;
3175
+ }
3176
+ }
3177
+ } finally {
3178
+ if (prevTownhouseHome === void 0) {
3179
+ delete process.env["TOWNHOUSE_HOME"];
3180
+ } else {
3181
+ process.env["TOWNHOUSE_HOME"] = prevTownhouseHome;
3182
+ }
3183
+ if (prevWalletPassword === void 0) {
3184
+ delete process.env["TOWNHOUSE_WALLET_PASSWORD"];
3185
+ } else {
3186
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = prevWalletPassword;
3187
+ }
3188
+ if (prevTownhouseUid === void 0) {
3189
+ delete process.env["TOWNHOUSE_UID"];
3190
+ } else {
3191
+ process.env["TOWNHOUSE_UID"] = prevTownhouseUid;
3192
+ }
3193
+ if (prevWalletDir === void 0) {
3194
+ delete process.env["TOWNHOUSE_WALLET_DIR"];
3195
+ } else {
3196
+ process.env["TOWNHOUSE_WALLET_DIR"] = prevWalletDir;
3197
+ }
3198
+ if (prevDockerGid === void 0) {
3199
+ delete process.env["TOWNHOUSE_DOCKER_GID"];
3200
+ } else {
3201
+ process.env["TOWNHOUSE_DOCKER_GID"] = prevDockerGid;
3202
+ }
3203
+ }
3204
+ const nodesYamlPath = join(configDir, "nodes.yaml");
3205
+ const reconcilerLogPath = join(configDir, "reconciler.log");
3206
+ const reconcilerFactory = hsOverrides?.createReconciler ?? ((nodesPath, logPath) => {
3207
+ const reconcilerAdminClient = new ConnectorAdminClient(
3208
+ HS_CONNECTOR_ADMIN_URL,
3209
+ 5e3
3210
+ );
3211
+ return new BootReconciler(reconcilerAdminClient, nodesPath, logPath);
3212
+ });
3213
+ const reconciler = reconcilerFactory(nodesYamlPath, reconcilerLogPath);
3214
+ try {
3215
+ await reconcileWithBriefRetry(reconciler, 5e3);
3216
+ } catch (reconcilerErr) {
3217
+ const detail = reconcilerErr instanceof Error ? reconcilerErr.stack ?? reconcilerErr.message : String(reconcilerErr);
3218
+ console.error(
3219
+ `[townhouse hs up] reconciler error (non-fatal): ${detail}`
3220
+ );
3221
+ }
3222
+ const adminClientFactory2 = hsOverrides?.createAdminClient ?? ((url, t) => new ConnectorAdminClient(url, t));
3223
+ const adminClient = adminClientFactory2(HS_CONNECTOR_ADMIN_URL, 5e3);
3224
+ const hsInfo = await adminClient.getHsHostname();
3225
+ const hostname = hsInfo.hostname ?? "";
3226
+ const publishedAt = hsInfo.publishedAt ?? (/* @__PURE__ */ new Date()).toISOString();
3227
+ _writeHostJson(configDir, {
3228
+ hostname,
3229
+ publishedAt,
3230
+ writtenAt: (/* @__PURE__ */ new Date()).toISOString()
3231
+ });
3232
+ ribbon.start("live", hostname);
3233
+ if (shouldRenderInk()) {
3234
+ const { mountTui } = await import("./tui-OIFXGBTL.js");
3235
+ const apiUrlOverride = process.env["HS_TOWNHOUSE_API_URL"];
3236
+ const mountOpts = apiUrlOverride !== void 0 ? { apiUrl: apiUrlOverride } : {};
3237
+ const instance = mountTui(mountOpts);
3238
+ await instance.waitUntilExit();
3239
+ }
3240
+ } catch (err) {
3241
+ const { exitCode } = renderFailure(err);
3242
+ process.exitCode = exitCode;
3243
+ } finally {
3244
+ ribbon.stop();
3245
+ if (walletManager) {
3246
+ walletManager.lock();
3247
+ }
3248
+ }
3249
+ }
3250
+ function _writeHostJson(configDir, data) {
3251
+ const hostJsonPath = join(configDir, "host.json");
3252
+ const tmpPath = `${hostJsonPath}.tmp`;
3253
+ const content = JSON.stringify(
3254
+ {
3255
+ hostname: data.hostname,
3256
+ publishedAt: data.publishedAt,
3257
+ connectorAdminUrl: HS_CONNECTOR_ADMIN_URL,
3258
+ townhouseApiUrl: HS_TOWNHOUSE_API_URL,
3259
+ writtenAt: data.writtenAt
3260
+ },
3261
+ null,
3262
+ 2
3263
+ );
3264
+ writeFileSync(tmpPath, content, { mode: 384, encoding: "utf-8" });
3265
+ renameSync(tmpPath, hostJsonPath);
3266
+ }
3267
+ async function handleHsDown(configDir, config, docker, options) {
3268
+ const { rotateKeys, hsOverrides } = options;
3269
+ const materialize = hsOverrides?.materializeComposeTemplate ?? materializeComposeTemplate;
3270
+ const { composePath } = materialize("hs", { townhouseHome: configDir });
3271
+ const prevTownhouseHome = process.env["TOWNHOUSE_HOME"];
3272
+ const prevTownhouseUid = process.env["TOWNHOUSE_UID"];
3273
+ const prevWalletDir = process.env["TOWNHOUSE_WALLET_DIR"];
3274
+ const prevDockerGid = process.env["TOWNHOUSE_DOCKER_GID"];
3275
+ const prevWalletPassword = process.env["TOWNHOUSE_WALLET_PASSWORD"];
3276
+ process.env["TOWNHOUSE_HOME"] = configDir;
3277
+ process.env["TOWNHOUSE_UID"] = String(process.getuid?.() ?? 1e3);
3278
+ process.env["TOWNHOUSE_WALLET_DIR"] = dirname(
3279
+ resolve(config.wallet.encrypted_path)
3280
+ );
3281
+ let dockerSockGid = 0;
3282
+ try {
3283
+ dockerSockGid = statSync("/var/run/docker.sock").gid;
3284
+ } catch {
3285
+ }
3286
+ process.env["TOWNHOUSE_DOCKER_GID"] = String(dockerSockGid);
3287
+ if (prevWalletPassword === void 0) {
3288
+ process.env["TOWNHOUSE_WALLET_PASSWORD"] = "";
3289
+ }
3290
+ const restoreTownhouseHome = () => {
3291
+ if (prevTownhouseHome === void 0) {
3292
+ delete process.env["TOWNHOUSE_HOME"];
3293
+ } else {
3294
+ process.env["TOWNHOUSE_HOME"] = prevTownhouseHome;
3295
+ }
3296
+ if (prevTownhouseUid === void 0) {
3297
+ delete process.env["TOWNHOUSE_UID"];
3298
+ } else {
3299
+ process.env["TOWNHOUSE_UID"] = prevTownhouseUid;
3300
+ }
3301
+ if (prevWalletDir === void 0) {
3302
+ delete process.env["TOWNHOUSE_WALLET_DIR"];
3303
+ } else {
3304
+ process.env["TOWNHOUSE_WALLET_DIR"] = prevWalletDir;
3305
+ }
3306
+ if (prevDockerGid === void 0) {
3307
+ delete process.env["TOWNHOUSE_DOCKER_GID"];
3308
+ } else {
3309
+ process.env["TOWNHOUSE_DOCKER_GID"] = prevDockerGid;
3310
+ }
3311
+ if (prevWalletPassword === void 0) {
3312
+ delete process.env["TOWNHOUSE_WALLET_PASSWORD"];
3313
+ }
3314
+ };
3315
+ if (rotateKeys) {
3316
+ if (process.stdin.isTTY) {
3317
+ let existingHostname = "(unknown)";
3318
+ const hostJsonPath = join(configDir, "host.json");
3319
+ if (existsSync(hostJsonPath)) {
3320
+ try {
3321
+ const { readFileSync } = await import("fs");
3322
+ const json = JSON.parse(readFileSync(hostJsonPath, "utf-8"));
3323
+ existingHostname = json.hostname ?? existingHostname;
3324
+ } catch {
3325
+ }
3326
+ }
3327
+ const { createInterface: createInterface3 } = await import("readline");
3328
+ const answer = await new Promise((resolve2) => {
3329
+ const rl = createInterface3({
3330
+ input: process.stdin,
3331
+ output: process.stdout
3332
+ });
3333
+ rl.question(
3334
+ `WARNING: --rotate-keys will permanently delete your current .anyone address (${existingHostname}). The next 'hs up' will publish a new address. Continue? [y/N] `,
3335
+ (ans) => {
3336
+ rl.close();
3337
+ resolve2(ans);
3338
+ }
3339
+ );
3340
+ });
3341
+ if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
3342
+ console.log("Cancelled.");
3343
+ return;
3344
+ }
3345
+ }
3346
+ const runDown = hsOverrides?.runComposeDown ?? _runDockerComposeDown;
3347
+ try {
3348
+ await runDown(composePath, true);
3349
+ } catch (err) {
3350
+ const { exitCode } = renderFailure(err);
3351
+ process.exitCode = exitCode;
3352
+ restoreTownhouseHome();
3353
+ return;
3354
+ }
3355
+ rmSync(join(configDir, "host.json"), { force: true });
3356
+ console.log(
3357
+ "Apex stopped. Volumes deleted \u2014 your next 'hs up' will publish a NEW .anyone address."
3358
+ );
3359
+ restoreTownhouseHome();
3360
+ return;
3361
+ }
3362
+ const orchestratorFactory = hsOverrides?.createOrchestrator ?? ((d, cfg, wm, opts) => new DockerOrchestrator(d, cfg, wm, opts));
3363
+ const orch = orchestratorFactory(docker, config, void 0, {
3364
+ profile: "hs",
3365
+ composePath
3366
+ });
3367
+ try {
3368
+ await orch.down();
3369
+ } catch (err) {
3370
+ const { exitCode } = renderFailure(err);
3371
+ process.exitCode = exitCode;
3372
+ restoreTownhouseHome();
3373
+ return;
3374
+ }
3375
+ restoreTownhouseHome();
3376
+ console.log(
3377
+ "Apex stopped. Volumes preserved \u2014 your .anyone address is stable."
3378
+ );
3379
+ }
3380
+ function _runDockerComposeDown(composePath, withVolumes) {
3381
+ return new Promise((resolve2, reject) => {
3382
+ const args = ["compose", "-f", composePath, "down"];
3383
+ if (withVolumes) args.push("-v");
3384
+ const child = spawn2("docker", args, {
3385
+ stdio: ["ignore", "inherit", "inherit"]
3386
+ });
3387
+ child.on("error", reject);
3388
+ child.on("close", (code) => {
3389
+ if (code === 0) {
3390
+ resolve2();
3391
+ } else {
3392
+ reject(new Error(`docker compose down exited with code ${code}`));
3393
+ }
3394
+ });
3395
+ });
3396
+ }
3397
+ async function main(argv, dockerInstance, browserOpener, hsOverrides, nodeCommandOverrides) {
549
3398
  const { values, positionals } = parseArgs({
550
3399
  args: argv,
551
3400
  options: {
@@ -561,16 +3410,40 @@ async function main(argv, dockerInstance, browserOpener) {
561
3410
  "no-browser": { type: "boolean" },
562
3411
  port: { type: "string" },
563
3412
  preset: { type: "string" },
564
- yes: { type: "boolean" }
3413
+ yes: { type: "boolean" },
3414
+ "rotate-keys": { type: "boolean" },
3415
+ "skip-preflight": { type: "boolean" },
3416
+ json: { type: "boolean" },
3417
+ "json-compact": { type: "boolean" },
3418
+ lines: { type: "string" },
3419
+ follow: { type: "boolean", short: "f" },
3420
+ units: { type: "string" },
3421
+ rate: { type: "string" },
3422
+ // credits buy / credits balance (epic-49, Phase 2)
3423
+ token: { type: "string" },
3424
+ amount: { type: "string" },
3425
+ "fee-multiplier": { type: "string" },
3426
+ "quote-only": { type: "boolean" },
3427
+ "credit-destination": { type: "string" },
3428
+ // wallet show / wallet seed (epic-49, Phase 3)
3429
+ hex: { type: "boolean" },
3430
+ paths: { type: "boolean" },
3431
+ confirm: { type: "boolean" }
565
3432
  },
566
3433
  strict: false,
567
3434
  allowPositionals: true
568
3435
  });
3436
+ const command = positionals[0];
3437
+ if (command === "node" && values.help) {
3438
+ const action = positionals[1];
3439
+ const subHelp = action === "add" ? NODE_ADD_HELP : action === "remove" ? NODE_REMOVE_HELP : action === "list" ? NODE_LIST_HELP : NODE_HELP;
3440
+ console.log(subHelp);
3441
+ throw new CliHelpRequested();
3442
+ }
569
3443
  if (values.help) {
570
3444
  console.log(HELP_TEXT);
571
3445
  throw new CliHelpRequested();
572
3446
  }
573
- const command = positionals[0];
574
3447
  if (!command) {
575
3448
  console.log(HELP_TEXT);
576
3449
  throw new CliHelpRequested();
@@ -614,26 +3487,76 @@ async function main(argv, dockerInstance, browserOpener) {
614
3487
  if (subCommand === "show") {
615
3488
  const configPath = values.config ?? DEFAULT_CONFIG_PATH;
616
3489
  const config = loadConfig(configPath);
617
- await handleWalletShow(config, values.password);
3490
+ await handleWalletShow(config, values.password, {
3491
+ json: values.json === true,
3492
+ hex: values.hex === true,
3493
+ paths: values.paths === true
3494
+ });
3495
+ } else if (subCommand === "seed") {
3496
+ const configPath = values.config ?? DEFAULT_CONFIG_PATH;
3497
+ const config = loadConfig(configPath);
3498
+ await handleWalletSeed(
3499
+ config,
3500
+ values.password,
3501
+ values.confirm === true
3502
+ );
618
3503
  } else {
619
3504
  console.error(
620
- "Usage: townhouse wallet show [-c <path>] [--password <pw>]"
3505
+ "Usage:\n townhouse wallet show [--json] [--hex] [--paths] [-c <path>] [--password <pw>]\n townhouse wallet seed --confirm [-c <path>] [--password <pw>]"
621
3506
  );
622
3507
  process.exitCode = 1;
623
3508
  }
624
3509
  break;
625
3510
  }
626
- case "status": {
3511
+ case "credits": {
3512
+ const subCommand = positionals[1];
627
3513
  const configPath = values.config ?? DEFAULT_CONFIG_PATH;
628
3514
  const config = loadConfig(configPath);
629
- const docker = dockerInstance ?? new Docker();
630
- await handleStatus(docker, config);
3515
+ if (subCommand === "buy") {
3516
+ await handleCreditsBuy(config, values);
3517
+ } else if (subCommand === "balance") {
3518
+ await handleCreditsBalance(config, values);
3519
+ } else {
3520
+ console.error(
3521
+ "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>]"
3522
+ );
3523
+ process.exitCode = 1;
3524
+ }
3525
+ break;
3526
+ }
3527
+ case "status": {
3528
+ const configPath = values["config"] ?? DEFAULT_CONFIG_PATH;
3529
+ const rawUnits = values["units"] ?? "usdc";
3530
+ if (rawUnits !== "usdc" && rawUnits !== "sats") {
3531
+ console.error(`--units must be 'usdc' or 'sats'`);
3532
+ process.exitCode = 1;
3533
+ break;
3534
+ }
3535
+ let satsPerUsdc;
3536
+ if (rawUnits === "sats") {
3537
+ const r = resolveSatsRate(
3538
+ values,
3539
+ process.env
3540
+ );
3541
+ if ("error" in r) {
3542
+ console.error(r.error);
3543
+ process.exitCode = 1;
3544
+ } else {
3545
+ satsPerUsdc = r.rate;
3546
+ }
3547
+ }
3548
+ const units = rawUnits;
3549
+ await handleStatus(
3550
+ dockerInstance ?? new Docker2(),
3551
+ loadConfig(configPath),
3552
+ { units, satsPerUsdc, configPath }
3553
+ );
631
3554
  break;
632
3555
  }
633
3556
  case "up": {
634
3557
  const configPath = values.config ?? DEFAULT_CONFIG_PATH;
635
3558
  const config = loadConfig(configPath);
636
- const docker = dockerInstance ?? new Docker();
3559
+ const docker = dockerInstance ?? new Docker2();
637
3560
  const profiles = resolveProfiles(values, config);
638
3561
  await handleUp(
639
3562
  configPath,
@@ -648,14 +3571,96 @@ async function main(argv, dockerInstance, browserOpener) {
648
3571
  case "down": {
649
3572
  const configPath = values.config ?? DEFAULT_CONFIG_PATH;
650
3573
  const config = loadConfig(configPath);
651
- const docker = dockerInstance ?? new Docker();
3574
+ const docker = dockerInstance ?? new Docker2();
652
3575
  await handleDown(config, docker);
653
3576
  break;
654
3577
  }
655
- case "metrics": {
3578
+ case "channels":
3579
+ case "metrics":
3580
+ case "logs":
3581
+ case "peer":
3582
+ case "health": {
3583
+ await dispatchDrillCommand(command, {
3584
+ adminUrl: HS_CONNECTOR_ADMIN_URL,
3585
+ apiUrl: HS_TOWNHOUSE_API_URL,
3586
+ values,
3587
+ positionals,
3588
+ docker: dockerInstance
3589
+ });
3590
+ break;
3591
+ }
3592
+ case "hs": {
3593
+ const action = positionals[1];
656
3594
  const configPath = values.config ?? DEFAULT_CONFIG_PATH;
657
3595
  const config = loadConfig(configPath);
658
- await handleMetrics(config);
3596
+ const docker = dockerInstance ?? new Docker2();
3597
+ const configDir = dirname(configPath);
3598
+ if (action === "up") {
3599
+ await handleHsUp(configPath, configDir, config, docker, {
3600
+ password: values.password,
3601
+ force: values.force === true,
3602
+ skipPreflight: values["skip-preflight"] === true,
3603
+ hsOverrides
3604
+ });
3605
+ } else if (action === "down") {
3606
+ await handleHsDown(configDir, config, docker, {
3607
+ rotateKeys: values["rotate-keys"] === true,
3608
+ hsOverrides
3609
+ });
3610
+ } else {
3611
+ console.error(
3612
+ "Usage: townhouse hs <up|down> [--rotate-keys] [--password <pw>] [-c <path>]"
3613
+ );
3614
+ process.exitCode = 1;
3615
+ }
3616
+ break;
3617
+ }
3618
+ case "node": {
3619
+ const action = positionals[1];
3620
+ const jsonMode = values.json === true;
3621
+ const yesMode = values.yes === true;
3622
+ const nodeApiUrl = nodeCommandOverrides?.apiUrl ?? HS_TOWNHOUSE_API_URL;
3623
+ if (!action) {
3624
+ console.log(NODE_HELP);
3625
+ throw new CliHelpRequested();
3626
+ }
3627
+ switch (action) {
3628
+ case "add": {
3629
+ const typeArg = positionals[2] ?? "town";
3630
+ await handleNodeAdd(typeArg, {
3631
+ json: jsonMode,
3632
+ apiUrl: nodeApiUrl,
3633
+ fetch: nodeCommandOverrides?.fetch,
3634
+ confirm: nodeCommandOverrides?.confirm
3635
+ });
3636
+ break;
3637
+ }
3638
+ case "remove": {
3639
+ const idArg = positionals[2] ?? "";
3640
+ await handleNodeRemove(idArg, {
3641
+ yes: yesMode,
3642
+ json: jsonMode,
3643
+ apiUrl: nodeApiUrl,
3644
+ fetch: nodeCommandOverrides?.fetch,
3645
+ confirm: nodeCommandOverrides?.confirm
3646
+ });
3647
+ break;
3648
+ }
3649
+ case "list": {
3650
+ await handleNodeList({
3651
+ json: jsonMode,
3652
+ apiUrl: nodeApiUrl,
3653
+ fetch: nodeCommandOverrides?.fetch
3654
+ });
3655
+ break;
3656
+ }
3657
+ default: {
3658
+ const safeAction = action.replace(/[\x00-\x1f\x7f]/g, "");
3659
+ console.error(`Unknown node subcommand: ${safeAction}`);
3660
+ console.log(NODE_HELP);
3661
+ process.exitCode = 1;
3662
+ }
3663
+ }
659
3664
  break;
660
3665
  }
661
3666
  default: {