@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
package/src/logs.test.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { mkdtemp, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import { followLogFile, parseLogLineCount, tailLogLines } from "./logs";
|
|
8
|
+
|
|
9
|
+
describe("logs helpers", () => {
|
|
10
|
+
test("tailLogLines returns the requested trailing lines", () => {
|
|
11
|
+
expect(tailLogLines("one\ntwo\nthree\n", 2)).toBe("two\nthree\n");
|
|
12
|
+
expect(tailLogLines("one\ntwo\nthree", 1)).toBe("three");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("parseLogLineCount requires a positive integer", () => {
|
|
16
|
+
expect(parseLogLineCount("25")).toBe(25);
|
|
17
|
+
expect(() => parseLogLineCount("0")).toThrow("--lines must be a positive integer");
|
|
18
|
+
expect(() => parseLogLineCount("2x")).toThrow("--lines must be a positive integer");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("followLogFile continues after log rotation", async () => {
|
|
22
|
+
const dir = await mkdtemp(join(tmpdir(), "cocod-logs-"));
|
|
23
|
+
const logFile = join(dir, "daemon.log");
|
|
24
|
+
await writeFile(logFile, "one\n", "utf8");
|
|
25
|
+
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const chunks: string[] = [];
|
|
28
|
+
const followPromise = followLogFile(
|
|
29
|
+
logFile,
|
|
30
|
+
(chunk) => {
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
controller.abort();
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
startPosition: Buffer.byteLength("one\n"),
|
|
36
|
+
pollIntervalMs: 10,
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await Bun.sleep(20);
|
|
42
|
+
await rename(logFile, `${logFile}.1`);
|
|
43
|
+
await writeFile(logFile, "two\n", "utf8");
|
|
44
|
+
|
|
45
|
+
await Promise.race([
|
|
46
|
+
followPromise,
|
|
47
|
+
Bun.sleep(500).then(() => {
|
|
48
|
+
throw new Error("Timed out waiting for followed log output");
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
expect(chunks).toEqual(["two\n"]);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/logs.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LOG_LINES = 50;
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
5
|
+
|
|
6
|
+
interface FollowLogOptions {
|
|
7
|
+
startPosition?: number;
|
|
8
|
+
pollIntervalMs?: number;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isNotFoundError(error: unknown): boolean {
|
|
13
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseLogLineCount(value: string): number {
|
|
17
|
+
if (!/^\d+$/.test(value)) {
|
|
18
|
+
throw new Error("--lines must be a positive integer");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lineCount = Number.parseInt(value, 10);
|
|
22
|
+
|
|
23
|
+
if (!Number.isInteger(lineCount) || lineCount < 1) {
|
|
24
|
+
throw new Error("--lines must be a positive integer");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return lineCount;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tailLogLines(content: string, lineCount: number): string {
|
|
31
|
+
if (content.length === 0) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hasTrailingNewline = content.endsWith("\n");
|
|
36
|
+
const normalizedContent = hasTrailingNewline ? content.slice(0, -1) : content;
|
|
37
|
+
|
|
38
|
+
if (normalizedContent.length === 0) {
|
|
39
|
+
return content;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const lines = normalizedContent.split("\n");
|
|
43
|
+
const tailedContent = lines.slice(-lineCount).join("\n");
|
|
44
|
+
|
|
45
|
+
return hasTrailingNewline ? `${tailedContent}\n` : tailedContent;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readRecentLogText(filePath: string, lineCount: number): Promise<string> {
|
|
49
|
+
const file = Bun.file(filePath);
|
|
50
|
+
|
|
51
|
+
if (!(await file.exists())) {
|
|
52
|
+
throw new Error(`Log file not found: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return tailLogLines(await file.text(), lineCount);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getLogFileSize(filePath: string): Promise<number> {
|
|
59
|
+
try {
|
|
60
|
+
return (await stat(filePath)).size;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isNotFoundError(error)) {
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function followLogFile(
|
|
71
|
+
filePath: string,
|
|
72
|
+
onChunk: (chunk: string) => void,
|
|
73
|
+
options: FollowLogOptions = {},
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const { startPosition = 0, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, signal } = options;
|
|
76
|
+
let position = startPosition;
|
|
77
|
+
let inode: number | undefined;
|
|
78
|
+
|
|
79
|
+
while (!signal?.aborted) {
|
|
80
|
+
let currentSize = 0;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const fileStat = await stat(filePath);
|
|
84
|
+
currentSize = fileStat.size;
|
|
85
|
+
|
|
86
|
+
if (inode !== undefined && fileStat.ino !== inode) {
|
|
87
|
+
position = 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
inode = fileStat.ino;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (isNotFoundError(error)) {
|
|
93
|
+
inode = undefined;
|
|
94
|
+
position = 0;
|
|
95
|
+
await Bun.sleep(pollIntervalMs);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (currentSize < position) {
|
|
103
|
+
position = 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (currentSize > position) {
|
|
107
|
+
const chunk = await Bun.file(filePath).slice(position, currentSize).text();
|
|
108
|
+
|
|
109
|
+
if (chunk.length > 0) {
|
|
110
|
+
onChunk(chunk);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
position = currentSize;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await Bun.sleep(pollIntervalMs);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createRouteHandlers } from "./routes";
|
|
4
|
+
import { DaemonStateManager } from "./utils/state";
|
|
5
|
+
|
|
6
|
+
describe("routes", () => {
|
|
7
|
+
test("/init validates invalid mnemonic", async () => {
|
|
8
|
+
const stateManager = new DaemonStateManager();
|
|
9
|
+
const routes = createRouteHandlers(stateManager);
|
|
10
|
+
|
|
11
|
+
const response = await routes["/init"]!.POST!(
|
|
12
|
+
new Request("http://localhost/init", {
|
|
13
|
+
method: "POST",
|
|
14
|
+
body: JSON.stringify({ mnemonic: "invalid mnemonic" }),
|
|
15
|
+
}),
|
|
16
|
+
stateManager.getState(),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const body = (await response.json()) as { error?: string };
|
|
20
|
+
expect(response.status).toBe(400);
|
|
21
|
+
expect(body.error).toBe("Invalid mnemonic");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("/unlock requires passphrase", async () => {
|
|
25
|
+
const stateManager = new DaemonStateManager();
|
|
26
|
+
stateManager.setLocked("encrypted", "https://mint.example.com");
|
|
27
|
+
const routes = createRouteHandlers(stateManager);
|
|
28
|
+
|
|
29
|
+
const response = await routes["/unlock"]!.POST!(
|
|
30
|
+
new Request("http://localhost/unlock", {
|
|
31
|
+
method: "POST",
|
|
32
|
+
body: JSON.stringify({}),
|
|
33
|
+
}),
|
|
34
|
+
stateManager.getState(),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const body = (await response.json()) as { error?: string };
|
|
38
|
+
expect(response.status).toBe(400);
|
|
39
|
+
expect(body.error).toBe("Passphrase required");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("/x-cashu/parse requires request field", async () => {
|
|
43
|
+
const stateManager = new DaemonStateManager();
|
|
44
|
+
const fakeManager = {} as unknown as import("coco-cashu-core").Manager;
|
|
45
|
+
stateManager.setUnlocked(fakeManager, "https://mint.example.com", new Uint8Array([1, 2, 3]));
|
|
46
|
+
const routes = createRouteHandlers(stateManager);
|
|
47
|
+
|
|
48
|
+
const response = await routes["/x-cashu/parse"]!.POST!(
|
|
49
|
+
new Request("http://localhost/x-cashu/parse", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
body: JSON.stringify({}),
|
|
52
|
+
}),
|
|
53
|
+
stateManager.getState(),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const body = (await response.json()) as { error?: string };
|
|
57
|
+
expect(response.status).toBe(400);
|
|
58
|
+
expect(body.error).toBe("Request is required");
|
|
59
|
+
});
|
|
60
|
+
});
|