@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.
- package/.github/workflows/ci.yml +21 -0
- package/.github/workflows/npm-publish.yml +78 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +244 -0
- package/CLAUDE.md +105 -0
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/SKILL.md +238 -0
- package/bun.lock +69 -0
- package/docs/API.md +92 -0
- package/docs/daemon-api.json +245 -0
- package/package.json +32 -0
- package/src/cli-shared.ts +164 -0
- package/src/cli.ts +317 -0
- package/src/daemon.ts +184 -0
- package/src/index.ts +4 -0
- package/src/logs.test.ts +54 -0
- package/src/logs.ts +118 -0
- package/src/routes.test.ts +60 -0
- package/src/routes.ts +523 -0
- package/src/utils/config.ts +17 -0
- package/src/utils/crypto.test.ts +24 -0
- package/src/utils/crypto.ts +68 -0
- package/src/utils/logger.test.ts +82 -0
- package/src/utils/logger.ts +359 -0
- package/src/utils/state.test.ts +55 -0
- package/src/utils/state.ts +128 -0
- package/src/utils/wallet.ts +51 -0
- package/tsconfig.json +29 -0
|
@@ -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