routstrd 0.4.1 → 0.4.2
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/bun.lock +316 -2
- package/dist/daemon/index.js +69744 -54589
- package/dist/index.js +183 -85
- package/package.json +2 -2
- package/src/cli.ts +98 -12
- package/src/daemon/config-store.ts +79 -4
- package/src/daemon/models.test.ts +81 -0
- package/src/daemon/models.ts +18 -2
- package/src/integrations/pi.ts +17 -1
- package/src/tui/usage/data.ts +13 -2
- package/src/tui/usage/render.ts +8 -0
- package/src/utils/config.ts +3 -1
- package/tests/daemon/config-store.perms.test.ts +55 -0
- package/tests/daemon/config-store.scenario.ts +113 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "routstrd",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"module": "src/index.ts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@cashu/cashu-ts": "^4.3.0",
|
|
27
27
|
"@cashu/coco-core": "^1.0.1",
|
|
28
28
|
"@cashu/coco-sqlite-bun": "^1.0.1",
|
|
29
|
-
"@routstr/sdk": "^0.4.
|
|
29
|
+
"@routstr/sdk": "^0.4.1",
|
|
30
30
|
"@scure/bip39": "^2.2.0",
|
|
31
31
|
"applesauce-core": "^5.1.0",
|
|
32
32
|
"applesauce-relay": "^5.1.0",
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { program } from "commander";
|
|
2
2
|
import { startDaemon } from "./start-daemon";
|
|
3
|
+
import { ensureDirsSync, saveDaemonConfig } from "./daemon/config-store";
|
|
3
4
|
import {
|
|
4
5
|
handleDaemonCommand,
|
|
5
6
|
callDaemon,
|
|
@@ -217,19 +218,19 @@ async function requireLocalDaemon(): Promise<void> {
|
|
|
217
218
|
async function initDaemon(): Promise<void> {
|
|
218
219
|
console.log("Initializing routstrd...");
|
|
219
220
|
|
|
220
|
-
// Create config directory
|
|
221
|
+
// Create config directory (0700, correcting existing installs too)
|
|
221
222
|
if (!existsSync(CONFIG_DIR)) {
|
|
222
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
223
223
|
console.log(`Created config directory: ${CONFIG_DIR}`);
|
|
224
224
|
}
|
|
225
|
+
ensureDirsSync();
|
|
225
226
|
|
|
226
|
-
// Create initial config
|
|
227
|
+
// Create initial config (0600, atomic write)
|
|
227
228
|
if (!existsSync(CONFIG_FILE)) {
|
|
228
229
|
const config: RoutstrdConfig = {
|
|
229
230
|
...DEFAULT_CONFIG,
|
|
230
231
|
cocodPath: null,
|
|
231
232
|
};
|
|
232
|
-
|
|
233
|
+
saveDaemonConfig(config);
|
|
233
234
|
console.log(`Created config file: ${CONFIG_FILE}`);
|
|
234
235
|
}
|
|
235
236
|
|
|
@@ -240,7 +241,7 @@ async function initDaemon(): Promise<void> {
|
|
|
240
241
|
const nsec = nip19.nsecEncode(secretKey);
|
|
241
242
|
const npub = npubFromSecretKey(secretKey);
|
|
242
243
|
config.nsec = nsec;
|
|
243
|
-
|
|
244
|
+
saveDaemonConfig(config);
|
|
244
245
|
console.log("\nA new Nostr identity has been generated for authentication.");
|
|
245
246
|
console.log(`Your npub: ${npub}`);
|
|
246
247
|
console.log(`You can view it in the config file at: ${CONFIG_FILE}\n`);
|
|
@@ -295,10 +296,7 @@ program
|
|
|
295
296
|
.command("update")
|
|
296
297
|
.description("Update routstrd to the latest version")
|
|
297
298
|
.action(async () => {
|
|
298
|
-
const packages = [
|
|
299
|
-
{ name: "routstrd", label: "routstrd" },
|
|
300
|
-
{ name: "@routstr/cocod", label: "cocod" },
|
|
301
|
-
];
|
|
299
|
+
const packages = [{ name: "routstrd", label: "routstrd" }];
|
|
302
300
|
|
|
303
301
|
let updatedAny = false;
|
|
304
302
|
|
|
@@ -520,7 +518,7 @@ program
|
|
|
520
518
|
...updates,
|
|
521
519
|
};
|
|
522
520
|
|
|
523
|
-
|
|
521
|
+
saveDaemonConfig(updatedConfig);
|
|
524
522
|
|
|
525
523
|
console.log(`Remote daemon URL set to: ${url}`);
|
|
526
524
|
if (options.authUrl) {
|
|
@@ -555,7 +553,7 @@ program
|
|
|
555
553
|
const { daemonUrl: _daemonUrl, authUrl: _authUrl, ...rest } = config;
|
|
556
554
|
const updatedConfig: RoutstrdConfig = rest;
|
|
557
555
|
|
|
558
|
-
|
|
556
|
+
saveDaemonConfig(updatedConfig);
|
|
559
557
|
|
|
560
558
|
console.log("Switched back to local daemon mode.");
|
|
561
559
|
if (previousDaemonUrl) {
|
|
@@ -1119,6 +1117,94 @@ providersCmd
|
|
|
1119
1117
|
}
|
|
1120
1118
|
});
|
|
1121
1119
|
|
|
1120
|
+
providersCmd
|
|
1121
|
+
.command("reviews")
|
|
1122
|
+
.description(
|
|
1123
|
+
"Show all known providers with their stored review events and event IDs",
|
|
1124
|
+
)
|
|
1125
|
+
.action(async () => {
|
|
1126
|
+
await ensureDaemonRunning();
|
|
1127
|
+
|
|
1128
|
+
const result = await callDaemon("/providers/reviews");
|
|
1129
|
+
if (result.error) {
|
|
1130
|
+
console.log(result.error);
|
|
1131
|
+
process.exit(1);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const output = result.output as
|
|
1135
|
+
| {
|
|
1136
|
+
providers: Array<{
|
|
1137
|
+
index: number;
|
|
1138
|
+
baseUrl: string;
|
|
1139
|
+
disabled: boolean;
|
|
1140
|
+
nodePubkeys: string[];
|
|
1141
|
+
reviewCount: number;
|
|
1142
|
+
reviews: Array<{
|
|
1143
|
+
eventId: string;
|
|
1144
|
+
createdAt: number;
|
|
1145
|
+
authorPubkey: string | null;
|
|
1146
|
+
nodePubkey: string | null;
|
|
1147
|
+
label: string;
|
|
1148
|
+
isLgtm: boolean;
|
|
1149
|
+
tags: string[][];
|
|
1150
|
+
}>;
|
|
1151
|
+
}>;
|
|
1152
|
+
unmatchedReviews: Array<{
|
|
1153
|
+
eventId: string;
|
|
1154
|
+
createdAt: number;
|
|
1155
|
+
authorPubkey: string | null;
|
|
1156
|
+
nodePubkey: string | null;
|
|
1157
|
+
label: string;
|
|
1158
|
+
isLgtm: boolean;
|
|
1159
|
+
tags: string[][];
|
|
1160
|
+
}>;
|
|
1161
|
+
totalCount: number;
|
|
1162
|
+
reviewEventCount: number;
|
|
1163
|
+
}
|
|
1164
|
+
| undefined;
|
|
1165
|
+
|
|
1166
|
+
if (!output) {
|
|
1167
|
+
console.log("No provider review data available.");
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
console.log(
|
|
1172
|
+
`Providers (${output.totalCount} total, ${output.reviewEventCount} stored review events):\n`,
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
for (const provider of output.providers) {
|
|
1176
|
+
const status = provider.disabled ? "DISABLED" : "enabled ";
|
|
1177
|
+
console.log(
|
|
1178
|
+
` [${provider.index}] ${status} ${provider.baseUrl} (${provider.reviewCount} review${provider.reviewCount === 1 ? "" : "s"})`,
|
|
1179
|
+
);
|
|
1180
|
+
|
|
1181
|
+
if (provider.nodePubkeys.length > 0) {
|
|
1182
|
+
console.log(` node pubkeys: ${provider.nodePubkeys.join(", ")}`);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
for (const review of provider.reviews) {
|
|
1186
|
+
const label = review.label || "review";
|
|
1187
|
+
const created = new Date(review.createdAt * 1000).toISOString();
|
|
1188
|
+
console.log(` - ${label} eventId: ${review.eventId}`);
|
|
1189
|
+
console.log(` node: ${review.nodePubkey ?? "(none)"}`);
|
|
1190
|
+
console.log(` author: ${review.authorPubkey ?? "(none)"}`);
|
|
1191
|
+
console.log(` at: ${created}`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if (output.unmatchedReviews.length > 0) {
|
|
1196
|
+
console.log(
|
|
1197
|
+
`\n Unmatched review events (${output.unmatchedReviews.length} — node pubkey not in any known provider):`,
|
|
1198
|
+
);
|
|
1199
|
+
for (const review of output.unmatchedReviews) {
|
|
1200
|
+
const created = new Date(review.createdAt * 1000).toISOString();
|
|
1201
|
+
console.log(` - eventId: ${review.eventId}`);
|
|
1202
|
+
console.log(` node: ${review.nodePubkey ?? "(none)"}`);
|
|
1203
|
+
console.log(` at: ${created}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1122
1208
|
// Clients - list and manage clients
|
|
1123
1209
|
const clientsCmd = program
|
|
1124
1210
|
.command("clients")
|
|
@@ -2142,7 +2228,7 @@ program
|
|
|
2142
2228
|
...config,
|
|
2143
2229
|
mode: selectedMode,
|
|
2144
2230
|
};
|
|
2145
|
-
|
|
2231
|
+
saveDaemonConfig(updatedConfig);
|
|
2146
2232
|
console.log(`Mode set to '${selectedMode}'. Restarting daemon...`);
|
|
2147
2233
|
|
|
2148
2234
|
// Restart daemon
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { mkdir } from "fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "fs";
|
|
3
11
|
import {
|
|
4
12
|
CONFIG_DIR,
|
|
5
13
|
CONFIG_FILE,
|
|
@@ -10,18 +18,61 @@ import { logger } from "../utils/logger";
|
|
|
10
18
|
|
|
11
19
|
export const REQUESTS_DIR = `${CONFIG_DIR}/requests`;
|
|
12
20
|
|
|
21
|
+
// The daemon config holds spend-capable credentials (the operator `nsec` and
|
|
22
|
+
// the NWC connection string), so the directory and file permissions must match
|
|
23
|
+
// the wallet seed handling (0700/0600), not the umask default (0755/0644).
|
|
24
|
+
|
|
25
|
+
function chmodIgnoreErrors(path: string, mode: number): void {
|
|
26
|
+
try {
|
|
27
|
+
chmodSync(path, mode);
|
|
28
|
+
} catch {
|
|
29
|
+
// Best effort on filesystems that do not support POSIX modes.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Create the config directories (0700), correcting existing installs too. */
|
|
34
|
+
export function ensureDirsSync(): void {
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
37
|
+
} catch {
|
|
38
|
+
// Directory may already exist
|
|
39
|
+
}
|
|
40
|
+
chmodSync(CONFIG_DIR, 0o700);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
mkdirSync(REQUESTS_DIR, { recursive: true, mode: 0o700 });
|
|
44
|
+
} catch {
|
|
45
|
+
// Directory may already exist
|
|
46
|
+
}
|
|
47
|
+
chmodSync(REQUESTS_DIR, 0o700);
|
|
48
|
+
}
|
|
49
|
+
|
|
13
50
|
export async function ensureDirs(): Promise<void> {
|
|
14
51
|
try {
|
|
15
|
-
await mkdir(CONFIG_DIR, { recursive: true });
|
|
16
|
-
|
|
52
|
+
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
53
|
+
} catch {
|
|
54
|
+
// Directory may already exist
|
|
55
|
+
}
|
|
56
|
+
chmodSync(CONFIG_DIR, 0o700);
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(REQUESTS_DIR, { recursive: true, mode: 0o700 });
|
|
17
60
|
} catch {
|
|
18
61
|
// Directory may already exist
|
|
19
62
|
}
|
|
63
|
+
chmodSync(REQUESTS_DIR, 0o700);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function repairConfigPermissions(): void {
|
|
67
|
+
if (existsSync(CONFIG_FILE)) {
|
|
68
|
+
chmodIgnoreErrors(CONFIG_FILE, 0o600);
|
|
69
|
+
}
|
|
20
70
|
}
|
|
21
71
|
|
|
22
72
|
export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
|
|
23
73
|
try {
|
|
24
74
|
if (existsSync(CONFIG_FILE)) {
|
|
75
|
+
repairConfigPermissions();
|
|
25
76
|
const content = await Bun.file(CONFIG_FILE).text();
|
|
26
77
|
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
27
78
|
}
|
|
@@ -34,6 +85,7 @@ export async function loadDaemonConfig(): Promise<RoutstrdConfig> {
|
|
|
34
85
|
export function loadDaemonConfigSync(): RoutstrdConfig {
|
|
35
86
|
try {
|
|
36
87
|
if (existsSync(CONFIG_FILE)) {
|
|
88
|
+
repairConfigPermissions();
|
|
37
89
|
const content = readFileSync(CONFIG_FILE, "utf-8");
|
|
38
90
|
return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
|
|
39
91
|
}
|
|
@@ -43,6 +95,29 @@ export function loadDaemonConfigSync(): RoutstrdConfig {
|
|
|
43
95
|
return DEFAULT_CONFIG;
|
|
44
96
|
}
|
|
45
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Persist the daemon config atomically with owner-only permissions, mirroring
|
|
100
|
+
* the wallet config writer (saveConfig in wallet/coco-client.ts): write a
|
|
101
|
+
* 0600 temp file, then rename over the target. Synchronous so callers get
|
|
102
|
+
* error propagation instead of silently dropping credential updates.
|
|
103
|
+
*/
|
|
46
104
|
export function saveDaemonConfig(config: RoutstrdConfig): void {
|
|
47
|
-
|
|
105
|
+
ensureDirsSync();
|
|
106
|
+
const temporaryFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
|
107
|
+
try {
|
|
108
|
+
writeFileSync(temporaryFile, JSON.stringify(config, null, 2), {
|
|
109
|
+
mode: 0o600,
|
|
110
|
+
});
|
|
111
|
+
renameSync(temporaryFile, CONFIG_FILE);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(temporaryFile);
|
|
115
|
+
} catch {
|
|
116
|
+
// The temporary file may not have been created.
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
// renameSync preserves the temp file's mode, but chmod anyway so an
|
|
121
|
+
// existing over-permissive file is corrected even on exotic filesystems.
|
|
122
|
+
chmodIgnoreErrors(CONFIG_FILE, 0o600);
|
|
48
123
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import type { SdkStore } from "@routstr/sdk";
|
|
3
|
+
import { createModelService } from "./models";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `ensureProvidersBootstrapped` must mirror the review-disabled provider set
|
|
7
|
+
* (kind 38425) into the SdkStore. The SDK applies review disables to the
|
|
8
|
+
* discovery adapter during bootstrap, but `providers list` and per-model
|
|
9
|
+
* provider views read the store — if the store is not kept in sync, a fresh
|
|
10
|
+
* install reports "0 disabled" while routing silently excludes them.
|
|
11
|
+
*/
|
|
12
|
+
describe("createModelService.ensureProvidersBootstrapped", () => {
|
|
13
|
+
function makeStore(initialBaseUrls: string[] = []) {
|
|
14
|
+
const disabledCalls: string[][] = [];
|
|
15
|
+
const state: Record<string, unknown> = {
|
|
16
|
+
baseUrlsList: initialBaseUrls,
|
|
17
|
+
setBaseUrlsList: (urls: string[]) => {
|
|
18
|
+
state.baseUrlsList = urls;
|
|
19
|
+
},
|
|
20
|
+
disabledProviders: [],
|
|
21
|
+
setDisabledProviders: (urls: string[]) => {
|
|
22
|
+
state.disabledProviders = urls;
|
|
23
|
+
disabledCalls.push(urls);
|
|
24
|
+
},
|
|
25
|
+
manuallyDisabledProviders: [],
|
|
26
|
+
manuallyEnabledProviders: [],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
store: { getState: () => state } as unknown as SdkStore,
|
|
31
|
+
state,
|
|
32
|
+
disabledCalls,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function makeModelManager(
|
|
37
|
+
providers: string[],
|
|
38
|
+
reviewedDisabled: string[] | null,
|
|
39
|
+
) {
|
|
40
|
+
return {
|
|
41
|
+
bootstrapProviders: async () => providers,
|
|
42
|
+
syncReviewedProvidersFromNostr: async () => reviewedDisabled,
|
|
43
|
+
} as never;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it("mirrors review-disabled providers into the store after bootstrap", async () => {
|
|
47
|
+
const { store, state, disabledCalls } = makeStore();
|
|
48
|
+
const modelManager = makeModelManager(
|
|
49
|
+
["https://ok.example/", "https://bad.example/"],
|
|
50
|
+
["https://bad.example/"],
|
|
51
|
+
);
|
|
52
|
+
const service = createModelService(
|
|
53
|
+
modelManager as never,
|
|
54
|
+
{} as never,
|
|
55
|
+
store,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await service.ensureProvidersBootstrapped();
|
|
59
|
+
|
|
60
|
+
expect(disabledCalls).toEqual([["https://bad.example/"]]);
|
|
61
|
+
expect(state.disabledProviders).toEqual(["https://bad.example/"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("leaves the store unchanged when review sync reports no change (null)", async () => {
|
|
65
|
+
const { store, state, disabledCalls } = makeStore(["https://ok.example/"]);
|
|
66
|
+
const modelManager = makeModelManager(
|
|
67
|
+
["https://ok.example/", "https://new.example/"],
|
|
68
|
+
null,
|
|
69
|
+
);
|
|
70
|
+
const service = createModelService(
|
|
71
|
+
modelManager as never,
|
|
72
|
+
{} as never,
|
|
73
|
+
store,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await service.ensureProvidersBootstrapped();
|
|
77
|
+
|
|
78
|
+
expect(disabledCalls).toEqual([]);
|
|
79
|
+
expect(state.disabledProviders).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/daemon/models.ts
CHANGED
|
@@ -89,7 +89,11 @@ export function createModelService(
|
|
|
89
89
|
|
|
90
90
|
// Sync discovered providers into the store so `providers list` reflects
|
|
91
91
|
// the same set that the model manager knows about.
|
|
92
|
-
const {
|
|
92
|
+
const {
|
|
93
|
+
baseUrlsList,
|
|
94
|
+
setBaseUrlsList,
|
|
95
|
+
setDisabledProviders,
|
|
96
|
+
} = store.getState();
|
|
93
97
|
const existing = new Set(baseUrlsList);
|
|
94
98
|
const merged = [
|
|
95
99
|
...baseUrlsList,
|
|
@@ -102,6 +106,18 @@ export function createModelService(
|
|
|
102
106
|
);
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
// Mirror the review-disabled set (kind 38425) into the store. The SDK
|
|
110
|
+
// applies review disables to the discovery adapter during bootstrap,
|
|
111
|
+
// but `providers list` and the per-model provider views read the store,
|
|
112
|
+
// so without this a fresh install reports "0 disabled" while routing
|
|
113
|
+
// silently excludes the review-disabled providers.
|
|
114
|
+
const reviewedDisabled = await modelManager.syncReviewedProvidersFromNostr(
|
|
115
|
+
providers,
|
|
116
|
+
);
|
|
117
|
+
if (reviewedDisabled !== null) {
|
|
118
|
+
setDisabledProviders(reviewedDisabled);
|
|
119
|
+
}
|
|
120
|
+
|
|
105
121
|
logger.log("Provider bootstrap complete.");
|
|
106
122
|
})().catch((error) => {
|
|
107
123
|
providerBootstrapPromise = null;
|
|
@@ -117,7 +133,7 @@ export function createModelService(
|
|
|
117
133
|
): Promise<ExposedModel[]> => {
|
|
118
134
|
const routstr21ModelIds = Array.from(
|
|
119
135
|
new Set(await modelManager.fetchRoutstr21Models(forceRefresh)),
|
|
120
|
-
)
|
|
136
|
+
);
|
|
121
137
|
|
|
122
138
|
let discoveredModels: ExposedModel[];
|
|
123
139
|
if (!forceRefresh) {
|
package/src/integrations/pi.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { callDaemon, getDaemonBaseUrl } from "../utils/daemon-client";
|
|
|
8
8
|
type PiModelEntry = {
|
|
9
9
|
id: string;
|
|
10
10
|
contextWindow?: number;
|
|
11
|
+
// Preserve arbitrary manual fields (reasoning, thinkingLevelMap, compat, cost, maxTokens, etc.)
|
|
12
|
+
[key: string]: unknown;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
type PiProviderConfig = {
|
|
@@ -61,15 +63,29 @@ export async function installPiIntegration(
|
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
// Preserve any manually-added per-model fields (reasoning, thinkingLevelMap,
|
|
67
|
+
// compat, cost, maxTokens, etc.) across refreshes. Only `id` and `contextWindow`
|
|
68
|
+
// are managed by routstrd.
|
|
69
|
+
const existingModels = new Map<string, PiModelEntry>(
|
|
70
|
+
(piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]),
|
|
71
|
+
);
|
|
72
|
+
|
|
64
73
|
const providerModels: PiModelEntry[] = models.map((model) => {
|
|
65
|
-
const
|
|
74
|
+
const previous = existingModels.get(model.id);
|
|
75
|
+
const entry: PiModelEntry = previous ? { ...previous } : { id: model.id };
|
|
76
|
+
|
|
77
|
+
entry.id = model.id;
|
|
66
78
|
if (model.context_length !== undefined && model.context_length > 0) {
|
|
67
79
|
entry.contextWindow = model.context_length;
|
|
68
80
|
}
|
|
81
|
+
|
|
69
82
|
return entry;
|
|
70
83
|
});
|
|
71
84
|
|
|
85
|
+
// Preserve provider-level manual fields (compat, headers, name, etc.); only update
|
|
86
|
+
// the routstrd-managed fields below.
|
|
72
87
|
piConfig.providers["routstr"] = {
|
|
88
|
+
...(piConfig.providers["routstr"] ?? {}),
|
|
73
89
|
baseUrl,
|
|
74
90
|
api: "openai-completions",
|
|
75
91
|
apiKey,
|
package/src/tui/usage/data.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { UsageTrackingEntry } from "../../daemon/types.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
callDaemon,
|
|
4
|
+
getDaemonBaseUrl,
|
|
5
|
+
isDaemonRunning,
|
|
6
|
+
loadConfig,
|
|
7
|
+
} from "../../utils/daemon-client.ts";
|
|
3
8
|
import type { UsageStats, UsageSummary } from "./types.ts";
|
|
4
9
|
|
|
5
10
|
export { isDaemonRunning };
|
|
@@ -21,12 +26,17 @@ export interface StatusInfo {
|
|
|
21
26
|
daemon: string;
|
|
22
27
|
wallet: string;
|
|
23
28
|
mode: "xcashu" | "apikeys";
|
|
29
|
+
/** URL the TUI is connected through (local bind or remote daemonUrl). */
|
|
30
|
+
url?: string;
|
|
24
31
|
error?: string;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export async function fetchStatus(): Promise<StatusInfo | null> {
|
|
28
35
|
try {
|
|
29
|
-
const result = await
|
|
36
|
+
const [result, config] = await Promise.all([
|
|
37
|
+
callDaemon("/status"),
|
|
38
|
+
loadConfig(),
|
|
39
|
+
]);
|
|
30
40
|
if (result.error) return null;
|
|
31
41
|
|
|
32
42
|
const output = result.output as {
|
|
@@ -40,6 +50,7 @@ export async function fetchStatus(): Promise<StatusInfo | null> {
|
|
|
40
50
|
daemon: output?.daemon || "unknown",
|
|
41
51
|
wallet: output?.wallet || "unknown",
|
|
42
52
|
mode: output?.mode || "apikeys",
|
|
53
|
+
url: getDaemonBaseUrl(config),
|
|
43
54
|
error: output?.error,
|
|
44
55
|
};
|
|
45
56
|
} catch {
|
package/src/tui/usage/render.ts
CHANGED
|
@@ -162,6 +162,14 @@ export function renderOverview(stats: UsageStats, balance: BalanceInfo | null, s
|
|
|
162
162
|
const walletColor = status.wallet === "connected" ? COLORS.green : COLORS.red;
|
|
163
163
|
const modeColor = COLORS.cyan;
|
|
164
164
|
statusLines.push(`${COLORS.bold}Daemon:${COLORS.reset} ${daemonColor}${status.daemon}${COLORS.reset}`);
|
|
165
|
+
if (status.url) {
|
|
166
|
+
const label = "Running on: ";
|
|
167
|
+
const maxUrlLen = Math.max(8, halfWidth2 - 4 - label.length);
|
|
168
|
+
const url = status.url.length > maxUrlLen
|
|
169
|
+
? `${status.url.slice(0, Math.max(0, maxUrlLen - 1))}…`
|
|
170
|
+
: status.url;
|
|
171
|
+
statusLines.push(`${COLORS.bold}${label}${COLORS.reset} ${COLORS.cyan}${url}${COLORS.reset}`);
|
|
172
|
+
}
|
|
165
173
|
statusLines.push(`${COLORS.bold}Wallet:${COLORS.reset} ${walletColor}${status.wallet}${COLORS.reset}`);
|
|
166
174
|
statusLines.push(`${COLORS.bold}Mode:${COLORS.reset} ${modeColor}${status.mode}${COLORS.reset}`);
|
|
167
175
|
if (status.error) {
|
package/src/utils/config.ts
CHANGED
|
@@ -48,8 +48,10 @@ export interface RoutstrdConfig {
|
|
|
48
48
|
* Defaults to daemonUrl or localhost:{port} if not set. */
|
|
49
49
|
authUrl?: string;
|
|
50
50
|
nsec?: string;
|
|
51
|
-
/** Nostr hex pubkey for routstr review/
|
|
51
|
+
/** Nostr hex pubkey for routstr review/audit events (kind 38425). */
|
|
52
52
|
routstrPubkey?: string;
|
|
53
|
+
/** Nostr hex pubkey for the routstr-21 model list only (kind 38423). Falls back to routstrPubkey. */
|
|
54
|
+
routstrModelsPubkey?: string;
|
|
53
55
|
/** Nostr relay URLs for provider/model discovery (kinds 38421/38423/38425).
|
|
54
56
|
* When unset, each method uses its own built-in defaults. */
|
|
55
57
|
relays?: string[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
|
|
7
|
+
// These scenarios mutate process.env.ROUTSTRD_DIR before the config-store
|
|
8
|
+
// module is loaded, and other test files import the same module singleton
|
|
9
|
+
// first — so each scenario runs in an isolated bun subprocess where the env
|
|
10
|
+
// is guaranteed to be in place before module evaluation.
|
|
11
|
+
|
|
12
|
+
const SCENARIO = join(import.meta.dir, "config-store.scenario.ts");
|
|
13
|
+
|
|
14
|
+
function runScenario(name: string): { code: number; out: string } {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), `routstrd-cfg-${name}-`));
|
|
16
|
+
const res = spawnSync("bun", [SCENARIO, name], {
|
|
17
|
+
env: {
|
|
18
|
+
...process.env,
|
|
19
|
+
NODE_ENV: "test",
|
|
20
|
+
ROUTSTRD_DIR: join(dir, "daemon"),
|
|
21
|
+
},
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
const out = `${res.stdout}${res.stderr}`;
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
// bun's spawnSync reports the exit code in `status` (exitCode is undefined).
|
|
28
|
+
return { code: res.status ?? res.exitCode ?? -1, out };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("daemon config store permissions", () => {
|
|
32
|
+
test("fresh install: config dir 0700, config file 0600, atomic write", () => {
|
|
33
|
+
const { code, out } = runScenario("fresh-install");
|
|
34
|
+
expect(out).toContain("SCENARIO-OK");
|
|
35
|
+
expect(code).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("existing over-permissive install (0755/0644) is repaired", () => {
|
|
39
|
+
const { code, out } = runScenario("repair-perms");
|
|
40
|
+
expect(out).toContain("SCENARIO-OK");
|
|
41
|
+
expect(code).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("write failure surfaces synchronously with no temp file left", () => {
|
|
45
|
+
const { code, out } = runScenario("write-failure");
|
|
46
|
+
expect(out).toContain("SCENARIO-OK");
|
|
47
|
+
expect(code).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("corrupt JSON falls back to defaults", () => {
|
|
51
|
+
const { code, out } = runScenario("corrupt-json");
|
|
52
|
+
expect(out).toContain("SCENARIO-OK");
|
|
53
|
+
expect(code).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|