@wendongfly/zihi 1.1.13 → 1.1.15

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/bin/daemon.js CHANGED
@@ -1,23 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * zihi 守护进程:写入 PID 并启动 server
3
+ * zihi 守护进程:标记为后台模式并启动 server
4
+ * PID 文件的写入/清理由 server 自己负责(写的是完整 JSON 元信息)。
4
5
  */
5
- import { writeFileSync, unlinkSync } from 'fs';
6
- import { join, dirname } from 'path';
7
- import { fileURLToPath } from 'url';
8
- import { homedir } from 'os';
9
-
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
- const pidFile = join(homedir(), '.zihi', 'daemon.pid');
12
-
13
- // 写入自身 PID
14
- writeFileSync(pidFile, String(process.pid));
15
-
16
- // 退出时清理 PID 文件
17
- function cleanup() { try { unlinkSync(pidFile); } catch {} }
18
- process.on('exit', cleanup);
19
- process.on('SIGTERM', () => process.exit(0));
20
- process.on('SIGINT', () => process.exit(0));
21
-
22
- // 启动 server
6
+ process.env.ZIHI_DAEMON = '1';
23
7
  await import('../dist/index.js');
package/bin/zihi.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'child_process';
2
+ import { spawn, execSync } from 'child_process';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import { mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
@@ -30,31 +30,178 @@ const logFile = join(configDir, 'daemon.log');
30
30
 
31
31
  // ── 工具函数 ──────────────────────────────────────────────
32
32
 
33
- function readPid() {
34
- try { return parseInt(readFileSync(pidFile, 'utf8').trim(), 10); }
35
- catch { return null; }
33
+ /** 读取 pidfile,兼容 JSON(新格式)和纯 PID 数字(旧格式)。 */
34
+ function readPidInfo() {
35
+ try {
36
+ const raw = readFileSync(pidFile, 'utf8').trim();
37
+ if (!raw) return null;
38
+ if (raw.startsWith('{')) return JSON.parse(raw);
39
+ const pid = parseInt(raw, 10);
40
+ return pid ? { pid } : null;
41
+ } catch { return null; }
36
42
  }
37
43
 
38
- function isRunning(pid) {
44
+ function isPidAlive(pid) {
39
45
  try { process.kill(pid, 0); return true; }
40
46
  catch { return false; }
41
47
  }
42
48
 
43
- function stopDaemon() {
44
- const pid = readPid();
45
- if (!pid) { console.log('[zihi] 没有正在运行的后台进程'); return false; }
46
- if (!isRunning(pid)) {
49
+ /** 查进程命令行是否含 zihi 标识,防 PID 复用导致误判成别的 node 进程。 */
50
+ function pidCmdlineLooksLikeZihi(pid) {
51
+ try {
52
+ if (process.platform === 'win32') {
53
+ const out = execSync(
54
+ `wmic process where "ProcessId=${pid}" get CommandLine /format:list`,
55
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }
56
+ );
57
+ return /zihi/i.test(out) && /(daemon\.js|dist[\\/]index\.js)/i.test(out);
58
+ } else {
59
+ const out = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
60
+ return /zihi/i.test(out) && /(daemon\.js|dist\/index\.js)/i.test(out);
61
+ }
62
+ } catch { return false; }
63
+ }
64
+
65
+ /** 向运行中的实例发健康探针:确认签名为 zihi 且 pid 匹配。 */
66
+ async function probeZihiPing(port, timeoutMs = 1500) {
67
+ if (!port) return null;
68
+ try {
69
+ const ctrl = new AbortController();
70
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
71
+ const resp = await fetch(`http://127.0.0.1:${port}/api/zihi-ping`, { signal: ctrl.signal });
72
+ clearTimeout(t);
73
+ if (!resp.ok) return null;
74
+ const data = await resp.json();
75
+ return data && data.zihi === true ? data : null;
76
+ } catch { return null; }
77
+ }
78
+
79
+ /**
80
+ * 校验当前 pidfile 指向的是否确实是一个活着的 zihi 实例。
81
+ * 两层校验:HTTP 探针 为主,cmdline 检查 为辅。
82
+ */
83
+ async function verifyRunning() {
84
+ const info = readPidInfo();
85
+ if (!info || !info.pid) return null;
86
+ if (!isPidAlive(info.pid)) return null;
87
+
88
+ // 主校验:健康探针
89
+ const ping = await probeZihiPing(info.port);
90
+ if (ping && ping.pid === info.pid) {
91
+ return { ...info, ...ping, verifiedBy: 'http' };
92
+ }
93
+
94
+ // 辅校验:命令行关键字
95
+ if (pidCmdlineLooksLikeZihi(info.pid)) {
96
+ return { ...info, verifiedBy: 'cmdline' };
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ function formatUptime(startedAt) {
103
+ if (!startedAt) return '未知';
104
+ const ms = Date.now() - startedAt;
105
+ const s = Math.floor(ms / 1000);
106
+ if (s < 60) return `${s}s`;
107
+ const m = Math.floor(s / 60);
108
+ if (m < 60) return `${m}m${s % 60}s`;
109
+ const h = Math.floor(m / 60);
110
+ return `${h}h${m % 60}m`;
111
+ }
112
+
113
+ function printRunningInfo(info, opts = {}) {
114
+ const started = info.startedAt ? new Date(info.startedAt).toLocaleString() : '未知';
115
+ console.log(`[zihi] 服务已在运行,跳过启动`);
116
+ console.log(` PID: ${info.pid}`);
117
+ if (info.port) console.log(` 端口: ${info.port}`);
118
+ if (info.version) console.log(` 版本: v${info.version}`);
119
+ if (info.cwd) console.log(` 工作目录: ${info.cwd}`);
120
+ if (info.execPath) console.log(` Node: ${info.execPath}`);
121
+ console.log(` 启动时间: ${started} (运行 ${formatUptime(info.startedAt)})`);
122
+ console.log(` 校验方式: ${info.verifiedBy === 'http' ? 'HTTP 探针' : '命令行匹配'}`);
123
+ if (info.port) console.log(` 管理界面: http://localhost:${info.port}/admin`);
124
+
125
+ const n = opts.logLines ?? 20;
126
+ if (n > 0) {
127
+ try {
128
+ const content = readFileSync(logFile, 'utf8');
129
+ const lines = content.split('\n');
130
+ console.log(`\n ── 最近 ${n} 行日志 ──`);
131
+ console.log(lines.slice(-n).join('\n'));
132
+ } catch {}
133
+ }
134
+ }
135
+
136
+ async function stopDaemon() {
137
+ // Linux 下优先使用 systemd
138
+ if (hasSystemdService()) {
139
+ try {
140
+ execSync('sudo systemctl stop zihi', { stdio: 'inherit' });
141
+ console.log('[zihi] 服务已停止');
142
+ return true;
143
+ } catch (e) {
144
+ console.error('[zihi] systemctl 停止失败:', e.message);
145
+ return false;
146
+ }
147
+ }
148
+
149
+ const info = readPidInfo();
150
+ if (!info || !info.pid) { console.log('[zihi] 没有正在运行的后台进程'); return false; }
151
+ if (!isPidAlive(info.pid)) {
47
152
  try { unlinkSync(pidFile); } catch {}
48
153
  console.log('[zihi] 进程已不存在,已清理 PID 文件');
49
154
  return false;
50
155
  }
51
- process.kill(pid, 'SIGTERM');
156
+ // 额外校验:确认 PID 确实是 zihi,避免 PID 复用后误杀别人
157
+ const verified = await verifyRunning();
158
+ if (!verified) {
159
+ try { unlinkSync(pidFile); } catch {}
160
+ console.log(`[zihi] PID ${info.pid} 存在但不是 zihi 进程,已清理陈旧 PID 文件`);
161
+ return false;
162
+ }
163
+ process.kill(info.pid, 'SIGTERM');
52
164
  try { unlinkSync(pidFile); } catch {}
53
- console.log(`[zihi] 已停止后台进程 (PID ${pid})`);
165
+ console.log(`[zihi] 已停止后台进程 (PID ${info.pid})`);
54
166
  return true;
55
167
  }
56
168
 
57
- function startDaemon() {
169
+ /** 检查是否已安装 systemd 服务 */
170
+ function hasSystemdService() {
171
+ if (process.platform !== 'linux') return false;
172
+ try {
173
+ execSync('systemctl cat zihi.service', { stdio: 'ignore', timeout: 2000 });
174
+ return true;
175
+ } catch { return false; }
176
+ }
177
+
178
+ async function startDaemon() {
179
+ // Linux 下优先使用 systemd
180
+ if (hasSystemdService()) {
181
+ console.log('[zihi] 检测到 systemd 服务,通过 systemctl 启动');
182
+ try {
183
+ execSync('sudo systemctl restart zihi', { stdio: 'inherit' });
184
+ console.log('[zihi] 服务已启动');
185
+ console.log(' 查看状态: sudo systemctl status zihi');
186
+ console.log(' 查看日志: sudo journalctl -u zihi -f');
187
+ process.exit(0);
188
+ } catch (e) {
189
+ console.error('[zihi] systemctl 启动失败:', e.message);
190
+ process.exit(1);
191
+ }
192
+ }
193
+
194
+ // 启动前唯一性校验
195
+ const running = await verifyRunning();
196
+ if (running) {
197
+ printRunningInfo(running);
198
+ process.exit(0);
199
+ }
200
+ // pidfile 存在但校验失败 → 清理陈旧文件
201
+ if (readPidInfo()) {
202
+ try { unlinkSync(pidFile); } catch {}
203
+ }
204
+
58
205
  mkdirSync(configDir, { recursive: true });
59
206
  const out = openSync(logFile, 'a');
60
207
  // 启动守护进程(daemon.js),由它管理 server 子进程
@@ -103,21 +250,28 @@ if (cmd === 'attach') {
103
250
  await import('../dist/attach.js');
104
251
 
105
252
  } else if (cmd === 'stop') {
106
- stopDaemon();
253
+ await stopDaemon();
107
254
 
108
255
  } else if (cmd === 'restart') {
109
- stopDaemon();
256
+ await stopDaemon();
110
257
  await new Promise(r => setTimeout(r, 500));
111
- startDaemon();
258
+ await startDaemon();
112
259
  process.exit(0);
113
260
 
114
261
  } else if (cmd === 'status') {
115
262
  const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
116
- const pid = readPid();
117
- if (pid && isRunning(pid)) {
118
- console.log(`[zihi] v${pkgVersion} 运行中 (PID ${pid})`);
263
+ const running = await verifyRunning();
264
+ if (running) {
265
+ console.log(`[zihi] v${pkgVersion} 运行中`);
266
+ printRunningInfo(running, { logLines: 0 });
119
267
  } else {
120
- console.log(`[zihi] v${pkgVersion} 未运行`);
268
+ const stale = readPidInfo();
269
+ if (stale) {
270
+ try { unlinkSync(pidFile); } catch {}
271
+ console.log(`[zihi] v${pkgVersion} 未运行(已清理陈旧 PID 文件)`);
272
+ } else {
273
+ console.log(`[zihi] v${pkgVersion} 未运行`);
274
+ }
121
275
  }
122
276
 
123
277
  } else if (cmd === 'log' || cmd === 'logs') {
@@ -130,6 +284,66 @@ if (cmd === 'attach') {
130
284
  console.log('[zihi] 日志文件不存在');
131
285
  }
132
286
 
287
+ } else if (cmd === 'install' || cmd === 'service') {
288
+ // Linux systemd 服务安装
289
+ if (process.platform !== 'linux') {
290
+ console.log('[zihi] systemd 服务安装仅支持 Linux');
291
+ process.exit(1);
292
+ }
293
+ const servicePath = '/etc/systemd/system/zihi.service';
294
+ const user = process.env.SUDO_USER || process.env.USER || 'root';
295
+ const nodeBin = process.execPath;
296
+ const zihiBin = join(__dirname, 'zihi.js');
297
+ const port = process.env.PORT || '12345';
298
+ const unit = `[Unit]
299
+ Description=ZiHi - AI Agent Terminal Sharing
300
+ After=network.target
301
+
302
+ [Service]
303
+ Type=simple
304
+ User=${user}
305
+ Environment=PORT=${port}
306
+ Environment=ZIHI_DAEMON=1
307
+ ExecStart=${nodeBin} ${join(__dirname, 'daemon.js')}
308
+ Restart=on-failure
309
+ RestartSec=5
310
+ StandardOutput=append:${join(homedir(), '.zihi/daemon.log')}
311
+ StandardError=append:${join(homedir(), '.zihi/daemon.log')}
312
+
313
+ [Install]
314
+ WantedBy=multi-user.target
315
+ `;
316
+ try {
317
+ execSync(`sudo tee ${servicePath}`, { input: unit, stdio: ['pipe', 'ignore', 'inherit'] });
318
+ execSync('sudo systemctl daemon-reload', { stdio: 'inherit' });
319
+ execSync('sudo systemctl enable zihi', { stdio: 'inherit' });
320
+ execSync('sudo systemctl restart zihi', { stdio: 'inherit' });
321
+ console.log('\n[zihi] systemd 服务已安装并启动');
322
+ console.log(' 查看状态: sudo systemctl status zihi');
323
+ console.log(' 查看日志: sudo journalctl -u zihi -f');
324
+ console.log(' 停止服务: sudo systemctl stop zihi');
325
+ console.log(' 卸载服务: zihi uninstall');
326
+ } catch (e) {
327
+ console.error('[zihi] 安装失败:', e.message);
328
+ process.exit(1);
329
+ }
330
+
331
+ } else if (cmd === 'uninstall') {
332
+ if (process.platform !== 'linux') {
333
+ console.log('[zihi] systemd 服务卸载仅支持 Linux');
334
+ process.exit(1);
335
+ }
336
+ try {
337
+ execSync('sudo systemctl stop zihi 2>/dev/null || true', { stdio: 'inherit' });
338
+ execSync('sudo systemctl disable zihi 2>/dev/null || true', { stdio: 'inherit' });
339
+ execSync('sudo rm -f /etc/systemd/system/zihi.service', { stdio: 'inherit' });
340
+ execSync('sudo systemctl daemon-reload', { stdio: 'inherit' });
341
+ console.log('[zihi] systemd 服务已卸载');
342
+ } catch (e) {
343
+ console.error('[zihi] 卸载失败:', e.message);
344
+ process.exit(1);
345
+ }
346
+
133
347
  } else if (cmd === 'exclusive') {
134
348
  // 独占模式开关
135
349
  const { existsSync } = await import('fs');
@@ -594,10 +808,17 @@ SSH 开发:
594
808
  `);
595
809
 
596
810
  } else if (cmd === '-d' || cmd === '--daemon' || cmd === 'daemon') {
597
- startDaemon();
598
- process.exit(0);
811
+ await startDaemon();
812
+ // startDaemon 内部会在检测到已运行时 exit(0)
813
+ // 正常启动路径由 poll 定时器结束进程。
599
814
 
600
815
  } else {
601
- // 默认 / 'start':前台运行
816
+ // 默认 / 'start':前台运行前也要做唯一性校验
817
+ const running = await verifyRunning();
818
+ if (running) {
819
+ printRunningInfo(running);
820
+ process.exit(0);
821
+ }
822
+ if (readPidInfo()) { try { unlinkSync(pidFile); } catch {} }
602
823
  await import('../dist/index.js');
603
824
  }