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