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.
- package/.github/workflows/npm-publish.yml +37 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +210 -0
- package/CLAUDE.md +105 -0
- package/README.md +179 -0
- package/package.json +32 -0
- package/src/cli-shared.ts +163 -0
- package/src/cli.ts +188 -0
- package/src/daemon.ts +115 -0
- package/src/index.ts +4 -0
- package/src/routes.ts +298 -0
- package/src/utils/config.ts +16 -0
- package/src/utils/crypto.ts +68 -0
- package/src/utils/state.ts +128 -0
- package/src/utils/wallet.ts +49 -0
- package/tsconfig.json +29 -0
|
@@ -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