routstrd 0.3.11 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/COCO-2.0.0-MIGRATION-PLAN.md +1191 -0
  2. package/IMPLEMENTATION.md +253 -0
  3. package/README.md +46 -0
  4. package/SECURITY.md +23 -0
  5. package/SKILL.md +13 -3
  6. package/bun.lock +68 -41
  7. package/dist/daemon/index.js +48837 -11383
  8. package/dist/index.js +5027 -421
  9. package/package.json +8 -3
  10. package/src/cli.test.ts +55 -0
  11. package/src/cli.ts +601 -217
  12. package/src/daemon/args.ts +8 -1
  13. package/src/daemon/fatal-error.test.ts +104 -0
  14. package/src/daemon/fatal-error.ts +48 -0
  15. package/src/daemon/models.ts +119 -39
  16. package/src/daemon/wallet/auto-refill.ts +4 -6
  17. package/src/daemon/wallet/cleanup.test.ts +168 -0
  18. package/src/daemon/wallet/cleanup.ts +104 -0
  19. package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
  20. package/src/daemon/wallet/coco-client.test.ts +669 -0
  21. package/src/daemon/wallet/coco-client.ts +1628 -0
  22. package/src/daemon/wallet/cocod-client.ts +135 -3
  23. package/src/daemon/wallet/diagnostics.test.ts +378 -0
  24. package/src/daemon/wallet/diagnostics.ts +471 -0
  25. package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
  26. package/src/daemon/wallet/migration.test.ts +171 -0
  27. package/src/daemon/wallet/migration.ts +154 -0
  28. package/src/daemon/wallet/paths.ts +33 -0
  29. package/src/daemon/wallet/receive-dedup.test.ts +238 -0
  30. package/src/daemon/wallet/receive-dedup.ts +384 -0
  31. package/src/daemon/wallet/wallet-state.ts +75 -0
  32. package/src/integrations/claudecode.ts +2 -1
  33. package/src/integrations/hermes.ts +46 -6
  34. package/src/integrations/openclaw.ts +3 -2
  35. package/src/integrations/opencode.ts +3 -2
  36. package/src/integrations/pi.ts +3 -2
  37. package/src/integrations/registry.ts +7 -5
  38. package/src/start-daemon.ts +189 -23
  39. package/src/utils/clients.ts +21 -0
  40. package/src/utils/config.ts +11 -0
  41. package/src/utils/daemon-client.ts +104 -25
  42. package/src/utils/logger.ts +42 -28
  43. package/tests/integrations/hermes.test.ts +42 -5
  44. package/tests/utils/daemon-client.test.ts +61 -0
  45. package/tests/wallet/short-keyset-token.test.ts +106 -0
@@ -1,52 +1,167 @@
1
- import { openSync } from "fs";
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fstatSync,
5
+ openSync,
6
+ readSync,
7
+ } from "fs";
2
8
  import { logger } from "./utils/logger";
3
9
  import { CONFIG_DIR, LOGS_DIR } from "./utils/config";
4
10
  import { withCrossProcessLock } from "./utils/process-lock";
11
+ import { urlHosts } from "./utils/daemon-client";
12
+ import { fileURLToPath } from "url";
5
13
 
6
14
  const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`;
7
15
  const DEBUG_LOG_PATH = `${CONFIG_DIR}/debug.log`;
8
16
 
9
- async function isDaemonHealthy(port: string): Promise<boolean> {
10
- const controller = new AbortController();
11
- const timeoutId = setTimeout(() => controller.abort(), 2000);
17
+ /**
18
+ * The spawned daemon's stdout/stderr is redirected to DEBUG_LOG_PATH, so when
19
+ * it exits early we can surface its actual error from there instead of just
20
+ * telling the user to go read logs.
21
+ */
22
+ function readDaemonOutput(offset: number): string {
23
+ let fd: number | undefined;
12
24
  try {
13
- const existing = await fetch(`http://localhost:${port}/health`, {
14
- signal: controller.signal,
15
- });
16
- return existing.ok;
25
+ if (!existsSync(DEBUG_LOG_PATH)) return "";
26
+ fd = openSync(DEBUG_LOG_PATH, "r");
27
+ const size = fstatSync(fd).size;
28
+ if (size <= offset) return "";
29
+ const bytesToRead = Math.min(size - offset, 256 * 1024);
30
+ const buffer = Buffer.allocUnsafe(bytesToRead);
31
+ const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
32
+ return buffer
33
+ .toString("utf8", 0, bytesRead)
34
+ .split("\n")
35
+ .map((line) => line.trim())
36
+ .filter(Boolean)
37
+ .slice(-30)
38
+ .join("\n");
17
39
  } catch {
18
- return false;
40
+ return "";
19
41
  } finally {
20
- clearTimeout(timeoutId);
42
+ if (fd !== undefined) closeSync(fd);
21
43
  }
22
44
  }
23
45
 
46
+ const STARTUP_LOG_PREFIX = "[routstrd:start]";
47
+
48
+ /**
49
+ * Print only explicitly tagged, user-safe progress emitted by the detached
50
+ * daemon. Return the next byte offset so each line is shown exactly once.
51
+ */
52
+ function printStartupProgress(offset: number): {
53
+ offset: number;
54
+ lastMessage?: string;
55
+ } {
56
+ let fd: number | undefined;
57
+ try {
58
+ if (!existsSync(DEBUG_LOG_PATH)) return { offset };
59
+ fd = openSync(DEBUG_LOG_PATH, "r");
60
+ const size = fstatSync(fd).size;
61
+ if (size <= offset) return { offset };
62
+
63
+ // Startup messages are short. Bound each read so a noisy daemon cannot
64
+ // make the waiting CLI repeatedly load a large debug log into memory.
65
+ const bytesToRead = Math.min(size - offset, 64 * 1024);
66
+ const buffer = Buffer.allocUnsafe(bytesToRead);
67
+ const bytesRead = readSync(fd, buffer, 0, bytesToRead, offset);
68
+ const lastNewlineIndex = buffer.subarray(0, bytesRead).lastIndexOf(0x0a);
69
+ const completeBytes = lastNewlineIndex + 1;
70
+ if (completeBytes === 0) return { offset };
71
+ const appended = buffer.toString("utf8", 0, completeBytes);
72
+
73
+ let lastMessage: string | undefined;
74
+ for (const line of appended.split("\n")) {
75
+ const markerIndex = line.indexOf(STARTUP_LOG_PREFIX);
76
+ if (markerIndex === -1) continue;
77
+ const message = line.slice(markerIndex + STARTUP_LOG_PREFIX.length).trim();
78
+ if (message) {
79
+ console.log(` ${message}`);
80
+ lastMessage = message;
81
+ }
82
+ }
83
+ return { offset: offset + completeBytes, lastMessage };
84
+ } catch {
85
+ return { offset };
86
+ } finally {
87
+ if (fd !== undefined) closeSync(fd);
88
+ }
89
+ }
90
+
91
+ function formatElapsed(elapsedMs: number): string {
92
+ const seconds = Math.floor(elapsedMs / 1000);
93
+ if (seconds < 60) return `${seconds}s`;
94
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
95
+ }
96
+
97
+ function fileSize(path: string): number {
98
+ let fd: number | undefined;
99
+ try {
100
+ fd = openSync(path, "r");
101
+ return fstatSync(fd).size;
102
+ } finally {
103
+ if (fd !== undefined) closeSync(fd);
104
+ }
105
+ }
106
+
107
+ async function healthyDaemonHost(
108
+ port: string,
109
+ host = "127.0.0.1",
110
+ ): Promise<string | null> {
111
+ // Return the responding candidate so status output shows a connectable URL.
112
+ for (const candidate of urlHosts(host)) {
113
+ const controller = new AbortController();
114
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
115
+ try {
116
+ const existing = await fetch(`http://${candidate}:${port}/health`, {
117
+ signal: controller.signal,
118
+ });
119
+ if (existing.ok) return candidate;
120
+ } catch {
121
+ // Try the next candidate host.
122
+ } finally {
123
+ clearTimeout(timeoutId);
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
24
129
  async function startDaemonUnlocked(
25
- options: { port?: string; provider?: string },
130
+ options: { port?: string; host?: string; provider?: string },
26
131
  ): Promise<void> {
27
132
  const args: string[] = [];
28
133
  const port = options.port || "8008";
134
+ const host = options.host || "127.0.0.1";
29
135
  const pollIntervalMs = 250;
30
136
  const startupTimeoutMs = 10 * 60 * 1000;
31
137
 
32
- if (await isDaemonHealthy(port)) {
33
- console.log(`Routstr daemon already running on http://localhost:${port}/v1`);
138
+ const existingHost = await healthyDaemonHost(port, host);
139
+ if (existingHost) {
140
+ console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`);
34
141
  return;
35
142
  }
36
143
 
37
144
  if (options.port) {
38
145
  args.push("--port", options.port);
39
146
  }
147
+ if (options.host) {
148
+ args.push("--host", options.host);
149
+ }
40
150
  if (options.provider) {
41
151
  args.push("--provider", options.provider);
42
152
  }
43
153
 
44
- const daemonScript = new URL("./daemon/index.js", import.meta.url).pathname;
45
- const shellCmd = `bun run "${daemonScript}" ${args.map((a) => `'${a}'`).join(" ")}`;
154
+ let daemonScript = fileURLToPath(new URL("./daemon/index.js", import.meta.url));
155
+ if (!existsSync(daemonScript)) {
156
+ daemonScript = fileURLToPath(new URL("./daemon/index.ts", import.meta.url));
157
+ }
46
158
 
159
+ const debugLogOffset = existsSync(DEBUG_LOG_PATH)
160
+ ? fileSize(DEBUG_LOG_PATH)
161
+ : 0;
47
162
  const debugLogFd = openSync(DEBUG_LOG_PATH, "a");
48
163
 
49
- const proc = Bun.spawn(["sh", "-c", shellCmd], {
164
+ const proc = Bun.spawn(["bun", daemonScript, ...args], {
50
165
  stdout: debugLogFd,
51
166
  stderr: debugLogFd,
52
167
  stdin: "ignore",
@@ -55,40 +170,91 @@ async function startDaemonUnlocked(
55
170
 
56
171
  proc.unref();
57
172
 
173
+ // The daemon must outlive a successful start, but a start we give up on must not
174
+ // leave a detached process holding the wallet locks. `detached` makes it a group leader.
175
+ const killSpawnedDaemon = (): void => {
176
+ try {
177
+ process.kill(-proc.pid, "SIGTERM");
178
+ } catch {
179
+ // Already exited.
180
+ }
181
+ };
182
+ const abandonOnSignal = (): void => {
183
+ killSpawnedDaemon();
184
+ process.exit(1);
185
+ };
186
+ process.once("SIGINT", abandonOnSignal);
187
+ process.once("SIGTERM", abandonOnSignal);
188
+
58
189
  let exitCode: number | null = null;
59
190
  proc.exited.then((code) => {
60
191
  exitCode = code;
61
192
  });
62
193
 
194
+ const startedAt = Date.now();
195
+ const heartbeatIntervalMs = 10_000;
196
+ let nextHeartbeatAt = heartbeatIntervalMs;
197
+ let progressLogOffset = debugLogOffset;
198
+ let currentPhase = "starting daemon";
199
+
63
200
  const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
64
201
  for (let i = 0; i < maxPolls; i++) {
65
202
  await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
66
203
 
204
+ const progress = printStartupProgress(progressLogOffset);
205
+ progressLogOffset = progress.offset;
206
+ if (progress.lastMessage) {
207
+ currentPhase = progress.lastMessage
208
+ .replace(/\.$/, "")
209
+ .toLowerCase();
210
+ }
211
+
212
+ const elapsedMs = Date.now() - startedAt;
213
+ if (elapsedMs >= nextHeartbeatAt) {
214
+ console.log(
215
+ ` Still ${currentPhase} (${formatElapsed(elapsedMs)} elapsed)...`,
216
+ );
217
+ nextHeartbeatAt += heartbeatIntervalMs;
218
+ }
219
+
67
220
  if (exitCode !== null) {
221
+ const daemonOutput = readDaemonOutput(debugLogOffset);
68
222
  throw new Error(
69
- `Daemon process exited early with code ${exitCode}. Check logs in ${LOGS_DIR}`,
223
+ `Daemon process exited early with code ${exitCode}.` +
224
+ (daemonOutput
225
+ ? `\n\nDaemon output:\n${daemonOutput}`
226
+ : ` Check logs in ${LOGS_DIR}`),
70
227
  );
71
228
  }
72
229
 
73
- if (await isDaemonHealthy(port)) {
74
- console.log(`Routstr daemon started (PID: ${proc.pid}).`);
230
+ if (await healthyDaemonHost(port, host)) {
231
+ process.off("SIGINT", abandonOnSignal);
232
+ process.off("SIGTERM", abandonOnSignal);
233
+ printStartupProgress(progressLogOffset);
234
+ console.log(
235
+ `Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`,
236
+ );
75
237
  return;
76
238
  }
77
239
  }
78
240
 
241
+ killSpawnedDaemon();
79
242
  throw new Error(
80
- `Daemon failed to start within ${Math.round(startupTimeoutMs / 1000)} seconds. Check logs in ${LOGS_DIR}`,
243
+ `Daemon did not become healthy within ${Math.round(startupTimeoutMs / 1000)} seconds; ` +
244
+ `the incomplete daemon (PID ${proc.pid}) was stopped. Check logs in ${LOGS_DIR}`,
81
245
  );
82
246
  }
83
247
 
84
248
  export async function startDaemon(
85
- options: { port?: string; provider?: string } = {},
249
+ options: { port?: string; host?: string; provider?: string } = {},
86
250
  ): Promise<void> {
87
251
  const port = options.port || "8008";
252
+ const host = options.host || "127.0.0.1";
88
253
  const startupTimeoutMs = 10 * 60 * 1000;
89
254
 
90
- if (await isDaemonHealthy(port)) {
91
- console.log(`Routstr daemon already running on http://localhost:${port}/v1`);
255
+ const existingHost = await healthyDaemonHost(port, host);
256
+ if (existingHost) {
257
+ console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`);
92
258
  return;
93
259
  }
94
260
 
@@ -2,6 +2,7 @@ import {
2
2
  callDaemon,
3
3
  loadConfig,
4
4
  getDaemonBaseUrl,
5
+ getUserNpub,
5
6
  ensureDaemonRunning,
6
7
  } from "./daemon-client";
7
8
  import { logger } from "./logger";
@@ -202,6 +203,7 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
202
203
  if (options.hermes) integrationKeys.push("hermes");
203
204
 
204
205
  if (integrationKeys.length > 0) {
206
+ let hadFailure = false;
205
207
  for (const key of integrationKeys) {
206
208
  const integrationFn = CLIENT_INTEGRATIONS[key];
207
209
  const integrationConfig = CLIENT_CONFIGS[key];
@@ -222,15 +224,34 @@ export async function addClientAction(options: AddClientOptions): Promise<void>
222
224
  console.log(` Client ID: ${client.id}`);
223
225
  console.log(` API Key: ${client.apiKey}`);
224
226
  } catch (error) {
227
+ const message = (error as Error)?.message ?? String(error);
225
228
  logger.error(
226
229
  `Failed to set up ${integrationConfig.name} integration:`,
227
230
  error,
228
231
  );
232
+ console.error(
233
+ `\n Error: failed to set up ${integrationConfig.name} integration.`,
234
+ );
235
+ console.error(` ${message}`);
236
+ if (/NIP-98|registered npub\/pubkey|registered/i.test(message)) {
237
+ const npub = getUserNpub(config);
238
+ console.error(
239
+ ` The daemon at ${getDaemonBaseUrl(config)} rejected this account.`,
240
+ );
241
+ if (npub) {
242
+ console.error(
243
+ ` Register/authorize this npub on the remote daemon first:`,
244
+ );
245
+ console.error(` ${npub}`);
246
+ }
247
+ }
248
+ hadFailure = true;
229
249
  continue;
230
250
  }
231
251
  }
232
252
 
233
253
  console.log(`\n Access Routstr at: ${getDaemonBaseUrl(config)}/v1`);
254
+ if (hadFailure) process.exit(1);
234
255
  return;
235
256
  }
236
257
 
@@ -32,6 +32,7 @@ export interface NwcConfig {
32
32
 
33
33
  export interface RoutstrdConfig {
34
34
  port: number;
35
+ host: string;
35
36
  provider: string | null;
36
37
  cocodPath: string | null;
37
38
  mode?: "xcashu" | "apikeys";
@@ -54,11 +55,21 @@ export interface RoutstrdConfig {
54
55
  relays?: string[];
55
56
  /** NWC integration configuration */
56
57
  nwc?: NwcConfig;
58
+ /**
59
+ * Default max_tokens (chat/completions) and max_output_tokens (responses)
60
+ * injected into proxied requests when the client does not supply one.
61
+ * Caps the completion budget the SDK prices against (completion × maxTokens)
62
+ * instead of the provider's worst-case max_completion_cost. Set to 0 to
63
+ * disable injection and always forward the client's value as-is.
64
+ */
65
+ maxTokens?: number;
57
66
  }
58
67
 
59
68
  export const DEFAULT_CONFIG: RoutstrdConfig = {
60
69
  port: 8008,
70
+ host: "127.0.0.1",
61
71
  provider: null,
62
72
  cocodPath: null,
63
73
  mode: "apikeys",
74
+ maxTokens: 64000,
64
75
  };
@@ -29,12 +29,40 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
29
29
  return DEFAULT_CONFIG;
30
30
  }
31
31
 
32
- export function getDaemonBaseUrl(config: RoutstrdConfig): string {
33
- return (
34
- config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}`
32
+ /** Format a bind address for use as a URL host. */
33
+ export function urlHost(host?: string): string {
34
+ return urlHosts(host)[0] ?? "127.0.0.1";
35
+ }
36
+
37
+ /** Return connectable URL hosts for a bind address, in preference order. */
38
+ export function urlHosts(host?: string): string[] {
39
+ if (!host || host === "0.0.0.0") return ["127.0.0.1", "[::1]"];
40
+ if (host === "::") return ["[::1]", "127.0.0.1"];
41
+ if (host.startsWith("[") && host.endsWith("]")) return [host];
42
+ if (host.includes(":")) return [`[${host.replace(/%/g, "%25")}]`];
43
+ return [host];
44
+ }
45
+
46
+ function localDaemonBaseUrls(config: RoutstrdConfig): string[] {
47
+ return urlHosts(config.host).map(
48
+ (host) => `http://${host}:${config.port}`,
35
49
  );
36
50
  }
37
51
 
52
+ class DaemonConnectionError extends Error {
53
+ constructor(cause: unknown) {
54
+ super("Failed to connect to daemon", { cause });
55
+ this.name = "DaemonConnectionError";
56
+ }
57
+ }
58
+
59
+ export function getDaemonBaseUrl(config: RoutstrdConfig): string {
60
+ if (config.daemonUrl) {
61
+ return config.daemonUrl.replace(/\/$/, "");
62
+ }
63
+ return `http://${urlHost(config.host)}:${config.port}`;
64
+ }
65
+
38
66
  export function getAuthBaseUrl(config: RoutstrdConfig): string {
39
67
  if (config.authUrl) {
40
68
  return config.authUrl.replace(/\/$/, "");
@@ -67,14 +95,20 @@ async function _callUrl(
67
95
  );
68
96
  }
69
97
 
70
- const response = await fetch(url, {
71
- method,
72
- headers: {
73
- ...(authorization ? { Authorization: authorization } : {}),
74
- ...(bodyString ? { "Content-Type": "application/json" } : {}),
75
- },
76
- body: bodyString,
77
- });
98
+ const headers = new Headers();
99
+ if (authorization) headers.set("Authorization", authorization);
100
+ if (bodyString) headers.set("Content-Type", "application/json");
101
+
102
+ let response: Response;
103
+ try {
104
+ response = await fetch(url, {
105
+ method,
106
+ headers,
107
+ body: bodyString,
108
+ });
109
+ } catch (error) {
110
+ throw new DaemonConnectionError(error);
111
+ }
78
112
 
79
113
  if (!response.ok) {
80
114
  const errorData = (await response.json()) as { error?: string };
@@ -84,13 +118,33 @@ async function _callUrl(
84
118
  return response.json() as Promise<CommandResponse>;
85
119
  }
86
120
 
121
+ async function callLocalDaemon(
122
+ path: string,
123
+ options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
124
+ config: RoutstrdConfig,
125
+ ): Promise<CommandResponse> {
126
+ // Retry only connection failures; HTTP errors prove that a server answered.
127
+ let connectionError: DaemonConnectionError | undefined;
128
+ for (const baseUrl of localDaemonBaseUrls(config)) {
129
+ try {
130
+ return await _callUrl(baseUrl, path, options, config);
131
+ } catch (error) {
132
+ if (!(error instanceof DaemonConnectionError)) throw error;
133
+ connectionError = error;
134
+ }
135
+ }
136
+ throw connectionError ?? new Error("No daemon host candidates available");
137
+ }
138
+
87
139
  export async function callDaemon(
88
140
  path: string,
89
141
  options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
90
142
  ): Promise<CommandResponse> {
91
143
  const config = await loadConfig();
92
- const baseUrl = getDaemonBaseUrl(config);
93
- return _callUrl(baseUrl, path, options, config);
144
+ if (config.daemonUrl) {
145
+ return _callUrl(getDaemonBaseUrl(config), path, options, config);
146
+ }
147
+ return callLocalDaemon(path, options, config);
94
148
  }
95
149
 
96
150
  /** Like callDaemon but sends requests to the auth proxy URL instead.
@@ -100,26 +154,50 @@ export async function callAuth(
100
154
  options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
101
155
  ): Promise<CommandResponse> {
102
156
  const config = await loadConfig();
103
- const baseUrl = getAuthBaseUrl(config);
104
- return _callUrl(baseUrl, path, options, config);
157
+ if (!config.authUrl && !config.daemonUrl) {
158
+ return callLocalDaemon(path, options, config);
159
+ }
160
+ return _callUrl(getAuthBaseUrl(config), path, options, config);
105
161
  }
106
162
 
107
163
  export async function isDaemonRunning(): Promise<boolean> {
108
164
  try {
109
165
  const config = await loadConfig();
110
- const baseUrl = getDaemonBaseUrl(config);
111
- const url = `${baseUrl}/health`;
112
166
 
113
- let authorization: string | undefined;
114
- if (config.daemonUrl && config.nsec) {
115
- const secretKey = parseSecretKey(config.nsec);
116
- authorization = await createNIP98Authorization(secretKey, url, "GET");
167
+ if (config.daemonUrl) {
168
+ const baseUrl = config.daemonUrl.replace(/\/$/, "");
169
+ const url = `${baseUrl}/health`;
170
+ let authorization: string | undefined;
171
+ if (config.nsec) {
172
+ const secretKey = parseSecretKey(config.nsec);
173
+ authorization = await createNIP98Authorization(secretKey, url, "GET");
174
+ }
175
+ try {
176
+ const response = await fetch(url, {
177
+ headers: authorization ? { Authorization: authorization } : {},
178
+ });
179
+ return response.ok;
180
+ } catch {
181
+ return false;
182
+ }
117
183
  }
118
184
 
119
- const response = await fetch(url, {
120
- headers: authorization ? { Authorization: authorization } : {},
121
- });
122
- return response.ok;
185
+ // A wildcard bind may be listening on either loopback family.
186
+ for (const baseUrl of localDaemonBaseUrls(config)) {
187
+ const controller = new AbortController();
188
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
189
+ try {
190
+ const response = await fetch(`${baseUrl}/health`, {
191
+ signal: controller.signal,
192
+ });
193
+ if (response.ok) return true;
194
+ } catch {
195
+ // Try the next candidate host.
196
+ } finally {
197
+ clearTimeout(timeoutId);
198
+ }
199
+ }
200
+ return false;
123
201
  } catch {
124
202
  return false;
125
203
  }
@@ -145,6 +223,7 @@ export async function startDaemonProcess(): Promise<void> {
145
223
  const config = await loadConfig();
146
224
  await startDaemon({
147
225
  port: String(config.port || 8008),
226
+ host: config.host || undefined,
148
227
  provider: config.provider || undefined,
149
228
  });
150
229
  }
@@ -1,26 +1,37 @@
1
- import { appendFile, mkdir } from "fs/promises";
2
- import { existsSync } from "fs";
1
+ import { appendFileSync, existsSync, mkdirSync } from "fs";
3
2
  import { join } from "path";
4
3
 
5
4
  const HOME = process.env.HOME || process.env.USERPROFILE || "";
5
+
6
+ // `bun test` sets NODE_ENV=test. Test runs import modules that pull in this
7
+ // singleton, so without this guard their output would be written into the real
8
+ // ~/.routstrd log files alongside production daemon output.
9
+ const isTest = process.env.NODE_ENV === "test";
10
+
6
11
  const LOG_DIR = process.env.ROUTSTRD_DIR || `${HOME}/.routstrd`;
7
12
  const LOGS_DIR = join(LOG_DIR, "logs");
13
+ /** Wallet-engine (coco-core / Cashu) diagnostics land in their own directory. */
14
+ export const COCO_LOGS_DIR = join(LOG_DIR, "coco-logs");
8
15
 
9
- function getLogFileForDate(date: Date = new Date()): string {
16
+ function getLogFileForDate(logsDir: string, date: Date = new Date()): string {
10
17
  const year = date.getFullYear();
11
18
  const month = String(date.getMonth() + 1).padStart(2, "0");
12
19
  const day = String(date.getDate()).padStart(2, "0");
13
- return join(LOGS_DIR, `${year}-${month}-${day}.log`);
20
+ return join(logsDir, `${year}-${month}-${day}.log`);
14
21
  }
15
22
 
16
- async function ensureLogDir() {
17
- if (!existsSync(LOGS_DIR)) {
18
- await mkdir(LOGS_DIR, { recursive: true });
23
+ function ensureDir(dir: string) {
24
+ if (!existsSync(dir)) {
25
+ mkdirSync(dir, { recursive: true });
19
26
  }
20
27
  }
21
28
 
22
- async function writeLog(level: string, ...args: unknown[]) {
23
- await ensureLogDir();
29
+ // NOTE: writes are synchronous on purpose — the daemon calls process.exit()
30
+ // right after logger.error(...) on fatal paths, and async writes were being
31
+ // silently dropped, making startup failures invisible.
32
+ function writeLog(logsDir: string, level: string, ...args: unknown[]) {
33
+ if (isTest) return;
34
+ ensureDir(logsDir);
24
35
  const timestamp = new Date().toISOString();
25
36
  const message = args
26
37
  .map((a) => {
@@ -38,28 +49,31 @@ async function writeLog(level: string, ...args: unknown[]) {
38
49
  })
39
50
  .join(" ");
40
51
  const line = `[${timestamp}] [${level}] ${message}\n`;
41
- const logFile = getLogFileForDate(new Date(timestamp));
52
+ const logFile = getLogFileForDate(logsDir, new Date(timestamp));
42
53
  try {
43
- await appendFile(logFile, line);
54
+ appendFileSync(logFile, line);
44
55
  } catch (error) {
45
56
  console.error("Failed to write log:", error);
46
57
  }
47
58
  }
48
59
 
49
- export const logger = {
50
- log: (...args: unknown[]) => {
51
- writeLog("INFO", ...args);
52
- },
53
- debug: (...args: unknown[]) => {
54
- writeLog("DEBUG", ...args);
55
- },
56
- warn: (...args: unknown[]) => {
57
- writeLog("WARN", ...args);
58
- },
59
- error: (...args: unknown[]) => {
60
- writeLog("ERROR", ...args);
61
- },
62
- info: (...args: unknown[]) => {
63
- writeLog("INFO", ...args);
64
- },
65
- };
60
+ export interface FileLogger {
61
+ log: (...args: unknown[]) => void;
62
+ debug: (...args: unknown[]) => void;
63
+ warn: (...args: unknown[]) => void;
64
+ error: (...args: unknown[]) => void;
65
+ info: (...args: unknown[]) => void;
66
+ }
67
+
68
+ function createLogger(logsDir: string): FileLogger {
69
+ return {
70
+ log: (...args) => writeLog(logsDir, "INFO", ...args),
71
+ debug: (...args) => writeLog(logsDir, "DEBUG", ...args),
72
+ warn: (...args) => writeLog(logsDir, "WARN", ...args),
73
+ error: (...args) => writeLog(logsDir, "ERROR", ...args),
74
+ info: (...args) => writeLog(logsDir, "INFO", ...args),
75
+ };
76
+ }
77
+
78
+ export const logger = createLogger(LOGS_DIR);
79
+ export const cocoLogger = createLogger(COCO_LOGS_DIR);