Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ cocod mints list
# History
cocod history --limit 10
cocod history --watch

# Logs
cocod logs
cocod logs --follow
cocod logs --path
```

## NPC (Lightning Address)
Expand Down Expand Up @@ -95,9 +100,16 @@ 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`

Logging defaults:

- Structured JSON logs are written to `~/.cocod/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`

## Development

```bash
Expand Down
54 changes: 54 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { startDaemon } from "./daemon";
import { program, handleDaemonCommand, callDaemonStream } from "./cli-shared";
import {
DEFAULT_LOG_LINES,
followLogFile,
getLogFileSize,
parseLogLineCount,
readRecentLogText,
} from "./logs";
import { LOG_FILE } from "./utils/config";
import packageJson from "../package.json" with { type: "json" };

const cliVersion = packageJson.version;
Expand Down Expand Up @@ -112,6 +120,52 @@ program
await handleDaemonCommand("/ping");
});

// Logs
program
.command("logs")
.description("Show daemon logs")
.option("--follow", "Stream log updates")
.option("--lines <number>", "Number of recent lines to show", String(DEFAULT_LOG_LINES))
.option("--path", "Print the resolved log file path")
.action(async (options: { follow?: boolean; lines?: string; path?: boolean }) => {
try {
if (options.path) {
console.log(LOG_FILE);
return;
}

const lineCount = parseLogLineCount(options.lines ?? String(DEFAULT_LOG_LINES));
const fileExists = await Bun.file(LOG_FILE).exists();
const startPosition = fileExists ? await getLogFileSize(LOG_FILE) : 0;

if (!fileExists && !options.follow) {
throw new Error(`Log file not found: ${LOG_FILE}`);
}

if (fileExists) {
const recentLogs = await readRecentLogText(LOG_FILE, lineCount);

if (recentLogs.length > 0) {
process.stdout.write(recentLogs);
}
}

if (options.follow) {
await followLogFile(
LOG_FILE,
(chunk) => {
process.stdout.write(chunk);
},
{ startPosition },
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
}
});

// Stop
program
.command("stop")
Expand Down
118 changes: 88 additions & 30 deletions src/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { mnemonicToSeedSync } from "@scure/bip39";
import { 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";

export async function startDaemon() {
const stateManager = new DaemonStateManager();
const logger = createDaemonLogger();

logger.info("daemon.start.requested", {
pidFile: PID_FILE,
socketPath: SOCKET_PATH,
});

try {
const testConn = await Bun.connect({
Expand All @@ -19,6 +26,11 @@ export async function startDaemon() {
},
});
testConn.end();
logger.warn("daemon.start.skipped", {
reason: "already_running",
socketPath: SOCKET_PATH,
});
await logger.flush();
console.error(`Error: Daemon is already running on ${SOCKET_PATH}`);
process.exit(1);
} catch {
Expand Down Expand Up @@ -53,63 +65,109 @@ export async function startDaemon() {

if (config.encrypted) {
stateManager.setLocked(config.mnemonic, config.mintUrl);
console.log("Wallet locked. Run 'cocod unlock <passphrase>' to decrypt.");
logger.info("wallet.config_loaded", {
encrypted: true,
mintUrl: config.mintUrl,
state: "LOCKED",
});
} else {
const manager = await initializeWallet(config);
const manager = await initializeWallet(
config,
undefined,
logger.child({ component: "wallet" }),
);
const seed = mnemonicToSeedSync(config.mnemonic);
stateManager.setUnlocked(manager, config.mintUrl, seed);
console.log("Wallet auto-initialized (unencrypted).");
logger.info("wallet.config_loaded", {
encrypted: false,
mintUrl: config.mintUrl,
state: "UNLOCKED",
});
}
} else {
logger.info("wallet.config_missing");
}
} catch (error) {
console.warn("Failed to load existing config:", error);
logger.warn("wallet.config_load_failed", { error: serializeError(error) });
stateManager.setError(String(error));
}

const routeHandlers = createRouteHandlers(stateManager);
const routes = buildRoutes(routeHandlers, () => stateManager.getState());
const routeHandlers = createRouteHandlers(stateManager, logger.child({ component: "wallet" }));
const routes = buildRoutes(
routeHandlers,
() => stateManager.getState(),
logger.child({
component: "http",
}),
);

let server: ReturnType<typeof Bun.serve> | undefined;
let isShuttingDown = false;

const cleanup = async (reason: string) => {
if (isShuttingDown) {
return;
}

isShuttingDown = true;
logger.info("daemon.shutdown.requested", { reason });

server?.stop();

try {
await Bun.file(PID_FILE).delete();
} catch {
// File might not exist
}

logger.info("daemon.shutdown.completed", { reason });
await logger.flush();
process.exit(0);
};

const server = Bun.serve({
server = Bun.serve({
unix: SOCKET_PATH,
routes: {
...routes,
"/stop": {
POST: async () => {
console.log("\nShutting down daemon...");
setTimeout(async () => {
server.stop();
try {
await Bun.file(PID_FILE).delete();
} catch {
// File might not exist
}
process.exit(0);
logger.info("daemon.stop_requested", { reason: "http_stop" });
setTimeout(() => {
void cleanup("http_stop");
}, 100);
return Response.json({ output: "Daemon stopping" });
},
},
},
async fetch(req) {
logger.warn("request.unknown_endpoint", {
method: req.method,
url: req.url,
});
return Response.json({ error: `Unknown endpoint: ${req.url}` }, { status: 404 });
},
});

console.log(`Daemon listening on ${SOCKET_PATH}`);
logger.info("daemon.started", { socketPath: SOCKET_PATH });
if (stateManager.isUninitialized()) {
console.log("Wallet not initialized. Run 'cocod init [mnemonic]' to set up.");
logger.info("wallet.uninitialized");
}

const cleanup = async () => {
console.log("\nShutting down daemon...");
server.stop();
try {
await Bun.file(PID_FILE).delete();
} catch {
// File might not exist
}
process.exit(0);
};
process.on("unhandledRejection", (error) => {
logger.error("daemon.unhandled_rejection", { error: serializeError(error) });
});

process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("uncaughtException", (error) => {
logger.error("daemon.uncaught_exception", { error: serializeError(error) });
void logger.flush().finally(() => {
process.exit(1);
});
});

process.on("SIGINT", () => {
void cleanup("sigint");
});
process.on("SIGTERM", () => {
void cleanup("sigterm");
});
}
54 changes: 54 additions & 0 deletions src/logs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { mkdtemp, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

import { describe, expect, test } from "bun:test";

import { followLogFile, parseLogLineCount, tailLogLines } from "./logs";

describe("logs helpers", () => {
test("tailLogLines returns the requested trailing lines", () => {
expect(tailLogLines("one\ntwo\nthree\n", 2)).toBe("two\nthree\n");
expect(tailLogLines("one\ntwo\nthree", 1)).toBe("three");
});

test("parseLogLineCount requires a positive integer", () => {
expect(parseLogLineCount("25")).toBe(25);
expect(() => parseLogLineCount("0")).toThrow("--lines must be a positive integer");
expect(() => parseLogLineCount("2x")).toThrow("--lines must be a positive integer");
});

test("followLogFile continues after log rotation", async () => {
const dir = await mkdtemp(join(tmpdir(), "cocod-logs-"));
const logFile = join(dir, "daemon.log");
await writeFile(logFile, "one\n", "utf8");

const controller = new AbortController();
const chunks: string[] = [];
const followPromise = followLogFile(
logFile,
(chunk) => {
chunks.push(chunk);
controller.abort();
},
{
startPosition: Buffer.byteLength("one\n"),
pollIntervalMs: 10,
signal: controller.signal,
},
);

await Bun.sleep(20);
await rename(logFile, `${logFile}.1`);
await writeFile(logFile, "two\n", "utf8");

await Promise.race([
followPromise,
Bun.sleep(500).then(() => {
throw new Error("Timed out waiting for followed log output");
}),
]);

expect(chunks).toEqual(["two\n"]);
});
});
Loading
Loading