routstrd 0.3.11 → 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 +41 -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 +4557 -399
- package/package.json +8 -3
- package/src/cli.test.ts +55 -0
- package/src/cli.ts +454 -204
- 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/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/tests/utils/daemon-client.test.ts +61 -0
- package/tests/wallet/short-keyset-token.test.ts +106 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
+
import { isAbsolute } from "path";
|
|
3
4
|
import { logger } from "../../utils/logger";
|
|
4
5
|
import { withCrossProcessLock } from "../../utils/process-lock";
|
|
6
|
+
import type { HistoryEntry } from "@cashu/coco-core";
|
|
5
7
|
|
|
6
8
|
const DEFAULT_CONFIG_DIR =
|
|
7
9
|
process.env.COCOD_DIR || `${process.env.HOME || process.env.USERPROFILE || ""}/.cocod`;
|
|
@@ -34,6 +36,27 @@ export type CocodState = "UNINITIALIZED" | "LOCKED" | "UNLOCKED" | "ERROR";
|
|
|
34
36
|
|
|
35
37
|
export type CocodBalanceOutput = Record<string, { sats?: number } | number>;
|
|
36
38
|
|
|
39
|
+
/** NPC (npubx.cash) Lightning address details for this wallet. */
|
|
40
|
+
export interface NpcAddress {
|
|
41
|
+
/** Full Lightning address, e.g. "alice@npubx.cash" (npub fallback when no username is set). */
|
|
42
|
+
address: string;
|
|
43
|
+
/** NPC username, when one has been claimed. */
|
|
44
|
+
name?: string;
|
|
45
|
+
/** Nostr hex pubkey of the NPC account (only available from the in-process wallet). */
|
|
46
|
+
pubkey?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Result of an NPC username claim attempt. */
|
|
50
|
+
export interface NpcUsernameResult {
|
|
51
|
+
success: boolean;
|
|
52
|
+
/** Present when NPC requires payment to claim the username. */
|
|
53
|
+
paymentRequest?: {
|
|
54
|
+
amount?: number;
|
|
55
|
+
mints?: string[];
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
export class CocodHttpError extends Error {
|
|
38
61
|
status: number;
|
|
39
62
|
|
|
@@ -56,6 +79,17 @@ export interface CocodClient {
|
|
|
56
79
|
listMints(): Promise<string[]>;
|
|
57
80
|
addMint(url: string): Promise<string>;
|
|
58
81
|
getMintInfo(url: string): Promise<unknown>;
|
|
82
|
+
getDefaultMint(): Promise<string | null>;
|
|
83
|
+
setDefaultMint(url: string): Promise<string>;
|
|
84
|
+
/** Release resources held by in-process wallet implementations. */
|
|
85
|
+
dispose?(): Promise<void>;
|
|
86
|
+
getHistory(offset?: number, limit?: number): Promise<HistoryEntry[]>;
|
|
87
|
+
/** NPC (npubx.cash) Lightning address for this wallet. */
|
|
88
|
+
getNpcAddress(): Promise<NpcAddress>;
|
|
89
|
+
/** Claim an NPC username; pass confirm=true to pay the claim fee from the wallet. */
|
|
90
|
+
setNpcUsername(username: string, confirm?: boolean): Promise<NpcUsernameResult>;
|
|
91
|
+
/** Manually trigger an NPC quote sync into the wallet. */
|
|
92
|
+
syncNpc(): Promise<void>;
|
|
59
93
|
}
|
|
60
94
|
|
|
61
95
|
export function resolveCocodExecutable(cocodPath?: string | null): string {
|
|
@@ -68,13 +102,18 @@ export async function isCocodInstalled(
|
|
|
68
102
|
): Promise<boolean> {
|
|
69
103
|
const executable = resolveCocodExecutable(cocodPath);
|
|
70
104
|
|
|
71
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
isAbsolute(executable) ||
|
|
107
|
+
executable.includes("/") ||
|
|
108
|
+
executable.includes("\\")
|
|
109
|
+
) {
|
|
72
110
|
return existsSync(executable);
|
|
73
111
|
}
|
|
74
112
|
|
|
75
113
|
try {
|
|
114
|
+
const command = process.platform === "win32" ? "where.exe" : "which";
|
|
76
115
|
const proc = Bun.spawn({
|
|
77
|
-
cmd: [
|
|
116
|
+
cmd: [command, executable],
|
|
78
117
|
stdout: "ignore",
|
|
79
118
|
stderr: "ignore",
|
|
80
119
|
});
|
|
@@ -353,5 +392,50 @@ export function createCocodClient(
|
|
|
353
392
|
async getMintInfo(url: string): Promise<unknown> {
|
|
354
393
|
return post<unknown>("/mints/info", { url });
|
|
355
394
|
},
|
|
395
|
+
async getDefaultMint(): Promise<string | null> {
|
|
396
|
+
return callDaemon<string | null>("/mints/default");
|
|
397
|
+
},
|
|
398
|
+
async setDefaultMint(url: string): Promise<string> {
|
|
399
|
+
return post<string>("/mints/default", { url });
|
|
400
|
+
},
|
|
401
|
+
async getHistory(_offset?: number, _limit?: number): Promise<HistoryEntry[]> {
|
|
402
|
+
return [];
|
|
403
|
+
},
|
|
404
|
+
async getNpcAddress(): Promise<NpcAddress> {
|
|
405
|
+
const address = await callDaemon<string>("/npc/address");
|
|
406
|
+
if (typeof address !== "string" || !address.trim()) {
|
|
407
|
+
throw new CocodHttpError(
|
|
408
|
+
502,
|
|
409
|
+
"Unexpected response from cocod while fetching NPC address.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const trimmed = address.trim();
|
|
413
|
+
const localPart = trimmed.split("@")[0];
|
|
414
|
+
return {
|
|
415
|
+
address: trimmed,
|
|
416
|
+
...(localPart && !localPart.startsWith("npub1")
|
|
417
|
+
? { name: localPart }
|
|
418
|
+
: {}),
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
async setNpcUsername(
|
|
422
|
+
username: string,
|
|
423
|
+
confirm?: boolean,
|
|
424
|
+
): Promise<NpcUsernameResult> {
|
|
425
|
+
// cocod answers 402 with a payment-required message when the claim fee
|
|
426
|
+
// has not been confirmed; fetchJson preserves that status on the thrown
|
|
427
|
+
// CocodHttpError, so callers can surface it unchanged.
|
|
428
|
+
const result = await post<{ success?: boolean }>("/npc/username", {
|
|
429
|
+
username,
|
|
430
|
+
confirm: confirm === true,
|
|
431
|
+
});
|
|
432
|
+
return { success: result?.success !== false };
|
|
433
|
+
},
|
|
434
|
+
async syncNpc(): Promise<void> {
|
|
435
|
+
throw new CocodHttpError(
|
|
436
|
+
501,
|
|
437
|
+
"Manual NPC sync is not supported by the legacy cocod client.",
|
|
438
|
+
);
|
|
439
|
+
},
|
|
356
440
|
};
|
|
357
441
|
}
|
|
Binary file
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "fs";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { migrateLegacyWallet } from "./migration";
|
|
16
|
+
|
|
17
|
+
const roots: string[] = [];
|
|
18
|
+
function root(): string {
|
|
19
|
+
const path = mkdtempSync(join(tmpdir(), "routstrd-migration-"));
|
|
20
|
+
roots.push(path);
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
for (const path of roots.splice(0)) rmSync(path, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("migrateLegacyWallet", () => {
|
|
28
|
+
it("snapshots committed WAL data and leaves process files behind", async () => {
|
|
29
|
+
const base = root();
|
|
30
|
+
const legacyDir = join(base, ".cocod");
|
|
31
|
+
const walletDir = join(base, ".routstrd", "wallet");
|
|
32
|
+
mkdirSync(legacyDir);
|
|
33
|
+
const config = JSON.stringify({
|
|
34
|
+
mnemonic: "seed words",
|
|
35
|
+
encrypted: false,
|
|
36
|
+
defaultMintUrl: "https://mint.example",
|
|
37
|
+
unknown: { preserved: true },
|
|
38
|
+
});
|
|
39
|
+
writeFileSync(join(legacyDir, "config.json"), config);
|
|
40
|
+
writeFileSync(join(legacyDir, "cocod.pid"), "123");
|
|
41
|
+
writeFileSync(join(legacyDir, "cocod.sock"), "not-a-real-socket");
|
|
42
|
+
|
|
43
|
+
const source = new Database(join(legacyDir, "coco.db"));
|
|
44
|
+
source.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0");
|
|
45
|
+
source.exec(`
|
|
46
|
+
CREATE TABLE coco_cashu_proofs (mintUrl TEXT, state TEXT, amount INTEGER);
|
|
47
|
+
CREATE TABLE coco_cashu_counters (mintUrl TEXT, keysetId TEXT, counter INTEGER);
|
|
48
|
+
CREATE TABLE coco_cashu_mint_operations (state TEXT);
|
|
49
|
+
CREATE TABLE coco_cashu_send_operations (state TEXT);
|
|
50
|
+
CREATE TABLE coco_cashu_melt_operations (state TEXT);
|
|
51
|
+
CREATE TABLE coco_cashu_mints (mintUrl TEXT, trusted INTEGER);
|
|
52
|
+
INSERT INTO coco_cashu_proofs VALUES ('https://mint.example', 'ready', 21);
|
|
53
|
+
INSERT INTO coco_cashu_counters VALUES ('https://mint.example', 'keyset', 3);
|
|
54
|
+
INSERT INTO coco_cashu_mints VALUES ('https://mint.example', 1);
|
|
55
|
+
`);
|
|
56
|
+
expect(existsSync(join(legacyDir, "coco.db-wal"))).toBe(true);
|
|
57
|
+
|
|
58
|
+
const assertStopped = mock(async () => {});
|
|
59
|
+
const releaseLock = mock(() => {});
|
|
60
|
+
const acquireLock = mock(() => releaseLock);
|
|
61
|
+
const result = await migrateLegacyWallet({
|
|
62
|
+
walletDir,
|
|
63
|
+
legacyDir,
|
|
64
|
+
assertLegacyStopped: assertStopped,
|
|
65
|
+
acquireLegacyLock: acquireLock,
|
|
66
|
+
});
|
|
67
|
+
source.close();
|
|
68
|
+
|
|
69
|
+
expect(result.status).toBe("migrated");
|
|
70
|
+
expect(assertStopped).toHaveBeenCalledTimes(2);
|
|
71
|
+
expect(acquireLock).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(releaseLock).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe(config);
|
|
74
|
+
const migrated = new Database(join(walletDir, "coco.db"), { readonly: true });
|
|
75
|
+
expect(migrated.query("PRAGMA quick_check").values()).toEqual([["ok"]]);
|
|
76
|
+
expect(migrated.query("SELECT state, amount FROM coco_cashu_proofs").values()).toEqual([
|
|
77
|
+
["ready", 21],
|
|
78
|
+
]);
|
|
79
|
+
expect(migrated.query("SELECT counter FROM coco_cashu_counters").get()).toEqual({
|
|
80
|
+
counter: 3,
|
|
81
|
+
});
|
|
82
|
+
migrated.close();
|
|
83
|
+
expect(existsSync(join(walletDir, "coco.db-wal"))).toBe(false);
|
|
84
|
+
expect(statSync(walletDir).mode & 0o777).toBe(0o700);
|
|
85
|
+
expect(statSync(join(walletDir, "config.json")).mode & 0o777).toBe(0o600);
|
|
86
|
+
expect(existsSync(join(walletDir, "cocod.pid"))).toBe(false);
|
|
87
|
+
expect(existsSync(join(walletDir, "cocod.sock"))).toBe(false);
|
|
88
|
+
expect(existsSync(join(legacyDir, "config.json"))).toBe(false);
|
|
89
|
+
expect(existsSync(join(legacyDir, "coco.db"))).toBe(false);
|
|
90
|
+
expect(existsSync(join(legacyDir, "cocod.pid"))).toBe(true);
|
|
91
|
+
expect(existsSync(join(legacyDir, "cocod.sock"))).toBe(true);
|
|
92
|
+
const archive = readdirSync(legacyDir).find((name) => name.startsWith("wallet-migrated-"));
|
|
93
|
+
expect(archive).toBeDefined();
|
|
94
|
+
expect(existsSync(join(legacyDir, archive!, "config.json"))).toBe(true);
|
|
95
|
+
expect(existsSync(join(legacyDir, archive!, "coco.db"))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("treats a config-only initialized wallet as migratable", async () => {
|
|
99
|
+
const base = root();
|
|
100
|
+
const legacyDir = join(base, ".cocod");
|
|
101
|
+
const walletDir = join(base, ".routstrd", "wallet");
|
|
102
|
+
mkdirSync(legacyDir);
|
|
103
|
+
writeFileSync(join(legacyDir, "config.json"), '{"mnemonic":"seed"}');
|
|
104
|
+
|
|
105
|
+
expect((await migrateLegacyWallet({ walletDir, legacyDir })).status).toBe("migrated");
|
|
106
|
+
expect(existsSync(join(walletDir, "config.json"))).toBe(true);
|
|
107
|
+
expect(existsSync(join(walletDir, "coco.db"))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("refuses a database without its mnemonic config", async () => {
|
|
111
|
+
const base = root();
|
|
112
|
+
const legacyDir = join(base, ".cocod");
|
|
113
|
+
mkdirSync(legacyDir);
|
|
114
|
+
writeFileSync(join(legacyDir, "coco.db"), "database");
|
|
115
|
+
|
|
116
|
+
await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow(
|
|
117
|
+
"without config.json",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("refuses orphaned SQLite sidecars", async () => {
|
|
122
|
+
const base = root();
|
|
123
|
+
const legacyDir = join(base, ".cocod");
|
|
124
|
+
mkdirSync(legacyDir);
|
|
125
|
+
writeFileSync(join(legacyDir, "coco.db-wal"), "orphaned WAL");
|
|
126
|
+
|
|
127
|
+
await expect(migrateLegacyWallet({ walletDir: join(base, "wallet"), legacyDir })).rejects.toThrow(
|
|
128
|
+
"sidecar files without coco.db",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("refuses when both canonical and legacy configs exist", async () => {
|
|
133
|
+
const base = root();
|
|
134
|
+
const legacyDir = join(base, ".cocod");
|
|
135
|
+
const walletDir = join(base, ".routstrd", "wallet");
|
|
136
|
+
mkdirSync(legacyDir, { recursive: true });
|
|
137
|
+
mkdirSync(walletDir, { recursive: true });
|
|
138
|
+
writeFileSync(join(legacyDir, "config.json"), "legacy");
|
|
139
|
+
writeFileSync(join(walletDir, "config.json"), "current");
|
|
140
|
+
|
|
141
|
+
await expect(migrateLegacyWallet({ walletDir, legacyDir })).rejects.toThrow(
|
|
142
|
+
"both",
|
|
143
|
+
);
|
|
144
|
+
expect(readFileSync(join(walletDir, "config.json"), "utf8")).toBe("current");
|
|
145
|
+
expect(readFileSync(join(legacyDir, "config.json"), "utf8")).toBe("legacy");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from "fs";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { basename, dirname, join } from "path";
|
|
13
|
+
import { legacyCocodDir, walletDir } from "./paths";
|
|
14
|
+
|
|
15
|
+
export type WalletMigrationResult =
|
|
16
|
+
| { status: "fresh" }
|
|
17
|
+
| { status: "already-current" }
|
|
18
|
+
| { status: "migrated"; from: string; to: string; cleanupWarnings: string[] };
|
|
19
|
+
|
|
20
|
+
export interface WalletMigrationOptions {
|
|
21
|
+
walletDir?: string;
|
|
22
|
+
legacyDir?: string;
|
|
23
|
+
/** Called after state validation and before legacy files are copied. */
|
|
24
|
+
assertLegacyStopped?: () => void | Promise<void>;
|
|
25
|
+
/** Claims the legacy cocod exclusion lock for the duration of the copy. */
|
|
26
|
+
acquireLegacyLock?: () => (() => void) | Promise<() => void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type WalletState = "absent" | "database-only" | "initialized";
|
|
30
|
+
|
|
31
|
+
function state(configPath: string, dbPath: string): WalletState {
|
|
32
|
+
const hasConfig = existsSync(configPath);
|
|
33
|
+
const hasDb = existsSync(dbPath);
|
|
34
|
+
if (hasConfig) return "initialized";
|
|
35
|
+
if (hasDb) return "database-only";
|
|
36
|
+
return "absent";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function filesEqual(left: string, right: string): boolean {
|
|
40
|
+
if (!existsSync(left) || !existsSync(right)) return false;
|
|
41
|
+
if (statSync(left).size !== statSync(right).size) return false;
|
|
42
|
+
return readFileSync(left).equals(readFileSync(right));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sqlString(value: string): string {
|
|
46
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type WalletSummary = Record<string, unknown[]>;
|
|
50
|
+
const SUMMARY_QUERIES: Record<string, string> = {
|
|
51
|
+
proofs:
|
|
52
|
+
"SELECT mintUrl, state, COUNT(*) count, COALESCE(SUM(amount), 0) amount FROM coco_cashu_proofs GROUP BY mintUrl, state ORDER BY mintUrl, state",
|
|
53
|
+
counters:
|
|
54
|
+
"SELECT mintUrl, keysetId, counter FROM coco_cashu_counters ORDER BY mintUrl, keysetId",
|
|
55
|
+
mintOperations:
|
|
56
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_mint_operations GROUP BY state ORDER BY state",
|
|
57
|
+
sendOperations:
|
|
58
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_send_operations GROUP BY state ORDER BY state",
|
|
59
|
+
meltOperations:
|
|
60
|
+
"SELECT state, COUNT(*) count FROM coco_cashu_melt_operations GROUP BY state ORDER BY state",
|
|
61
|
+
mints: "SELECT mintUrl, trusted FROM coco_cashu_mints ORDER BY mintUrl",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function verifyDatabase(database: Database, label: string): WalletSummary {
|
|
65
|
+
const checks = database.query("PRAGMA quick_check").values() as unknown[][];
|
|
66
|
+
if (checks.length !== 1 || checks[0]?.[0] !== "ok") {
|
|
67
|
+
throw new Error(`${label} failed PRAGMA quick_check: ${JSON.stringify(checks)}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tables = new Set(
|
|
71
|
+
(database
|
|
72
|
+
.query("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
73
|
+
.values() as string[][]).map(([name]) => name),
|
|
74
|
+
);
|
|
75
|
+
const summary: WalletSummary = {};
|
|
76
|
+
for (const [name, query] of Object.entries(SUMMARY_QUERIES)) {
|
|
77
|
+
const table = query.match(/FROM\s+(coco_cashu_\w+)/i)?.[1];
|
|
78
|
+
summary[name] = table && tables.has(table) ? database.query(query).all() : [];
|
|
79
|
+
}
|
|
80
|
+
return summary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Write a standalone SQLite snapshot containing committed WAL frames. */
|
|
84
|
+
function snapshotDatabase(sourcePath: string, destinationPath: string): void {
|
|
85
|
+
const source = new Database(sourcePath);
|
|
86
|
+
let sourceSummary: WalletSummary;
|
|
87
|
+
try {
|
|
88
|
+
sourceSummary = verifyDatabase(source, "Legacy wallet database");
|
|
89
|
+
source.exec(`VACUUM INTO ${sqlString(destinationPath)}`);
|
|
90
|
+
} finally {
|
|
91
|
+
source.close();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const destination = new Database(destinationPath, { readonly: true });
|
|
95
|
+
try {
|
|
96
|
+
const destinationSummary = verifyDatabase(destination, "Staged wallet database");
|
|
97
|
+
if (JSON.stringify(sourceSummary) !== JSON.stringify(destinationSummary)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"Staged wallet database does not contain the same proofs, counters, operations, and mints as the legacy database.",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
destination.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Copy a legacy cocod wallet into the canonical routstrd wallet directory.
|
|
109
|
+
* The staging directory is renamed atomically, so the canonical path is never
|
|
110
|
+
* exposed with only one of its two required files.
|
|
111
|
+
*/
|
|
112
|
+
export async function migrateLegacyWallet(
|
|
113
|
+
options: WalletMigrationOptions = {},
|
|
114
|
+
): Promise<WalletMigrationResult> {
|
|
115
|
+
const targetDir = options.walletDir || walletDir();
|
|
116
|
+
const sourceDir = options.legacyDir || legacyCocodDir();
|
|
117
|
+
const targetConfig = join(targetDir, "config.json");
|
|
118
|
+
const targetDb = join(targetDir, "coco.db");
|
|
119
|
+
const sourceConfig = join(sourceDir, "config.json");
|
|
120
|
+
const sourceDb = join(sourceDir, "coco.db");
|
|
121
|
+
const sourceWal = `${sourceDb}-wal`;
|
|
122
|
+
const sourceShm = `${sourceDb}-shm`;
|
|
123
|
+
const targetState = state(targetConfig, targetDb);
|
|
124
|
+
const sourceState = state(sourceConfig, sourceDb);
|
|
125
|
+
|
|
126
|
+
// config.json is sufficient for a newly initialized wallet; coco.db is
|
|
127
|
+
// created on first open. A database without its mnemonic is never usable.
|
|
128
|
+
if (targetState === "initialized" && sourceState === "initialized") {
|
|
129
|
+
// A prior migration may have committed successfully but failed to remove
|
|
130
|
+
// its source files. Identical leftovers are safe; divergent wallets are not.
|
|
131
|
+
const configsMatch = filesEqual(targetConfig, sourceConfig);
|
|
132
|
+
const databasesMatch =
|
|
133
|
+
!existsSync(targetDb) && !existsSync(sourceDb)
|
|
134
|
+
? true
|
|
135
|
+
: filesEqual(targetDb, sourceDb);
|
|
136
|
+
if (configsMatch && databasesMatch) return { status: "already-current" };
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Cannot migrate wallet: both ${targetDir} and ${sourceDir} contain different wallet data. ` +
|
|
139
|
+
"Refusing to choose a mnemonic or merge wallet databases automatically.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (targetState === "initialized") return { status: "already-current" };
|
|
143
|
+
if (!existsSync(sourceDb) && (existsSync(sourceWal) || existsSync(sourceShm))) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Cannot migrate wallet: ${sourceDir} contains SQLite sidecar files without coco.db. ` +
|
|
146
|
+
"Restore the matching main database before migration.",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (targetState === "database-only" || sourceState === "database-only") {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`Cannot migrate wallet: ${targetDir} is ${targetState} and ${sourceDir} is ${sourceState}. ` +
|
|
152
|
+
"A coco.db without config.json cannot be opened; restore the matching config first.",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (sourceState === "absent") return { status: "fresh" };
|
|
156
|
+
|
|
157
|
+
await options.assertLegacyStopped?.();
|
|
158
|
+
const releaseLegacyLock = await options.acquireLegacyLock?.();
|
|
159
|
+
const stagingDir = join(
|
|
160
|
+
dirname(targetDir),
|
|
161
|
+
`.${targetDir.split(/[\\/]/).at(-1) || "wallet"}.staging-${process.pid}-${Date.now()}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Recheck after claiming the exclusion lock to close the probe/claim race.
|
|
166
|
+
await options.assertLegacyStopped?.();
|
|
167
|
+
mkdirSync(dirname(targetDir), { recursive: true, mode: 0o700 });
|
|
168
|
+
mkdirSync(stagingDir, { mode: 0o700 });
|
|
169
|
+
const stagedConfig = join(stagingDir, "config.json");
|
|
170
|
+
const stagedDb = join(stagingDir, "coco.db");
|
|
171
|
+
copyFileSync(sourceConfig, stagedConfig);
|
|
172
|
+
chmodSync(stagedConfig, 0o600);
|
|
173
|
+
const hasSourceDb = existsSync(sourceDb);
|
|
174
|
+
if (hasSourceDb) {
|
|
175
|
+
// A SQLite WAL is part of the logical database. Raw-copying coco.db can
|
|
176
|
+
// silently omit proofs and counters that have not yet been checkpointed.
|
|
177
|
+
snapshotDatabase(sourceDb, stagedDb);
|
|
178
|
+
chmodSync(stagedDb, 0o600);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (statSync(stagedConfig).size !== statSync(sourceConfig).size) {
|
|
182
|
+
throw new Error("Cannot migrate wallet: staged config failed size validation.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
renameSync(stagingDir, targetDir);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
188
|
+
throw error;
|
|
189
|
+
} finally {
|
|
190
|
+
releaseLegacyLock?.();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Preserve the complete legacy SQLite file set for manual recovery instead
|
|
194
|
+
// of deleting the only rollback copy immediately after migration.
|
|
195
|
+
const cleanupWarnings: string[] = [];
|
|
196
|
+
const sourceFiles = [sourceConfig, sourceDb, sourceWal, sourceShm].filter(existsSync);
|
|
197
|
+
if (sourceFiles.length > 0) {
|
|
198
|
+
const archiveDir = join(sourceDir, `wallet-migrated-${Date.now()}`);
|
|
199
|
+
try {
|
|
200
|
+
mkdirSync(archiveDir, { mode: 0o700 });
|
|
201
|
+
for (const path of sourceFiles) renameSync(path, join(archiveDir, basename(path)));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
204
|
+
cleanupWarnings.push(`Could not archive legacy wallet files: ${message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { status: "migrated", from: sourceDir, to: targetDir, cleanupWarnings };
|
|
209
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
|
|
3
|
+
function homeDir(): string {
|
|
4
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Root directory for routstrd state. Evaluated lazily so env overrides work in tests. */
|
|
8
|
+
export function routstrdConfigDir(): string {
|
|
9
|
+
return process.env.ROUTSTRD_DIR || join(homeDir(), ".routstrd");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Canonical directory for the in-process Cashu wallet. */
|
|
13
|
+
export function walletDir(): string {
|
|
14
|
+
return process.env.ROUTSTRD_WALLET_DIR || join(routstrdConfigDir(), "wallet");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Lock owned by an in-process routstrd wallet instance. */
|
|
18
|
+
export function walletPidPath(): string {
|
|
19
|
+
return process.env.ROUTSTRD_WALLET_PID || join(walletDir(), "wallet.pid");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Legacy external cocod directory. This is not the routstrd wallet directory. */
|
|
23
|
+
export function legacyCocodDir(): string {
|
|
24
|
+
return process.env.COCOD_DIR || join(homeDir(), ".cocod");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function legacyCocodSocketPath(): string {
|
|
28
|
+
return process.env.COCOD_SOCKET || join(legacyCocodDir(), "cocod.sock");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function legacyCocodPidPath(): string {
|
|
32
|
+
return process.env.COCOD_PID || join(legacyCocodDir(), "cocod.pid");
|
|
33
|
+
}
|
|
@@ -27,7 +27,8 @@ export async function installClaudeCodeIntegration(
|
|
|
27
27
|
settings = JSON.parse(content);
|
|
28
28
|
}
|
|
29
29
|
} catch (error) {
|
|
30
|
-
console.error(`
|
|
30
|
+
console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
|
|
31
|
+
return;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
if (!settings.env) {
|
|
@@ -17,6 +17,13 @@ interface HermesCustomProvider {
|
|
|
17
17
|
[key: string]: unknown;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** Hermes references a custom provider as `custom:<slug>`, where the slug is
|
|
21
|
+
* the provider name lowercased with runs of whitespace collapsed to hyphens
|
|
22
|
+
* (e.g. `Routstr (routstr.ft.hn)` -> `custom:routstr-(routstr.ft.hn)`). */
|
|
23
|
+
function hermesProviderRef(name: string): string {
|
|
24
|
+
return `custom:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function mergeHermesConfig(
|
|
21
28
|
content: string,
|
|
22
29
|
routstr: HermesRoutstrConfig,
|
|
@@ -29,8 +36,9 @@ export function mergeHermesConfig(
|
|
|
29
36
|
const urlDisplay = routstr.baseUrl
|
|
30
37
|
.replace(/\/v1$/, "")
|
|
31
38
|
.replace(/^https?:\/\//, "");
|
|
39
|
+
const providerName = `Routstr (${urlDisplay})`;
|
|
32
40
|
const provider = {
|
|
33
|
-
name:
|
|
41
|
+
name: providerName,
|
|
34
42
|
base_url: routstr.baseUrl,
|
|
35
43
|
api_key: routstr.apiKey,
|
|
36
44
|
model: routstr.defaultModel,
|
|
@@ -46,17 +54,48 @@ export function mergeHermesConfig(
|
|
|
46
54
|
});
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
// Replace an existing Routstr custom provider in place so that re-running
|
|
58
|
+
// `clients add --hermes` after changing the daemon URL updates base_url /
|
|
59
|
+
// api_key / model instead of silently keeping the stale entry. We only touch
|
|
60
|
+
// the first matching entry; any other providers are left as-is.
|
|
49
61
|
const existingConfig = document.toJS() as {
|
|
50
62
|
custom_providers?: HermesCustomProvider[];
|
|
51
63
|
};
|
|
52
64
|
const existingProviders = existingConfig.custom_providers;
|
|
53
|
-
const providers = Array.isArray(existingProviders)
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
const providers = Array.isArray(existingProviders)
|
|
66
|
+
? existingProviders.slice()
|
|
67
|
+
: [];
|
|
68
|
+
const routstrIndex = providers.findIndex(
|
|
69
|
+
(item) => typeof item?.name === "string" && item.name.startsWith("Routstr ("),
|
|
70
|
+
);
|
|
71
|
+
let previousName: string | undefined;
|
|
72
|
+
if (routstrIndex >= 0) {
|
|
73
|
+
previousName = providers[routstrIndex]!.name;
|
|
74
|
+
providers[routstrIndex] = { ...providers[routstrIndex], ...provider };
|
|
75
|
+
} else {
|
|
76
|
+
providers.push(provider);
|
|
56
77
|
}
|
|
57
|
-
providers.push(provider);
|
|
58
78
|
document.set("custom_providers", providers);
|
|
59
79
|
|
|
80
|
+
// If we renamed the Routstr provider, keep `model.provider` pointing at it so
|
|
81
|
+
// Hermes doesn't end up referencing a provider that no longer exists. We only
|
|
82
|
+
// adjust configs whose default model already routes through a Routstr custom
|
|
83
|
+
// provider, leaving any other selection untouched.
|
|
84
|
+
if (
|
|
85
|
+
!isNewConfig &&
|
|
86
|
+
previousName &&
|
|
87
|
+
previousName !== providerName &&
|
|
88
|
+
document.hasIn(["model", "provider"])
|
|
89
|
+
) {
|
|
90
|
+
const currentRef = document.getIn(["model", "provider"]);
|
|
91
|
+
if (
|
|
92
|
+
typeof currentRef === "string" &&
|
|
93
|
+
currentRef === hermesProviderRef(previousName)
|
|
94
|
+
) {
|
|
95
|
+
document.setIn(["model", "provider"], hermesProviderRef(providerName));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
60
99
|
return document.toString();
|
|
61
100
|
}
|
|
62
101
|
|
|
@@ -99,7 +138,8 @@ export async function installHermesIntegration(
|
|
|
99
138
|
content = await readFile(configPath, "utf-8");
|
|
100
139
|
}
|
|
101
140
|
} catch (error) {
|
|
102
|
-
console.error(`
|
|
141
|
+
console.error(`Failed to read ${configPath}; leaving it unchanged:`, error);
|
|
142
|
+
return;
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
let newContent: string;
|
|
@@ -67,8 +67,9 @@ export async function installOpenClawIntegration(
|
|
|
67
67
|
const content = await readFile(configPath, "utf-8");
|
|
68
68
|
openclawConfig = JSON.parse(content) as OpenClawConfig;
|
|
69
69
|
}
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
|
|
72
|
+
return;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
if (!openclawConfig.models) {
|
|
@@ -40,8 +40,9 @@ export async function installOpencodeIntegration(
|
|
|
40
40
|
} else {
|
|
41
41
|
opencodeConfig = { provider: {} };
|
|
42
42
|
}
|
|
43
|
-
} catch {
|
|
44
|
-
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
|
|
45
|
+
return;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (!opencodeConfig.provider) {
|
package/src/integrations/pi.ts
CHANGED
|
@@ -40,8 +40,9 @@ export async function installPiIntegration(
|
|
|
40
40
|
const content = await readFile(configPath, "utf-8");
|
|
41
41
|
piConfig = JSON.parse(content) as PiConfig;
|
|
42
42
|
}
|
|
43
|
-
} catch {
|
|
44
|
-
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`Failed to read or parse ${configPath}; leaving it unchanged:`, error);
|
|
45
|
+
return;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (!piConfig.providers) {
|