@wendongfly/myhi 1.3.181 → 1.3.184
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/myhi.js +873 -873
- package/dist/chat.html +85 -201
- package/dist/index.html +296 -30
- package/dist/index.js +4 -4
- package/dist/index.min.js +2 -2
- package/package.json +1 -1
package/bin/myhi.js
CHANGED
|
@@ -1,873 +1,873 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from 'child_process';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
import { dirname, join } from 'path';
|
|
5
|
-
import { mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
6
|
-
import { homedir } from 'os';
|
|
7
|
-
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
|
|
11
|
-
// 解析 -p / --port 参数
|
|
12
|
-
const args = process.argv.slice(2);
|
|
13
|
-
let cmd = null;
|
|
14
|
-
let httpOnlyFlag = null; // null=不改;true=切 HTTP-only;false=切回 HTTPS
|
|
15
|
-
let hostFlag = null; // null=不改;否则持久化监听地址到 config.json(如 Tailscale IP,公网不可达)
|
|
16
|
-
for (let i = 0; i < args.length; i++) {
|
|
17
|
-
if ((args[i] === '-p' || args[i] === '--port') && args[i + 1]) {
|
|
18
|
-
process.env.PORT = args[i + 1];
|
|
19
|
-
args.splice(i, 2); i--;
|
|
20
|
-
} else if (args[i].startsWith('--port=')) {
|
|
21
|
-
process.env.PORT = args[i].split('=')[1];
|
|
22
|
-
args.splice(i, 1); i--;
|
|
23
|
-
} else if ((args[i] === '--host' || args[i].startsWith('--host=')) && args[i].includes('=')) {
|
|
24
|
-
hostFlag = args[i].split('=')[1]; args.splice(i, 1); i--;
|
|
25
|
-
} else if (args[i] === '--host' && args[i + 1]) {
|
|
26
|
-
hostFlag = args[i + 1]; args.splice(i, 2); i--;
|
|
27
|
-
} else if (args[i] === '--http' || args[i] === '--no-https') {
|
|
28
|
-
httpOnlyFlag = true; args.splice(i, 1); i--;
|
|
29
|
-
} else if (args[i] === '--https') {
|
|
30
|
-
httpOnlyFlag = false; args.splice(i, 1); i--;
|
|
31
|
-
} else if (!cmd && !args[i].startsWith('-')) {
|
|
32
|
-
cmd = args[i];
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
if (!cmd) cmd = args[0];
|
|
36
|
-
|
|
37
|
-
// --version / -v:只打印版本号就退出,绝不启动 server(否则会起孤儿进程霸占端口)
|
|
38
|
-
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
39
|
-
try {
|
|
40
|
-
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'));
|
|
41
|
-
console.log(pkg.version);
|
|
42
|
-
} catch (e) {
|
|
43
|
-
console.error('读取版本失败:', e.message);
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const configDir = join(homedir(), '.myhi');
|
|
50
|
-
const pidFile = join(configDir, 'daemon.pid');
|
|
51
|
-
const logFile = join(configDir, 'daemon.log');
|
|
52
|
-
const configFile = join(configDir, 'config.json');
|
|
53
|
-
|
|
54
|
-
// --http / --https:持久化 HTTP-only 开关到 config.json,server 启动时读取(任何重启都保留,
|
|
55
|
-
// 不依赖环境变量从 daemon 往下传)。设完若没显式命令,就自动 restart 让它立刻生效。
|
|
56
|
-
if (httpOnlyFlag !== null) {
|
|
57
|
-
try {
|
|
58
|
-
mkdirSync(configDir, { recursive: true });
|
|
59
|
-
let cfg = {};
|
|
60
|
-
try { cfg = JSON.parse(readFileSync(configFile, 'utf8')); } catch {}
|
|
61
|
-
cfg.httpOnly = httpOnlyFlag;
|
|
62
|
-
writeFileSync(configFile, JSON.stringify(cfg, null, 2));
|
|
63
|
-
console.log(`[myhi] 已切换为 ${httpOnlyFlag ? 'HTTP(无 TLS,麦克风/摄像头等在非 localhost 会受限)' : 'HTTPS'} 模式`);
|
|
64
|
-
} catch (e) {
|
|
65
|
-
console.error('[myhi] 写入 config.json 失败:', e.message);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
if (!cmd) cmd = 'restart'; // 无显式命令 → 切完直接重启使其生效
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// --host <addr>:持久化监听地址到 config.json(server 启动时读取,任何重启都保留)。
|
|
72
|
-
// 典型用法:`myhi --host <Tailscale IP>` 把 myhi 只绑内网,公网不可达。设完自动 restart。
|
|
73
|
-
if (hostFlag !== null) {
|
|
74
|
-
try {
|
|
75
|
-
mkdirSync(configDir, { recursive: true });
|
|
76
|
-
let cfg = {};
|
|
77
|
-
try { cfg = JSON.parse(readFileSync(configFile, 'utf8')); } catch {}
|
|
78
|
-
cfg.host = hostFlag.trim() || undefined; // 空串 = 回到默认 0.0.0.0
|
|
79
|
-
if (!cfg.host) delete cfg.host;
|
|
80
|
-
writeFileSync(configFile, JSON.stringify(cfg, null, 2));
|
|
81
|
-
console.log(`[myhi] 监听地址已设为 ${cfg.host || '0.0.0.0(全部接口)'},重启后仍保留`);
|
|
82
|
-
} catch (e) {
|
|
83
|
-
console.error('[myhi] 写入 config.json 失败:', e.message);
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
86
|
-
if (!cmd) cmd = 'restart';
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ── 工具函数 ──────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
function readPid() {
|
|
92
|
-
try { return parseInt(readFileSync(pidFile, 'utf8').trim(), 10); }
|
|
93
|
-
catch { return null; }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function isRunning(pid) {
|
|
97
|
-
try { process.kill(pid, 0); return true; }
|
|
98
|
-
catch { return false; }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function stopDaemon() {
|
|
102
|
-
const serverPidFile = join(configDir, 'server.pid');
|
|
103
|
-
let stopped = false;
|
|
104
|
-
|
|
105
|
-
// 停止 daemon 进程
|
|
106
|
-
const daemonPid = readPid();
|
|
107
|
-
if (daemonPid && isRunning(daemonPid)) {
|
|
108
|
-
process.kill(daemonPid, 'SIGTERM');
|
|
109
|
-
try { unlinkSync(pidFile); } catch {}
|
|
110
|
-
console.log(`[myhi] 已停止 daemon (PID ${daemonPid})`);
|
|
111
|
-
stopped = true;
|
|
112
|
-
} else if (daemonPid) {
|
|
113
|
-
try { unlinkSync(pidFile); } catch {}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 停止 server 进程(普通模式或残留进程)
|
|
117
|
-
try {
|
|
118
|
-
const serverPid = parseInt(readFileSync(serverPidFile, 'utf8').trim(), 10);
|
|
119
|
-
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 {}
|
|
122
|
-
} else {
|
|
123
|
-
process.kill(serverPid, 'SIGTERM');
|
|
124
|
-
}
|
|
125
|
-
try { unlinkSync(serverPidFile); } catch {}
|
|
126
|
-
console.log(`[myhi] 已停止 server (PID ${serverPid})`);
|
|
127
|
-
stopped = true;
|
|
128
|
-
} else if (serverPid) {
|
|
129
|
-
try { unlinkSync(serverPidFile); } catch {}
|
|
130
|
-
}
|
|
131
|
-
} catch {}
|
|
132
|
-
|
|
133
|
-
if (!stopped) console.log('[myhi] 没有正在运行的 myhi 进程');
|
|
134
|
-
return stopped;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function startDaemon() {
|
|
138
|
-
mkdirSync(configDir, { recursive: true });
|
|
139
|
-
const out = openSync(logFile, 'a');
|
|
140
|
-
// 启动守护进程(daemon.js),由它管理 server 子进程
|
|
141
|
-
const child = spawn(process.execPath, [join(__dirname, 'daemon.js')], {
|
|
142
|
-
detached: true,
|
|
143
|
-
stdio: ['ignore', out, out],
|
|
144
|
-
cwd: process.cwd(),
|
|
145
|
-
env: { ...process.env },
|
|
146
|
-
windowsHide: true,
|
|
147
|
-
});
|
|
148
|
-
child.unref();
|
|
149
|
-
console.log(`[myhi] 已在后台启动 (PID ${child.pid})`);
|
|
150
|
-
|
|
151
|
-
// 等待 server 启动,显示日志
|
|
152
|
-
let waited = 0;
|
|
153
|
-
const poll = setInterval(() => {
|
|
154
|
-
waited += 500;
|
|
155
|
-
try {
|
|
156
|
-
const content = readFileSync(logFile, 'utf8');
|
|
157
|
-
const lines = content.split('\n');
|
|
158
|
-
// 找到最新的启动信息
|
|
159
|
-
let startIdx = -1;
|
|
160
|
-
for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].includes('━━━━')) { startIdx = i; break; } }
|
|
161
|
-
if (startIdx >= 0) {
|
|
162
|
-
clearInterval(poll);
|
|
163
|
-
// 显示从启动横幅开始的日志
|
|
164
|
-
const recent = lines.slice(Math.max(0, startIdx - 1));
|
|
165
|
-
console.log(recent.join('\n'));
|
|
166
|
-
process.exit(0);
|
|
167
|
-
}
|
|
168
|
-
} catch {}
|
|
169
|
-
if (waited > 10000) {
|
|
170
|
-
clearInterval(poll);
|
|
171
|
-
console.log(`[myhi] 停止: myhi stop`);
|
|
172
|
-
console.log(`[myhi] 日志: myhi log`);
|
|
173
|
-
console.log(`[myhi] 管理: http://localhost:${process.env.PORT || 12300}/admin`);
|
|
174
|
-
process.exit(0);
|
|
175
|
-
}
|
|
176
|
-
}, 500);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── 命令分发 ──────────────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
if (cmd === 'attach') {
|
|
182
|
-
process.argv.splice(2, 1);
|
|
183
|
-
await import('../dist/attach.js');
|
|
184
|
-
|
|
185
|
-
} else if (cmd === 'stop') {
|
|
186
|
-
stopDaemon();
|
|
187
|
-
|
|
188
|
-
} else if (cmd === 'restart') {
|
|
189
|
-
stopDaemon();
|
|
190
|
-
await new Promise(r => setTimeout(r, 500));
|
|
191
|
-
startDaemon();
|
|
192
|
-
process.exit(0);
|
|
193
|
-
|
|
194
|
-
} else if (cmd === 'status') {
|
|
195
|
-
const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
|
|
196
|
-
const daemonPid = readPid();
|
|
197
|
-
let serverPid = null;
|
|
198
|
-
try { serverPid = parseInt(readFileSync(join(configDir, 'server.pid'), 'utf8').trim(), 10); } catch {}
|
|
199
|
-
const daemonAlive = daemonPid && isRunning(daemonPid);
|
|
200
|
-
const serverAlive = serverPid && isRunning(serverPid);
|
|
201
|
-
if (daemonAlive || serverAlive) {
|
|
202
|
-
const mode = daemonAlive ? 'daemon' : '前台';
|
|
203
|
-
const pid = daemonAlive ? daemonPid : serverPid;
|
|
204
|
-
console.log(`[myhi] v${pkgVersion} 运行中 (${mode}, PID ${pid})`);
|
|
205
|
-
} else {
|
|
206
|
-
console.log(`[myhi] v${pkgVersion} 未运行`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
} else if (cmd === 'log' || cmd === 'logs') {
|
|
210
|
-
try {
|
|
211
|
-
const content = readFileSync(logFile, 'utf8');
|
|
212
|
-
const lines = content.split('\n');
|
|
213
|
-
const n = parseInt(process.argv[3], 10) || 50;
|
|
214
|
-
console.log(lines.slice(-n).join('\n'));
|
|
215
|
-
} catch {
|
|
216
|
-
console.log('[myhi] 日志文件不存在');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
} else if (cmd === 'exclusive') {
|
|
220
|
-
// 独占模式开关
|
|
221
|
-
const { existsSync } = await import('fs');
|
|
222
|
-
const usersFile = join(configDir, 'users.json');
|
|
223
|
-
function loadCfg() {
|
|
224
|
-
try { if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8')); } catch {}
|
|
225
|
-
return { users: {} };
|
|
226
|
-
}
|
|
227
|
-
function saveCfg(data) {
|
|
228
|
-
mkdirSync(configDir, { recursive: true });
|
|
229
|
-
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const sub = args[args.indexOf('exclusive') + 1] || process.argv[3];
|
|
233
|
-
if (sub === 'on' || sub === 'enable') {
|
|
234
|
-
const data = loadCfg();
|
|
235
|
-
data.exclusive = true;
|
|
236
|
-
saveCfg(data);
|
|
237
|
-
console.log('[myhi] 独占模式已开启(同一时间只允许一个账号登录)');
|
|
238
|
-
} else if (sub === 'off' || sub === 'disable') {
|
|
239
|
-
const data = loadCfg();
|
|
240
|
-
data.exclusive = false;
|
|
241
|
-
saveCfg(data);
|
|
242
|
-
console.log('[myhi] 独占模式已关闭(允许多人同时登录)');
|
|
243
|
-
} else {
|
|
244
|
-
const data = loadCfg();
|
|
245
|
-
const mode = data.exclusive ? '开启' : '关闭';
|
|
246
|
-
console.log(`[myhi] 独占模式: ${mode}`);
|
|
247
|
-
console.log(`\n用法:
|
|
248
|
-
myhi exclusive on 开启独占模式(同一时间只允许一个账号)
|
|
249
|
-
myhi exclusive off 关闭独占模式(允许多人同时登录)`);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
} else if (cmd === 'user') {
|
|
253
|
-
// 用户管理:直接读写 ~/.myhi/users.json,不依赖 src/
|
|
254
|
-
const { existsSync } = await import('fs');
|
|
255
|
-
const usersFile = join(configDir, 'users.json');
|
|
256
|
-
|
|
257
|
-
function loadUsersFile() {
|
|
258
|
-
try {
|
|
259
|
-
if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8'));
|
|
260
|
-
} catch {}
|
|
261
|
-
return { users: {} };
|
|
262
|
-
}
|
|
263
|
-
function saveUsersFile(data) {
|
|
264
|
-
mkdirSync(configDir, { recursive: true });
|
|
265
|
-
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
const sub = args[args.indexOf('user') + 1] || process.argv[3];
|
|
269
|
-
if (sub === 'list' || sub === 'ls') {
|
|
270
|
-
const data = loadUsersFile();
|
|
271
|
-
const entries = Object.entries(data.users || {});
|
|
272
|
-
if (entries.length === 0) {
|
|
273
|
-
console.log('[myhi] 还没有用户,使用 myhi user add <密码> <名称> <目录> 添加');
|
|
274
|
-
} else {
|
|
275
|
-
console.log('\n 密码\t\t名称\t\t目录');
|
|
276
|
-
console.log(' ────\t\t────\t\t────');
|
|
277
|
-
for (const [pwd, info] of entries) {
|
|
278
|
-
const masked = pwd.slice(0, 2) + '*'.repeat(pwd.length - 2);
|
|
279
|
-
console.log(` ${masked}\t\t${info.name}\t\t${info.dir}`);
|
|
280
|
-
}
|
|
281
|
-
console.log();
|
|
282
|
-
}
|
|
283
|
-
} else if (sub === 'add') {
|
|
284
|
-
const pwd = process.argv[4];
|
|
285
|
-
const name = process.argv[5];
|
|
286
|
-
const dir = process.argv[6];
|
|
287
|
-
if (!pwd || !name || !dir) {
|
|
288
|
-
console.log('用法: myhi user add <密码> <名称> <目录>');
|
|
289
|
-
console.log('示例: myhi user add 1234 张三 D:\\project\\app1');
|
|
290
|
-
process.exit(1);
|
|
291
|
-
}
|
|
292
|
-
mkdirSync(dir, { recursive: true });
|
|
293
|
-
// 为用户创建独立的 git 配置(隔离凭据)
|
|
294
|
-
const gitconfigPath = join(dir, '.gitconfig');
|
|
295
|
-
const gitcredPath = join(dir, '.git-credentials');
|
|
296
|
-
if (!existsSync(gitconfigPath)) {
|
|
297
|
-
writeFileSync(gitconfigPath, `[credential]\n\thelper = store --file ${gitcredPath.replace(/\\/g, '/')}\n[user]\n\tname = ${name}\n`, { mode: 0o600 });
|
|
298
|
-
console.log(`[myhi] 已创建 git 配置: ${gitconfigPath}`);
|
|
299
|
-
}
|
|
300
|
-
const data = loadUsersFile();
|
|
301
|
-
data.users[pwd] = { name, dir };
|
|
302
|
-
saveUsersFile(data);
|
|
303
|
-
console.log(`[myhi] 已添加用户 "${name}" (目录: ${dir})`);
|
|
304
|
-
} else if (sub === 'remove' || sub === 'rm') {
|
|
305
|
-
const pwd = process.argv[4];
|
|
306
|
-
if (!pwd) {
|
|
307
|
-
console.log('用法: myhi user remove <密码>');
|
|
308
|
-
process.exit(1);
|
|
309
|
-
}
|
|
310
|
-
const data = loadUsersFile();
|
|
311
|
-
if (data.users[pwd]) {
|
|
312
|
-
delete data.users[pwd];
|
|
313
|
-
saveUsersFile(data);
|
|
314
|
-
console.log(`[myhi] 已删除用户`);
|
|
315
|
-
} else {
|
|
316
|
-
console.log('[myhi] 未找到该密码对应的用户');
|
|
317
|
-
}
|
|
318
|
-
} else {
|
|
319
|
-
console.log(`
|
|
320
|
-
用户管理命令:
|
|
321
|
-
myhi user list 列出所有用户
|
|
322
|
-
myhi user add <密码> <名称> <目录> 添加用户
|
|
323
|
-
myhi user remove <密码> 删除用户
|
|
324
|
-
|
|
325
|
-
示例:
|
|
326
|
-
myhi user add 1234 张三 D:\\project\\app1
|
|
327
|
-
myhi user add 5678 李四 D:\\project\\app2
|
|
328
|
-
myhi user list
|
|
329
|
-
myhi user remove 1234
|
|
330
|
-
`);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
} else if (cmd === 'ssh') {
|
|
334
|
-
// SSH 账号管理:为 myhi 用户创建系统 SSH 账号,隔离目录权限
|
|
335
|
-
const { existsSync } = await import('fs');
|
|
336
|
-
const { platform } = await import('os');
|
|
337
|
-
const usersFile = join(configDir, 'users.json');
|
|
338
|
-
const isWindows = platform() === 'win32';
|
|
339
|
-
|
|
340
|
-
function loadUsersFile() {
|
|
341
|
-
try { if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8')); } catch {}
|
|
342
|
-
return { users: {} };
|
|
343
|
-
}
|
|
344
|
-
function saveUsersFile(data) {
|
|
345
|
-
mkdirSync(configDir, { recursive: true });
|
|
346
|
-
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function run(cmd, opts = {}) {
|
|
350
|
-
try {
|
|
351
|
-
return execSync(cmd, { encoding: 'utf8', stdio: opts.silent ? 'pipe' : 'inherit', ...opts }).trim();
|
|
352
|
-
} catch (e) {
|
|
353
|
-
if (!opts.ignoreError) { console.error(`[myhi] 命令失败: ${cmd}\n${e.stderr || e.message}`); process.exit(1); }
|
|
354
|
-
return '';
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function findUserByName(name) {
|
|
359
|
-
const data = loadUsersFile();
|
|
360
|
-
for (const [pwd, info] of Object.entries(data.users || {})) {
|
|
361
|
-
if (info.name === name) return { pwd, ...info };
|
|
362
|
-
}
|
|
363
|
-
return null;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const sub = args[args.indexOf('ssh') + 1] || process.argv[3];
|
|
367
|
-
|
|
368
|
-
if (sub === 'add' || sub === 'setup') {
|
|
369
|
-
const myName = process.argv[4];
|
|
370
|
-
const sshPass = process.argv[5];
|
|
371
|
-
if (!myName) {
|
|
372
|
-
console.log('用法: myhi ssh add <myhi用户名> [SSH密码]');
|
|
373
|
-
console.log('示例: myhi ssh add 张三 mypassword');
|
|
374
|
-
console.log('\n将为 myhi 用户创建系统 SSH 账号,绑定到该用户的项目目录');
|
|
375
|
-
process.exit(1);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const user = findUserByName(myName);
|
|
379
|
-
if (!user) { console.log(`[myhi] 未找到 myhi 用户 "${myName}",请先 myhi user add`); process.exit(1); }
|
|
380
|
-
|
|
381
|
-
const dir = user.dir;
|
|
382
|
-
// SSH 用户名:myhi-<name> (ASCII 化)
|
|
383
|
-
const sshUser = 'myhi-' + myName.replace(/[^a-zA-Z0-9_-]/g, () => Math.random().toString(36)[2]);
|
|
384
|
-
const sshPassword = sshPass || Math.random().toString(36).slice(2, 10) + 'A1!';
|
|
385
|
-
|
|
386
|
-
console.log(`[myhi] 创建 SSH 账号...`);
|
|
387
|
-
console.log(` myhi 用户: ${myName}`);
|
|
388
|
-
console.log(` 项目目录: ${dir}`);
|
|
389
|
-
console.log(` SSH 用户: ${sshUser}`);
|
|
390
|
-
|
|
391
|
-
if (isWindows) {
|
|
392
|
-
// ── Windows ──
|
|
393
|
-
// 创建本地用户
|
|
394
|
-
run(`net user "${sshUser}" "${sshPassword}" /add /y`, { silent: true, ignoreError: true });
|
|
395
|
-
run(`net user "${sshUser}" "${sshPassword}" /y`, { silent: true, ignoreError: true });
|
|
396
|
-
|
|
397
|
-
// 设置目录权限
|
|
398
|
-
mkdirSync(dir, { recursive: true });
|
|
399
|
-
// 上级目录:允许穿越(Traverse)但不允许列目录
|
|
400
|
-
const parent = join(dir, '..');
|
|
401
|
-
run(`icacls "${parent}" /grant "${sshUser}:(RC,S,X,RA,REA)"`, { silent: true, ignoreError: true });
|
|
402
|
-
// 用户自己的目录:完全控制
|
|
403
|
-
run(`icacls "${dir}" /grant "${sshUser}:(OI)(CI)F"`, { silent: true });
|
|
404
|
-
|
|
405
|
-
// 在用户 home 下创建 project 符号链接
|
|
406
|
-
const userHome = `C:\\Users\\${sshUser}`;
|
|
407
|
-
if (!existsSync(userHome)) mkdirSync(userHome, { recursive: true });
|
|
408
|
-
const link = join(userHome, 'project');
|
|
409
|
-
if (!existsSync(link)) {
|
|
410
|
-
run(`cmd /c mklink /D "${link}" "${dir}"`, { silent: true, ignoreError: true });
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// 确保 OpenSSH Server 已启动
|
|
414
|
-
run('powershell -Command "Start-Service sshd 2>$null; Set-Service -Name sshd -StartupType Automatic 2>$null"', { silent: true, ignoreError: true });
|
|
415
|
-
|
|
416
|
-
} else {
|
|
417
|
-
// ── Linux / Ubuntu ──
|
|
418
|
-
// 创建系统用户,home 指向项目目录
|
|
419
|
-
run(`id "${sshUser}" 2>/dev/null || useradd -m -d "/home/${sshUser}" -s /bin/bash "${sshUser}"`, { silent: true, ignoreError: true });
|
|
420
|
-
run(`echo "${sshUser}:${sshPassword}" | chpasswd`, { silent: true });
|
|
421
|
-
|
|
422
|
-
mkdirSync(dir, { recursive: true });
|
|
423
|
-
|
|
424
|
-
// 在用户 home 下创建 project 符号链接
|
|
425
|
-
const homeDir = `/home/${sshUser}`;
|
|
426
|
-
const link = `${homeDir}/project`;
|
|
427
|
-
run(`ln -sfn "${dir}" "${link}"`, { silent: true, ignoreError: true });
|
|
428
|
-
run(`chown -h ${sshUser}:${sshUser} "${link}"`, { silent: true, ignoreError: true });
|
|
429
|
-
|
|
430
|
-
// 项目目录:设置为该用户所有
|
|
431
|
-
run(`chown -R ${sshUser}:${sshUser} "${dir}"`, { silent: true, ignoreError: true });
|
|
432
|
-
|
|
433
|
-
// 上级目录权限:移除 others 的读和执行
|
|
434
|
-
const parent = join(dir, '..');
|
|
435
|
-
run(`chmod 711 "${parent}"`, { silent: true, ignoreError: true });
|
|
436
|
-
// 用户自己的目录
|
|
437
|
-
run(`chmod 700 "${dir}"`, { silent: true, ignoreError: true });
|
|
438
|
-
|
|
439
|
-
// 确保 SSH 服务运行
|
|
440
|
-
run('systemctl enable ssh 2>/dev/null; systemctl start ssh 2>/dev/null || service ssh start 2>/dev/null', { silent: true, ignoreError: true });
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// 保存 SSH 映射到 users.json
|
|
444
|
-
const data = loadUsersFile();
|
|
445
|
-
if (data.users[user.pwd]) {
|
|
446
|
-
data.users[user.pwd].sshUser = sshUser;
|
|
447
|
-
saveUsersFile(data);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
console.log(`\n ✓ SSH 账号创建成功!`);
|
|
451
|
-
console.log(` ┌─────────────────────────────────`);
|
|
452
|
-
console.log(` │ SSH 用户名: ${sshUser}`);
|
|
453
|
-
console.log(` │ SSH 密码: ${sshPassword}`);
|
|
454
|
-
console.log(` │ 项目目录: ${dir}`);
|
|
455
|
-
console.log(` └─────────────────────────────────`);
|
|
456
|
-
console.log(`\n VS Code 连接方式:`);
|
|
457
|
-
console.log(` 1. 安装 Remote-SSH 扩展`);
|
|
458
|
-
console.log(` 2. 按 F1 → "Remote-SSH: Connect to Host"`);
|
|
459
|
-
console.log(` 3. 输入: ${sshUser}@<服务器IP>`);
|
|
460
|
-
console.log(` 4. 打开目录: ~/project`);
|
|
461
|
-
|
|
462
|
-
} else if (sub === 'list' || sub === 'ls') {
|
|
463
|
-
const data = loadUsersFile();
|
|
464
|
-
const entries = Object.entries(data.users || {}).filter(([, info]) => info.sshUser);
|
|
465
|
-
if (entries.length === 0) {
|
|
466
|
-
console.log('[myhi] 还没有 SSH 账号,使用 myhi ssh add <用户名> 创建');
|
|
467
|
-
} else {
|
|
468
|
-
console.log('\n myhi用户\tSSH账号\t\t\t目录');
|
|
469
|
-
console.log(' ────────\t───────\t\t\t────');
|
|
470
|
-
for (const [, info] of entries) {
|
|
471
|
-
console.log(` ${info.name}\t\t${info.sshUser}\t\t${info.dir}`);
|
|
472
|
-
}
|
|
473
|
-
console.log();
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
} else if (sub === 'remove' || sub === 'rm') {
|
|
477
|
-
const myName = process.argv[4];
|
|
478
|
-
if (!myName) { console.log('用法: myhi ssh remove <myhi用户名>'); process.exit(1); }
|
|
479
|
-
|
|
480
|
-
const user = findUserByName(myName);
|
|
481
|
-
if (!user || !user.sshUser) { console.log(`[myhi] 未找到 "${myName}" 的 SSH 账号`); process.exit(1); }
|
|
482
|
-
|
|
483
|
-
const sshUser = user.sshUser;
|
|
484
|
-
console.log(`[myhi] 删除 SSH 账号: ${sshUser}`);
|
|
485
|
-
|
|
486
|
-
if (isWindows) {
|
|
487
|
-
run(`net user "${sshUser}" /delete`, { silent: true, ignoreError: true });
|
|
488
|
-
} else {
|
|
489
|
-
run(`userdel "${sshUser}" 2>/dev/null`, { silent: true, ignoreError: true });
|
|
490
|
-
// 不删除 home,避免误删项目数据
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
const data = loadUsersFile();
|
|
494
|
-
if (data.users[user.pwd]) {
|
|
495
|
-
delete data.users[user.pwd].sshUser;
|
|
496
|
-
saveUsersFile(data);
|
|
497
|
-
}
|
|
498
|
-
console.log(`[myhi] 已删除 SSH 账号 "${sshUser}"(项目目录保留)`);
|
|
499
|
-
|
|
500
|
-
} else {
|
|
501
|
-
console.log(`
|
|
502
|
-
SSH 账号管理:为 myhi 用户创建独立的 SSH 开发账号
|
|
503
|
-
|
|
504
|
-
myhi ssh add <用户名> [密码] 创建 SSH 账号(绑定到 myhi 用户的项目目录)
|
|
505
|
-
myhi ssh list 列出所有 SSH 账号
|
|
506
|
-
myhi ssh remove <用户名> 删除 SSH 账号(保留项目目录)
|
|
507
|
-
|
|
508
|
-
示例:
|
|
509
|
-
myhi user add 1234 张三 /data/projects/zhangsan # 先添加 myhi 用户
|
|
510
|
-
myhi ssh add 张三 sshpass123 # 再创建 SSH 账号
|
|
511
|
-
myhi ssh list # 查看所有 SSH 账号
|
|
512
|
-
|
|
513
|
-
员工使用 VS Code Remote-SSH 连接后打开 ~/project 即可开发。
|
|
514
|
-
每个用户只能访问自己的项目目录,无法查看其他用户的文件。
|
|
515
|
-
`);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
} else if (cmd === 'kick') {
|
|
519
|
-
// 踢出会话中的用户
|
|
520
|
-
const { createRequire } = await import('module');
|
|
521
|
-
const require = createRequire(import.meta.url);
|
|
522
|
-
const { io } = require('socket.io-client');
|
|
523
|
-
const { createInterface } = await import('readline');
|
|
524
|
-
|
|
525
|
-
const SERVER = process.env.MYHI_SERVER || 'http://localhost:12300';
|
|
526
|
-
let token;
|
|
527
|
-
try {
|
|
528
|
-
token = readFileSync(join(configDir, 'token'), 'utf8').trim();
|
|
529
|
-
} catch {
|
|
530
|
-
console.log('[myhi] 未找到 token,请先启动服务器');
|
|
531
|
-
process.exit(1);
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
const socket = io(SERVER, { auth: { token }, reconnection: false });
|
|
535
|
-
|
|
536
|
-
socket.on('connect_error', (err) => {
|
|
537
|
-
console.log(`[myhi] 连接失败: ${err.message}`);
|
|
538
|
-
process.exit(1);
|
|
539
|
-
});
|
|
540
|
-
|
|
541
|
-
socket.on('connect', () => {
|
|
542
|
-
// 获取所有会话
|
|
543
|
-
socket.emit('sessions', null, (sessions) => {
|
|
544
|
-
// sessions 事件可能不存在,用 HTTP 回退
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
// 通过 HTTP 获取会话列表
|
|
548
|
-
fetch(`${SERVER}/api/sessions?token=${token}`)
|
|
549
|
-
.then(r => r.json())
|
|
550
|
-
.then(async (sessions) => {
|
|
551
|
-
const alive = sessions.filter(s => s.alive);
|
|
552
|
-
if (alive.length === 0) {
|
|
553
|
-
console.log('[myhi] 没有活跃的会话');
|
|
554
|
-
socket.disconnect();
|
|
555
|
-
process.exit(0);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// 收集所有会话的在线用户
|
|
559
|
-
const allViewers = [];
|
|
560
|
-
for (const session of alive) {
|
|
561
|
-
const result = await new Promise(resolve => {
|
|
562
|
-
socket.emit('join', session.id);
|
|
563
|
-
socket.emit('list-viewers', { sessionId: session.id }, resolve);
|
|
564
|
-
});
|
|
565
|
-
if (result.ok) {
|
|
566
|
-
for (const v of result.viewers) {
|
|
567
|
-
allViewers.push({ ...v, sessionId: session.id, sessionTitle: session.title });
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if (allViewers.length === 0) {
|
|
573
|
-
console.log('[myhi] 没有在线用户');
|
|
574
|
-
socket.disconnect();
|
|
575
|
-
process.exit(0);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// 去重(同一个 socket 可能只在一个会话中)
|
|
579
|
-
const unique = [...new Map(allViewers.map(v => [v.socketId, v])).values()];
|
|
580
|
-
|
|
581
|
-
console.log('\n 在线用户:');
|
|
582
|
-
console.log(' ────────────────────────────────────────');
|
|
583
|
-
unique.forEach((v, i) => {
|
|
584
|
-
const ctrl = v.isController ? ' [控制中]' : '';
|
|
585
|
-
console.log(` [${i + 1}] ${v.userName} (${v.role})${ctrl} 会话: ${v.sessionTitle}`);
|
|
586
|
-
});
|
|
587
|
-
console.log();
|
|
588
|
-
|
|
589
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
590
|
-
rl.question(' 输入序号踢出用户(0 取消): ', (answer) => {
|
|
591
|
-
rl.close();
|
|
592
|
-
const idx = parseInt(answer, 10);
|
|
593
|
-
if (!idx || idx < 1 || idx > unique.length) {
|
|
594
|
-
console.log('[myhi] 已取消');
|
|
595
|
-
socket.disconnect();
|
|
596
|
-
process.exit(0);
|
|
597
|
-
}
|
|
598
|
-
const target = unique[idx - 1];
|
|
599
|
-
socket.emit('kick-user', { socketId: target.socketId, sessionId: target.sessionId }, (res) => {
|
|
600
|
-
if (res.ok) {
|
|
601
|
-
console.log(`[myhi] 已踢出用户 "${res.name}"`);
|
|
602
|
-
} else {
|
|
603
|
-
console.log(`[myhi] 踢出失败: ${res.error}`);
|
|
604
|
-
}
|
|
605
|
-
socket.disconnect();
|
|
606
|
-
process.exit(0);
|
|
607
|
-
});
|
|
608
|
-
});
|
|
609
|
-
})
|
|
610
|
-
.catch((err) => {
|
|
611
|
-
console.log(`[myhi] 获取会话失败: ${err.message}`);
|
|
612
|
-
socket.disconnect();
|
|
613
|
-
process.exit(1);
|
|
614
|
-
});
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
618
|
-
const isWin = process.platform === 'win32';
|
|
619
|
-
const isLinux = process.platform === 'linux';
|
|
620
|
-
const isMac = process.platform === 'darwin';
|
|
621
|
-
const platform = isWin ? 'Windows' : isMac ? 'macOS' : 'Linux';
|
|
622
|
-
const configPath = isWin ? '%USERPROFILE%\\.myhi\\' : '~/.myhi/';
|
|
623
|
-
const sep = isWin ? '\\' : '/';
|
|
624
|
-
|
|
625
|
-
let help = `
|
|
626
|
-
myhi — 基于 Web 的终端共享工具 (${platform})
|
|
627
|
-
通过局域网或 Tailscale 在手机/平板上控制电脑终端,无需中继服务器。
|
|
628
|
-
|
|
629
|
-
启动服务:
|
|
630
|
-
myhi 前台启动服务器
|
|
631
|
-
myhi start 前台启动服务器
|
|
632
|
-
myhi -p <port> 指定监听端口(默认 12300)
|
|
633
|
-
myhi -d 后台启动(日志写入 ${configPath}daemon.log)
|
|
634
|
-
myhi --http 切为纯 HTTP(无 TLS)并重启,持久生效
|
|
635
|
-
myhi --https 切回 HTTPS(默认)并重启
|
|
636
|
-
myhi stop 停止后台服务器
|
|
637
|
-
myhi restart 重启后台服务器
|
|
638
|
-
myhi status 查看运行状态
|
|
639
|
-
myhi log [N] 查看后台日志(最后 N 行,默认 50)
|
|
640
|
-
`;
|
|
641
|
-
|
|
642
|
-
if (isLinux) {
|
|
643
|
-
help += `
|
|
644
|
-
系统服务(systemd):
|
|
645
|
-
sudo myhi service install 安装为系统服务(开机自启)
|
|
646
|
-
sudo myhi service uninstall 卸载系统服务
|
|
647
|
-
myhi service status 查看服务状态
|
|
648
|
-
|
|
649
|
-
提示: 使用 systemd 管理时,不要混用 myhi -d / myhi stop
|
|
650
|
-
统一用 systemctl start/stop/restart myhi
|
|
651
|
-
`;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
help += `
|
|
655
|
-
用户管理:
|
|
656
|
-
myhi user list 列出所有用户
|
|
657
|
-
myhi user add <密码> <名称> <目录> 添加用户
|
|
658
|
-
myhi user remove <密码> 删除用户
|
|
659
|
-
myhi exclusive on 开启独占模式(一次只能一个账号)
|
|
660
|
-
myhi exclusive off 关闭独占模式(多人同时使用,默认)
|
|
661
|
-
`;
|
|
662
|
-
|
|
663
|
-
if (!isWin) {
|
|
664
|
-
help += `
|
|
665
|
-
SSH 开发:
|
|
666
|
-
myhi ssh add <用户名> [密码] 为 myhi 用户创建 SSH 账号
|
|
667
|
-
myhi ssh list 列出所有 SSH 账号
|
|
668
|
-
myhi ssh remove <用户名> 删除 SSH 账号
|
|
669
|
-
`;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
help += `
|
|
673
|
-
远程连接:
|
|
674
|
-
myhi attach 交互式选择会话并附加
|
|
675
|
-
myhi attach <id> 直接附加到指定会话
|
|
676
|
-
myhi attach --new 创建新会话并附加
|
|
677
|
-
myhi attach --password <密码> 用指定用户身份连接
|
|
678
|
-
myhi kick 踢出在线用户
|
|
679
|
-
|
|
680
|
-
快捷键(attach 模式):
|
|
681
|
-
Ctrl+] 分离终端(退出但不关闭会话)
|
|
682
|
-
|
|
683
|
-
选项:
|
|
684
|
-
-p, --port <port> 监听端口(默认 12300)
|
|
685
|
-
|
|
686
|
-
环境变量:
|
|
687
|
-
PORT 监听端口(默认 12300)
|
|
688
|
-
HOST 监听地址(默认 0.0.0.0)
|
|
689
|
-
MYHI_AUTO_ATTACH 启动时自动创建会话(默认 1,设 0 关闭)
|
|
690
|
-
MYHI_CWD 会话工作目录(默认当前目录)
|
|
691
|
-
MYHI_SERVER attach 连接的服务器地址(默认 http://localhost:12300)
|
|
692
|
-
|
|
693
|
-
配置目录:${configPath}
|
|
694
|
-
token 认证令牌(自动生成)
|
|
695
|
-
password 登录密码(自动生成,可手动修改)
|
|
696
|
-
users.json 独占用户配置
|
|
697
|
-
roles.json 多用户角色配置(可选)
|
|
698
|
-
sessions.json 会话持久化数据
|
|
699
|
-
daemon.pid 后台进程 PID
|
|
700
|
-
daemon.log 后台运行日志
|
|
701
|
-
|
|
702
|
-
示例:`;
|
|
703
|
-
|
|
704
|
-
if (isWin) {
|
|
705
|
-
help += `
|
|
706
|
-
myhi # 启动后扫描二维码即可在手机上操作
|
|
707
|
-
myhi -p 8080 # 指定 8080 端口启动
|
|
708
|
-
myhi -p 8080 -d # 8080 端口后台启动
|
|
709
|
-
set MYHI_CWD=D:\\project && myhi # 指定工作目录
|
|
710
|
-
myhi attach # 从另一台机器连接
|
|
711
|
-
myhi user add 1234 张三 D:\\project # 添加用户`;
|
|
712
|
-
} else if (isLinux) {
|
|
713
|
-
help += `
|
|
714
|
-
myhi # 启动后扫描二维码即可在手机上操作
|
|
715
|
-
myhi -p 8080 # 指定 8080 端口启动
|
|
716
|
-
sudo myhi service install # 安装为系统服务(推荐)
|
|
717
|
-
MYHI_CWD=/project myhi # 指定工作目录
|
|
718
|
-
myhi attach # 从另一台机器连接
|
|
719
|
-
myhi user add 1234 张三 /home/dev # 添加用户`;
|
|
720
|
-
} else {
|
|
721
|
-
help += `
|
|
722
|
-
myhi # 启动后扫描二维码即可在手机上操作
|
|
723
|
-
myhi -p 8080 # 指定 8080 端口启动
|
|
724
|
-
myhi -p 8080 -d # 8080 端口后台启动
|
|
725
|
-
MYHI_CWD=/project myhi # 指定工作目录
|
|
726
|
-
myhi attach # 从另一台机器连接
|
|
727
|
-
myhi user add 1234 张三 /Users/dev # 添加用户`;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
console.log(help + '\n');
|
|
731
|
-
|
|
732
|
-
} else if (cmd === 'service') {
|
|
733
|
-
// systemd service 管理
|
|
734
|
-
const subCmd = args[1] || 'install';
|
|
735
|
-
const { execSync: ex } = await import('child_process');
|
|
736
|
-
|
|
737
|
-
if (process.platform !== 'linux') {
|
|
738
|
-
console.log('[myhi] service 命令仅支持 Linux (systemd)');
|
|
739
|
-
process.exit(1);
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
// 找到 myhi 可执行文件路径
|
|
743
|
-
let myhiBin;
|
|
744
|
-
try { myhiBin = ex('which myhi', { encoding: 'utf8' }).trim(); } catch { myhiBin = '/usr/local/bin/myhi'; }
|
|
745
|
-
|
|
746
|
-
// sudo 运行时用 SUDO_USER 获取真实用户
|
|
747
|
-
const user = process.env.SUDO_USER || process.env.USER || 'root';
|
|
748
|
-
let home;
|
|
749
|
-
if (process.env.SUDO_USER) {
|
|
750
|
-
// sudo 时 homedir() 返回 root 的 home,需要查真实用户的 home
|
|
751
|
-
try { home = ex(`getent passwd ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim().split(':')[5]; } catch { home = `/home/${process.env.SUDO_USER}`; }
|
|
752
|
-
} else {
|
|
753
|
-
home = homedir();
|
|
754
|
-
}
|
|
755
|
-
const cwd = process.env.MYHI_CWD || (process.env.SUDO_USER ? home : process.cwd());
|
|
756
|
-
const port = process.env.PORT || '12300';
|
|
757
|
-
const serviceFile = '/etc/systemd/system/myhi.service';
|
|
758
|
-
|
|
759
|
-
// 找到 node 和 daemon.js 的路径,用 Type=simple 直接前台运行 daemon
|
|
760
|
-
let nodeBin;
|
|
761
|
-
try { nodeBin = ex('which node', { encoding: 'utf8' }).trim(); } catch { nodeBin = '/usr/bin/node'; }
|
|
762
|
-
// myhi bin 可能是 symlink,解析真实路径后找到同目录的 daemon.js
|
|
763
|
-
let realMyhiBin;
|
|
764
|
-
try { realMyhiBin = ex(`readlink -f ${myhiBin}`, { encoding: 'utf8' }).trim(); } catch { realMyhiBin = myhiBin; }
|
|
765
|
-
const daemonScript = join(dirname(realMyhiBin), 'daemon.js');
|
|
766
|
-
|
|
767
|
-
const unitContent = `[Unit]
|
|
768
|
-
Description=myhi - Web Terminal Sharing
|
|
769
|
-
After=network.target
|
|
770
|
-
|
|
771
|
-
[Service]
|
|
772
|
-
Type=simple
|
|
773
|
-
ExecStart=${nodeBin} ${daemonScript}
|
|
774
|
-
ExecStop=${myhiBin} stop
|
|
775
|
-
WorkingDirectory=${cwd}
|
|
776
|
-
Restart=on-failure
|
|
777
|
-
RestartSec=10
|
|
778
|
-
User=${user}
|
|
779
|
-
Environment=PORT=${port}
|
|
780
|
-
Environment=HOME=${home}
|
|
781
|
-
${['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy','NO_PROXY','no_proxy','ANTHROPIC_BASE_URL','ANTHROPIC_API_KEY'].filter(k => process.env[k]).map(k => `Environment=${k}=${process.env[k]}`).join('\n')}
|
|
782
|
-
|
|
783
|
-
[Install]
|
|
784
|
-
WantedBy=multi-user.target
|
|
785
|
-
`;
|
|
786
|
-
|
|
787
|
-
if (subCmd === 'install') {
|
|
788
|
-
try {
|
|
789
|
-
writeFileSync(serviceFile, unitContent);
|
|
790
|
-
} catch (e) {
|
|
791
|
-
if (e.code === 'EACCES') {
|
|
792
|
-
console.log('[myhi] 需要 root 权限,请使用: sudo myhi service install');
|
|
793
|
-
process.exit(1);
|
|
794
|
-
}
|
|
795
|
-
throw e;
|
|
796
|
-
}
|
|
797
|
-
console.log(`[myhi] 已创建 ${serviceFile}`);
|
|
798
|
-
console.log(` User: ${user}`);
|
|
799
|
-
console.log(` Port: ${port}`);
|
|
800
|
-
console.log(` WorkingDirectory: ${cwd}`);
|
|
801
|
-
console.log(` ExecStart: ${myhiBin} -d`);
|
|
802
|
-
|
|
803
|
-
// 配置 sudoers 让服务用户可以自升级和重启
|
|
804
|
-
if (user !== 'root') {
|
|
805
|
-
const sudoersFile = `/etc/sudoers.d/myhi-${user}`;
|
|
806
|
-
const npmBin = (() => { try { return ex('which npm', { encoding: 'utf8' }).trim(); } catch { return '/usr/bin/npm'; } })();
|
|
807
|
-
const sudoersContent = `# myhi: allow ${user} to upgrade and restart service\n${user} ALL=(ALL) NOPASSWD: ${npmBin} install -g @wendongfly/myhi*\n${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myhi.service\n${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop myhi.service\n`;
|
|
808
|
-
try {
|
|
809
|
-
writeFileSync(sudoersFile, sudoersContent, { mode: 0o440 });
|
|
810
|
-
console.log(`[myhi] 已配置 sudoers: ${sudoersFile}`);
|
|
811
|
-
} catch {
|
|
812
|
-
console.log(`[myhi] 提示: 如需 Web 端升级功能,请手动配置 sudoers:`);
|
|
813
|
-
console.log(` echo '${user} ALL=(ALL) NOPASSWD: ${npmBin} install -g @wendongfly/myhi*' | sudo tee ${sudoersFile}`);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
console.log('');
|
|
818
|
-
try {
|
|
819
|
-
ex('systemctl daemon-reload', { stdio: 'inherit' });
|
|
820
|
-
ex('systemctl enable myhi.service', { stdio: 'inherit' });
|
|
821
|
-
ex('systemctl start myhi.service', { stdio: 'inherit' });
|
|
822
|
-
console.log('');
|
|
823
|
-
console.log('[myhi] 服务已启动并设为开机自启');
|
|
824
|
-
console.log(' 查看状态: systemctl status myhi.service');
|
|
825
|
-
console.log(' 查看日志: journalctl -u myhi -f');
|
|
826
|
-
console.log(' 停止服务: sudo systemctl stop myhi.service');
|
|
827
|
-
console.log(' Web 端升级: 会话页 ⬆ 按钮一键升级');
|
|
828
|
-
} catch (e) {
|
|
829
|
-
console.log('[myhi] systemctl 执行失败,请手动运行:');
|
|
830
|
-
console.log(' sudo systemctl daemon-reload');
|
|
831
|
-
console.log(' sudo systemctl enable myhi.service');
|
|
832
|
-
console.log(' sudo systemctl start myhi.service');
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
} else if (subCmd === 'uninstall' || subCmd === 'remove') {
|
|
836
|
-
try {
|
|
837
|
-
ex('systemctl stop myhi.service', { stdio: 'pipe' });
|
|
838
|
-
ex('systemctl disable myhi.service', { stdio: 'pipe' });
|
|
839
|
-
} catch {}
|
|
840
|
-
try {
|
|
841
|
-
unlinkSync(serviceFile);
|
|
842
|
-
// 清理 sudoers
|
|
843
|
-
try { unlinkSync(`/etc/sudoers.d/myhi-${user}`); } catch {}
|
|
844
|
-
ex('systemctl daemon-reload', { stdio: 'pipe' });
|
|
845
|
-
console.log('[myhi] 已卸载 systemd 服务');
|
|
846
|
-
} catch (e) {
|
|
847
|
-
if (e.code === 'EACCES') {
|
|
848
|
-
console.log('[myhi] 需要 root 权限,请使用: sudo myhi service uninstall');
|
|
849
|
-
} else if (e.code === 'ENOENT') {
|
|
850
|
-
console.log('[myhi] 服务文件不存在');
|
|
851
|
-
} else {
|
|
852
|
-
console.log('[myhi] 卸载失败:', e.message);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
} else if (subCmd === 'status') {
|
|
857
|
-
try {
|
|
858
|
-
ex('systemctl status myhi.service', { stdio: 'inherit' });
|
|
859
|
-
} catch {}
|
|
860
|
-
|
|
861
|
-
} else {
|
|
862
|
-
console.log('用法: myhi service [install|uninstall|status]');
|
|
863
|
-
}
|
|
864
|
-
process.exit(0);
|
|
865
|
-
|
|
866
|
-
} else if (cmd === '-d' || cmd === '--daemon' || cmd === 'daemon') {
|
|
867
|
-
startDaemon();
|
|
868
|
-
process.exit(0);
|
|
869
|
-
|
|
870
|
-
} else {
|
|
871
|
-
// 默认 / 'start':前台运行
|
|
872
|
-
await import('../dist/index.js');
|
|
873
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'child_process';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { mkdirSync, openSync, readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
// 解析 -p / --port 参数
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let cmd = null;
|
|
14
|
+
let httpOnlyFlag = null; // null=不改;true=切 HTTP-only;false=切回 HTTPS
|
|
15
|
+
let hostFlag = null; // null=不改;否则持久化监听地址到 config.json(如 Tailscale IP,公网不可达)
|
|
16
|
+
for (let i = 0; i < args.length; i++) {
|
|
17
|
+
if ((args[i] === '-p' || args[i] === '--port') && args[i + 1]) {
|
|
18
|
+
process.env.PORT = args[i + 1];
|
|
19
|
+
args.splice(i, 2); i--;
|
|
20
|
+
} else if (args[i].startsWith('--port=')) {
|
|
21
|
+
process.env.PORT = args[i].split('=')[1];
|
|
22
|
+
args.splice(i, 1); i--;
|
|
23
|
+
} else if ((args[i] === '--host' || args[i].startsWith('--host=')) && args[i].includes('=')) {
|
|
24
|
+
hostFlag = args[i].split('=')[1]; args.splice(i, 1); i--;
|
|
25
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
26
|
+
hostFlag = args[i + 1]; args.splice(i, 2); i--;
|
|
27
|
+
} else if (args[i] === '--http' || args[i] === '--no-https') {
|
|
28
|
+
httpOnlyFlag = true; args.splice(i, 1); i--;
|
|
29
|
+
} else if (args[i] === '--https') {
|
|
30
|
+
httpOnlyFlag = false; args.splice(i, 1); i--;
|
|
31
|
+
} else if (!cmd && !args[i].startsWith('-')) {
|
|
32
|
+
cmd = args[i];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!cmd) cmd = args[0];
|
|
36
|
+
|
|
37
|
+
// --version / -v:只打印版本号就退出,绝不启动 server(否则会起孤儿进程霸占端口)
|
|
38
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'));
|
|
41
|
+
console.log(pkg.version);
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error('读取版本失败:', e.message);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const configDir = join(homedir(), '.myhi');
|
|
50
|
+
const pidFile = join(configDir, 'daemon.pid');
|
|
51
|
+
const logFile = join(configDir, 'daemon.log');
|
|
52
|
+
const configFile = join(configDir, 'config.json');
|
|
53
|
+
|
|
54
|
+
// --http / --https:持久化 HTTP-only 开关到 config.json,server 启动时读取(任何重启都保留,
|
|
55
|
+
// 不依赖环境变量从 daemon 往下传)。设完若没显式命令,就自动 restart 让它立刻生效。
|
|
56
|
+
if (httpOnlyFlag !== null) {
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(configDir, { recursive: true });
|
|
59
|
+
let cfg = {};
|
|
60
|
+
try { cfg = JSON.parse(readFileSync(configFile, 'utf8').replace(/^\uFEFF/, '')); } catch {}
|
|
61
|
+
cfg.httpOnly = httpOnlyFlag;
|
|
62
|
+
writeFileSync(configFile, JSON.stringify(cfg, null, 2));
|
|
63
|
+
console.log(`[myhi] 已切换为 ${httpOnlyFlag ? 'HTTP(无 TLS,麦克风/摄像头等在非 localhost 会受限)' : 'HTTPS'} 模式`);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error('[myhi] 写入 config.json 失败:', e.message);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
if (!cmd) cmd = 'restart'; // 无显式命令 → 切完直接重启使其生效
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --host <addr>:持久化监听地址到 config.json(server 启动时读取,任何重启都保留)。
|
|
72
|
+
// 典型用法:`myhi --host <Tailscale IP>` 把 myhi 只绑内网,公网不可达。设完自动 restart。
|
|
73
|
+
if (hostFlag !== null) {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(configDir, { recursive: true });
|
|
76
|
+
let cfg = {};
|
|
77
|
+
try { cfg = JSON.parse(readFileSync(configFile, 'utf8').replace(/^\uFEFF/, '')); } catch {}
|
|
78
|
+
cfg.host = hostFlag.trim() || undefined; // 空串 = 回到默认 0.0.0.0
|
|
79
|
+
if (!cfg.host) delete cfg.host;
|
|
80
|
+
writeFileSync(configFile, JSON.stringify(cfg, null, 2));
|
|
81
|
+
console.log(`[myhi] 监听地址已设为 ${cfg.host || '0.0.0.0(全部接口)'},重启后仍保留`);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.error('[myhi] 写入 config.json 失败:', e.message);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
if (!cmd) cmd = 'restart';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 工具函数 ──────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function readPid() {
|
|
92
|
+
try { return parseInt(readFileSync(pidFile, 'utf8').trim(), 10); }
|
|
93
|
+
catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isRunning(pid) {
|
|
97
|
+
try { process.kill(pid, 0); return true; }
|
|
98
|
+
catch { return false; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stopDaemon() {
|
|
102
|
+
const serverPidFile = join(configDir, 'server.pid');
|
|
103
|
+
let stopped = false;
|
|
104
|
+
|
|
105
|
+
// 停止 daemon 进程
|
|
106
|
+
const daemonPid = readPid();
|
|
107
|
+
if (daemonPid && isRunning(daemonPid)) {
|
|
108
|
+
process.kill(daemonPid, 'SIGTERM');
|
|
109
|
+
try { unlinkSync(pidFile); } catch {}
|
|
110
|
+
console.log(`[myhi] 已停止 daemon (PID ${daemonPid})`);
|
|
111
|
+
stopped = true;
|
|
112
|
+
} else if (daemonPid) {
|
|
113
|
+
try { unlinkSync(pidFile); } catch {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 停止 server 进程(普通模式或残留进程)
|
|
117
|
+
try {
|
|
118
|
+
const serverPid = parseInt(readFileSync(serverPidFile, 'utf8').trim(), 10);
|
|
119
|
+
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 {}
|
|
122
|
+
} else {
|
|
123
|
+
process.kill(serverPid, 'SIGTERM');
|
|
124
|
+
}
|
|
125
|
+
try { unlinkSync(serverPidFile); } catch {}
|
|
126
|
+
console.log(`[myhi] 已停止 server (PID ${serverPid})`);
|
|
127
|
+
stopped = true;
|
|
128
|
+
} else if (serverPid) {
|
|
129
|
+
try { unlinkSync(serverPidFile); } catch {}
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
|
|
133
|
+
if (!stopped) console.log('[myhi] 没有正在运行的 myhi 进程');
|
|
134
|
+
return stopped;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function startDaemon() {
|
|
138
|
+
mkdirSync(configDir, { recursive: true });
|
|
139
|
+
const out = openSync(logFile, 'a');
|
|
140
|
+
// 启动守护进程(daemon.js),由它管理 server 子进程
|
|
141
|
+
const child = spawn(process.execPath, [join(__dirname, 'daemon.js')], {
|
|
142
|
+
detached: true,
|
|
143
|
+
stdio: ['ignore', out, out],
|
|
144
|
+
cwd: process.cwd(),
|
|
145
|
+
env: { ...process.env },
|
|
146
|
+
windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
child.unref();
|
|
149
|
+
console.log(`[myhi] 已在后台启动 (PID ${child.pid})`);
|
|
150
|
+
|
|
151
|
+
// 等待 server 启动,显示日志
|
|
152
|
+
let waited = 0;
|
|
153
|
+
const poll = setInterval(() => {
|
|
154
|
+
waited += 500;
|
|
155
|
+
try {
|
|
156
|
+
const content = readFileSync(logFile, 'utf8');
|
|
157
|
+
const lines = content.split('\n');
|
|
158
|
+
// 找到最新的启动信息
|
|
159
|
+
let startIdx = -1;
|
|
160
|
+
for (let i = lines.length - 1; i >= 0; i--) { if (lines[i].includes('━━━━')) { startIdx = i; break; } }
|
|
161
|
+
if (startIdx >= 0) {
|
|
162
|
+
clearInterval(poll);
|
|
163
|
+
// 显示从启动横幅开始的日志
|
|
164
|
+
const recent = lines.slice(Math.max(0, startIdx - 1));
|
|
165
|
+
console.log(recent.join('\n'));
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
} catch {}
|
|
169
|
+
if (waited > 10000) {
|
|
170
|
+
clearInterval(poll);
|
|
171
|
+
console.log(`[myhi] 停止: myhi stop`);
|
|
172
|
+
console.log(`[myhi] 日志: myhi log`);
|
|
173
|
+
console.log(`[myhi] 管理: http://localhost:${process.env.PORT || 12300}/admin`);
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
}, 500);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── 命令分发 ──────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
if (cmd === 'attach') {
|
|
182
|
+
process.argv.splice(2, 1);
|
|
183
|
+
await import('../dist/attach.js');
|
|
184
|
+
|
|
185
|
+
} else if (cmd === 'stop') {
|
|
186
|
+
stopDaemon();
|
|
187
|
+
|
|
188
|
+
} else if (cmd === 'restart') {
|
|
189
|
+
stopDaemon();
|
|
190
|
+
await new Promise(r => setTimeout(r, 500));
|
|
191
|
+
startDaemon();
|
|
192
|
+
process.exit(0);
|
|
193
|
+
|
|
194
|
+
} else if (cmd === 'status') {
|
|
195
|
+
const pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')).version;
|
|
196
|
+
const daemonPid = readPid();
|
|
197
|
+
let serverPid = null;
|
|
198
|
+
try { serverPid = parseInt(readFileSync(join(configDir, 'server.pid'), 'utf8').trim(), 10); } catch {}
|
|
199
|
+
const daemonAlive = daemonPid && isRunning(daemonPid);
|
|
200
|
+
const serverAlive = serverPid && isRunning(serverPid);
|
|
201
|
+
if (daemonAlive || serverAlive) {
|
|
202
|
+
const mode = daemonAlive ? 'daemon' : '前台';
|
|
203
|
+
const pid = daemonAlive ? daemonPid : serverPid;
|
|
204
|
+
console.log(`[myhi] v${pkgVersion} 运行中 (${mode}, PID ${pid})`);
|
|
205
|
+
} else {
|
|
206
|
+
console.log(`[myhi] v${pkgVersion} 未运行`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
} else if (cmd === 'log' || cmd === 'logs') {
|
|
210
|
+
try {
|
|
211
|
+
const content = readFileSync(logFile, 'utf8');
|
|
212
|
+
const lines = content.split('\n');
|
|
213
|
+
const n = parseInt(process.argv[3], 10) || 50;
|
|
214
|
+
console.log(lines.slice(-n).join('\n'));
|
|
215
|
+
} catch {
|
|
216
|
+
console.log('[myhi] 日志文件不存在');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
} else if (cmd === 'exclusive') {
|
|
220
|
+
// 独占模式开关
|
|
221
|
+
const { existsSync } = await import('fs');
|
|
222
|
+
const usersFile = join(configDir, 'users.json');
|
|
223
|
+
function loadCfg() {
|
|
224
|
+
try { if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8')); } catch {}
|
|
225
|
+
return { users: {} };
|
|
226
|
+
}
|
|
227
|
+
function saveCfg(data) {
|
|
228
|
+
mkdirSync(configDir, { recursive: true });
|
|
229
|
+
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const sub = args[args.indexOf('exclusive') + 1] || process.argv[3];
|
|
233
|
+
if (sub === 'on' || sub === 'enable') {
|
|
234
|
+
const data = loadCfg();
|
|
235
|
+
data.exclusive = true;
|
|
236
|
+
saveCfg(data);
|
|
237
|
+
console.log('[myhi] 独占模式已开启(同一时间只允许一个账号登录)');
|
|
238
|
+
} else if (sub === 'off' || sub === 'disable') {
|
|
239
|
+
const data = loadCfg();
|
|
240
|
+
data.exclusive = false;
|
|
241
|
+
saveCfg(data);
|
|
242
|
+
console.log('[myhi] 独占模式已关闭(允许多人同时登录)');
|
|
243
|
+
} else {
|
|
244
|
+
const data = loadCfg();
|
|
245
|
+
const mode = data.exclusive ? '开启' : '关闭';
|
|
246
|
+
console.log(`[myhi] 独占模式: ${mode}`);
|
|
247
|
+
console.log(`\n用法:
|
|
248
|
+
myhi exclusive on 开启独占模式(同一时间只允许一个账号)
|
|
249
|
+
myhi exclusive off 关闭独占模式(允许多人同时登录)`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
} else if (cmd === 'user') {
|
|
253
|
+
// 用户管理:直接读写 ~/.myhi/users.json,不依赖 src/
|
|
254
|
+
const { existsSync } = await import('fs');
|
|
255
|
+
const usersFile = join(configDir, 'users.json');
|
|
256
|
+
|
|
257
|
+
function loadUsersFile() {
|
|
258
|
+
try {
|
|
259
|
+
if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8'));
|
|
260
|
+
} catch {}
|
|
261
|
+
return { users: {} };
|
|
262
|
+
}
|
|
263
|
+
function saveUsersFile(data) {
|
|
264
|
+
mkdirSync(configDir, { recursive: true });
|
|
265
|
+
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const sub = args[args.indexOf('user') + 1] || process.argv[3];
|
|
269
|
+
if (sub === 'list' || sub === 'ls') {
|
|
270
|
+
const data = loadUsersFile();
|
|
271
|
+
const entries = Object.entries(data.users || {});
|
|
272
|
+
if (entries.length === 0) {
|
|
273
|
+
console.log('[myhi] 还没有用户,使用 myhi user add <密码> <名称> <目录> 添加');
|
|
274
|
+
} else {
|
|
275
|
+
console.log('\n 密码\t\t名称\t\t目录');
|
|
276
|
+
console.log(' ────\t\t────\t\t────');
|
|
277
|
+
for (const [pwd, info] of entries) {
|
|
278
|
+
const masked = pwd.slice(0, 2) + '*'.repeat(pwd.length - 2);
|
|
279
|
+
console.log(` ${masked}\t\t${info.name}\t\t${info.dir}`);
|
|
280
|
+
}
|
|
281
|
+
console.log();
|
|
282
|
+
}
|
|
283
|
+
} else if (sub === 'add') {
|
|
284
|
+
const pwd = process.argv[4];
|
|
285
|
+
const name = process.argv[5];
|
|
286
|
+
const dir = process.argv[6];
|
|
287
|
+
if (!pwd || !name || !dir) {
|
|
288
|
+
console.log('用法: myhi user add <密码> <名称> <目录>');
|
|
289
|
+
console.log('示例: myhi user add 1234 张三 D:\\project\\app1');
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
mkdirSync(dir, { recursive: true });
|
|
293
|
+
// 为用户创建独立的 git 配置(隔离凭据)
|
|
294
|
+
const gitconfigPath = join(dir, '.gitconfig');
|
|
295
|
+
const gitcredPath = join(dir, '.git-credentials');
|
|
296
|
+
if (!existsSync(gitconfigPath)) {
|
|
297
|
+
writeFileSync(gitconfigPath, `[credential]\n\thelper = store --file ${gitcredPath.replace(/\\/g, '/')}\n[user]\n\tname = ${name}\n`, { mode: 0o600 });
|
|
298
|
+
console.log(`[myhi] 已创建 git 配置: ${gitconfigPath}`);
|
|
299
|
+
}
|
|
300
|
+
const data = loadUsersFile();
|
|
301
|
+
data.users[pwd] = { name, dir };
|
|
302
|
+
saveUsersFile(data);
|
|
303
|
+
console.log(`[myhi] 已添加用户 "${name}" (目录: ${dir})`);
|
|
304
|
+
} else if (sub === 'remove' || sub === 'rm') {
|
|
305
|
+
const pwd = process.argv[4];
|
|
306
|
+
if (!pwd) {
|
|
307
|
+
console.log('用法: myhi user remove <密码>');
|
|
308
|
+
process.exit(1);
|
|
309
|
+
}
|
|
310
|
+
const data = loadUsersFile();
|
|
311
|
+
if (data.users[pwd]) {
|
|
312
|
+
delete data.users[pwd];
|
|
313
|
+
saveUsersFile(data);
|
|
314
|
+
console.log(`[myhi] 已删除用户`);
|
|
315
|
+
} else {
|
|
316
|
+
console.log('[myhi] 未找到该密码对应的用户');
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
console.log(`
|
|
320
|
+
用户管理命令:
|
|
321
|
+
myhi user list 列出所有用户
|
|
322
|
+
myhi user add <密码> <名称> <目录> 添加用户
|
|
323
|
+
myhi user remove <密码> 删除用户
|
|
324
|
+
|
|
325
|
+
示例:
|
|
326
|
+
myhi user add 1234 张三 D:\\project\\app1
|
|
327
|
+
myhi user add 5678 李四 D:\\project\\app2
|
|
328
|
+
myhi user list
|
|
329
|
+
myhi user remove 1234
|
|
330
|
+
`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
} else if (cmd === 'ssh') {
|
|
334
|
+
// SSH 账号管理:为 myhi 用户创建系统 SSH 账号,隔离目录权限
|
|
335
|
+
const { existsSync } = await import('fs');
|
|
336
|
+
const { platform } = await import('os');
|
|
337
|
+
const usersFile = join(configDir, 'users.json');
|
|
338
|
+
const isWindows = platform() === 'win32';
|
|
339
|
+
|
|
340
|
+
function loadUsersFile() {
|
|
341
|
+
try { if (existsSync(usersFile)) return JSON.parse(readFileSync(usersFile, 'utf8')); } catch {}
|
|
342
|
+
return { users: {} };
|
|
343
|
+
}
|
|
344
|
+
function saveUsersFile(data) {
|
|
345
|
+
mkdirSync(configDir, { recursive: true });
|
|
346
|
+
writeFileSync(usersFile, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function run(cmd, opts = {}) {
|
|
350
|
+
try {
|
|
351
|
+
return execSync(cmd, { encoding: 'utf8', stdio: opts.silent ? 'pipe' : 'inherit', ...opts }).trim();
|
|
352
|
+
} catch (e) {
|
|
353
|
+
if (!opts.ignoreError) { console.error(`[myhi] 命令失败: ${cmd}\n${e.stderr || e.message}`); process.exit(1); }
|
|
354
|
+
return '';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function findUserByName(name) {
|
|
359
|
+
const data = loadUsersFile();
|
|
360
|
+
for (const [pwd, info] of Object.entries(data.users || {})) {
|
|
361
|
+
if (info.name === name) return { pwd, ...info };
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const sub = args[args.indexOf('ssh') + 1] || process.argv[3];
|
|
367
|
+
|
|
368
|
+
if (sub === 'add' || sub === 'setup') {
|
|
369
|
+
const myName = process.argv[4];
|
|
370
|
+
const sshPass = process.argv[5];
|
|
371
|
+
if (!myName) {
|
|
372
|
+
console.log('用法: myhi ssh add <myhi用户名> [SSH密码]');
|
|
373
|
+
console.log('示例: myhi ssh add 张三 mypassword');
|
|
374
|
+
console.log('\n将为 myhi 用户创建系统 SSH 账号,绑定到该用户的项目目录');
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const user = findUserByName(myName);
|
|
379
|
+
if (!user) { console.log(`[myhi] 未找到 myhi 用户 "${myName}",请先 myhi user add`); process.exit(1); }
|
|
380
|
+
|
|
381
|
+
const dir = user.dir;
|
|
382
|
+
// SSH 用户名:myhi-<name> (ASCII 化)
|
|
383
|
+
const sshUser = 'myhi-' + myName.replace(/[^a-zA-Z0-9_-]/g, () => Math.random().toString(36)[2]);
|
|
384
|
+
const sshPassword = sshPass || Math.random().toString(36).slice(2, 10) + 'A1!';
|
|
385
|
+
|
|
386
|
+
console.log(`[myhi] 创建 SSH 账号...`);
|
|
387
|
+
console.log(` myhi 用户: ${myName}`);
|
|
388
|
+
console.log(` 项目目录: ${dir}`);
|
|
389
|
+
console.log(` SSH 用户: ${sshUser}`);
|
|
390
|
+
|
|
391
|
+
if (isWindows) {
|
|
392
|
+
// ── Windows ──
|
|
393
|
+
// 创建本地用户
|
|
394
|
+
run(`net user "${sshUser}" "${sshPassword}" /add /y`, { silent: true, ignoreError: true });
|
|
395
|
+
run(`net user "${sshUser}" "${sshPassword}" /y`, { silent: true, ignoreError: true });
|
|
396
|
+
|
|
397
|
+
// 设置目录权限
|
|
398
|
+
mkdirSync(dir, { recursive: true });
|
|
399
|
+
// 上级目录:允许穿越(Traverse)但不允许列目录
|
|
400
|
+
const parent = join(dir, '..');
|
|
401
|
+
run(`icacls "${parent}" /grant "${sshUser}:(RC,S,X,RA,REA)"`, { silent: true, ignoreError: true });
|
|
402
|
+
// 用户自己的目录:完全控制
|
|
403
|
+
run(`icacls "${dir}" /grant "${sshUser}:(OI)(CI)F"`, { silent: true });
|
|
404
|
+
|
|
405
|
+
// 在用户 home 下创建 project 符号链接
|
|
406
|
+
const userHome = `C:\\Users\\${sshUser}`;
|
|
407
|
+
if (!existsSync(userHome)) mkdirSync(userHome, { recursive: true });
|
|
408
|
+
const link = join(userHome, 'project');
|
|
409
|
+
if (!existsSync(link)) {
|
|
410
|
+
run(`cmd /c mklink /D "${link}" "${dir}"`, { silent: true, ignoreError: true });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 确保 OpenSSH Server 已启动
|
|
414
|
+
run('powershell -Command "Start-Service sshd 2>$null; Set-Service -Name sshd -StartupType Automatic 2>$null"', { silent: true, ignoreError: true });
|
|
415
|
+
|
|
416
|
+
} else {
|
|
417
|
+
// ── Linux / Ubuntu ──
|
|
418
|
+
// 创建系统用户,home 指向项目目录
|
|
419
|
+
run(`id "${sshUser}" 2>/dev/null || useradd -m -d "/home/${sshUser}" -s /bin/bash "${sshUser}"`, { silent: true, ignoreError: true });
|
|
420
|
+
run(`echo "${sshUser}:${sshPassword}" | chpasswd`, { silent: true });
|
|
421
|
+
|
|
422
|
+
mkdirSync(dir, { recursive: true });
|
|
423
|
+
|
|
424
|
+
// 在用户 home 下创建 project 符号链接
|
|
425
|
+
const homeDir = `/home/${sshUser}`;
|
|
426
|
+
const link = `${homeDir}/project`;
|
|
427
|
+
run(`ln -sfn "${dir}" "${link}"`, { silent: true, ignoreError: true });
|
|
428
|
+
run(`chown -h ${sshUser}:${sshUser} "${link}"`, { silent: true, ignoreError: true });
|
|
429
|
+
|
|
430
|
+
// 项目目录:设置为该用户所有
|
|
431
|
+
run(`chown -R ${sshUser}:${sshUser} "${dir}"`, { silent: true, ignoreError: true });
|
|
432
|
+
|
|
433
|
+
// 上级目录权限:移除 others 的读和执行
|
|
434
|
+
const parent = join(dir, '..');
|
|
435
|
+
run(`chmod 711 "${parent}"`, { silent: true, ignoreError: true });
|
|
436
|
+
// 用户自己的目录
|
|
437
|
+
run(`chmod 700 "${dir}"`, { silent: true, ignoreError: true });
|
|
438
|
+
|
|
439
|
+
// 确保 SSH 服务运行
|
|
440
|
+
run('systemctl enable ssh 2>/dev/null; systemctl start ssh 2>/dev/null || service ssh start 2>/dev/null', { silent: true, ignoreError: true });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 保存 SSH 映射到 users.json
|
|
444
|
+
const data = loadUsersFile();
|
|
445
|
+
if (data.users[user.pwd]) {
|
|
446
|
+
data.users[user.pwd].sshUser = sshUser;
|
|
447
|
+
saveUsersFile(data);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
console.log(`\n ✓ SSH 账号创建成功!`);
|
|
451
|
+
console.log(` ┌─────────────────────────────────`);
|
|
452
|
+
console.log(` │ SSH 用户名: ${sshUser}`);
|
|
453
|
+
console.log(` │ SSH 密码: ${sshPassword}`);
|
|
454
|
+
console.log(` │ 项目目录: ${dir}`);
|
|
455
|
+
console.log(` └─────────────────────────────────`);
|
|
456
|
+
console.log(`\n VS Code 连接方式:`);
|
|
457
|
+
console.log(` 1. 安装 Remote-SSH 扩展`);
|
|
458
|
+
console.log(` 2. 按 F1 → "Remote-SSH: Connect to Host"`);
|
|
459
|
+
console.log(` 3. 输入: ${sshUser}@<服务器IP>`);
|
|
460
|
+
console.log(` 4. 打开目录: ~/project`);
|
|
461
|
+
|
|
462
|
+
} else if (sub === 'list' || sub === 'ls') {
|
|
463
|
+
const data = loadUsersFile();
|
|
464
|
+
const entries = Object.entries(data.users || {}).filter(([, info]) => info.sshUser);
|
|
465
|
+
if (entries.length === 0) {
|
|
466
|
+
console.log('[myhi] 还没有 SSH 账号,使用 myhi ssh add <用户名> 创建');
|
|
467
|
+
} else {
|
|
468
|
+
console.log('\n myhi用户\tSSH账号\t\t\t目录');
|
|
469
|
+
console.log(' ────────\t───────\t\t\t────');
|
|
470
|
+
for (const [, info] of entries) {
|
|
471
|
+
console.log(` ${info.name}\t\t${info.sshUser}\t\t${info.dir}`);
|
|
472
|
+
}
|
|
473
|
+
console.log();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
} else if (sub === 'remove' || sub === 'rm') {
|
|
477
|
+
const myName = process.argv[4];
|
|
478
|
+
if (!myName) { console.log('用法: myhi ssh remove <myhi用户名>'); process.exit(1); }
|
|
479
|
+
|
|
480
|
+
const user = findUserByName(myName);
|
|
481
|
+
if (!user || !user.sshUser) { console.log(`[myhi] 未找到 "${myName}" 的 SSH 账号`); process.exit(1); }
|
|
482
|
+
|
|
483
|
+
const sshUser = user.sshUser;
|
|
484
|
+
console.log(`[myhi] 删除 SSH 账号: ${sshUser}`);
|
|
485
|
+
|
|
486
|
+
if (isWindows) {
|
|
487
|
+
run(`net user "${sshUser}" /delete`, { silent: true, ignoreError: true });
|
|
488
|
+
} else {
|
|
489
|
+
run(`userdel "${sshUser}" 2>/dev/null`, { silent: true, ignoreError: true });
|
|
490
|
+
// 不删除 home,避免误删项目数据
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const data = loadUsersFile();
|
|
494
|
+
if (data.users[user.pwd]) {
|
|
495
|
+
delete data.users[user.pwd].sshUser;
|
|
496
|
+
saveUsersFile(data);
|
|
497
|
+
}
|
|
498
|
+
console.log(`[myhi] 已删除 SSH 账号 "${sshUser}"(项目目录保留)`);
|
|
499
|
+
|
|
500
|
+
} else {
|
|
501
|
+
console.log(`
|
|
502
|
+
SSH 账号管理:为 myhi 用户创建独立的 SSH 开发账号
|
|
503
|
+
|
|
504
|
+
myhi ssh add <用户名> [密码] 创建 SSH 账号(绑定到 myhi 用户的项目目录)
|
|
505
|
+
myhi ssh list 列出所有 SSH 账号
|
|
506
|
+
myhi ssh remove <用户名> 删除 SSH 账号(保留项目目录)
|
|
507
|
+
|
|
508
|
+
示例:
|
|
509
|
+
myhi user add 1234 张三 /data/projects/zhangsan # 先添加 myhi 用户
|
|
510
|
+
myhi ssh add 张三 sshpass123 # 再创建 SSH 账号
|
|
511
|
+
myhi ssh list # 查看所有 SSH 账号
|
|
512
|
+
|
|
513
|
+
员工使用 VS Code Remote-SSH 连接后打开 ~/project 即可开发。
|
|
514
|
+
每个用户只能访问自己的项目目录,无法查看其他用户的文件。
|
|
515
|
+
`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
} else if (cmd === 'kick') {
|
|
519
|
+
// 踢出会话中的用户
|
|
520
|
+
const { createRequire } = await import('module');
|
|
521
|
+
const require = createRequire(import.meta.url);
|
|
522
|
+
const { io } = require('socket.io-client');
|
|
523
|
+
const { createInterface } = await import('readline');
|
|
524
|
+
|
|
525
|
+
const SERVER = process.env.MYHI_SERVER || 'http://localhost:12300';
|
|
526
|
+
let token;
|
|
527
|
+
try {
|
|
528
|
+
token = readFileSync(join(configDir, 'token'), 'utf8').trim();
|
|
529
|
+
} catch {
|
|
530
|
+
console.log('[myhi] 未找到 token,请先启动服务器');
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const socket = io(SERVER, { auth: { token }, reconnection: false });
|
|
535
|
+
|
|
536
|
+
socket.on('connect_error', (err) => {
|
|
537
|
+
console.log(`[myhi] 连接失败: ${err.message}`);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
socket.on('connect', () => {
|
|
542
|
+
// 获取所有会话
|
|
543
|
+
socket.emit('sessions', null, (sessions) => {
|
|
544
|
+
// sessions 事件可能不存在,用 HTTP 回退
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// 通过 HTTP 获取会话列表
|
|
548
|
+
fetch(`${SERVER}/api/sessions?token=${token}`)
|
|
549
|
+
.then(r => r.json())
|
|
550
|
+
.then(async (sessions) => {
|
|
551
|
+
const alive = sessions.filter(s => s.alive);
|
|
552
|
+
if (alive.length === 0) {
|
|
553
|
+
console.log('[myhi] 没有活跃的会话');
|
|
554
|
+
socket.disconnect();
|
|
555
|
+
process.exit(0);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 收集所有会话的在线用户
|
|
559
|
+
const allViewers = [];
|
|
560
|
+
for (const session of alive) {
|
|
561
|
+
const result = await new Promise(resolve => {
|
|
562
|
+
socket.emit('join', session.id);
|
|
563
|
+
socket.emit('list-viewers', { sessionId: session.id }, resolve);
|
|
564
|
+
});
|
|
565
|
+
if (result.ok) {
|
|
566
|
+
for (const v of result.viewers) {
|
|
567
|
+
allViewers.push({ ...v, sessionId: session.id, sessionTitle: session.title });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (allViewers.length === 0) {
|
|
573
|
+
console.log('[myhi] 没有在线用户');
|
|
574
|
+
socket.disconnect();
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 去重(同一个 socket 可能只在一个会话中)
|
|
579
|
+
const unique = [...new Map(allViewers.map(v => [v.socketId, v])).values()];
|
|
580
|
+
|
|
581
|
+
console.log('\n 在线用户:');
|
|
582
|
+
console.log(' ────────────────────────────────────────');
|
|
583
|
+
unique.forEach((v, i) => {
|
|
584
|
+
const ctrl = v.isController ? ' [控制中]' : '';
|
|
585
|
+
console.log(` [${i + 1}] ${v.userName} (${v.role})${ctrl} 会话: ${v.sessionTitle}`);
|
|
586
|
+
});
|
|
587
|
+
console.log();
|
|
588
|
+
|
|
589
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
590
|
+
rl.question(' 输入序号踢出用户(0 取消): ', (answer) => {
|
|
591
|
+
rl.close();
|
|
592
|
+
const idx = parseInt(answer, 10);
|
|
593
|
+
if (!idx || idx < 1 || idx > unique.length) {
|
|
594
|
+
console.log('[myhi] 已取消');
|
|
595
|
+
socket.disconnect();
|
|
596
|
+
process.exit(0);
|
|
597
|
+
}
|
|
598
|
+
const target = unique[idx - 1];
|
|
599
|
+
socket.emit('kick-user', { socketId: target.socketId, sessionId: target.sessionId }, (res) => {
|
|
600
|
+
if (res.ok) {
|
|
601
|
+
console.log(`[myhi] 已踢出用户 "${res.name}"`);
|
|
602
|
+
} else {
|
|
603
|
+
console.log(`[myhi] 踢出失败: ${res.error}`);
|
|
604
|
+
}
|
|
605
|
+
socket.disconnect();
|
|
606
|
+
process.exit(0);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
})
|
|
610
|
+
.catch((err) => {
|
|
611
|
+
console.log(`[myhi] 获取会话失败: ${err.message}`);
|
|
612
|
+
socket.disconnect();
|
|
613
|
+
process.exit(1);
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
618
|
+
const isWin = process.platform === 'win32';
|
|
619
|
+
const isLinux = process.platform === 'linux';
|
|
620
|
+
const isMac = process.platform === 'darwin';
|
|
621
|
+
const platform = isWin ? 'Windows' : isMac ? 'macOS' : 'Linux';
|
|
622
|
+
const configPath = isWin ? '%USERPROFILE%\\.myhi\\' : '~/.myhi/';
|
|
623
|
+
const sep = isWin ? '\\' : '/';
|
|
624
|
+
|
|
625
|
+
let help = `
|
|
626
|
+
myhi — 基于 Web 的终端共享工具 (${platform})
|
|
627
|
+
通过局域网或 Tailscale 在手机/平板上控制电脑终端,无需中继服务器。
|
|
628
|
+
|
|
629
|
+
启动服务:
|
|
630
|
+
myhi 前台启动服务器
|
|
631
|
+
myhi start 前台启动服务器
|
|
632
|
+
myhi -p <port> 指定监听端口(默认 12300)
|
|
633
|
+
myhi -d 后台启动(日志写入 ${configPath}daemon.log)
|
|
634
|
+
myhi --http 切为纯 HTTP(无 TLS)并重启,持久生效
|
|
635
|
+
myhi --https 切回 HTTPS(默认)并重启
|
|
636
|
+
myhi stop 停止后台服务器
|
|
637
|
+
myhi restart 重启后台服务器
|
|
638
|
+
myhi status 查看运行状态
|
|
639
|
+
myhi log [N] 查看后台日志(最后 N 行,默认 50)
|
|
640
|
+
`;
|
|
641
|
+
|
|
642
|
+
if (isLinux) {
|
|
643
|
+
help += `
|
|
644
|
+
系统服务(systemd):
|
|
645
|
+
sudo myhi service install 安装为系统服务(开机自启)
|
|
646
|
+
sudo myhi service uninstall 卸载系统服务
|
|
647
|
+
myhi service status 查看服务状态
|
|
648
|
+
|
|
649
|
+
提示: 使用 systemd 管理时,不要混用 myhi -d / myhi stop
|
|
650
|
+
统一用 systemctl start/stop/restart myhi
|
|
651
|
+
`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
help += `
|
|
655
|
+
用户管理:
|
|
656
|
+
myhi user list 列出所有用户
|
|
657
|
+
myhi user add <密码> <名称> <目录> 添加用户
|
|
658
|
+
myhi user remove <密码> 删除用户
|
|
659
|
+
myhi exclusive on 开启独占模式(一次只能一个账号)
|
|
660
|
+
myhi exclusive off 关闭独占模式(多人同时使用,默认)
|
|
661
|
+
`;
|
|
662
|
+
|
|
663
|
+
if (!isWin) {
|
|
664
|
+
help += `
|
|
665
|
+
SSH 开发:
|
|
666
|
+
myhi ssh add <用户名> [密码] 为 myhi 用户创建 SSH 账号
|
|
667
|
+
myhi ssh list 列出所有 SSH 账号
|
|
668
|
+
myhi ssh remove <用户名> 删除 SSH 账号
|
|
669
|
+
`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
help += `
|
|
673
|
+
远程连接:
|
|
674
|
+
myhi attach 交互式选择会话并附加
|
|
675
|
+
myhi attach <id> 直接附加到指定会话
|
|
676
|
+
myhi attach --new 创建新会话并附加
|
|
677
|
+
myhi attach --password <密码> 用指定用户身份连接
|
|
678
|
+
myhi kick 踢出在线用户
|
|
679
|
+
|
|
680
|
+
快捷键(attach 模式):
|
|
681
|
+
Ctrl+] 分离终端(退出但不关闭会话)
|
|
682
|
+
|
|
683
|
+
选项:
|
|
684
|
+
-p, --port <port> 监听端口(默认 12300)
|
|
685
|
+
|
|
686
|
+
环境变量:
|
|
687
|
+
PORT 监听端口(默认 12300)
|
|
688
|
+
HOST 监听地址(默认 0.0.0.0)
|
|
689
|
+
MYHI_AUTO_ATTACH 启动时自动创建会话(默认 1,设 0 关闭)
|
|
690
|
+
MYHI_CWD 会话工作目录(默认当前目录)
|
|
691
|
+
MYHI_SERVER attach 连接的服务器地址(默认 http://localhost:12300)
|
|
692
|
+
|
|
693
|
+
配置目录:${configPath}
|
|
694
|
+
token 认证令牌(自动生成)
|
|
695
|
+
password 登录密码(自动生成,可手动修改)
|
|
696
|
+
users.json 独占用户配置
|
|
697
|
+
roles.json 多用户角色配置(可选)
|
|
698
|
+
sessions.json 会话持久化数据
|
|
699
|
+
daemon.pid 后台进程 PID
|
|
700
|
+
daemon.log 后台运行日志
|
|
701
|
+
|
|
702
|
+
示例:`;
|
|
703
|
+
|
|
704
|
+
if (isWin) {
|
|
705
|
+
help += `
|
|
706
|
+
myhi # 启动后扫描二维码即可在手机上操作
|
|
707
|
+
myhi -p 8080 # 指定 8080 端口启动
|
|
708
|
+
myhi -p 8080 -d # 8080 端口后台启动
|
|
709
|
+
set MYHI_CWD=D:\\project && myhi # 指定工作目录
|
|
710
|
+
myhi attach # 从另一台机器连接
|
|
711
|
+
myhi user add 1234 张三 D:\\project # 添加用户`;
|
|
712
|
+
} else if (isLinux) {
|
|
713
|
+
help += `
|
|
714
|
+
myhi # 启动后扫描二维码即可在手机上操作
|
|
715
|
+
myhi -p 8080 # 指定 8080 端口启动
|
|
716
|
+
sudo myhi service install # 安装为系统服务(推荐)
|
|
717
|
+
MYHI_CWD=/project myhi # 指定工作目录
|
|
718
|
+
myhi attach # 从另一台机器连接
|
|
719
|
+
myhi user add 1234 张三 /home/dev # 添加用户`;
|
|
720
|
+
} else {
|
|
721
|
+
help += `
|
|
722
|
+
myhi # 启动后扫描二维码即可在手机上操作
|
|
723
|
+
myhi -p 8080 # 指定 8080 端口启动
|
|
724
|
+
myhi -p 8080 -d # 8080 端口后台启动
|
|
725
|
+
MYHI_CWD=/project myhi # 指定工作目录
|
|
726
|
+
myhi attach # 从另一台机器连接
|
|
727
|
+
myhi user add 1234 张三 /Users/dev # 添加用户`;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
console.log(help + '\n');
|
|
731
|
+
|
|
732
|
+
} else if (cmd === 'service') {
|
|
733
|
+
// systemd service 管理
|
|
734
|
+
const subCmd = args[1] || 'install';
|
|
735
|
+
const { execSync: ex } = await import('child_process');
|
|
736
|
+
|
|
737
|
+
if (process.platform !== 'linux') {
|
|
738
|
+
console.log('[myhi] service 命令仅支持 Linux (systemd)');
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 找到 myhi 可执行文件路径
|
|
743
|
+
let myhiBin;
|
|
744
|
+
try { myhiBin = ex('which myhi', { encoding: 'utf8' }).trim(); } catch { myhiBin = '/usr/local/bin/myhi'; }
|
|
745
|
+
|
|
746
|
+
// sudo 运行时用 SUDO_USER 获取真实用户
|
|
747
|
+
const user = process.env.SUDO_USER || process.env.USER || 'root';
|
|
748
|
+
let home;
|
|
749
|
+
if (process.env.SUDO_USER) {
|
|
750
|
+
// sudo 时 homedir() 返回 root 的 home,需要查真实用户的 home
|
|
751
|
+
try { home = ex(`getent passwd ${process.env.SUDO_USER}`, { encoding: 'utf8' }).trim().split(':')[5]; } catch { home = `/home/${process.env.SUDO_USER}`; }
|
|
752
|
+
} else {
|
|
753
|
+
home = homedir();
|
|
754
|
+
}
|
|
755
|
+
const cwd = process.env.MYHI_CWD || (process.env.SUDO_USER ? home : process.cwd());
|
|
756
|
+
const port = process.env.PORT || '12300';
|
|
757
|
+
const serviceFile = '/etc/systemd/system/myhi.service';
|
|
758
|
+
|
|
759
|
+
// 找到 node 和 daemon.js 的路径,用 Type=simple 直接前台运行 daemon
|
|
760
|
+
let nodeBin;
|
|
761
|
+
try { nodeBin = ex('which node', { encoding: 'utf8' }).trim(); } catch { nodeBin = '/usr/bin/node'; }
|
|
762
|
+
// myhi bin 可能是 symlink,解析真实路径后找到同目录的 daemon.js
|
|
763
|
+
let realMyhiBin;
|
|
764
|
+
try { realMyhiBin = ex(`readlink -f ${myhiBin}`, { encoding: 'utf8' }).trim(); } catch { realMyhiBin = myhiBin; }
|
|
765
|
+
const daemonScript = join(dirname(realMyhiBin), 'daemon.js');
|
|
766
|
+
|
|
767
|
+
const unitContent = `[Unit]
|
|
768
|
+
Description=myhi - Web Terminal Sharing
|
|
769
|
+
After=network.target
|
|
770
|
+
|
|
771
|
+
[Service]
|
|
772
|
+
Type=simple
|
|
773
|
+
ExecStart=${nodeBin} ${daemonScript}
|
|
774
|
+
ExecStop=${myhiBin} stop
|
|
775
|
+
WorkingDirectory=${cwd}
|
|
776
|
+
Restart=on-failure
|
|
777
|
+
RestartSec=10
|
|
778
|
+
User=${user}
|
|
779
|
+
Environment=PORT=${port}
|
|
780
|
+
Environment=HOME=${home}
|
|
781
|
+
${['HTTP_PROXY','HTTPS_PROXY','http_proxy','https_proxy','NO_PROXY','no_proxy','ANTHROPIC_BASE_URL','ANTHROPIC_API_KEY'].filter(k => process.env[k]).map(k => `Environment=${k}=${process.env[k]}`).join('\n')}
|
|
782
|
+
|
|
783
|
+
[Install]
|
|
784
|
+
WantedBy=multi-user.target
|
|
785
|
+
`;
|
|
786
|
+
|
|
787
|
+
if (subCmd === 'install') {
|
|
788
|
+
try {
|
|
789
|
+
writeFileSync(serviceFile, unitContent);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
if (e.code === 'EACCES') {
|
|
792
|
+
console.log('[myhi] 需要 root 权限,请使用: sudo myhi service install');
|
|
793
|
+
process.exit(1);
|
|
794
|
+
}
|
|
795
|
+
throw e;
|
|
796
|
+
}
|
|
797
|
+
console.log(`[myhi] 已创建 ${serviceFile}`);
|
|
798
|
+
console.log(` User: ${user}`);
|
|
799
|
+
console.log(` Port: ${port}`);
|
|
800
|
+
console.log(` WorkingDirectory: ${cwd}`);
|
|
801
|
+
console.log(` ExecStart: ${myhiBin} -d`);
|
|
802
|
+
|
|
803
|
+
// 配置 sudoers 让服务用户可以自升级和重启
|
|
804
|
+
if (user !== 'root') {
|
|
805
|
+
const sudoersFile = `/etc/sudoers.d/myhi-${user}`;
|
|
806
|
+
const npmBin = (() => { try { return ex('which npm', { encoding: 'utf8' }).trim(); } catch { return '/usr/bin/npm'; } })();
|
|
807
|
+
const sudoersContent = `# myhi: allow ${user} to upgrade and restart service\n${user} ALL=(ALL) NOPASSWD: ${npmBin} install -g @wendongfly/myhi*\n${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myhi.service\n${user} ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop myhi.service\n`;
|
|
808
|
+
try {
|
|
809
|
+
writeFileSync(sudoersFile, sudoersContent, { mode: 0o440 });
|
|
810
|
+
console.log(`[myhi] 已配置 sudoers: ${sudoersFile}`);
|
|
811
|
+
} catch {
|
|
812
|
+
console.log(`[myhi] 提示: 如需 Web 端升级功能,请手动配置 sudoers:`);
|
|
813
|
+
console.log(` echo '${user} ALL=(ALL) NOPASSWD: ${npmBin} install -g @wendongfly/myhi*' | sudo tee ${sudoersFile}`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
console.log('');
|
|
818
|
+
try {
|
|
819
|
+
ex('systemctl daemon-reload', { stdio: 'inherit' });
|
|
820
|
+
ex('systemctl enable myhi.service', { stdio: 'inherit' });
|
|
821
|
+
ex('systemctl start myhi.service', { stdio: 'inherit' });
|
|
822
|
+
console.log('');
|
|
823
|
+
console.log('[myhi] 服务已启动并设为开机自启');
|
|
824
|
+
console.log(' 查看状态: systemctl status myhi.service');
|
|
825
|
+
console.log(' 查看日志: journalctl -u myhi -f');
|
|
826
|
+
console.log(' 停止服务: sudo systemctl stop myhi.service');
|
|
827
|
+
console.log(' Web 端升级: 会话页 ⬆ 按钮一键升级');
|
|
828
|
+
} catch (e) {
|
|
829
|
+
console.log('[myhi] systemctl 执行失败,请手动运行:');
|
|
830
|
+
console.log(' sudo systemctl daemon-reload');
|
|
831
|
+
console.log(' sudo systemctl enable myhi.service');
|
|
832
|
+
console.log(' sudo systemctl start myhi.service');
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
} else if (subCmd === 'uninstall' || subCmd === 'remove') {
|
|
836
|
+
try {
|
|
837
|
+
ex('systemctl stop myhi.service', { stdio: 'pipe' });
|
|
838
|
+
ex('systemctl disable myhi.service', { stdio: 'pipe' });
|
|
839
|
+
} catch {}
|
|
840
|
+
try {
|
|
841
|
+
unlinkSync(serviceFile);
|
|
842
|
+
// 清理 sudoers
|
|
843
|
+
try { unlinkSync(`/etc/sudoers.d/myhi-${user}`); } catch {}
|
|
844
|
+
ex('systemctl daemon-reload', { stdio: 'pipe' });
|
|
845
|
+
console.log('[myhi] 已卸载 systemd 服务');
|
|
846
|
+
} catch (e) {
|
|
847
|
+
if (e.code === 'EACCES') {
|
|
848
|
+
console.log('[myhi] 需要 root 权限,请使用: sudo myhi service uninstall');
|
|
849
|
+
} else if (e.code === 'ENOENT') {
|
|
850
|
+
console.log('[myhi] 服务文件不存在');
|
|
851
|
+
} else {
|
|
852
|
+
console.log('[myhi] 卸载失败:', e.message);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
} else if (subCmd === 'status') {
|
|
857
|
+
try {
|
|
858
|
+
ex('systemctl status myhi.service', { stdio: 'inherit' });
|
|
859
|
+
} catch {}
|
|
860
|
+
|
|
861
|
+
} else {
|
|
862
|
+
console.log('用法: myhi service [install|uninstall|status]');
|
|
863
|
+
}
|
|
864
|
+
process.exit(0);
|
|
865
|
+
|
|
866
|
+
} else if (cmd === '-d' || cmd === '--daemon' || cmd === 'daemon') {
|
|
867
|
+
startDaemon();
|
|
868
|
+
process.exit(0);
|
|
869
|
+
|
|
870
|
+
} else {
|
|
871
|
+
// 默认 / 'start':前台运行
|
|
872
|
+
await import('../dist/index.js');
|
|
873
|
+
}
|