diff --git a/README.md b/README.md index c93d236..550f1db 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ cocod history --watch cocod logs cocod logs --follow cocod logs --path + +# Debug a stuck init/unlock in another terminal +cocod logs --follow ``` ## NPC (Lightning Address) @@ -98,15 +101,16 @@ cocod x-cashu handle "" Defaults: -- Socket: `~/.cocod/cocod.sock` (or `COCOD_SOCKET`) -- PID file: `~/.cocod/cocod.pid` (or `COCOD_PID`) -- Daemon log: `~/.cocod/daemon.log` (or `COCOD_LOG_FILE`) -- Config: `~/.cocod/config.json` -- Database: `~/.cocod/coco.db` +- Base directory: `~/.cocod` (or `COCOD_DIR`) +- Socket: `/cocod.sock` (or `COCOD_SOCKET`) +- PID file: `/cocod.pid` (or `COCOD_PID`) +- Daemon log: `/daemon.log` (or `COCOD_LOG_FILE`) +- Config: `/config.json` +- Database: `/coco.db` Logging defaults: -- Structured JSON logs are written to `~/.cocod/daemon.log` +- Structured JSON logs are written to `/daemon.log` - Rotation keeps 5 files at 5 MiB each by default - Override with `COCOD_LOG_LEVEL`, `COCOD_LOG_MAX_BYTES`, and `COCOD_LOG_MAX_FILES` diff --git a/bun.lock b/bun.lock old mode 100644 new mode 100755 index b8ab0d4..6abafd4 --- a/bun.lock +++ b/bun.lock @@ -1,9 +1,7 @@ { - "lockfileVersion": 1, - "configVersion": 1, + "lockfileVersion": 0, "workspaces": { "": { - "name": "cocod", "dependencies": { "@scure/bip39": "^2.0.1", "coco-cashu-core": "1.1.2-rc.50", @@ -22,25 +20,25 @@ }, }, "packages": { - "@cashu/cashu-ts": ["@cashu/cashu-ts@3.6.1", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1" } }, "sha512-ynncvX5vv/m2Dzp28m1ApxNsmrsw1p6laOdOnvlHVOPK3x4Nz3K/ScV7UjnbuwubTVeIcKP1FXEdycedhhlJbQ=="], + "@cashu/cashu-ts": ["@cashu/cashu-ts@3.6.4", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1" } }, "sha512-a6Asqk+wPEk9a6BQmdLMeegngj0KHKicABUEtr1cUMLTAUYHVHfdfEuHDlU+bNUqXXyaTssG7yo/ZhfHrgbSkQ=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], - "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + "@noble/curves": ["@noble/curves@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0" } }, "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ=="], - "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], - "@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], - "@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + "@scure/bip32": ["@scure/bip32@2.2.0", "", { "dependencies": { "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg=="], - "@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], + "@scure/bip39": ["@scure/bip39@2.2.0", "", { "dependencies": { "@noble/hashes": "2.2.0", "@scure/base": "2.2.0" } }, "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A=="], - "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], - "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], - "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "coco-cashu-core": ["coco-cashu-core@1.1.2-rc.50", "", { "dependencies": { "@cashu/cashu-ts": "^3.5.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/bip32": "^2.0.1" }, "peerDependencies": { "typescript": "^5" } }, "sha512-eK5YuwvCWpeCwF/GEkMo90FCyek2mS+smQ51wKjkjTKzpkVVlNln52l2h1Wwce4VMnW6GSeL2IgT8IksM8Umuw=="], @@ -48,9 +46,9 @@ "coco-cashu-sqlite-bun": ["coco-cashu-sqlite-bun@1.1.2-rc.50", "", { "peerDependencies": { "coco-cashu-core": "1.1.2-rc.50", "typescript": "^5" } }, "sha512-wYAZjGmk3Xd75rq7y6AGLji4z5/3v5rWj6xgQoa3Qq+37zKnuCeLqVt+A3x/3EcNRLmNoW/u7vQJTJP4APHhIw=="], - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "nostr-tools": ["nostr-tools@2.22.1", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-LJKy4lU6thO6Z6CVWkfqHGDt9m/M5IfRlmEI2hBXYLw4xa3jpfIHKJxXQhx/8C3TcN0YPkMRJlhGmu/g0VH80g=="], + "nostr-tools": ["nostr-tools@2.23.3", "", { "dependencies": { "@noble/ciphers": "2.1.1", "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0", "@scure/bip32": "2.0.1", "@scure/bip39": "2.0.1", "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" }, "optionalPeers": ["typescript"] }, "sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA=="], "nostr-wasm": ["nostr-wasm@0.1.0", "", {}, "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA=="], @@ -58,12 +56,20 @@ "npubcash-types": ["npubcash-types@0.1.1", "", {}, "sha512-/HGfes2cvQpkrWOuUrdemJJhJUQu+xiXl4x+AxQtKUMGCB8uEhfrXJfAQ0n+DgtkIX1YVLOh0a/e6E5LX+fUew=="], - "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "npubcash-sdk/@cashu/cashu-ts": ["@cashu/cashu-ts@3.3.0", "", { "dependencies": { "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", "@scure/bip32": "^2.0.1" } }, "sha512-hHUOhjPLK77axDdj/4qO5mJ14rkh+rZGe1Gz2RbkW2j+SIglNbc2NsKSvjvoQcshupm02FudKi9Y3s3M8ZSYzg=="], + "nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="], + + "nostr-tools/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + + "nostr-tools/@scure/base": ["@scure/base@2.0.0", "", {}, "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w=="], + + "nostr-tools/@scure/bip32": ["@scure/bip32@2.0.1", "", { "dependencies": { "@noble/curves": "2.0.1", "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA=="], + + "nostr-tools/@scure/bip39": ["@scure/bip39@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1", "@scure/base": "2.0.0" } }, "sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg=="], } } diff --git a/docs/API.md b/docs/API.md index b8f541c..05fc607 100644 --- a/docs/API.md +++ b/docs/API.md @@ -59,8 +59,8 @@ All commands are available under `cocod`. The CLI talks to the daemon over HTTP on a UNIX socket. -- Socket path env var: `COCOD_SOCKET` -- Default socket: `~/.cocod/cocod.sock` +- Base directory env var: `COCOD_DIR` (default `~/.cocod`) +- Socket path env var: `COCOD_SOCKET` (default `/cocod.sock`) ### Response shape diff --git a/docs/daemon-api.json b/docs/daemon-api.json index 7f8ef63..98c6b90 100644 --- a/docs/daemon-api.json +++ b/docs/daemon-api.json @@ -3,8 +3,10 @@ "version": "0.0.12", "transport": { "protocol": "http", + "baseDirEnv": "COCOD_DIR", + "defaultBaseDir": "~/.cocod", "unixSocketEnv": "COCOD_SOCKET", - "defaultUnixSocket": "~/.cocod/cocod.sock" + "defaultUnixSocket": "/cocod.sock" }, "responseContract": { "success": { diff --git a/package.json b/package.json index 18723ad..0ebf83d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "cocod", - "version": "0.0.16", + "name": "@routstr/cocod", + "version": "0.0.20", "module": "src/index.ts", "type": "module", "private": false, diff --git a/src/cli-shared.ts b/src/cli-shared.ts index 781b8af..3cd1227 100644 --- a/src/cli-shared.ts +++ b/src/cli-shared.ts @@ -1,7 +1,6 @@ import { program } from "commander"; -const CONFIG_DIR = `${process.env.HOME || process.env.USERPROFILE}/.cocod`; -const SOCKET_PATH = process.env.COCOD_SOCKET || `${CONFIG_DIR}/cocod.sock`; +import { LOG_FILE, SOCKET_PATH } from "./utils/config"; export interface CommandResponse { output?: unknown; @@ -42,6 +41,64 @@ export async function isDaemonRunning(): Promise { } } +const DAEMON_POLL_INTERVAL_MS = 1_000; +const DAEMON_SLOW_START_WARNING_MS = 30_000; +const DAEMON_START_TIMEOUT_MS = 60_000; +const DAEMON_START_LOG_LINES = 40; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForDaemonReady(startedAt: number, warningShown: { value: boolean }): Promise { + for (;;) { + try { + const result = await callDaemon("/status"); + if (typeof result.output === "string") { + return; + } + } catch { + // Daemon may not be accepting requests yet + } + + const elapsedMs = Date.now() - startedAt; + + if (!warningShown.value && elapsedMs >= DAEMON_SLOW_START_WARNING_MS) { + warningShown.value = true; + console.log("Daemon is taking longer than expected, please wait..."); + } + + if (elapsedMs >= DAEMON_START_TIMEOUT_MS) { + throw new Error("Daemon failed to start after 1 minute"); + } + + await sleep(DAEMON_POLL_INTERVAL_MS); + } +} + +function printProgressStep(message: string): void { + console.log(`• ${message}`); +} + +function maybePrintFriendlyProgress(path: string, body?: object): void { + if (path === "/init") { + const mintUrl = + body && "mintUrl" in body && typeof body.mintUrl === "string" + ? body.mintUrl + : "https://mint.minibits.cash/Bitcoin"; + + printProgressStep("Preparing wallet..."); + printProgressStep(`Connecting to mint: ${mintUrl}`); + printProgressStep("This can take a few seconds on first run."); + return; + } + + if (path === "/unlock") { + printProgressStep("Unlocking wallet..."); + printProgressStep("Reconnecting wallet services..."); + } +} + export async function startDaemonProcess(): Promise { const proc = Bun.spawn({ cmd: ["bun", "run", `${import.meta.dir}/index.ts`, "daemon"], @@ -51,14 +108,30 @@ export async function startDaemonProcess(): Promise { }); proc.unref(); - for (let i = 0; i < 50; i++) { - await new Promise((resolve) => setTimeout(resolve, 100)); + const startedAt = Date.now(); + const warningShown = { value: false }; + + for (;;) { + await sleep(DAEMON_POLL_INTERVAL_MS); if (await isDaemonRunning()) { + await waitForDaemonReady(startedAt, warningShown); return; } - } - throw new Error("Daemon failed to start within 5 seconds"); + const elapsedMs = Date.now() - startedAt; + + if (!warningShown.value && elapsedMs >= DAEMON_SLOW_START_WARNING_MS) { + warningShown.value = true; + console.log("Daemon is taking longer than expected, please wait..."); + console.log( + `Tip: run 'cocod logs --follow' or 'tail -n ${DAEMON_START_LOG_LINES} ${LOG_FILE}' in another terminal.`, + ); + } + + if (elapsedMs >= DAEMON_START_TIMEOUT_MS) { + throw new Error("Daemon failed to start after 1 minute"); + } + } } export async function ensureDaemonRunning(): Promise { @@ -76,6 +149,7 @@ export async function handleDaemonCommand( ): Promise { try { await ensureDaemonRunning(); + maybePrintFriendlyProgress(path, options.body); const result = await callDaemon(path, options); if (result.error) { diff --git a/src/daemon.ts b/src/daemon.ts index 5a25cd8..5ad7d93 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,20 +1,90 @@ import { mnemonicToSeedSync } from "@scure/bip39"; -import { CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js"; +import { closeSync, openSync, writeFileSync } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import process from "node:process"; +import { CONFIG_DIR, CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js"; import { createDaemonLogger, serializeError } from "./utils/logger.js"; import { DaemonStateManager } from "./utils/state.js"; import { initializeWallet } from "./utils/wallet.js"; import { createRouteHandlers, buildRoutes } from "./routes.js"; import type { WalletConfig } from "./utils/config.js"; +async function isProcessAlive(pid: number): Promise { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function acquirePidLock(logger: ReturnType): Promise { + await mkdir(CONFIG_DIR, { recursive: true }); + + const pidFile = Bun.file(PID_FILE); + if (await pidFile.exists()) { + const existingPidText = (await pidFile.text()).trim(); + const existingPid = Number.parseInt(existingPidText, 10); + + if (await isProcessAlive(existingPid)) { + logger.warn("daemon.start.skipped", { + reason: "already_running", + pid: existingPid, + pidFile: PID_FILE, + }); + await logger.flush(); + console.error(`Error: Daemon is already running with PID ${existingPid}`); + process.exit(1); + } + + logger.warn("daemon.pid.stale", { + pid: existingPidText || null, + pidFile: PID_FILE, + }); + try { + await unlink(PID_FILE); + } catch { + // File may already be gone + } + } + + try { + const fd = openSync(PID_FILE, "wx"); + try { + writeFileSync(fd, `${process.pid}`); + } finally { + closeSync(fd); + } + } catch { + const currentPidText = (await Bun.file(PID_FILE).text()).trim(); + const currentPid = Number.parseInt(currentPidText, 10); + + logger.warn("daemon.start.skipped", { + reason: "pid_lock_exists", + pid: Number.isNaN(currentPid) ? currentPidText : currentPid, + pidFile: PID_FILE, + }); + await logger.flush(); + console.error("Error: Daemon is already starting or running"); + process.exit(1); + } +} + export async function startDaemon() { const stateManager = new DaemonStateManager(); - const logger = createDaemonLogger(); + const logger = createDaemonLogger({ mirrorToConsole: false }); logger.info("daemon.start.requested", { pidFile: PID_FILE, socketPath: SOCKET_PATH, }); + await acquirePidLock(logger); + try { const testConn = await Bun.connect({ unix: SOCKET_PATH, @@ -30,6 +100,11 @@ export async function startDaemon() { reason: "already_running", socketPath: SOCKET_PATH, }); + try { + await unlink(PID_FILE); + } catch { + // File might not exist + } await logger.flush(); console.error(`Error: Daemon is already running on ${SOCKET_PATH}`); process.exit(1); @@ -38,24 +113,10 @@ export async function startDaemon() { } try { - await Bun.write(PID_FILE, ""); - await Bun.file(PID_FILE).delete(); - } catch { - // Directory creation failed or file didn't exist - } - - try { - await Bun.file(SOCKET_PATH).delete(); + await unlink(SOCKET_PATH); } catch { // File might not exist } - try { - await Bun.file(PID_FILE).delete(); - } catch { - // File might not exist - } - - await Bun.write(PID_FILE, process.pid.toString()); try { const configExists = await Bun.file(CONFIG_FILE).exists(); @@ -86,6 +147,7 @@ export async function startDaemon() { } } else { logger.info("wallet.config_missing"); + logger.info("wallet.uninitialized"); } } catch (error) { logger.warn("wallet.config_load_failed", { error: serializeError(error) }); @@ -115,7 +177,7 @@ export async function startDaemon() { server?.stop(); try { - await Bun.file(PID_FILE).delete(); + await unlink(PID_FILE); } catch { // File might not exist } @@ -127,31 +189,38 @@ export async function startDaemon() { server = Bun.serve({ unix: SOCKET_PATH, - routes: { - ...routes, - "/stop": { - POST: async () => { - logger.info("daemon.stop_requested", { reason: "http_stop" }); - setTimeout(() => { - void cleanup("http_stop"); - }, 100); - return Response.json({ output: "Daemon stopping" }); - }, - }, - }, async fetch(req) { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + // Stop endpoint (special daemon control) + if (path === "/stop" && method === "POST") { + logger.info("daemon.stop_requested", { reason: "http_stop" }); + setTimeout(() => { + void cleanup("http_stop"); + }, 100); + return Response.json({ output: "Daemon stopping" }); + } + + // Look up route in the built routes table + const route = routes[path]; + if (route) { + const handler = method === "GET" ? route.GET : method === "POST" ? route.POST : undefined; + if (handler) { + return handler(req); + } + } + logger.warn("request.unknown_endpoint", { - method: req.method, + method, url: req.url, }); - return Response.json({ error: `Unknown endpoint: ${req.url}` }, { status: 404 }); + return Response.json({ error: `Unknown endpoint: ${method} ${path}` }, { status: 404 }); }, }); logger.info("daemon.started", { socketPath: SOCKET_PATH }); - if (stateManager.isUninitialized()) { - logger.info("wallet.uninitialized"); - } process.on("unhandledRejection", (error) => { logger.error("daemon.unhandled_rejection", { error: serializeError(error) }); diff --git a/src/routes.ts b/src/routes.ts index f7623bb..a7bb89b 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -7,6 +7,7 @@ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/b import { wordlist } from "@scure/bip39/wordlists/english.js"; import { nip19 } from "nostr-tools"; +import { unlink } from "node:fs/promises"; import { encryptMnemonic } from "./utils/crypto.js"; import { CONFIG_FILE, SALT_FILE } from "./utils/config.js"; import { serializeError } from "./utils/logger.js"; @@ -35,34 +36,50 @@ export function createRouteHandlers( }, "/init": { POST: stateManager.requireUninitialized(async (req: Request) => { + const initLogger = logger?.child?.({ route: "/init" }) ?? logger; + try { + initLogger?.info?.("wallet.init.started"); + const body = (await req.json()) as { mnemonic?: string; passphrase?: string; mintUrl?: string; }; + initLogger?.info?.("wallet.init.request_parsed", { + encrypted: Boolean(body.passphrase), + hasMnemonic: Boolean(body.mnemonic), + mintUrl: body.mintUrl || "https://mint.minibits.cash/Bitcoin", + }); + let mnemonic: string; if (body.mnemonic) { + initLogger?.info?.("wallet.init.validating_mnemonic"); if (!validateMnemonic(body.mnemonic, wordlist)) { + initLogger?.warn?.("wallet.init.invalid_mnemonic"); return Response.json({ error: "Invalid mnemonic" }, { status: 400 }); } mnemonic = body.mnemonic; } else { + initLogger?.info?.("wallet.init.generating_mnemonic"); mnemonic = generateMnemonic(wordlist, 256); } const mintUrl = body.mintUrl || "https://mint.minibits.cash/Bitcoin"; const encrypted = !!body.passphrase; + initLogger?.info?.("wallet.init.resetting_config_file", { configFile: CONFIG_FILE }); await Bun.write(CONFIG_FILE, ""); - await Bun.file(CONFIG_FILE).delete(); + await unlink(CONFIG_FILE); let config: WalletConfig; if (encrypted && body.passphrase) { + initLogger?.info?.("wallet.init.encrypting_mnemonic"); const { ciphertext, salt } = await encryptMnemonic(mnemonic, body.passphrase); + initLogger?.info?.("wallet.init.writing_salt_file", { saltFile: SALT_FILE }); await Bun.write(SALT_FILE, salt); config = { @@ -74,6 +91,7 @@ export function createRouteHandlers( }; stateManager.setLocked(ciphertext, mintUrl); + initLogger?.info?.("wallet.init.completed_locked", { mintUrl, state: "LOCKED" }); } else { config = { version: 1, @@ -83,19 +101,25 @@ export function createRouteHandlers( createdAt: new Date().toISOString(), }; - const manager = await initializeWallet(config, undefined, logger); + initLogger?.info?.("wallet.init.initializing_wallet_manager", { mintUrl }); + const manager = await initializeWallet(config, undefined, initLogger); + initLogger?.info?.("wallet.init.wallet_manager_ready", { mintUrl }); const seed = mnemonicToSeedSync(mnemonic); stateManager.setUnlocked(manager, mintUrl, seed); + initLogger?.info?.("wallet.init.completed_unlocked", { mintUrl, state: "UNLOCKED" }); } + initLogger?.info?.("wallet.init.writing_config_file", { configFile: CONFIG_FILE }); await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2)); const output = encrypted ? `Initialized (locked). Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!` : `Initialized. Mnemonic: ${mnemonic}\nIMPORTANT: Write down this mnemonic and keep it safe!`; + initLogger?.info?.("wallet.init.response_ready", { encrypted, mintUrl }); return Response.json({ output }); } catch (error) { + initLogger?.error?.("wallet.init.failed", { error: serializeError(error) }); const message = error instanceof Error ? error.message : String(error); return Response.json({ error: `Init failed: ${message}` }, { status: 500 }); } @@ -103,15 +127,21 @@ export function createRouteHandlers( }, "/unlock": { POST: stateManager.requireLocked(async (req: Request, state: LockedState) => { + const unlockLogger = logger?.child?.({ route: "/unlock" }) ?? logger; + try { + unlockLogger?.info?.("wallet.unlock.started", { mintUrl: state.mintUrl }); const body = (await req.json()) as { passphrase: string }; if (!body.passphrase) { + unlockLogger?.warn?.("wallet.unlock.missing_passphrase"); return Response.json({ error: "Passphrase required" }, { status: 400 }); } + unlockLogger?.info?.("wallet.unlock.reading_salt_file", { saltFile: SALT_FILE }); const salt = await Bun.file(SALT_FILE).text(); const { decryptMnemonic } = await import("./utils/crypto.js"); + unlockLogger?.info?.("wallet.unlock.decrypting_mnemonic"); const mnemonic = await decryptMnemonic(state.encryptedMnemonic, body.passphrase, salt); const config: WalletConfig = { @@ -122,13 +152,22 @@ export function createRouteHandlers( createdAt: new Date().toISOString(), }; - const manager = await initializeWallet(config, undefined, logger); + unlockLogger?.info?.("wallet.unlock.initializing_wallet_manager", { + mintUrl: state.mintUrl, + }); + const manager = await initializeWallet(config, undefined, unlockLogger); + unlockLogger?.info?.("wallet.unlock.wallet_manager_ready", { mintUrl: state.mintUrl }); const seed = mnemonicToSeedSync(mnemonic); stateManager.setUnlocked(manager, state.mintUrl, seed); + unlockLogger?.info?.("wallet.unlock.completed", { + mintUrl: state.mintUrl, + state: "UNLOCKED", + }); return Response.json({ output: "Unlocked" }); } catch (error) { + unlockLogger?.error?.("wallet.unlock.failed", { error: serializeError(error) }); const message = error instanceof Error ? error.message : String(error); return Response.json({ error: `Unlock failed: ${message}` }, { status: 401 }); } @@ -503,11 +542,13 @@ async function runRoute( const durationMs = Math.round(performance.now() - startedAt); const level = response.status >= 500 ? "error" : response.status >= 400 ? "warn" : "info"; - requestLogger?.log?.(level, "request.completed", { - durationMs, - state: getState().status, - status: response.status, - }); + if (path !== "/status") { + requestLogger?.log?.(level, "request.completed", { + durationMs, + state: getState().status, + status: response.status, + }); + } return response; } catch (error) { diff --git a/src/utils/config.ts b/src/utils/config.ts index 1eec89a..add6de7 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; -export const CONFIG_DIR = `${homedir()}/.cocod`; +export const CONFIG_DIR = process.env.COCOD_DIR || `${homedir()}/.cocod`; export const SOCKET_PATH = process.env.COCOD_SOCKET || `${CONFIG_DIR}/cocod.sock`; export const PID_FILE = process.env.COCOD_PID || `${CONFIG_DIR}/cocod.pid`; export const LOG_FILE = process.env.COCOD_LOG_FILE || `${CONFIG_DIR}/daemon.log`; diff --git a/src/utils/state.ts b/src/utils/state.ts index 25efbca..bf4b68c 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -1,5 +1,7 @@ import type { Manager } from "coco-cashu-core"; +import { CONFIG_FILE } from "./config"; + export interface UninitializedState { status: "UNINITIALIZED"; } @@ -97,7 +99,7 @@ export class DaemonStateManager { if (state.status !== "UNINITIALIZED") { return Response.json( { - error: "Wallet already initialized. Delete ~/.cocod/config.json to reset.", + error: `Wallet already initialized. Delete ${CONFIG_FILE} to reset.`, }, { status: 409 }, ); diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 442d9ab..993e260 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -14,38 +14,54 @@ export async function initializeWallet( passphrase?: string, logger?: Logger, ): Promise { + const walletLogger = logger?.child?.({ component: "wallet-init" }) ?? logger; + walletLogger?.info?.("wallet.initialize.started", { + encrypted: config.encrypted, + mintUrl: config.mintUrl, + dbFile: DB_FILE, + }); + let mnemonic: string; if (config.encrypted) { + walletLogger?.info?.("wallet.initialize.decrypting_config_mnemonic", { saltFile: SALT_FILE }); if (!passphrase) { throw new Error("Passphrase required for encrypted wallet"); } const salt = await Bun.file(SALT_FILE).text(); mnemonic = await decryptMnemonic(config.mnemonic, passphrase, salt); } else { + walletLogger?.info?.("wallet.initialize.using_plaintext_config_mnemonic"); mnemonic = config.mnemonic; } + walletLogger?.info?.("wallet.initialize.derived_mnemonic"); const seed = mnemonicToSeedSync(mnemonic); + walletLogger?.info?.("wallet.initialize.opening_database", { dbFile: DB_FILE }); const repo = new SqliteRepositories({ database: new Database(DB_FILE) }); - const walletLogger = logger?.child?.({ component: "coco" }) ?? logger; - const cocoLogger = walletLogger ?? new ConsoleLogger("Coco", { level: "info" }); + const cocoLogger = walletLogger?.child?.({ component: "coco" }) ?? new ConsoleLogger("Coco", { level: "info" }); + walletLogger?.info?.("wallet.initialize.preparing_signer"); const sk = privateKeyFromSeedWords(mnemonic); const signer = async (t: EventTemplate) => finalizeEvent(t, sk); + walletLogger?.info?.("wallet.initialize.creating_npc_plugin", { npcUrl: "https://npubx.cash" }); const npcPlugin = new NPCPlugin("https://npubx.cash", signer, { useWebsocket: true, logger: cocoLogger, }); + walletLogger?.info?.("wallet.initialize.initializing_coco_core", { mintUrl: config.mintUrl }); const coco = await initializeCoco({ repo, seedGetter: async () => seed, logger: cocoLogger, }); + walletLogger?.info?.("wallet.initialize.registering_npc_plugin"); coco.use(npcPlugin); + walletLogger?.info?.("wallet.initialize.adding_trusted_mint", { mintUrl: config.mintUrl }); await coco.mint.addMint(config.mintUrl, { trusted: true }); + walletLogger?.info?.("wallet.initialize.completed", { mintUrl: config.mintUrl }); return coco; }