@toon-protocol/townhouse 0.1.0-rc5 → 0.1.0

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