cocod 0.0.1

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,163 @@
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.error(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
+ throw error;
107
+ }
108
+ }
109
+
110
+ export async function callDaemonStream(
111
+ path: string,
112
+ onData: (data: unknown) => void,
113
+ ): Promise<void> {
114
+ await ensureDaemonRunning();
115
+
116
+ const init: RequestInit & { unix: string } = {
117
+ unix: SOCKET_PATH,
118
+ method: "GET",
119
+ } as RequestInit & { unix: string };
120
+
121
+ const response = await fetch(`http://localhost${path}`, init);
122
+
123
+ if (!response.ok) {
124
+ const errorData = (await response.json()) as { error?: string };
125
+ throw new Error(errorData.error || `HTTP ${response.status}`);
126
+ }
127
+
128
+ if (!response.body) {
129
+ throw new Error("No response body");
130
+ }
131
+
132
+ const reader = response.body.getReader();
133
+ const decoder = new TextDecoder();
134
+ let buffer = "";
135
+
136
+ try {
137
+ while (true) {
138
+ const { done, value } = await reader.read();
139
+ if (done) break;
140
+
141
+ buffer += decoder.decode(value, { stream: true });
142
+
143
+ const lines = buffer.split("\n");
144
+ buffer = lines.pop() || "";
145
+
146
+ for (const line of lines) {
147
+ if (line.startsWith("data: ")) {
148
+ const jsonStr = line.slice(6);
149
+ try {
150
+ const data = JSON.parse(jsonStr);
151
+ onData(data);
152
+ } catch {
153
+ // Skip malformed JSON
154
+ }
155
+ }
156
+ }
157
+ }
158
+ } finally {
159
+ reader.releaseLock();
160
+ }
161
+ }
162
+
163
+ export { program, callDaemon };
package/src/cli.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { startDaemon } from "./daemon";
2
+ import { program, handleDaemonCommand, callDaemonStream } from "./cli-shared";
3
+
4
+ program.name("cocod").description("Coco CLI - A Cashu wallet daemon");
5
+
6
+ // Status - check daemon/wallet state
7
+ program
8
+ .command("status")
9
+ .description("Check daemon and wallet status")
10
+ .action(async () => {
11
+ await handleDaemonCommand("/status");
12
+ });
13
+
14
+ // Init - initialize wallet
15
+ program
16
+ .command("init [mnemonic]")
17
+ .description("Initialize wallet with optional mnemonic (generates one if not provided)")
18
+ .option("--passphrase <passphrase>", "Encrypt wallet with passphrase")
19
+ .action(async (mnemonic: string | undefined, options: { passphrase?: string }) => {
20
+ await handleDaemonCommand("/init", {
21
+ method: "POST",
22
+ body: {
23
+ mnemonic,
24
+ passphrase: options.passphrase,
25
+ },
26
+ });
27
+ });
28
+
29
+ // Unlock - unlock encrypted wallet
30
+ program
31
+ .command("unlock <passphrase>")
32
+ .description("Unlock encrypted wallet with passphrase")
33
+ .action(async (passphrase: string) => {
34
+ await handleDaemonCommand("/unlock", {
35
+ method: "POST",
36
+ body: { passphrase },
37
+ });
38
+ });
39
+
40
+ // Balance - simple GET command
41
+ program
42
+ .command("balance")
43
+ .description("Get wallet balance")
44
+ .action(async () => {
45
+ await handleDaemonCommand("/balance");
46
+ });
47
+
48
+ // Receive - POST command with argument
49
+ program
50
+ .command("receive <token>")
51
+ .description("Receive Cashu token")
52
+ .action(async (token: string) => {
53
+ await handleDaemonCommand("/receive", {
54
+ method: "POST",
55
+ body: { token },
56
+ });
57
+ });
58
+
59
+ // Ping
60
+ program
61
+ .command("ping")
62
+ .description("Test connection to the daemon")
63
+ .action(async () => {
64
+ await handleDaemonCommand("/ping");
65
+ });
66
+
67
+ // Stop
68
+ program
69
+ .command("stop")
70
+ .description("Stop the background daemon")
71
+ .action(async () => {
72
+ await handleDaemonCommand("/stop", { method: "POST" });
73
+ });
74
+
75
+ // Mints - nested subcommands
76
+ const mintsCmd = program.command("mints").description("Mints operations");
77
+
78
+ mintsCmd
79
+ .command("add <url>")
80
+ .description("Add a mint URL")
81
+ .action(async (url: string) => {
82
+ await handleDaemonCommand("/mints/add", {
83
+ method: "POST",
84
+ body: { url },
85
+ });
86
+ });
87
+
88
+ mintsCmd
89
+ .command("list")
90
+ .description("List configured mints")
91
+ .action(async () => {
92
+ await handleDaemonCommand("/mints/list");
93
+ });
94
+
95
+ mintsCmd
96
+ .command("info <url>")
97
+ .description("Get mint info")
98
+ .action(async (url: string) => {
99
+ await handleDaemonCommand("/mints/info", {
100
+ method: "POST",
101
+ body: { url },
102
+ });
103
+ });
104
+
105
+ mintsCmd
106
+ .command("bolt11 <amount>")
107
+ .description("Create Lightning invoice to mint tokens")
108
+ .action(async (amount: string) => {
109
+ await handleDaemonCommand("/mints/bolt11", {
110
+ method: "POST",
111
+ body: { amount: parseInt(amount) },
112
+ });
113
+ });
114
+
115
+ // NPC - nested subcommands
116
+ const npcCmd = program.command("npc").description("NPC operations");
117
+
118
+ npcCmd
119
+ .command("address")
120
+ .description("Get NPC user address")
121
+ .action(async () => {
122
+ await handleDaemonCommand("/npc/address");
123
+ });
124
+
125
+ // History - with pagination and watch options
126
+ program
127
+ .command("history")
128
+ .description("Wallet history operations")
129
+ .option("--offset <number>", "Pagination offset (cannot be combined with --watch)", "0")
130
+ .option("--limit <number>", "Number of entries to fetch (1-100, default: 20)", "20")
131
+ .option(
132
+ "--watch",
133
+ "Stream history updates in real-time after fetching (can be combined with --limit)",
134
+ )
135
+ .action(async (options: { offset?: string; limit?: string; watch?: boolean }) => {
136
+ const offset = parseInt(options.offset || "0", 10);
137
+ const limit = parseInt(options.limit || "20", 10);
138
+
139
+ // Validate: offset and watch cannot be combined
140
+ if (offset > 0 && options.watch) {
141
+ console.error("Error: --offset cannot be combined with --watch");
142
+ process.exit(1);
143
+ }
144
+
145
+ // Validate numbers
146
+ if (isNaN(offset) || offset < 0) {
147
+ console.error("Error: --offset must be a non-negative number");
148
+ process.exit(1);
149
+ }
150
+
151
+ if (isNaN(limit) || limit < 1 || limit > 100) {
152
+ console.error("Error: --limit must be between 1 and 100");
153
+ process.exit(1);
154
+ }
155
+
156
+ // Fetch paginated history first (pass params as query string, not body)
157
+ const queryParams = new URLSearchParams();
158
+ queryParams.set("offset", offset.toString());
159
+ queryParams.set("limit", limit.toString());
160
+ const path = `/history?${queryParams.toString()}`;
161
+
162
+ await handleDaemonCommand(path);
163
+
164
+ // If watch is enabled, continue streaming after initial fetch
165
+ if (options.watch) {
166
+ try {
167
+ await callDaemonStream("/events", (data) => {
168
+ console.log(JSON.stringify(data));
169
+ });
170
+ } catch (error) {
171
+ const message = error instanceof Error ? error.message : String(error);
172
+ console.error(message);
173
+ process.exit(1);
174
+ }
175
+ }
176
+ });
177
+
178
+ // Daemon command - special case, doesn't go through IPC
179
+ program
180
+ .command("daemon")
181
+ .description("Start the background daemon")
182
+ .action(async () => {
183
+ await startDaemon();
184
+ });
185
+
186
+ export function cli(args: string[]) {
187
+ program.parse(args);
188
+ }
package/src/daemon.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { mnemonicToSeedSync } from "@scure/bip39";
2
+ import { CONFIG_FILE, SOCKET_PATH, PID_FILE } from "./utils/config.js";
3
+ import { DaemonStateManager } from "./utils/state.js";
4
+ import { initializeWallet } from "./utils/wallet.js";
5
+ import { createRouteHandlers, buildRoutes } from "./routes.js";
6
+ import type { WalletConfig } from "./utils/config.js";
7
+
8
+ export async function startDaemon() {
9
+ const stateManager = new DaemonStateManager();
10
+
11
+ try {
12
+ const testConn = await Bun.connect({
13
+ unix: SOCKET_PATH,
14
+ socket: {
15
+ data() {},
16
+ open() {},
17
+ close() {},
18
+ drain() {},
19
+ },
20
+ });
21
+ testConn.end();
22
+ console.error(`Error: Daemon is already running on ${SOCKET_PATH}`);
23
+ process.exit(1);
24
+ } catch {
25
+ // Not running, safe to proceed
26
+ }
27
+
28
+ try {
29
+ await Bun.write(PID_FILE, "");
30
+ await Bun.file(PID_FILE).delete();
31
+ } catch {
32
+ // Directory creation failed or file didn't exist
33
+ }
34
+
35
+ try {
36
+ await Bun.file(SOCKET_PATH).delete();
37
+ } catch {
38
+ // File might not exist
39
+ }
40
+ try {
41
+ await Bun.file(PID_FILE).delete();
42
+ } catch {
43
+ // File might not exist
44
+ }
45
+
46
+ await Bun.write(PID_FILE, process.pid.toString());
47
+
48
+ try {
49
+ const configExists = await Bun.file(CONFIG_FILE).exists();
50
+ if (configExists) {
51
+ const configText = await Bun.file(CONFIG_FILE).text();
52
+ const config: WalletConfig = JSON.parse(configText);
53
+
54
+ if (config.encrypted) {
55
+ stateManager.setLocked(config.mnemonic, config.mintUrl);
56
+ console.log("Wallet locked. Run 'cocod unlock <passphrase>' to decrypt.");
57
+ } else {
58
+ const manager = await initializeWallet(config);
59
+ const seed = mnemonicToSeedSync(config.mnemonic);
60
+ stateManager.setUnlocked(manager, config.mintUrl, seed);
61
+ console.log("Wallet auto-initialized (unencrypted).");
62
+ }
63
+ }
64
+ } catch (error) {
65
+ console.warn("Failed to load existing config:", error);
66
+ stateManager.setError(String(error));
67
+ }
68
+
69
+ const routeHandlers = createRouteHandlers(stateManager);
70
+ const routes = buildRoutes(routeHandlers, () => stateManager.getState());
71
+
72
+ const server = Bun.serve({
73
+ unix: SOCKET_PATH,
74
+ routes: {
75
+ ...routes,
76
+ "/stop": {
77
+ POST: async () => {
78
+ console.log("\nShutting down daemon...");
79
+ setTimeout(async () => {
80
+ server.stop();
81
+ try {
82
+ await Bun.file(PID_FILE).delete();
83
+ } catch {
84
+ // File might not exist
85
+ }
86
+ process.exit(0);
87
+ }, 100);
88
+ return Response.json({ output: "Daemon stopping" });
89
+ },
90
+ },
91
+ },
92
+ async fetch(req) {
93
+ return Response.json({ error: `Unknown endpoint: ${req.url}` }, { status: 404 });
94
+ },
95
+ });
96
+
97
+ console.log(`Daemon listening on ${SOCKET_PATH}`);
98
+ if (stateManager.isUninitialized()) {
99
+ console.log("Wallet not initialized. Run 'cocod init [mnemonic]' to set up.");
100
+ }
101
+
102
+ const cleanup = async () => {
103
+ console.log("\nShutting down daemon...");
104
+ server.stop();
105
+ try {
106
+ await Bun.file(PID_FILE).delete();
107
+ } catch {
108
+ // File might not exist
109
+ }
110
+ process.exit(0);
111
+ };
112
+
113
+ process.on("SIGINT", cleanup);
114
+ process.on("SIGTERM", cleanup);
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { cli } from "./cli";
3
+
4
+ cli(process.argv);