@wendongfly/myhi 1.3.200 → 1.3.201

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
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'url';
9
9
  import { dirname, join } from 'path';
10
10
  import { writeFileSync, readFileSync, mkdirSync, unlinkSync } from 'fs';
11
11
  import { homedir } from 'os';
12
- import { checkInstall, upgradeStatusFile } from './install-check.js';
12
+ import { checkInstall, upgradeStatusFile, npmUpgradeCmd } from './install-check.js';
13
13
 
14
14
  const __filename = fileURLToPath(import.meta.url);
15
15
  const __dirname = dirname(__filename);
@@ -129,8 +129,7 @@ function doUpgrade(target) {
129
129
  const from = (() => { try { return JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')).version; } catch { return null; } })();
130
130
  log(`收到升级请求(${from} → ${target || 'latest'}),先停 server 释放文件占用...`);
131
131
  stopChild(() => {
132
- const cmd = (process.platform === 'win32' ? 'npm.cmd' : 'npm') +
133
- ' install -g @wendongfly/myhi@latest --registry https://registry.npmjs.org';
132
+ const cmd = npmUpgradeCmd(pkgRoot); // 同一套 Node 的 npm + 装回 myhi 当前目录(见 install-check.js)
134
133
  log('执行: ' + cmd);
135
134
  exec(cmd, { timeout: 600000, encoding: 'utf8', windowsHide: true }, (err, stdout, stderr) => {
136
135
  let result;
@@ -2,7 +2,7 @@
2
2
  // 都会留下缺文件的半成品——带着它重启,新进程起不来,服务就挂了(测试机缺 dist/login.html 即此类)。
3
3
  // server(网页升级)和 daemon(Windows 停服升级)共用。
4
4
  import { existsSync, readFileSync } from 'fs';
5
- import { join } from 'path';
5
+ import { join, dirname, basename, resolve } from 'path';
6
6
 
7
7
  export const REQUIRED_FILES = [
8
8
  'package.json', 'bin/myhi.js', 'bin/daemon.js',
@@ -23,3 +23,29 @@ export function checkInstall(pkgRoot, expectVersion) {
23
23
 
24
24
  // 上次升级结果,落 ~/.myhi/upgrade-status.json:重启后的新进程(或 daemon 停服升级时)靠它把成败告诉网页
25
25
  export function upgradeStatusFile(configDir) { return join(configDir, 'upgrade-status.json'); }
26
+
27
+ // ── 升级命令(server 网页升级、daemon 停服升级共用)────────────────────────
28
+ // 以前直接跑 PATH 里的 `npm install -g`:机器上装了不止一套 Node(典型:hibox 自带 Node 在 D:\hibox\nodejs,
29
+ // 系统另装了一套)时,PATH 里先找到的 npm 会把新版装进**它自己的**全局目录,而 myhi 从原目录重启
30
+ // → 永远还是旧版,网页报「npm 可能装到了别的目录」。现在:
31
+ // ① 优先用和当前 myhi **同一套 Node** 自带的 npm(node 可执行文件旁边的 npm / npm.cmd);
32
+ // ② 用 --prefix 明确装回 myhi **当前所在**的全局目录,原地覆盖。
33
+ // 认不出全局安装布局(如开发时从源码目录跑)就不加 --prefix,行为同以前。
34
+
35
+ /** pkgRoot 所在的 npm 全局前缀;Windows:<prefix>\node_modules\@wendongfly\myhi,其他:<prefix>/lib/node_modules/@wendongfly/myhi */
36
+ export function globalPrefixOf(pkgRoot) {
37
+ const r = resolve(pkgRoot);
38
+ const nm = dirname(dirname(r));
39
+ if (basename(dirname(r)) !== '@wendongfly' || basename(nm) !== 'node_modules') return null;
40
+ if (process.platform === 'win32') return dirname(nm);
41
+ return basename(dirname(nm)) === 'lib' ? dirname(dirname(nm)) : null;
42
+ }
43
+
44
+ export function npmUpgradeCmd(pkgRoot) {
45
+ const win = process.platform === 'win32';
46
+ const local = join(dirname(process.execPath), win ? 'npm.cmd' : 'npm');
47
+ const npm = existsSync(local) ? `"${local}"` : (win ? 'npm.cmd' : 'npm');
48
+ const prefix = globalPrefixOf(pkgRoot);
49
+ // 显式官方 registry:更新检查读官方源,安装若走镜像(同步延迟)会「一直提示新版却装到旧版」
50
+ return `${npm} install -g @wendongfly/myhi@latest --registry https://registry.npmjs.org${prefix ? ` --prefix "${prefix}"` : ''}`;
51
+ }
package/bin/myhi.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'child_process';
2
+ import { spawn, execSync } from 'child_process';
3
+ import { connect } from 'net';
3
4
  import { fileURLToPath } from 'url';
4
5
  import { dirname, join } from 'path';
5
6
  import { mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
@@ -28,6 +29,9 @@ for (let i = 0; i < args.length; i++) {
28
29
  httpOnlyFlag = true; args.splice(i, 1); i--;
29
30
  } else if (args[i] === '--https') {
30
31
  httpOnlyFlag = false; args.splice(i, 1); i--;
32
+ } else if (!cmd && (args[i] === 'http' || args[i] === 'https')) {
33
+ // 不带横杠也认:`myhi http` / `myhi https`(以前会被当成「前台启动」,协议根本没切)
34
+ httpOnlyFlag = args[i] === 'http'; args.splice(i, 1); i--;
31
35
  } else if (!cmd && !args[i].startsWith('-')) {
32
36
  cmd = args[i];
33
37
  }
@@ -60,7 +64,8 @@ if (httpOnlyFlag !== null) {
60
64
  try { cfg = JSON.parse(readFileSync(configFile, 'utf8').replace(/^\uFEFF/, '')); } catch {}
61
65
  cfg.httpOnly = httpOnlyFlag;
62
66
  writeFileSync(configFile, JSON.stringify(cfg, null, 2));
63
- console.log(`[myhi] 已切换为 ${httpOnlyFlag ? 'HTTP(无 TLS,麦克风/摄像头等在非 localhost 会受限)' : 'HTTPS'} 模式`);
67
+ console.log(`[myhi] 已切换为 ${httpOnlyFlag ? 'HTTP(无 TLS,麦克风/摄像头等在非 localhost 会受限)' : 'HTTPS'} 模式,访问地址:${httpOnlyFlag ? 'http' : 'https'}://localhost:${process.env.PORT || 12300}/`);
68
+ console.log(`[myhi] 配置写在 ${configFile}。若 myhi 是被别的程序(如 hibox)拉起的、这里改了不生效:在网页 设置 › 服务 › 访问协议 里切`);
64
69
  } catch (e) {
65
70
  console.error('[myhi] 写入 config.json 失败:', e.message);
66
71
  process.exit(1);
@@ -93,22 +98,56 @@ function readPid() {
93
98
  catch { return null; }
94
99
  }
95
100
 
96
- function isRunning(pid) {
97
- try { process.kill(pid, 0); return true; }
98
- catch { return false; }
101
+ // 进程状态:alive / denied / dead。
102
+ // denied:进程在,但当前权限探测不了 —— Windows 上 myhi 若是在「以管理员身份运行」的命令行里启动的,
103
+ // 普通命令行 process.kill(pid, 0) 会得到 EPERM;以前把它当成「不存在」,status 就误报「未运行」
104
+ function procState(pid) {
105
+ if (!pid) return 'dead';
106
+ try { process.kill(pid, 0); return 'alive'; }
107
+ catch (e) { return e.code === 'EPERM' ? 'denied' : 'dead'; }
108
+ }
109
+ function isRunning(pid) { return procState(pid) !== 'dead'; }
110
+ const ELEVATED_TIP = process.platform === 'win32'
111
+ ? '请在「以管理员身份运行」的命令行里执行'
112
+ : '请用启动它的同一用户(如 sudo)执行';
113
+
114
+ // 端口上有没有程序在监听(pid 文件不在 / 不是本账号启动的 myhi 时,用来给出提示)
115
+ function portOpen(port) {
116
+ return new Promise((r) => {
117
+ const sk = connect({ port, host: '127.0.0.1' });
118
+ const done = (v) => { sk.destroy(); r(v); };
119
+ sk.once('connect', () => done(true)); sk.once('error', () => done(false));
120
+ sk.setTimeout(1500, () => done(false));
121
+ });
122
+ }
123
+ function listenerPid(port) {
124
+ try {
125
+ if (process.platform === 'win32') {
126
+ const out = execSync('netstat -ano -p tcp', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true });
127
+ const line = out.split('\n').find((l) => /LISTENING/i.test(l) && new RegExp(':' + port + '\\s').test(l));
128
+ return line ? parseInt(line.trim().split(/\s+/).pop(), 10) || null : null;
129
+ }
130
+ const out = execSync(`ss -ltnp 'sport = :${port}'`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
131
+ const m = /pid=(\d+)/.exec(out); return m ? parseInt(m[1], 10) : null;
132
+ } catch { return null; }
99
133
  }
100
134
 
101
135
  function stopDaemon() {
102
136
  const serverPidFile = join(configDir, 'server.pid');
103
- let stopped = false;
137
+ let stopped = false, denied = false;
104
138
 
105
139
  // 停止 daemon 进程
106
140
  const daemonPid = readPid();
107
141
  if (daemonPid && isRunning(daemonPid)) {
108
- process.kill(daemonPid, 'SIGTERM');
109
- try { unlinkSync(pidFile); } catch {}
110
- console.log(`[myhi] 已停止 daemon (PID ${daemonPid})`);
111
- stopped = true;
142
+ try {
143
+ process.kill(daemonPid, 'SIGTERM');
144
+ try { unlinkSync(pidFile); } catch {}
145
+ console.log(`[myhi] 已停止 daemon (PID ${daemonPid})`);
146
+ stopped = true;
147
+ } catch {
148
+ console.log(`[myhi] 停不掉 daemon (PID ${daemonPid}):权限不够(它可能是以管理员身份或别的账号启动的),${ELEVATED_TIP} myhi stop`);
149
+ denied = true;
150
+ }
112
151
  } else if (daemonPid) {
113
152
  try { unlinkSync(pidFile); } catch {}
114
153
  }
@@ -117,20 +156,25 @@ function stopDaemon() {
117
156
  try {
118
157
  const serverPid = parseInt(readFileSync(serverPidFile, 'utf8').trim(), 10);
119
158
  if (serverPid && isRunning(serverPid)) {
120
- if (process.platform === 'win32') {
121
- try { require('child_process').execSync(`taskkill /PID ${serverPid} /F /T`, { stdio: 'pipe', timeout: 5000 }); } catch {}
159
+ let ok = true;
160
+ try {
161
+ if (process.platform === 'win32') execSync(`taskkill /PID ${serverPid} /F /T`, { stdio: 'pipe', timeout: 5000, windowsHide: true });
162
+ else process.kill(serverPid, 'SIGTERM');
163
+ } catch { ok = false; }
164
+ if (ok) {
165
+ try { unlinkSync(serverPidFile); } catch {}
166
+ console.log(`[myhi] 已停止 server (PID ${serverPid})`);
167
+ stopped = true;
122
168
  } else {
123
- process.kill(serverPid, 'SIGTERM');
169
+ console.log(`[myhi] 停不掉 server (PID ${serverPid}):权限不够(它可能是以管理员身份或别的账号启动的),${ELEVATED_TIP} myhi stop`);
170
+ denied = true;
124
171
  }
125
- try { unlinkSync(serverPidFile); } catch {}
126
- console.log(`[myhi] 已停止 server (PID ${serverPid})`);
127
- stopped = true;
128
172
  } else if (serverPid) {
129
173
  try { unlinkSync(serverPidFile); } catch {}
130
174
  }
131
175
  } catch {}
132
176
 
133
- if (!stopped) console.log('[myhi] 没有正在运行的 myhi 进程');
177
+ if (!stopped && !denied) console.log('[myhi] 没有正在运行的 myhi 进程');
134
178
  return stopped;
135
179
  }
136
180
 
@@ -196,14 +240,25 @@ if (cmd === 'attach') {
196
240
  const daemonPid = readPid();
197
241
  let serverPid = null;
198
242
  try { serverPid = parseInt(readFileSync(join(configDir, 'server.pid'), 'utf8').trim(), 10); } catch {}
199
- const daemonAlive = daemonPid && isRunning(daemonPid);
200
- const serverAlive = serverPid && isRunning(serverPid);
243
+ const dState = procState(daemonPid), sState = procState(serverPid);
244
+ const daemonAlive = dState !== 'dead';
245
+ const serverAlive = sState !== 'dead';
201
246
  if (daemonAlive || serverAlive) {
202
247
  const mode = daemonAlive ? 'daemon' : '前台';
203
248
  const pid = daemonAlive ? daemonPid : serverPid;
204
- console.log(`[myhi] v${pkgVersion} 运行中 (${mode}, PID ${pid})`);
249
+ let httpOnly = false; try { httpOnly = !!JSON.parse(readFileSync(configFile, 'utf8').replace(/^\uFEFF/, '')).httpOnly; } catch {}
250
+ console.log(`[myhi] v${pkgVersion} 运行中 (${mode}, PID ${pid}),访问地址 ${httpOnly ? 'http' : 'https'}://localhost:${process.env.PORT || 12300}/`);
251
+ if ((daemonAlive ? dState : sState) === 'denied') console.log(`[myhi] 注意:它是以更高权限(管理员 / 别的账号)启动的,${ELEVATED_TIP} myhi stop / restart 才能管理它`);
205
252
  } else {
206
- console.log(`[myhi] v${pkgVersion} 未运行`);
253
+ const port = parseInt(process.env.PORT || '12300', 10);
254
+ if (await portOpen(port)) {
255
+ const lp = listenerPid(port);
256
+ console.log(`[myhi] v${pkgVersion} 本账号没有记录到运行中的 myhi,但端口 ${port} 正在被${lp ? ` PID ${lp} ` : '某个程序'}监听`);
257
+ console.log(`[myhi] 多半是 myhi 以管理员身份、或用另一个系统账号启动的,${ELEVATED_TIP} myhi status / myhi stop`);
258
+ if (process.platform === 'win32' && lp) console.log(`[myhi] 也可以在管理员命令行里直接结束它:taskkill /PID ${lp} /F /T`);
259
+ } else {
260
+ console.log(`[myhi] v${pkgVersion} 未运行`);
261
+ }
207
262
  }
208
263
 
209
264
  } else if (cmd === 'log' || cmd === 'logs') {
@@ -631,8 +686,9 @@ myhi — 基于 Web 的终端共享工具 (${platform})
631
686
  myhi start 前台启动服务器
632
687
  myhi -p <port> 指定监听端口(默认 12300)
633
688
  myhi -d 后台启动(日志写入 ${configPath}daemon.log)
634
- myhi --http 切为纯 HTTP(无 TLS)并重启,持久生效
635
- myhi --https 切回 HTTPS(默认)并重启
689
+ myhi http 切为纯 HTTP(无 TLS)并重启,持久生效(同 --http)
690
+ myhi https 切回 HTTPS(默认)并重启(同 --https)
691
+ 也可在网页 设置 › 服务 › 访问协议 里点选
636
692
  myhi stop 停止后台服务器
637
693
  myhi restart 重启后台服务器
638
694
  myhi status 查看运行状态
package/dist/index.html CHANGED
@@ -963,6 +963,11 @@
963
963
  <button id="set-up-btn" class="set-btn dirty" style="display:none" onclick="homeUpgrade()">升级</button>
964
964
  </div>
965
965
  <div id="set-up-status" class="set-status"></div>
966
+ <div class="set-item admin-only" id="set-proto-row">
967
+ <div class="set-lbl">访问协议<small id="set-proto-sub">HTTPS 才能在手机上用麦克风 / 摄像头;内网只想少点「不安全」提示可用 HTTP</small></div>
968
+ <div class="set-seg" id="set-proto"><button data-p="https">HTTPS</button><button data-p="http">HTTP</button></div>
969
+ </div>
970
+ <div id="set-proto-status" class="set-status"></div>
966
971
  <a id="set-admin-link" class="set-link admin-only" href="/admin">🛠 管理后台 <small>用户 · 网关 · 密钥 · Claude 升级</small></a>
967
972
  <button class="set-link" onclick="restartMyhi()">🔄 重启 myhi</button>
968
973
  </div>
@@ -1302,7 +1307,8 @@
1302
1307
 
1303
1308
  window.openSettings = function() {
1304
1309
  renderAppearance();
1305
- ['set-machine-status', 'set-asr-status', 'set-restart-status', 'set-up-status'].filter(id => document.getElementById(id)).forEach(id => setStatus(id, ''));
1310
+ ['set-machine-status', 'set-asr-status', 'set-restart-status', 'set-up-status', 'set-proto-status'].filter(id => document.getElementById(id)).forEach(id => setStatus(id, ''));
1311
+ if (_isAdmin) loadProto();
1306
1312
  document.querySelectorAll('#set-sheet .set-btn').forEach(b => b.classList.remove('dirty'));
1307
1313
  document.getElementById('set-backdrop').classList.add('open');
1308
1314
  document.getElementById('set-sheet').classList.add('open');
@@ -1456,6 +1462,28 @@
1456
1462
  } catch (e) { btn.disabled = false; setStatus('set-up-status', '升级失败:' + e.message, 'red'); }
1457
1463
  };
1458
1464
 
1465
+ // 访问协议切换:由正在运行的 myhi 改它自己的配置并重启,重启后跳到新地址
1466
+ async function loadProto() {
1467
+ let d; try { d = await (await fetch('/api/server-mode')).json(); } catch { return; }
1468
+ if (!d.ok) return;
1469
+ const seg = document.getElementById('set-proto');
1470
+ seg.querySelectorAll('button').forEach(b => {
1471
+ b.classList.toggle('on', (b.dataset.p === 'http') === d.httpOnly);
1472
+ b.onclick = () => switchProto(b.dataset.p === 'http', d);
1473
+ });
1474
+ if (d.fromEnv) setStatus('set-proto-status', '启动时设了环境变量 MYHI_NO_HTTPS,固定为 HTTP');
1475
+ }
1476
+ async function switchProto(httpOnly, cur) {
1477
+ if (httpOnly === cur.httpOnly) return;
1478
+ const url = `${httpOnly ? 'http' : 'https'}://${location.hostname}:${cur.port}/`;
1479
+ if (!confirm(`切换为 ${httpOnly ? 'HTTP' : 'HTTPS'}?\n\nmyhi 会重启(约 5 秒,正在执行的任务会被打断这一轮),之后访问地址变为:\n${url}\n\n${httpOnly ? '注意:HTTP 下手机等非本机浏览器不能用麦克风 / 摄像头(语音输入、拍照)。' : '注意:HTTPS 用的是自签名证书,浏览器会提示「不安全」,点「继续访问」即可。'}`)) return;
1480
+ let d; try { d = await (await fetch('/api/server-mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ httpOnly }) })).json(); } catch (e) { d = { ok: false, error: e.message }; }
1481
+ if (!d.ok) { setStatus('set-proto-status', '切换失败:' + (d.error || '')); return; }
1482
+ let n = 8;
1483
+ const tick = () => { setStatus('set-proto-status', `myhi 正在重启,${n} 秒后自动打开 ${url}(没跳转就手动打开它)`); if (n-- <= 0) location.href = url; else setTimeout(tick, 1000); };
1484
+ tick();
1485
+ }
1486
+
1459
1487
  window.restartMyhi = async function() {
1460
1488
  if (!confirm('确认重启 myhi 服务?\n重启期间连接会短暂中断,页面将自动重连。')) return;
1461
1489
  try { await fetch('/api/restart', { method: 'POST' }); } catch {}