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