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
package/src/utils/logger.ts
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
20
|
+
return join(logsDir, `${year}-${month}-${day}.log`);
|
|
14
21
|
}
|
|
15
22
|
|
|
16
|
-
|
|
17
|
-
if (!existsSync(
|
|
18
|
-
|
|
23
|
+
function ensureDir(dir: string) {
|
|
24
|
+
if (!existsSync(dir)) {
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
19
26
|
}
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
log: (...args: unknown[]) =>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
writeLog("
|
|
61
|
-
|
|
62
|
-
|
|
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);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
2
|
+
|
|
3
|
+
/** Packages that `routstrd update` manages. */
|
|
4
|
+
export const UPDATE_PACKAGES = [
|
|
5
|
+
{ name: "routstrd", label: "routstrd" },
|
|
6
|
+
{ name: "@routstr/cocod", label: "cocod" },
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fetch the latest published version of a package from the npm registry.
|
|
11
|
+
* Returns null if the version cannot be determined (e.g. offline, not found).
|
|
12
|
+
*/
|
|
13
|
+
export async function getLatestNpmVersion(
|
|
14
|
+
packageName: string,
|
|
15
|
+
): Promise<string | null> {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(
|
|
18
|
+
`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`,
|
|
19
|
+
);
|
|
20
|
+
if (!response.ok) return null;
|
|
21
|
+
const data = (await response.json()) as { version?: string };
|
|
22
|
+
return data.version ?? null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get the version of a globally-installed bun package.
|
|
30
|
+
* Returns null when the package is not installed globally or the version
|
|
31
|
+
* cannot be parsed as semver (e.g. installed from a git URL).
|
|
32
|
+
*/
|
|
33
|
+
export async function getGlobalPackageVersion(
|
|
34
|
+
packageName: string,
|
|
35
|
+
): Promise<string | null> {
|
|
36
|
+
try {
|
|
37
|
+
const proc = Bun.spawn(["bun", "pm", "ls", "-g"], {
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "ignore",
|
|
40
|
+
});
|
|
41
|
+
const output = await new Response(proc.stdout).text();
|
|
42
|
+
await proc.exited;
|
|
43
|
+
// Lines look like: ├── routstrd@0.3.10 or └── @routstr/cocod@0.0.24
|
|
44
|
+
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
const match = output.match(new RegExp(`${escaped}@([^\\s]+)`));
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
const version = match[1];
|
|
48
|
+
// Reject non-semver versions (e.g. github:routstr/cocod#3f6ac14)
|
|
49
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(version)) return null;
|
|
50
|
+
return version;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compare two semver version strings.
|
|
58
|
+
* Returns a positive number if `a` is newer, negative if `b` is newer,
|
|
59
|
+
* 0 if equal, or null if either value is not parseable semver.
|
|
60
|
+
*/
|
|
61
|
+
export function compareVersions(a: string, b: string): number | null {
|
|
62
|
+
const parse = (v: string): [number, number, number] | null => {
|
|
63
|
+
const match = v.replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
64
|
+
if (!match?.[1] || !match[2] || !match[3]) return null;
|
|
65
|
+
return [
|
|
66
|
+
parseInt(match[1], 10),
|
|
67
|
+
parseInt(match[2], 10),
|
|
68
|
+
parseInt(match[3], 10),
|
|
69
|
+
];
|
|
70
|
+
};
|
|
71
|
+
const va = parse(a);
|
|
72
|
+
const vb = parse(b);
|
|
73
|
+
if (!va || !vb) return null;
|
|
74
|
+
const [a1, a2, a3] = va;
|
|
75
|
+
const [b1, b2, b3] = vb;
|
|
76
|
+
if (a1 !== b1) return a1 - b1;
|
|
77
|
+
if (a2 !== b2) return a2 - b2;
|
|
78
|
+
if (a3 !== b3) return a3 - b3;
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PackageUpdate {
|
|
83
|
+
name: string;
|
|
84
|
+
label: string;
|
|
85
|
+
current: string | null;
|
|
86
|
+
latest: string | null;
|
|
87
|
+
hasUpdate: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UpdateCheckResult {
|
|
91
|
+
hasUpdate: boolean;
|
|
92
|
+
packages: PackageUpdate[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check both routstrd and cocod for available updates.
|
|
97
|
+
* Returns a result with per-package details and an overall `hasUpdate` flag.
|
|
98
|
+
*/
|
|
99
|
+
export async function checkForUpdates(): Promise<UpdateCheckResult> {
|
|
100
|
+
const packages = await Promise.all(
|
|
101
|
+
UPDATE_PACKAGES.map(async ({ name, label }) => {
|
|
102
|
+
const [current, latest] = await Promise.all([
|
|
103
|
+
getGlobalPackageVersion(name),
|
|
104
|
+
getLatestNpmVersion(name),
|
|
105
|
+
]);
|
|
106
|
+
const hasUpdate = !!(current && latest && (compareVersions(current, latest) ?? -1) < 0);
|
|
107
|
+
return { name, label, current, latest, hasUpdate } satisfies PackageUpdate;
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
hasUpdate: packages.some((p) => p.hasUpdate),
|
|
112
|
+
packages,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
getDaemonBaseUrl,
|
|
4
|
+
urlHost,
|
|
5
|
+
urlHosts,
|
|
6
|
+
} from "../../src/utils/daemon-client";
|
|
7
|
+
import type { RoutstrdConfig } from "../../src/utils/config";
|
|
8
|
+
|
|
9
|
+
function config(host: string): RoutstrdConfig {
|
|
10
|
+
return {
|
|
11
|
+
port: 8008,
|
|
12
|
+
host,
|
|
13
|
+
provider: null,
|
|
14
|
+
cocodPath: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("daemon URL host formatting", () => {
|
|
19
|
+
test("prefers IPv4 loopback for an IPv4 wildcard", () => {
|
|
20
|
+
expect(urlHosts("0.0.0.0")).toEqual(["127.0.0.1", "[::1]"]);
|
|
21
|
+
expect(getDaemonBaseUrl(config("0.0.0.0"))).toBe("http://127.0.0.1:8008");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("prefers IPv6 loopback for an IPv6 wildcard", () => {
|
|
25
|
+
expect(urlHosts("::")).toEqual(["[::1]", "127.0.0.1"]);
|
|
26
|
+
expect(getDaemonBaseUrl(config("::"))).toBe("http://[::1]:8008");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("brackets IPv6 literals", () => {
|
|
30
|
+
expect(urlHost("2001:db8::1")).toBe("[2001:db8::1]");
|
|
31
|
+
expect(getDaemonBaseUrl(config("2001:db8::1"))).toBe(
|
|
32
|
+
"http://[2001:db8::1]:8008",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not bracket an already-bracketed IPv6 literal twice", () => {
|
|
37
|
+
expect(urlHost("[::1]")).toBe("[::1]");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("escapes an IPv6 zone identifier for use in a URL", () => {
|
|
41
|
+
expect(urlHost("fe80::1%en0")).toBe("[fe80::1%25en0]");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("leaves IPv4 addresses and hostnames unchanged", () => {
|
|
45
|
+
expect(urlHost("127.0.0.1")).toBe("127.0.0.1");
|
|
46
|
+
expect(urlHost("localhost")).toBe("localhost");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("uses the default loopback when the host is absent", () => {
|
|
50
|
+
expect(urlHost()).toBe("127.0.0.1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("preserves an explicitly configured daemon URL", () => {
|
|
54
|
+
expect(
|
|
55
|
+
getDaemonBaseUrl({
|
|
56
|
+
...config("::"),
|
|
57
|
+
daemonUrl: "https://daemon.example/",
|
|
58
|
+
}),
|
|
59
|
+
).toBe("https://daemon.example");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
Amount,
|
|
4
|
+
getDecodedToken,
|
|
5
|
+
getEncodedToken,
|
|
6
|
+
} from "@cashu/cashu-ts";
|
|
7
|
+
import {
|
|
8
|
+
createWalletAdapter,
|
|
9
|
+
decodeCashuTokenAmount,
|
|
10
|
+
} from "../../src/daemon/wallet";
|
|
11
|
+
import type { CocodClient } from "../../src/daemon/wallet/cocod-client";
|
|
12
|
+
|
|
13
|
+
// A full modern keyset ID with the same format as Minibits' post-migration
|
|
14
|
+
// active keyset. getEncodedToken stores only its first eight bytes in TokenV4.
|
|
15
|
+
const FULL_KEYSET_ID =
|
|
16
|
+
"01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
|
|
17
|
+
const LEGACY_KEYSET_ID = "00107937db0cc865";
|
|
18
|
+
|
|
19
|
+
function makeToken({
|
|
20
|
+
amounts = [5],
|
|
21
|
+
keysetId = FULL_KEYSET_ID,
|
|
22
|
+
unit = "sat",
|
|
23
|
+
}: {
|
|
24
|
+
amounts?: number[];
|
|
25
|
+
keysetId?: string;
|
|
26
|
+
unit?: string;
|
|
27
|
+
} = {}): string {
|
|
28
|
+
return getEncodedToken({
|
|
29
|
+
mint: "https://mint.minibits.cash/Bitcoin",
|
|
30
|
+
unit,
|
|
31
|
+
proofs: amounts.map((amount, index) => ({
|
|
32
|
+
id: keysetId,
|
|
33
|
+
amount: Amount.from(amount),
|
|
34
|
+
secret: `short-keyset-regression-fixture-${index}`,
|
|
35
|
+
C: `02${(index + 1).toString(16).padStart(2, "0").repeat(32)}`,
|
|
36
|
+
})),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeWalletClient(
|
|
41
|
+
receiveCashu: CocodClient["receiveCashu"],
|
|
42
|
+
): CocodClient {
|
|
43
|
+
// createWalletAdapter is intentionally lazy; this receive-path test only
|
|
44
|
+
// needs the one capability exercised by receiveToken.
|
|
45
|
+
return { receiveCashu } as CocodClient;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("short keyset TokenV4 compatibility", () => {
|
|
49
|
+
it("reads amount metadata without resolving the shortened proof keyset ID", () => {
|
|
50
|
+
const token = makeToken({ amounts: [1, 4] });
|
|
51
|
+
|
|
52
|
+
// Guard the fixture itself: a full proof decode with no mint keysets must
|
|
53
|
+
// exercise the same short-ID failure that triggered this regression.
|
|
54
|
+
expect(() => getDecodedToken(token, [])).toThrow(/short keyset ID/i);
|
|
55
|
+
expect(decodeCashuTokenAmount(token)).toEqual({
|
|
56
|
+
amount: 5,
|
|
57
|
+
unit: "sat",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
for (const tokenCase of [
|
|
62
|
+
{
|
|
63
|
+
name: "modern keyset with msat unit",
|
|
64
|
+
input: { amounts: [500, 1000], unit: "msat" },
|
|
65
|
+
expected: { amount: 1500, unit: "msat" as const },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: "legacy v0 keyset",
|
|
69
|
+
input: { amounts: [2, 8], keysetId: LEGACY_KEYSET_ID },
|
|
70
|
+
expected: { amount: 10, unit: "sat" as const },
|
|
71
|
+
},
|
|
72
|
+
]) {
|
|
73
|
+
it(`reads ${tokenCase.name} metadata`, () => {
|
|
74
|
+
expect(decodeCashuTokenAmount(makeToken(tokenCase.input))).toEqual(
|
|
75
|
+
tokenCase.expected,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("reports success after cocod receives a short-keyset token", async () => {
|
|
81
|
+
const receiveCashu = mock(async () => "Received 5");
|
|
82
|
+
const walletClient = makeWalletClient(receiveCashu);
|
|
83
|
+
const adapter = await createWalletAdapter({ walletClient });
|
|
84
|
+
|
|
85
|
+
const result = await adapter.receiveToken(makeToken());
|
|
86
|
+
|
|
87
|
+
expect(receiveCashu).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
success: true,
|
|
90
|
+
amount: 5,
|
|
91
|
+
unit: "sat",
|
|
92
|
+
message: "Received 5",
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not call the wallet when token metadata is invalid", async () => {
|
|
97
|
+
const receiveCashu = mock(async () => "should not be called");
|
|
98
|
+
const walletClient = makeWalletClient(receiveCashu);
|
|
99
|
+
const adapter = await createWalletAdapter({ walletClient });
|
|
100
|
+
|
|
101
|
+
const result = await adapter.receiveToken("cashuBnot-a-valid-token");
|
|
102
|
+
|
|
103
|
+
expect(receiveCashu).not.toHaveBeenCalled();
|
|
104
|
+
expect(result.success).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|