@raolin2025/claude-code-node 1.1.0 → 2.0.0

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,592 @@
1
+ import { ChannelManager } from "./index.js";
2
+ /**
3
+ * cc-notify — 通知守护进程(C 方案:智能路由)
4
+ *
5
+ * 核心逻辑:
6
+ * 手机发消息 → cc-notify 收到
7
+ * → 检查 cc-node 是否在运行
8
+ * → 在运行:转发消息给已运行的 cc-node(通过 Unix socket)
9
+ * → 没在运行:spawn 一个新的 cc-node 执行,完成后退出
10
+ *
11
+ * 用法:
12
+ * cc-notify # 前台运行
13
+ * cc-notify --daemon # 后台守护进程
14
+ * cc-notify --stop # 停止守护进程
15
+ * cc-notify --status # 查看状态
16
+ */
17
+ import { createServer } from "http";
18
+ import {
19
+ readFileSync,
20
+ writeFileSync,
21
+ unlinkSync,
22
+ existsSync,
23
+ appendFileSync,
24
+ mkdirSync,
25
+ openSync,
26
+ closeSync,
27
+ } from "fs";
28
+ import { resolve, join } from "path";
29
+ import { homedir } from "os";
30
+ import { spawn } from "child_process";
31
+ import { createConnection } from "net";
32
+ import crypto from "crypto";
33
+ import {
34
+ SOCK_DIR,
35
+ SOCK_PATH,
36
+ CC_NODE_PID,
37
+ CC_NOTIFY_PID,
38
+ CC_NOTIFY_LOG,
39
+ DEFAULT_HTTP_PORT,
40
+ } from "../core/paths.js";
41
+
42
+ // ============================================================
43
+ // 配置加载
44
+ // ============================================================
45
+ function loadConfig() {
46
+ // 生成或加载 API Key
47
+ let apiKey = process.env.CC_NOTIFY_API_KEY || "";
48
+ if (!apiKey) {
49
+ // 自动生成并保存 API Key
50
+ apiKey = crypto.randomBytes(32).toString("hex");
51
+ const configDir = join(process.cwd(), ".claude-code");
52
+ const configPath = join(configDir, "notify-api-key.txt");
53
+ try {
54
+ mkdirSync(configDir, { recursive: true });
55
+ writeFileSync(configPath, apiKey, "utf8");
56
+ console.log(`[notify] Generated API Key: ${apiKey} (saved to ${configPath})`);
57
+ } catch (e) {
58
+ console.warn("[notify] Failed to save API Key:", e.message);
59
+ }
60
+ }
61
+
62
+ const config = {
63
+ channels: {},
64
+ defaultChannel: process.env.CC_NODE_CHANNEL_DEFAULT || null,
65
+ port: parseInt(process.env.CC_NOTIFY_PORT || String(DEFAULT_HTTP_PORT), 10),
66
+ pidFile: process.env.CC_NOTIFY_CC_NODE_PID || CC_NOTIFY_PID,
67
+ logFile: process.env.CC_NOTIFY_LOG_FILE || CC_NOTIFY_LOG,
68
+ ccNodePath: process.env.CC_NODE_PATH || "cc-node",
69
+ apiKey,
70
+ };
71
+
72
+ for (const dir of [process.cwd(), homedir()]) {
73
+ const cfgPath = join(dir, ".claude-code", "config.json");
74
+ if (existsSync(cfgPath)) {
75
+ try {
76
+ const data = JSON.parse(readFileSync(cfgPath, "utf8"));
77
+ if (data.channels) Object.assign(config.channels, data.channels);
78
+ if (data.defaultChannel && !config.defaultChannel)
79
+ config.defaultChannel = data.defaultChannel;
80
+ if (data.notify?.port) config.port = data.notify.port;
81
+ if (data.notify?.ccNodePath) config.ccNodePath = data.notify.ccNodePath;
82
+ } catch {}
83
+ }
84
+ }
85
+
86
+ for (const [key, value] of Object.entries(process.env)) {
87
+ if (!key.startsWith("CC_NODE_CHANNEL_")) continue;
88
+ const rest = key.slice("CC_NODE_CHANNEL_".length);
89
+ if (rest === "DEFAULT") continue;
90
+ const parts = rest.split("_");
91
+ const type = parts[0].toLowerCase();
92
+ const param = parts.slice(1).join("_").toLowerCase();
93
+ if (!config.channels[type]) config.channels[type] = { type };
94
+ const camelKey = param.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
95
+ config.channels[type][camelKey] = value;
96
+ }
97
+
98
+ return config;
99
+ }
100
+
101
+ // ============================================================
102
+ // 通道发送
103
+ // ============================================================
104
+ async function sendToChannel(channels, defaultChannel, text) {
105
+ const cm = new ChannelManager({ channels, defaultChannel });
106
+ return await cm.send(text);
107
+ }
108
+
109
+ // ============================================================
110
+ // 进程发现 — cc-node 是否在跑?
111
+ // ============================================================
112
+ function findCcNode() {
113
+ if (existsSync(SOCK_PATH)) {
114
+ return new Promise((resolve) => {
115
+ const client = createConnection(SOCK_PATH, () => {
116
+ client.end();
117
+ resolve({ running: true, socketPath: SOCK_PATH });
118
+ });
119
+ client.on("error", () => {
120
+ try {
121
+ unlinkSync(SOCK_PATH);
122
+ } catch {}
123
+ resolve({ running: false });
124
+ });
125
+ setTimeout(() => {
126
+ client.destroy();
127
+ resolve({ running: false });
128
+ }, 2000);
129
+ });
130
+ }
131
+ if (existsSync(CC_NODE_PID)) {
132
+ const pid = parseInt(readFileSync(CC_NODE_PID, "utf8").trim(), 10);
133
+ try {
134
+ process.kill(pid, 0);
135
+ return { running: true, pid };
136
+ } catch {
137
+ try {
138
+ unlinkSync(CC_NODE_PID);
139
+ } catch {}
140
+ }
141
+ }
142
+ return { running: false };
143
+ }
144
+
145
+ // ============================================================
146
+ // 消息路由 — C 方案核心
147
+ // ============================================================
148
+ function sendToExistingNode(socketPath, text) {
149
+ return new Promise((resolve, reject) => {
150
+ const client = createConnection(socketPath, () => {
151
+ const msg = JSON.stringify({ type: "user_input", text });
152
+ client.write(msg + "\n");
153
+ });
154
+ let buffer = "";
155
+ client.on("data", (data) => {
156
+ buffer += data.toString();
157
+ const lines = buffer.split("\n");
158
+ if (lines.length > 1) {
159
+ try {
160
+ const response = JSON.parse(lines[0]);
161
+ client.end();
162
+ resolve(response);
163
+ } catch {
164
+ client.end();
165
+ resolve({ type: "reply", text: buffer.trim() });
166
+ }
167
+ }
168
+ });
169
+ client.on("error", (err) => reject(err));
170
+ setTimeout(() => {
171
+ client.destroy();
172
+ reject(new Error("timeout waiting for cc-node reply"));
173
+ }, 60000);
174
+ });
175
+ }
176
+
177
+ function spawnNewNode(ccNodePath, text) {
178
+ return new Promise((resolve) => {
179
+ const timeout = 120000;
180
+ const timer = setTimeout(() => {
181
+ child.kill();
182
+ resolve({ type: "reply", text: "⏰ 执行超时(2 分钟)" });
183
+ }, timeout);
184
+ try {
185
+ const child = spawn(ccNodePath, [text], {
186
+ stdio: ["pipe", "pipe", "pipe"],
187
+ });
188
+ let stdout = "";
189
+ let stderr = "";
190
+ child.stdout.on("data", (d) => (stdout += d.toString()));
191
+ child.stderr.on("data", (d) => (stderr += d.toString()));
192
+ child.on("close", (code) => {
193
+ clearTimeout(timer);
194
+ if (stdout.trim()) {
195
+ resolve({ type: "reply", text: stdout.trim().slice(0, 4000) });
196
+ } else if (stderr.trim()) {
197
+ resolve({ type: "reply", text: `❌ Error: ${stderr.trim().slice(0, 1000)}` });
198
+ } else {
199
+ resolve({ type: "reply", text: "(no output)" });
200
+ }
201
+ });
202
+ } catch (e) {
203
+ clearTimeout(timer);
204
+ resolve({ type: "reply", text: `❌ Failed: ${e.message}` });
205
+ }
206
+ });
207
+ }
208
+
209
+ async function routeMessage(text, config) {
210
+ const nodeInfo = await findCcNode();
211
+ if (nodeInfo.running && nodeInfo.socketPath) {
212
+ log(`[route] cc-node running → forwarding via socket`);
213
+ try {
214
+ const reply = await sendToExistingNode(nodeInfo.socketPath, text);
215
+ return reply.text || JSON.stringify(reply);
216
+ } catch (e) {
217
+ log(`[route] socket forward failed: ${e.message} → spawning new`);
218
+ return (await spawnNewNode(config.ccNodePath, text)).text;
219
+ }
220
+ } else if (nodeInfo.running && nodeInfo.pid) {
221
+ log(`[route] cc-node running (PID ${nodeInfo.pid}) but no socket → spawning new (one-shot mode)`);
222
+ return (await spawnNewNode(config.ccNodePath, text)).text;
223
+ } else {
224
+ log(`[route] cc-node not running → spawning new`);
225
+ return (await spawnNewNode(config.ccNodePath, text)).text;
226
+ }
227
+ }
228
+
229
+ // ============================================================
230
+ // Telegram Bot 长轮询
231
+ // ============================================================
232
+ class TelegramListener {
233
+ constructor(config) {
234
+ this.config = config;
235
+ this.lastUpdateId = 0;
236
+ this.running = false;
237
+ }
238
+ async start(onMessage) {
239
+ const ch = this.config.channels.telegram;
240
+ if (!ch?.token) {
241
+ log("Telegram: no token, skipping");
242
+ return;
243
+ }
244
+ this.running = true;
245
+ log("Telegram: started (long polling)");
246
+ while (this.running) {
247
+ try {
248
+ const url = `https://api.telegram.org/bot${ch.token}/getUpdates`;
249
+ const res = await fetch(url, {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify({ offset: this.lastUpdateId + 1, timeout: 30, allowed_updates: ["message"] }),
253
+ });
254
+ const data = await res.json();
255
+ if (data.ok && data.result?.length) {
256
+ for (const update of data.result) {
257
+ this.lastUpdateId = update.update_id;
258
+ if (update.message?.text) {
259
+ const msg = {
260
+ text: update.message.text,
261
+ chatId: update.message.chat.id,
262
+ from: update.message.from?.username || update.message.from?.first_name || "?",
263
+ };
264
+ log(`TG ← ${msg.from}: ${msg.text.slice(0, 60)}`);
265
+ try {
266
+ await onMessage(msg);
267
+ } catch (e) {
268
+ log(`handler error: ${e.message}`);
269
+ }
270
+ }
271
+ }
272
+ }
273
+ } catch (e) {
274
+ log(`TG poll error: ${e.message}`);
275
+ await sleep(5000);
276
+ }
277
+ }
278
+ }
279
+ stop() {
280
+ this.running = false;
281
+ }
282
+ }
283
+
284
+ // ============================================================
285
+ // HTTP API — 带 API Key 认证
286
+ // ============================================================
287
+ class HttpServer {
288
+ constructor(config, channels) {
289
+ this.config = config;
290
+ this.channels = channels;
291
+ this.server = null;
292
+ }
293
+
294
+ /** 验证 API Key */
295
+ _validateApiKey(req) {
296
+ const authHeader = req.headers["x-api-key"] || "";
297
+ const url = new URL(req.url, `http://localhost`);
298
+ const queryKey = url.searchParams.get("api_key") || "";
299
+ const providedKey = authHeader || queryKey;
300
+
301
+ if (!providedKey) {
302
+ return { valid: false, error: "API Key required. Use X-API-Key header or ?api_key=xxx" };
303
+ }
304
+ if (providedKey !== this.config.apiKey) {
305
+ return { valid: false, error: "Invalid API Key" };
306
+ }
307
+ return { valid: true };
308
+ }
309
+
310
+ start(onMessage) {
311
+ this.server = createServer(async (req, res) => {
312
+ const url = new URL(req.url, `http://localhost:${this.config.port}`);
313
+ res.setHeader("Access-Control-Allow-Origin", "*");
314
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
315
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-API-Key");
316
+
317
+ if (req.method === "OPTIONS") {
318
+ res.writeHead(204);
319
+ res.end();
320
+ return;
321
+ }
322
+
323
+ // API Key 认证(/status 端点不需要认证)
324
+ if (req.method !== "GET" || url.pathname !== "/status") {
325
+ const authResult = this._validateApiKey(req);
326
+ if (!authResult.valid) {
327
+ res.writeHead(401, { "Content-Type": "application/json" });
328
+ res.end(JSON.stringify({ error: authResult.error }));
329
+ return;
330
+ }
331
+ }
332
+
333
+ try {
334
+ if (req.method === "GET" && url.pathname === "/status") {
335
+ const nodeInfo = await findCcNode();
336
+ res.writeHead(200, { "Content-Type": "application/json" });
337
+ res.end(
338
+ JSON.stringify({
339
+ status: "running",
340
+ channels: Object.keys(this.channels),
341
+ defaultChannel: this.config.defaultChannel,
342
+ uptime: Math.floor(process.uptime()),
343
+ ccNodeRunning: nodeInfo.running,
344
+ }),
345
+ );
346
+ } else if (req.method === "POST" && url.pathname === "/send") {
347
+ const body = JSON.parse(await readBody(req));
348
+ const { text, channel } = body;
349
+ if (!text) {
350
+ res.writeHead(400, { "Content-Type": "application/json" });
351
+ res.end(JSON.stringify({ error: "text is required" }));
352
+ return;
353
+ }
354
+ const results = await sendToChannel(this.channels, channel || this.config.defaultChannel, text);
355
+ res.writeHead(200, { "Content-Type": "application/json" });
356
+ res.end(JSON.stringify({ results }));
357
+ } else if (req.method === "POST" && url.pathname === "/chat") {
358
+ const body = JSON.parse(await readBody(req));
359
+ const { text } = body;
360
+ if (!text) {
361
+ res.writeHead(400, { "Content-Type": "application/json" });
362
+ res.end(JSON.stringify({ error: "text is required" }));
363
+ return;
364
+ }
365
+ const reply = await routeMessage(text, this.config);
366
+ res.writeHead(200, { "Content-Type": "application/json" });
367
+ res.end(JSON.stringify({ reply }));
368
+ } else {
369
+ res.writeHead(404, { "Content-Type": "application/json" });
370
+ res.end(JSON.stringify({ error: "not found" }));
371
+ }
372
+ } catch (e) {
373
+ res.writeHead(500, { "Content-Type": "application/json" });
374
+ res.end(JSON.stringify({ error: e.message }));
375
+ }
376
+ });
377
+ this.server.listen(this.config.port, () => {
378
+ log(`HTTP API: http://localhost:${this.config.port} (API Key protected)`);
379
+ });
380
+ }
381
+
382
+ stop() {
383
+ this.server?.close();
384
+ }
385
+ }
386
+
387
+ // ============================================================
388
+ // 守护进程管理
389
+ // ============================================================
390
+ function startDaemon(config) {
391
+ if (existsSync(config.pidFile)) {
392
+ const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
393
+ try {
394
+ process.kill(pid, 0);
395
+ console.error(`cc-notify already running (PID ${pid})`);
396
+ process.exit(1);
397
+ } catch {
398
+ try {
399
+ unlinkSync(config.pidFile);
400
+ } catch {}
401
+ }
402
+ }
403
+ const child = spawn(process.execPath, [import.meta.url], {
404
+ detached: true,
405
+ stdio: "ignore",
406
+ env: { ...process.env, CC_NOTIFY_DAEMON: "1" },
407
+ });
408
+ child.unref();
409
+ console.log(`cc-notify daemon started (PID ${child.pid})`);
410
+ console.log(`PID: ${config.pidFile}`);
411
+ console.log(`Log: ${config.logFile}`);
412
+ console.log(`HTTP: http://localhost:${config.port}`);
413
+ console.log(`API Key: ${config.apiKey}`);
414
+ process.exit(0);
415
+ }
416
+
417
+ function stopDaemon(config) {
418
+ if (!existsSync(config.pidFile)) {
419
+ console.log("cc-notify not running");
420
+ process.exit(0);
421
+ }
422
+ const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
423
+ try {
424
+ process.kill(pid, "SIGTERM");
425
+ console.log(`cc-notify stopped (PID ${pid})`);
426
+ } catch {
427
+ console.log(`PID ${pid} not found`);
428
+ }
429
+ try {
430
+ unlinkSync(config.pidFile);
431
+ } catch {}
432
+ process.exit(0);
433
+ }
434
+
435
+ function showStatus(config) {
436
+ if (!existsSync(config.pidFile)) {
437
+ console.log("cc-notify not running");
438
+ process.exit(0);
439
+ }
440
+ const pid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
441
+ try {
442
+ process.kill(pid, 0);
443
+ console.log(`cc-notify running (PID ${pid})`);
444
+ fetch(`http://localhost:${config.port}/status`)
445
+ .then((r) => r.json())
446
+ .then((d) => console.log(JSON.stringify(d, null, 2)))
447
+ .catch(() => console.log("(HTTP API not responding)"));
448
+ } catch {
449
+ console.log(`PID ${pid} is dead`);
450
+ try {
451
+ unlinkSync(config.pidFile);
452
+ } catch {}
453
+ }
454
+ }
455
+
456
+ // ============================================================
457
+ // 工具
458
+ // ============================================================
459
+ function sleep(ms) {
460
+ return new Promise((r) => setTimeout(r, ms));
461
+ }
462
+
463
+ function readBody(req) {
464
+ return new Promise((r) => {
465
+ let b = "";
466
+ req.on("data", (d) => (b += d));
467
+ req.on("end", () => r(b));
468
+ });
469
+ }
470
+
471
+ function log(msg) {
472
+ const ts = new Date().toISOString().slice(11, 19);
473
+ const line = `[${ts}] ${msg}\n`;
474
+ process.stdout.write(line);
475
+ try {
476
+ appendFileSync(CC_NOTIFY_LOG, line);
477
+ } catch {}
478
+ }
479
+
480
+ // ============================================================
481
+ // 主入口
482
+ // ============================================================
483
+ async function main() {
484
+ const config = loadConfig();
485
+ const args = process.argv.slice(2);
486
+
487
+ if (args.includes("--stop")) return stopDaemon(config);
488
+ if (args.includes("--status")) return showStatus(config);
489
+ if (args.includes("--daemon")) return startDaemon(config);
490
+
491
+ // 确保 socket 目录存在
492
+ mkdirSync(SOCK_DIR, { recursive: true });
493
+
494
+ // v1.1: PID file lock — atomic create, prevent multiple instances
495
+ try {
496
+ const fd = openSync(config.pidFile, "wx");
497
+ writeFileSync(fd, String(process.pid));
498
+ closeSync(fd);
499
+ } catch (err) {
500
+ if (err.code === "EEXIST") {
501
+ const oldPid = parseInt(readFileSync(config.pidFile, "utf8").trim(), 10);
502
+ try {
503
+ process.kill(oldPid, 0);
504
+ console.error("cc-notify already running (PID " + oldPid + "). Use --stop first.");
505
+ process.exit(1);
506
+ } catch {
507
+ try {
508
+ unlinkSync(config.pidFile);
509
+ } catch {}
510
+ writeFileSync(config.pidFile, String(process.pid));
511
+ }
512
+ } else {
513
+ throw err;
514
+ }
515
+ }
516
+
517
+ const cleanup = () => {
518
+ log("Shutting down...");
519
+ try {
520
+ unlinkSync(config.pidFile);
521
+ } catch {}
522
+ process.exit(0);
523
+ };
524
+ process.on("SIGTERM", cleanup);
525
+ process.on("SIGINT", cleanup);
526
+
527
+ log("cc-notify starting...");
528
+ log(`Channels: ${Object.keys(config.channels).join(", ") || "none"}`);
529
+ log(`API Key: ${config.apiKey}`);
530
+
531
+ // Telegram 监听
532
+ const tg = new TelegramListener(config);
533
+ tg.start(async (msg) => {
534
+ const text = msg.text;
535
+ // 内部命令
536
+ if (text.startsWith("/")) {
537
+ const [cmd, ...rest] = text.split(" ");
538
+ let reply;
539
+ switch (cmd) {
540
+ case "/start":
541
+ case "/help":
542
+ reply = "🤖 *cc-notify* — AI Code Agent 通知服务\n\nCommands:\n/ping — 检查服务\n/status — 状态\n/notify <text> — 广播通知\n其他消息 → 自动发给 cc-node 处理";
543
+ break;
544
+ case "/ping":
545
+ reply = "🏓 pong!";
546
+ break;
547
+ case "/status": {
548
+ const nodeInfo = await findCcNode();
549
+ reply = `📊 cc-notify\nChannels: ${Object.keys(config.channels).join(", ")}\ncc-node: ${nodeInfo.running ? "✅ running" : "❌ not running"}\nUptime: ${Math.floor(process.uptime())}s`;
550
+ break;
551
+ }
552
+ case "/notify": {
553
+ const notifyText = rest.join(" ");
554
+ if (!notifyText) {
555
+ reply = "Usage: /notify <text>";
556
+ break;
557
+ }
558
+ const results = await sendToChannel(config.channels, config.defaultChannel, notifyText);
559
+ reply = results
560
+ .map((r) => (r.ok ? `✅ ${r.channel}` : `❌ ${r.channel}: ${r.error}`))
561
+ .join("\n");
562
+ break;
563
+ }
564
+ default:
565
+ reply = await routeMessage(text, config);
566
+ break;
567
+ }
568
+ if (config.channels.telegram?.token) {
569
+ await sendToChannel(config.channels, "telegram", reply);
570
+ }
571
+ return;
572
+ }
573
+ // 普通消息 → C 方案路由
574
+ log(`[route] processing: "${text.slice(0, 50)}"`);
575
+ const reply = await routeMessage(text, config);
576
+ if (config.channels.telegram?.token) {
577
+ await sendToChannel(config.channels, "telegram", reply);
578
+ }
579
+ });
580
+
581
+ // HTTP API
582
+ const http = new HttpServer(config, config.channels);
583
+ http.start();
584
+
585
+ log("cc-notify ready ✅");
586
+ setInterval(() => {}, 60000); // keep alive
587
+ }
588
+
589
+ main().catch((err) => {
590
+ console.error("Fatal:", err);
591
+ process.exit(1);
592
+ });