@routstr/cocod 0.0.16

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.
@@ -0,0 +1,164 @@
1
+ import { program } from "commander";
2
+
3
+ const CONFIG_DIR = `${process.env.HOME || process.env.USERPROFILE}/.cocod`;
4
+ const SOCKET_PATH = process.env.COCOD_SOCKET || `${CONFIG_DIR}/cocod.sock`;
5
+
6
+ export interface CommandResponse {
7
+ output?: unknown;
8
+ error?: string;
9
+ }
10
+
11
+ async function callDaemon(
12
+ path: string,
13
+ options: { method?: "GET" | "POST"; body?: object } = {},
14
+ ): Promise<CommandResponse> {
15
+ const { method = "GET", body } = options;
16
+
17
+ const init: RequestInit & { unix: string } = {
18
+ unix: SOCKET_PATH,
19
+ method,
20
+ headers: body ? { "Content-Type": "application/json" } : {},
21
+ body: body ? JSON.stringify(body) : undefined,
22
+ } as RequestInit & { unix: string };
23
+
24
+ const response = await fetch(`http://localhost${path}`, init);
25
+
26
+ if (!response.ok) {
27
+ const errorData = (await response.json()) as { error?: string };
28
+ throw new Error(errorData.error || `HTTP ${response.status}`);
29
+ }
30
+
31
+ return response.json() as Promise<CommandResponse>;
32
+ }
33
+
34
+ export async function isDaemonRunning(): Promise<boolean> {
35
+ try {
36
+ const response = await fetch(`http://localhost/ping`, {
37
+ unix: SOCKET_PATH,
38
+ } as RequestInit);
39
+ return response.ok;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ export async function startDaemonProcess(): Promise<void> {
46
+ const proc = Bun.spawn({
47
+ cmd: ["bun", "run", `${import.meta.dir}/index.ts`, "daemon"],
48
+ stdout: "ignore",
49
+ stderr: "ignore",
50
+ stdin: "ignore",
51
+ });
52
+ proc.unref();
53
+
54
+ for (let i = 0; i < 50; i++) {
55
+ await new Promise((resolve) => setTimeout(resolve, 100));
56
+ if (await isDaemonRunning()) {
57
+ return;
58
+ }
59
+ }
60
+
61
+ throw new Error("Daemon failed to start within 5 seconds");
62
+ }
63
+
64
+ export async function ensureDaemonRunning(): Promise<void> {
65
+ if (await isDaemonRunning()) {
66
+ return;
67
+ }
68
+
69
+ console.log("Starting daemon...");
70
+ await startDaemonProcess();
71
+ }
72
+
73
+ export async function handleDaemonCommand(
74
+ path: string,
75
+ options: { method?: "GET" | "POST"; body?: object } = {},
76
+ ): Promise<CommandResponse> {
77
+ try {
78
+ await ensureDaemonRunning();
79
+ const result = await callDaemon(path, options);
80
+
81
+ if (result.error) {
82
+ console.log(result.error);
83
+ process.exit(1);
84
+ }
85
+
86
+ if (result.output !== undefined) {
87
+ if (typeof result.output === "string") {
88
+ console.log(result.output);
89
+ } else {
90
+ try {
91
+ const formatted = JSON.stringify(result.output, null, 2);
92
+ console.log(formatted ?? String(result.output));
93
+ } catch {
94
+ console.log(String(result.output));
95
+ }
96
+ }
97
+ }
98
+
99
+ return result;
100
+ } catch (error) {
101
+ const message = (error as Error).message;
102
+ if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
103
+ console.error("Daemon is not running and failed to auto-start");
104
+ process.exit(1);
105
+ }
106
+ console.error(message);
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ export async function callDaemonStream(
112
+ path: string,
113
+ onData: (data: unknown) => void,
114
+ ): Promise<void> {
115
+ await ensureDaemonRunning();
116
+
117
+ const init: RequestInit & { unix: string } = {
118
+ unix: SOCKET_PATH,
119
+ method: "GET",
120
+ } as RequestInit & { unix: string };
121
+
122
+ const response = await fetch(`http://localhost${path}`, init);
123
+
124
+ if (!response.ok) {
125
+ const errorData = (await response.json()) as { error?: string };
126
+ throw new Error(errorData.error || `HTTP ${response.status}`);
127
+ }
128
+
129
+ if (!response.body) {
130
+ throw new Error("No response body");
131
+ }
132
+
133
+ const reader = response.body.getReader();
134
+ const decoder = new TextDecoder();
135
+ let buffer = "";
136
+
137
+ try {
138
+ while (true) {
139
+ const { done, value } = await reader.read();
140
+ if (done) break;
141
+
142
+ buffer += decoder.decode(value, { stream: true });
143
+
144
+ const lines = buffer.split("\n");
145
+ buffer = lines.pop() || "";
146
+
147
+ for (const line of lines) {
148
+ if (line.startsWith("data: ")) {
149
+ const jsonStr = line.slice(6);
150
+ try {
151
+ const data = JSON.parse(jsonStr);
152
+ onData(data);
153
+ } catch {
154
+ // Skip malformed JSON
155
+ }
156
+ }
157
+ }
158
+ }
159
+ } finally {
160
+ reader.releaseLock();
161
+ }
162
+ }
163
+
164
+ export { program, callDaemon };
package/src/cli.ts ADDED
@@ -0,0 +1,317 @@
1
+ import { startDaemon } from "./daemon";
2
+ import { program, handleDaemonCommand, callDaemonStream } from "./cli-shared";
3
+ import {
4
+ DEFAULT_LOG_LINES,
5
+ followLogFile,
6
+ getLogFileSize,
7
+ parseLogLineCount,
8
+ readRecentLogText,
9
+ } from "./logs";
10
+ import { LOG_FILE } from "./utils/config";
11
+ import packageJson from "../package.json" with { type: "json" };
12
+
13
+ const cliVersion = packageJson.version;
14
+
15
+ program
16
+ .name("cocod")
17
+ .description("Coco CLI - A Cashu wallet daemon")
18
+ .version(cliVersion, "--version", "output the version number");
19
+
20
+ // Status - check daemon/wallet state
21
+ program
22
+ .command("status")
23
+ .description("Check daemon and wallet status")
24
+ .action(async () => {
25
+ await handleDaemonCommand("/status");
26
+ });
27
+
28
+ // Init - initialize wallet
29
+ program
30
+ .command("init [mnemonic]")
31
+ .description("Initialize wallet with optional mnemonic (generates one if not provided)")
32
+ .option("--passphrase <passphrase>", "Encrypt wallet with passphrase")
33
+ .option("--mint-url <url>", "Default mint URL (default: https://mint.minibits.cash/Bitcoin)")
34
+ .action(
35
+ async (mnemonic: string | undefined, options: { passphrase?: string; mintUrl?: string }) => {
36
+ await handleDaemonCommand("/init", {
37
+ method: "POST",
38
+ body: {
39
+ mnemonic,
40
+ passphrase: options.passphrase,
41
+ mintUrl: options.mintUrl,
42
+ },
43
+ });
44
+ },
45
+ );
46
+
47
+ // Unlock - unlock encrypted wallet
48
+ program
49
+ .command("unlock <passphrase>")
50
+ .description("Unlock encrypted wallet with passphrase")
51
+ .action(async (passphrase: string) => {
52
+ await handleDaemonCommand("/unlock", {
53
+ method: "POST",
54
+ body: { passphrase },
55
+ });
56
+ });
57
+
58
+ // Balance - simple GET command
59
+ program
60
+ .command("balance")
61
+ .description("Get wallet balance")
62
+ .action(async () => {
63
+ await handleDaemonCommand("/balance");
64
+ });
65
+
66
+ // Receive - nested subcommands
67
+ const receiveCmd = program.command("receive").description("Receive operations");
68
+
69
+ receiveCmd
70
+ .command("cashu <token>")
71
+ .description("Receive Cashu token")
72
+ .action(async (token: string) => {
73
+ await handleDaemonCommand("/receive/cashu", {
74
+ method: "POST",
75
+ body: { token },
76
+ });
77
+ });
78
+
79
+ receiveCmd
80
+ .command("bolt11 <amount>")
81
+ .description("Create Lightning invoice to receive tokens")
82
+ .option("--mint-url <url>", "Mint URL to use (defaults to the mint URL configured during init)")
83
+ .action(async (amount: string, options: { mintUrl?: string }) => {
84
+ await handleDaemonCommand("/receive/bolt11", {
85
+ method: "POST",
86
+ body: { amount: parseInt(amount), mintUrl: options.mintUrl },
87
+ });
88
+ });
89
+
90
+ // Send - nested subcommands
91
+ const sendCmd = program.command("send").description("Send operations");
92
+
93
+ sendCmd
94
+ .command("cashu <amount>")
95
+ .description("Create Cashu token to send")
96
+ .option("--mint-url <url>", "Mint URL to use (defaults to the mint URL configured during init)")
97
+ .action(async (amount: string, options: { mintUrl?: string }) => {
98
+ await handleDaemonCommand("/send/cashu", {
99
+ method: "POST",
100
+ body: { amount: parseInt(amount), mintUrl: options.mintUrl },
101
+ });
102
+ });
103
+
104
+ sendCmd
105
+ .command("bolt11 <invoice>")
106
+ .description("Pay Lightning invoice")
107
+ .option("--mint-url <url>", "Mint URL to use (defaults to the mint URL configured during init)")
108
+ .action(async (invoice: string, options: { mintUrl?: string }) => {
109
+ await handleDaemonCommand("/send/bolt11", {
110
+ method: "POST",
111
+ body: { invoice, mintUrl: options.mintUrl },
112
+ });
113
+ });
114
+
115
+ // Ping
116
+ program
117
+ .command("ping")
118
+ .description("Test connection to the daemon")
119
+ .action(async () => {
120
+ await handleDaemonCommand("/ping");
121
+ });
122
+
123
+ // Logs
124
+ program
125
+ .command("logs")
126
+ .description("Show daemon logs")
127
+ .option("--follow", "Stream log updates")
128
+ .option("--lines <number>", "Number of recent lines to show", String(DEFAULT_LOG_LINES))
129
+ .option("--path", "Print the resolved log file path")
130
+ .action(async (options: { follow?: boolean; lines?: string; path?: boolean }) => {
131
+ try {
132
+ if (options.path) {
133
+ console.log(LOG_FILE);
134
+ return;
135
+ }
136
+
137
+ const lineCount = parseLogLineCount(options.lines ?? String(DEFAULT_LOG_LINES));
138
+ const fileExists = await Bun.file(LOG_FILE).exists();
139
+ const startPosition = fileExists ? await getLogFileSize(LOG_FILE) : 0;
140
+
141
+ if (!fileExists && !options.follow) {
142
+ throw new Error(`Log file not found: ${LOG_FILE}`);
143
+ }
144
+
145
+ if (fileExists) {
146
+ const recentLogs = await readRecentLogText(LOG_FILE, lineCount);
147
+
148
+ if (recentLogs.length > 0) {
149
+ process.stdout.write(recentLogs);
150
+ }
151
+ }
152
+
153
+ if (options.follow) {
154
+ await followLogFile(
155
+ LOG_FILE,
156
+ (chunk) => {
157
+ process.stdout.write(chunk);
158
+ },
159
+ { startPosition },
160
+ );
161
+ }
162
+ } catch (error) {
163
+ const message = error instanceof Error ? error.message : String(error);
164
+ console.error(message);
165
+ process.exit(1);
166
+ }
167
+ });
168
+
169
+ // Stop
170
+ program
171
+ .command("stop")
172
+ .description("Stop the background daemon")
173
+ .action(async () => {
174
+ await handleDaemonCommand("/stop", { method: "POST" });
175
+ });
176
+
177
+ // Mints - nested subcommands
178
+ const mintsCmd = program.command("mints").description("Mints operations");
179
+
180
+ mintsCmd
181
+ .command("add <url>")
182
+ .description("Add a mint URL")
183
+ .action(async (url: string) => {
184
+ await handleDaemonCommand("/mints/add", {
185
+ method: "POST",
186
+ body: { url },
187
+ });
188
+ });
189
+
190
+ mintsCmd
191
+ .command("list")
192
+ .description("List configured mints")
193
+ .action(async () => {
194
+ await handleDaemonCommand("/mints/list");
195
+ });
196
+
197
+ mintsCmd
198
+ .command("info <url>")
199
+ .description("Get mint info")
200
+ .action(async (url: string) => {
201
+ await handleDaemonCommand("/mints/info", {
202
+ method: "POST",
203
+ body: { url },
204
+ });
205
+ });
206
+
207
+ // NPC - nested subcommands
208
+ const npcCmd = program.command("npc").description("NPC operations");
209
+
210
+ npcCmd
211
+ .command("address")
212
+ .description("Get NPC user address")
213
+ .action(async () => {
214
+ await handleDaemonCommand("/npc/address");
215
+ });
216
+
217
+ npcCmd
218
+ .command("username <name>")
219
+ .description("Buy/set NPC username")
220
+ .option("--confirm", "Confirm payment to set username")
221
+ .action(async (name: string, options: { confirm?: boolean }) => {
222
+ await handleDaemonCommand("/npc/username", {
223
+ method: "POST",
224
+ body: {
225
+ username: name,
226
+ confirm: options.confirm,
227
+ },
228
+ });
229
+ });
230
+
231
+ // x-cashu - nested subcommands
232
+ const xCashuCmd = program.command("x-cashu").description("x-cashu operations");
233
+
234
+ xCashuCmd
235
+ .command("parse <request>")
236
+ .description("Parse x-cashu request")
237
+ .action(async (request: string) => {
238
+ await handleDaemonCommand("/x-cashu/parse", {
239
+ method: "POST",
240
+ body: { request },
241
+ });
242
+ });
243
+
244
+ xCashuCmd
245
+ .command("handle <request>")
246
+ .description("Handle x-cashu request. Returns a X-Cashu header")
247
+ .action(async (request: string) => {
248
+ await handleDaemonCommand("/x-cashu/handle", {
249
+ method: "POST",
250
+ body: { request },
251
+ });
252
+ });
253
+
254
+ // History - with pagination and watch options
255
+ program
256
+ .command("history")
257
+ .description("Wallet history operations")
258
+ .option("--offset <number>", "Pagination offset (cannot be combined with --watch)", "0")
259
+ .option("--limit <number>", "Number of entries to fetch (1-100, default: 20)", "20")
260
+ .option(
261
+ "--watch",
262
+ "Stream history updates in real-time after fetching (can be combined with --limit)",
263
+ )
264
+ .action(async (options: { offset?: string; limit?: string; watch?: boolean }) => {
265
+ const offset = parseInt(options.offset || "0", 10);
266
+ const limit = parseInt(options.limit || "20", 10);
267
+
268
+ // Validate: offset and watch cannot be combined
269
+ if (offset > 0 && options.watch) {
270
+ console.error("Error: --offset cannot be combined with --watch");
271
+ process.exit(1);
272
+ }
273
+
274
+ // Validate numbers
275
+ if (isNaN(offset) || offset < 0) {
276
+ console.error("Error: --offset must be a non-negative number");
277
+ process.exit(1);
278
+ }
279
+
280
+ if (isNaN(limit) || limit < 1 || limit > 100) {
281
+ console.error("Error: --limit must be between 1 and 100");
282
+ process.exit(1);
283
+ }
284
+
285
+ // Fetch paginated history first (pass params as query string, not body)
286
+ const queryParams = new URLSearchParams();
287
+ queryParams.set("offset", offset.toString());
288
+ queryParams.set("limit", limit.toString());
289
+ const path = `/history?${queryParams.toString()}`;
290
+
291
+ await handleDaemonCommand(path);
292
+
293
+ // If watch is enabled, continue streaming after initial fetch
294
+ if (options.watch) {
295
+ try {
296
+ await callDaemonStream("/events", (data) => {
297
+ console.log(JSON.stringify(data));
298
+ });
299
+ } catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ console.error(message);
302
+ process.exit(1);
303
+ }
304
+ }
305
+ });
306
+
307
+ // Daemon command - special case, doesn't go through IPC
308
+ program
309
+ .command("daemon")
310
+ .description("Start the background daemon")
311
+ .action(async () => {
312
+ await startDaemon();
313
+ });
314
+
315
+ export function cli(args: string[]) {
316
+ program.parse(args);
317
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { mnemonicToSeedSync } from "@scure/bip39";
2
+ import { unlink } from "node:fs/promises";
3
+ import { CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js";
4
+ import { createDaemonLogger, serializeError } from "./utils/logger.js";
5
+ import { DaemonStateManager } from "./utils/state.js";
6
+ import { initializeWallet } from "./utils/wallet.js";
7
+ import { createRouteHandlers, buildRoutes } from "./routes.js";
8
+ import type { WalletConfig } from "./utils/config.js";
9
+
10
+ export async function startDaemon() {
11
+ const stateManager = new DaemonStateManager();
12
+ const logger = createDaemonLogger();
13
+
14
+ logger.info("daemon.start.requested", {
15
+ pidFile: PID_FILE,
16
+ socketPath: SOCKET_PATH,
17
+ });
18
+
19
+ try {
20
+ const testConn = await Bun.connect({
21
+ unix: SOCKET_PATH,
22
+ socket: {
23
+ data() {},
24
+ open() {},
25
+ close() {},
26
+ drain() {},
27
+ },
28
+ });
29
+ testConn.end();
30
+ logger.warn("daemon.start.skipped", {
31
+ reason: "already_running",
32
+ socketPath: SOCKET_PATH,
33
+ });
34
+ await logger.flush();
35
+ console.error(`Error: Daemon is already running on ${SOCKET_PATH}`);
36
+ process.exit(1);
37
+ } catch {
38
+ // Not running, safe to proceed
39
+ }
40
+
41
+ try {
42
+ await Bun.write(PID_FILE, "");
43
+ await unlink(PID_FILE);
44
+ } catch {
45
+ // Directory creation failed or file didn't exist
46
+ }
47
+
48
+ try {
49
+ await unlink(SOCKET_PATH);
50
+ } catch {
51
+ // File might not exist
52
+ }
53
+ try {
54
+ await unlink(PID_FILE);
55
+ } catch {
56
+ // File might not exist
57
+ }
58
+
59
+ await Bun.write(PID_FILE, process.pid.toString());
60
+
61
+ try {
62
+ const configExists = await Bun.file(CONFIG_FILE).exists();
63
+ if (configExists) {
64
+ const configText = await Bun.file(CONFIG_FILE).text();
65
+ const config: WalletConfig = JSON.parse(configText);
66
+
67
+ if (config.encrypted) {
68
+ stateManager.setLocked(config.mnemonic, config.mintUrl);
69
+ logger.info("wallet.config_loaded", {
70
+ encrypted: true,
71
+ mintUrl: config.mintUrl,
72
+ state: "LOCKED",
73
+ });
74
+ } else {
75
+ const manager = await initializeWallet(
76
+ config,
77
+ undefined,
78
+ logger.child({ component: "wallet" }),
79
+ );
80
+ const seed = mnemonicToSeedSync(config.mnemonic);
81
+ stateManager.setUnlocked(manager, config.mintUrl, seed);
82
+ logger.info("wallet.config_loaded", {
83
+ encrypted: false,
84
+ mintUrl: config.mintUrl,
85
+ state: "UNLOCKED",
86
+ });
87
+ }
88
+ } else {
89
+ logger.info("wallet.config_missing");
90
+ }
91
+ } catch (error) {
92
+ logger.warn("wallet.config_load_failed", { error: serializeError(error) });
93
+ stateManager.setError(String(error));
94
+ }
95
+
96
+ const routeHandlers = createRouteHandlers(stateManager, logger.child({ component: "wallet" }));
97
+ const routes = buildRoutes(
98
+ routeHandlers,
99
+ () => stateManager.getState(),
100
+ logger.child({
101
+ component: "http",
102
+ }),
103
+ );
104
+
105
+ let server: ReturnType<typeof Bun.serve> | undefined;
106
+ let isShuttingDown = false;
107
+
108
+ const cleanup = async (reason: string) => {
109
+ if (isShuttingDown) {
110
+ return;
111
+ }
112
+
113
+ isShuttingDown = true;
114
+ logger.info("daemon.shutdown.requested", { reason });
115
+
116
+ server?.stop();
117
+
118
+ try {
119
+ await unlink(PID_FILE);
120
+ } catch {
121
+ // File might not exist
122
+ }
123
+
124
+ logger.info("daemon.shutdown.completed", { reason });
125
+ await logger.flush();
126
+ process.exit(0);
127
+ };
128
+
129
+ server = Bun.serve({
130
+ unix: SOCKET_PATH,
131
+ async fetch(req) {
132
+ const url = new URL(req.url);
133
+ const path = url.pathname;
134
+ const method = req.method;
135
+
136
+ // Stop endpoint (special daemon control)
137
+ if (path === "/stop" && method === "POST") {
138
+ logger.info("daemon.stop_requested", { reason: "http_stop" });
139
+ setTimeout(() => {
140
+ void cleanup("http_stop");
141
+ }, 100);
142
+ return Response.json({ output: "Daemon stopping" });
143
+ }
144
+
145
+ // Look up route in the built routes table
146
+ const route = routes[path];
147
+ if (route) {
148
+ const handler = method === "GET" ? route.GET : method === "POST" ? route.POST : undefined;
149
+ if (handler) {
150
+ return handler(req);
151
+ }
152
+ }
153
+
154
+ logger.warn("request.unknown_endpoint", {
155
+ method,
156
+ url: req.url,
157
+ });
158
+ return Response.json({ error: `Unknown endpoint: ${method} ${path}` }, { status: 404 });
159
+ },
160
+ });
161
+
162
+ logger.info("daemon.started", { socketPath: SOCKET_PATH });
163
+ if (stateManager.isUninitialized()) {
164
+ logger.info("wallet.uninitialized");
165
+ }
166
+
167
+ process.on("unhandledRejection", (error) => {
168
+ logger.error("daemon.unhandled_rejection", { error: serializeError(error) });
169
+ });
170
+
171
+ process.on("uncaughtException", (error) => {
172
+ logger.error("daemon.uncaught_exception", { error: serializeError(error) });
173
+ void logger.flush().finally(() => {
174
+ process.exit(1);
175
+ });
176
+ });
177
+
178
+ process.on("SIGINT", () => {
179
+ void cleanup("sigint");
180
+ });
181
+ process.on("SIGTERM", () => {
182
+ void cleanup("sigterm");
183
+ });
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { cli } from "./cli";
3
+
4
+ cli(process.argv);