routstrd 0.3.10 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/IMPLEMENTATION.md +253 -0
- package/README.md +45 -0
- package/SECURITY.md +23 -0
- package/SKILL.md +13 -3
- package/bun.lock +68 -41
- package/dist/daemon/index.js +47850 -11349
- package/dist/index.js +4730 -448
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +502 -229
- package/src/daemon/args.ts +8 -1
- package/src/daemon/models.ts +119 -39
- package/src/daemon/wallet/coco-client.npc.test.ts +252 -0
- package/src/daemon/wallet/coco-client.test.ts +508 -0
- package/src/daemon/wallet/coco-client.ts +835 -0
- package/src/daemon/wallet/cocod-client.ts +86 -2
- package/src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz +0 -0
- package/src/daemon/wallet/migration.test.ts +147 -0
- package/src/daemon/wallet/migration.ts +209 -0
- package/src/daemon/wallet/paths.ts +33 -0
- package/src/integrations/claudecode.ts +2 -1
- package/src/integrations/hermes.ts +46 -6
- package/src/integrations/openclaw.ts +3 -2
- package/src/integrations/opencode.ts +3 -2
- package/src/integrations/pi.ts +3 -2
- package/src/integrations/registry.ts +7 -5
- package/src/start-daemon.ts +168 -22
- package/src/tui/usage/app.ts +41 -2
- package/src/tui/usage/render.ts +10 -3
- package/src/tui/usage/types.ts +6 -0
- package/src/utils/clients.ts +21 -0
- package/src/utils/config.ts +11 -0
- package/src/utils/daemon-client.ts +104 -25
- package/src/utils/logger.ts +42 -28
- package/src/utils/update-checker.ts +114 -0
- package/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
|
@@ -6,6 +6,8 @@ import { installOpenClawIntegration } from "./openclaw";
|
|
|
6
6
|
import { installClaudeCodeIntegration } from "./claudecode";
|
|
7
7
|
import { installHermesIntegration } from "./hermes";
|
|
8
8
|
|
|
9
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
10
|
+
|
|
9
11
|
export interface IntegrationConfig {
|
|
10
12
|
clientId: string;
|
|
11
13
|
name: string;
|
|
@@ -28,27 +30,27 @@ export const CLIENT_CONFIGS: Record<string, IntegrationConfig> = {
|
|
|
28
30
|
opencode: {
|
|
29
31
|
clientId: "opencode",
|
|
30
32
|
name: "OpenCode",
|
|
31
|
-
configPath: join(
|
|
33
|
+
configPath: join(HOME, ".config/opencode/opencode.json"),
|
|
32
34
|
},
|
|
33
35
|
"pi-agent": {
|
|
34
36
|
clientId: "pi-agent",
|
|
35
37
|
name: "Pi Agent",
|
|
36
|
-
configPath: join(
|
|
38
|
+
configPath: join(HOME, ".pi/agent/models.json"),
|
|
37
39
|
},
|
|
38
40
|
openclaw: {
|
|
39
41
|
clientId: "openclaw",
|
|
40
42
|
name: "OpenClaw",
|
|
41
|
-
configPath: join(
|
|
43
|
+
configPath: join(HOME, ".openclaw/openclaw.json"),
|
|
42
44
|
},
|
|
43
45
|
"claude-code": {
|
|
44
46
|
clientId: "claude-code",
|
|
45
47
|
name: "Claude Code",
|
|
46
|
-
configPath: join(
|
|
48
|
+
configPath: join(HOME, ".claude/settings.json"),
|
|
47
49
|
},
|
|
48
50
|
hermes: {
|
|
49
51
|
clientId: "hermes",
|
|
50
52
|
name: "Hermes",
|
|
51
|
-
configPath: join(
|
|
53
|
+
configPath: join(HOME, ".hermes/config.yaml"),
|
|
52
54
|
},
|
|
53
55
|
};
|
|
54
56
|
|
package/src/start-daemon.ts
CHANGED
|
@@ -1,52 +1,167 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
return
|
|
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
|
|
40
|
+
return "";
|
|
19
41
|
} finally {
|
|
20
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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(["
|
|
164
|
+
const proc = Bun.spawn(["bun", daemonScript, ...args], {
|
|
50
165
|
stdout: debugLogFd,
|
|
51
166
|
stderr: debugLogFd,
|
|
52
167
|
stdin: "ignore",
|
|
@@ -60,18 +175,47 @@ async function startDaemonUnlocked(
|
|
|
60
175
|
exitCode = code;
|
|
61
176
|
});
|
|
62
177
|
|
|
178
|
+
const startedAt = Date.now();
|
|
179
|
+
const heartbeatIntervalMs = 10_000;
|
|
180
|
+
let nextHeartbeatAt = heartbeatIntervalMs;
|
|
181
|
+
let progressLogOffset = debugLogOffset;
|
|
182
|
+
let currentPhase = "starting daemon";
|
|
183
|
+
|
|
63
184
|
const maxPolls = Math.ceil(startupTimeoutMs / pollIntervalMs);
|
|
64
185
|
for (let i = 0; i < maxPolls; i++) {
|
|
65
186
|
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
66
187
|
|
|
188
|
+
const progress = printStartupProgress(progressLogOffset);
|
|
189
|
+
progressLogOffset = progress.offset;
|
|
190
|
+
if (progress.lastMessage) {
|
|
191
|
+
currentPhase = progress.lastMessage
|
|
192
|
+
.replace(/\.$/, "")
|
|
193
|
+
.toLowerCase();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const elapsedMs = Date.now() - startedAt;
|
|
197
|
+
if (elapsedMs >= nextHeartbeatAt) {
|
|
198
|
+
console.log(
|
|
199
|
+
` Still ${currentPhase} (${formatElapsed(elapsedMs)} elapsed)...`,
|
|
200
|
+
);
|
|
201
|
+
nextHeartbeatAt += heartbeatIntervalMs;
|
|
202
|
+
}
|
|
203
|
+
|
|
67
204
|
if (exitCode !== null) {
|
|
205
|
+
const daemonOutput = readDaemonOutput(debugLogOffset);
|
|
68
206
|
throw new Error(
|
|
69
|
-
`Daemon process exited early with code ${exitCode}
|
|
207
|
+
`Daemon process exited early with code ${exitCode}.` +
|
|
208
|
+
(daemonOutput
|
|
209
|
+
? `\n\nDaemon output:\n${daemonOutput}`
|
|
210
|
+
: ` Check logs in ${LOGS_DIR}`),
|
|
70
211
|
);
|
|
71
212
|
}
|
|
72
213
|
|
|
73
|
-
if (await
|
|
74
|
-
|
|
214
|
+
if (await healthyDaemonHost(port, host)) {
|
|
215
|
+
printStartupProgress(progressLogOffset);
|
|
216
|
+
console.log(
|
|
217
|
+
`Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`,
|
|
218
|
+
);
|
|
75
219
|
return;
|
|
76
220
|
}
|
|
77
221
|
}
|
|
@@ -82,13 +226,15 @@ async function startDaemonUnlocked(
|
|
|
82
226
|
}
|
|
83
227
|
|
|
84
228
|
export async function startDaemon(
|
|
85
|
-
options: { port?: string; provider?: string } = {},
|
|
229
|
+
options: { port?: string; host?: string; provider?: string } = {},
|
|
86
230
|
): Promise<void> {
|
|
87
231
|
const port = options.port || "8008";
|
|
232
|
+
const host = options.host || "127.0.0.1";
|
|
88
233
|
const startupTimeoutMs = 10 * 60 * 1000;
|
|
89
234
|
|
|
90
|
-
|
|
91
|
-
|
|
235
|
+
const existingHost = await healthyDaemonHost(port, host);
|
|
236
|
+
if (existingHost) {
|
|
237
|
+
console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`);
|
|
92
238
|
return;
|
|
93
239
|
}
|
|
94
240
|
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
} from "./terminal.ts";
|
|
29
29
|
import { COLORS } from "./constants.ts";
|
|
30
30
|
import { renderHeader, renderSearchBar, renderSeparator, renderTabContent, renderTabs } from "./render.ts";
|
|
31
|
-
import type { TabId, UsageStats } from "./types.ts";
|
|
31
|
+
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
|
|
32
|
+
import { checkForUpdates } from "../../utils/update-checker.ts";
|
|
32
33
|
|
|
33
34
|
export async function runUsageTui(): Promise<void> {
|
|
34
35
|
const running = await isDaemonRunning();
|
|
@@ -53,6 +54,39 @@ export async function runUsageTui(): Promise<void> {
|
|
|
53
54
|
let cleanedUp = false;
|
|
54
55
|
let fetching = false;
|
|
55
56
|
|
|
57
|
+
// Update-check state — re-checked at most every 210 minutes to avoid
|
|
58
|
+
// spamming the npm registry. The first check happens shortly after
|
|
59
|
+
// startup so the TUI doesn't block on a network call.
|
|
60
|
+
const UPDATE_CHECK_INTERVAL_MS = 210 * 60 * 1000;
|
|
61
|
+
const UPDATE_CHECK_DELAY_MS = 3000;
|
|
62
|
+
let updateInfo: UpdateInfo | null = null;
|
|
63
|
+
let lastUpdateCheck = 0;
|
|
64
|
+
let updateCheckInProgress = false;
|
|
65
|
+
|
|
66
|
+
async function maybeCheckForUpdates(): Promise<void> {
|
|
67
|
+
if (updateCheckInProgress) return;
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (updateInfo && now - lastUpdateCheck < UPDATE_CHECK_INTERVAL_MS) return;
|
|
70
|
+
|
|
71
|
+
updateCheckInProgress = true;
|
|
72
|
+
lastUpdateCheck = now;
|
|
73
|
+
try {
|
|
74
|
+
const result = await checkForUpdates();
|
|
75
|
+
const outdated = result.packages.filter((p) => p.hasUpdate);
|
|
76
|
+
updateInfo = {
|
|
77
|
+
hasUpdate: result.hasUpdate,
|
|
78
|
+
text: outdated.length > 0
|
|
79
|
+
? `Update available: ${outdated.map((p) => p.label).join(", ")} ${outdated.map((p) => p.latest).join(", ")}`
|
|
80
|
+
: "",
|
|
81
|
+
};
|
|
82
|
+
render();
|
|
83
|
+
} catch {
|
|
84
|
+
// Silently ignore — update checks are best-effort
|
|
85
|
+
} finally {
|
|
86
|
+
updateCheckInProgress = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
56
90
|
if (isInteractive) {
|
|
57
91
|
stdout.write(enterAlternateScreen() + hideCursor());
|
|
58
92
|
stdin.setRawMode?.(true);
|
|
@@ -138,7 +172,7 @@ export async function runUsageTui(): Promise<void> {
|
|
|
138
172
|
|
|
139
173
|
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
|
140
174
|
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
|
|
141
|
-
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
175
|
+
const chrome = renderHeader(currentTab, width, visibleTabs, updateInfo ?? undefined) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
142
176
|
const chromeLines = chrome.split("\n").length - 1;
|
|
143
177
|
const footerSeparator = renderSeparator(width);
|
|
144
178
|
const footerLines = footerSeparator.split("\n").length - 1;
|
|
@@ -265,9 +299,14 @@ export async function runUsageTui(): Promise<void> {
|
|
|
265
299
|
|
|
266
300
|
await fetchData();
|
|
267
301
|
|
|
302
|
+
// Kick off the first update check after a short delay so the initial
|
|
303
|
+
// data fetch isn't blocked by a network round-trip to npm.
|
|
304
|
+
setTimeout(() => void maybeCheckForUpdates(), UPDATE_CHECK_DELAY_MS);
|
|
305
|
+
|
|
268
306
|
refreshInterval = setInterval(() => {
|
|
269
307
|
if (autoRefresh) {
|
|
270
308
|
void fetchData();
|
|
271
309
|
}
|
|
310
|
+
void maybeCheckForUpdates();
|
|
272
311
|
}, 2000);
|
|
273
312
|
}
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { vimState } from "./state.ts";
|
|
9
9
|
import { stripAnsi } from "./terminal.ts";
|
|
10
10
|
import type { BalanceInfo, StatusInfo } from "./data.ts";
|
|
11
|
-
import type { TabId, UsageStats } from "./types.ts";
|
|
11
|
+
import type { TabId, UpdateInfo, UsageStats } from "./types.ts";
|
|
12
12
|
|
|
13
13
|
/** Format a cost value: 0.12, 1.23, 12.34, 123.45, 1.23k, 1.23m */
|
|
14
14
|
function formatCost(value: number): string {
|
|
@@ -24,13 +24,20 @@ function formatReqs(value: number): string {
|
|
|
24
24
|
return value.toString();
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[]): string {
|
|
27
|
+
export function renderHeader(activeTab: TabId, width: number, visibleTabs: Tab[], updateInfo?: UpdateInfo): string {
|
|
28
28
|
const title = `${COLORS.bold}${COLORS.cyan}ROUTSTRD USAGE MONITOR${COLORS.reset}`;
|
|
29
29
|
const vimIndicator = `${COLORS.yellow}[vim]${COLORS.reset}`;
|
|
30
30
|
const maxKey = visibleTabs.length;
|
|
31
31
|
const help = `${COLORS.dim}[Q] Quit [↑↓] Scroll [←→] Tabs [1-${maxKey}] Tabs [R] Refresh${COLORS.reset}`;
|
|
32
32
|
const fill = width - title.length - help.length - vimIndicator.length - 6;
|
|
33
|
-
|
|
33
|
+
const headerLine = `${title}${vimIndicator}${" ".repeat(Math.max(1, fill))}${help}`;
|
|
34
|
+
|
|
35
|
+
if (updateInfo?.hasUpdate) {
|
|
36
|
+
const banner = `${COLORS.bold}${COLORS.yellow} ↻ UPDATE AVAILABLE — run ${COLORS.green}routstrd update${COLORS.yellow} to update${COLORS.reset}`;
|
|
37
|
+
return `${headerLine}\n${banner}\n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${headerLine}\n`;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function renderSearchBar(): string {
|
package/src/tui/usage/types.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
|
2
2
|
import type { UsageSummary } from "../../daemon/http/usage-summary.ts";
|
|
3
|
+
import type { UpdateCheckResult } from "../../utils/update-checker.ts";
|
|
3
4
|
|
|
4
5
|
export type { UsageSummary };
|
|
5
6
|
|
|
7
|
+
export interface UpdateInfo {
|
|
8
|
+
hasUpdate: boolean;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export interface UsageStats {
|
|
7
13
|
entries: UsageTrackingEntry[];
|
|
8
14
|
totalEntries: number;
|
package/src/utils/clients.ts
CHANGED
|
@@ -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
|
|
package/src/utils/config.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
authorization
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
}
|