@wendongfly/myhi 1.0.1
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 +143 -0
- package/dist/attach.js +188 -0
- package/dist/chat.html +1484 -0
- package/dist/client-dist/socket.io.esm.min.js +7 -0
- package/dist/client-dist/socket.io.js +4955 -0
- package/dist/client-dist/socket.io.min.js +7 -0
- package/dist/client-dist/socket.io.msgpack.min.js +7 -0
- package/dist/icon.png +0 -0
- package/dist/icon.svg +4 -0
- package/dist/index.html +871 -0
- package/dist/index.js +367 -0
- package/dist/lib/ansi_up.js +431 -0
- package/dist/login.html +125 -0
- package/dist/manifest.json +13 -0
- package/dist/package.json +3 -0
- package/dist/terminal.html +445 -0
- package/package.json +55 -0
package/bin/myhi.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
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 } from 'fs';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const cmd = process.argv[2];
|
|
11
|
+
|
|
12
|
+
const configDir = join(homedir(), '.myhi');
|
|
13
|
+
const pidFile = join(configDir, 'daemon.pid');
|
|
14
|
+
const logFile = join(configDir, 'daemon.log');
|
|
15
|
+
|
|
16
|
+
// ── 工具函数 ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function readPid() {
|
|
19
|
+
try { return parseInt(readFileSync(pidFile, 'utf8').trim(), 10); }
|
|
20
|
+
catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRunning(pid) {
|
|
24
|
+
try { process.kill(pid, 0); return true; }
|
|
25
|
+
catch { return false; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stopDaemon() {
|
|
29
|
+
const pid = readPid();
|
|
30
|
+
if (!pid) { console.log('[myhi] 没有正在运行的后台进程'); return false; }
|
|
31
|
+
if (!isRunning(pid)) {
|
|
32
|
+
try { unlinkSync(pidFile); } catch {}
|
|
33
|
+
console.log('[myhi] 进程已不存在,已清理 PID 文件');
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
process.kill(pid, 'SIGTERM');
|
|
37
|
+
try { unlinkSync(pidFile); } catch {}
|
|
38
|
+
console.log(`[myhi] 已停止后台进程 (PID ${pid})`);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function startDaemon() {
|
|
43
|
+
mkdirSync(configDir, { recursive: true });
|
|
44
|
+
const out = openSync(logFile, 'a');
|
|
45
|
+
const child = spawn(process.execPath, [join(__dirname, '..', 'dist', 'index.js')], {
|
|
46
|
+
detached: true,
|
|
47
|
+
stdio: ['ignore', out, out],
|
|
48
|
+
cwd: process.cwd(),
|
|
49
|
+
env: { ...process.env },
|
|
50
|
+
});
|
|
51
|
+
writeFileSync(pidFile, String(child.pid));
|
|
52
|
+
child.unref();
|
|
53
|
+
console.log(`[myhi] 已在后台启动 (PID ${child.pid})`);
|
|
54
|
+
console.log(`[myhi] 日志: ${logFile}`);
|
|
55
|
+
console.log(`[myhi] 停止: myhi stop`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 命令分发 ──────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
if (cmd === 'attach') {
|
|
61
|
+
process.argv.splice(2, 1);
|
|
62
|
+
await import('../dist/attach.js');
|
|
63
|
+
|
|
64
|
+
} else if (cmd === 'stop') {
|
|
65
|
+
stopDaemon();
|
|
66
|
+
|
|
67
|
+
} else if (cmd === 'restart') {
|
|
68
|
+
stopDaemon();
|
|
69
|
+
await new Promise(r => setTimeout(r, 500));
|
|
70
|
+
startDaemon();
|
|
71
|
+
process.exit(0);
|
|
72
|
+
|
|
73
|
+
} else if (cmd === 'status') {
|
|
74
|
+
const pid = readPid();
|
|
75
|
+
if (pid && isRunning(pid)) {
|
|
76
|
+
console.log(`[myhi] 运行中 (PID ${pid})`);
|
|
77
|
+
} else {
|
|
78
|
+
console.log('[myhi] 未运行');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
} else if (cmd === 'log' || cmd === 'logs') {
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(logFile, 'utf8');
|
|
84
|
+
const lines = content.split('\n');
|
|
85
|
+
const n = parseInt(process.argv[3], 10) || 50;
|
|
86
|
+
console.log(lines.slice(-n).join('\n'));
|
|
87
|
+
} catch {
|
|
88
|
+
console.log('[myhi] 日志文件不存在');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
92
|
+
console.log(`
|
|
93
|
+
myhi v1.0.0 — 基于 Web 的终端共享工具
|
|
94
|
+
通过局域网或 Tailscale 在手机/平板上控制电脑终端,无需中继服务器。
|
|
95
|
+
|
|
96
|
+
服务器:
|
|
97
|
+
myhi 前台启动服务器
|
|
98
|
+
myhi start 前台启动服务器
|
|
99
|
+
myhi -d, --daemon 后台启动服务器(日志写入 ~/.myhi/daemon.log)
|
|
100
|
+
myhi stop 停止后台服务器
|
|
101
|
+
myhi restart 重启后台服务器
|
|
102
|
+
myhi status 查看后台运行状态
|
|
103
|
+
myhi log [N] 查看后台日志(最后 N 行,默认 50)
|
|
104
|
+
|
|
105
|
+
远程连接:
|
|
106
|
+
myhi attach 列出活跃会话,交互式选择后附加
|
|
107
|
+
myhi attach <id> 直接附加到指定会话
|
|
108
|
+
myhi attach --new 创建新会话并附加
|
|
109
|
+
|
|
110
|
+
快捷键(attach 模式):
|
|
111
|
+
Ctrl+] 分离终端(退出但不关闭会话)
|
|
112
|
+
|
|
113
|
+
环境变量:
|
|
114
|
+
PORT 监听端口(默认 3000)
|
|
115
|
+
HOST 监听地址(默认 0.0.0.0)
|
|
116
|
+
HTTPS_PORT HTTPS 端口(默认 3443)
|
|
117
|
+
MYHI_AUTO_ATTACH 启动时自动创建会话(默认 1,设 0 关闭)
|
|
118
|
+
MYHI_CWD 会话工作目录(默认当前目录)
|
|
119
|
+
MYHI_SERVER attach 连接的服务器地址(默认 http://localhost:3000)
|
|
120
|
+
|
|
121
|
+
配置目录:~/.myhi/
|
|
122
|
+
token 认证令牌(自动生成)
|
|
123
|
+
password 登录密码(自动生成,可手动修改)
|
|
124
|
+
roles.json 多用户角色配置(可选)
|
|
125
|
+
sessions.json 会话持久化数据
|
|
126
|
+
daemon.pid 后台进程 PID
|
|
127
|
+
daemon.log 后台运行日志
|
|
128
|
+
|
|
129
|
+
示例:
|
|
130
|
+
myhi # 启动后扫描二维码即可在手机上操作
|
|
131
|
+
PORT=8080 myhi -d # 8080 端口后台启动
|
|
132
|
+
MYHI_CWD=/project myhi # 指定会话工作目录
|
|
133
|
+
myhi attach # 从另一台机器连接到会话
|
|
134
|
+
`);
|
|
135
|
+
|
|
136
|
+
} else if (cmd === '-d' || cmd === '--daemon' || cmd === 'daemon') {
|
|
137
|
+
startDaemon();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
|
|
140
|
+
} else {
|
|
141
|
+
// 默认 / 'start':前台运行
|
|
142
|
+
await import('../dist/index.js');
|
|
143
|
+
}
|
package/dist/attach.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* myhi attach — 将本地终端连接到运行中的 PTY 会话
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node src/attach.js # 列出会话,然后选择
|
|
7
|
+
* node src/attach.js <id> # 直接附加到指定会话
|
|
8
|
+
*/
|
|
9
|
+
import { createRequire } from 'module';
|
|
10
|
+
import { readFileSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
import { createInterface } from 'readline';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { io } = require('socket.io-client');
|
|
17
|
+
|
|
18
|
+
const SERVER = process.env.MYHI_SERVER || 'http://localhost:3000';
|
|
19
|
+
const TOKEN = readFileSync(join(homedir(), '.myhi', 'token'), 'utf8').trim();
|
|
20
|
+
|
|
21
|
+
function cleanup(socket) {
|
|
22
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
23
|
+
process.stdin.pause();
|
|
24
|
+
socket.disconnect();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function attach(socket, sessionId) {
|
|
28
|
+
socket.emit('join', sessionId);
|
|
29
|
+
|
|
30
|
+
socket.on('joined', (session) => {
|
|
31
|
+
process.stderr.write(`\r\n[myhi] 已附加到 "${session.title}" (${sessionId})\r\n`);
|
|
32
|
+
process.stderr.write('[myhi] 按 Ctrl+] 分离\r\n\r\n');
|
|
33
|
+
|
|
34
|
+
// 本地终端自动获取控制权
|
|
35
|
+
socket.emit('take-control', { sessionId });
|
|
36
|
+
|
|
37
|
+
try { process.stdin.setRawMode(true); } catch {}
|
|
38
|
+
process.stdin.resume();
|
|
39
|
+
process.stdin.setEncoding('binary');
|
|
40
|
+
|
|
41
|
+
// 标准输入 → PTY
|
|
42
|
+
process.stdin.on('data', (data) => {
|
|
43
|
+
// Ctrl+] (0x1d) = 分离
|
|
44
|
+
if (data === '\x1d') {
|
|
45
|
+
process.stderr.write('\r\n[myhi] 已分离\r\n');
|
|
46
|
+
cleanup(socket);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
socket.emit('input', data);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// PTY → 标准输出
|
|
53
|
+
socket.on('output', (data) => {
|
|
54
|
+
process.stdout.write(data, 'binary');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// 仅在本地窗口实际调整大小时同步尺寸,
|
|
58
|
+
// 避免触发 PTY 重绘导致输出重复
|
|
59
|
+
process.stdout.on('resize', () => {
|
|
60
|
+
socket.emit('resize', {
|
|
61
|
+
cols: process.stdout.columns || 80,
|
|
62
|
+
rows: process.stdout.rows || 24,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// 控制权被拒绝
|
|
68
|
+
socket.on('control-denied', ({ reason }) => {
|
|
69
|
+
process.stderr.write(`\r\n[myhi] 获取控制权失败: ${reason}\r\n`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// 控制权被其他用户获取
|
|
73
|
+
socket.on('control-changed', ({ holder, holderName }) => {
|
|
74
|
+
if (holder && holder !== socket.id) {
|
|
75
|
+
process.stderr.write(`\r\n[myhi] ${holderName || '其他用户'} 已获取控制权,当前为只读\r\n`);
|
|
76
|
+
} else if (!holder) {
|
|
77
|
+
process.stderr.write('\r\n[myhi] 控制权已释放\r\n');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
socket.on('session-exit', ({ code }) => {
|
|
82
|
+
process.stderr.write(`\r\n[myhi] 会话已退出 (code ${code})\r\n`);
|
|
83
|
+
cleanup(socket);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
socket.on('error', ({ message }) => {
|
|
88
|
+
process.stderr.write(`\r\n[myhi] 错误: ${message}\r\n`);
|
|
89
|
+
cleanup(socket);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function pickSession(socket) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
socket.emit('list');
|
|
97
|
+
socket.once('sessions', (sessions) => {
|
|
98
|
+
const alive = sessions.filter(s => s.alive);
|
|
99
|
+
if (!alive.length) {
|
|
100
|
+
process.stderr.write('[myhi] 没有活跃的会话。\n');
|
|
101
|
+
cleanup(socket);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
process.stdout.write('\n活跃会话:\n');
|
|
106
|
+
alive.forEach((s, i) => {
|
|
107
|
+
const viewers = s.viewers > 0 ? ` (${s.viewers} 人在线)` : '';
|
|
108
|
+
process.stdout.write(` [${i + 1}] ${s.title}${viewers} — ${s.id}\n`);
|
|
109
|
+
});
|
|
110
|
+
process.stdout.write('\n');
|
|
111
|
+
|
|
112
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
113
|
+
rl.question('选择会话编号: ', (answer) => {
|
|
114
|
+
rl.close();
|
|
115
|
+
const idx = parseInt(answer, 10) - 1;
|
|
116
|
+
if (idx < 0 || idx >= alive.length) {
|
|
117
|
+
process.stderr.write('[myhi] 无效的选择。\n');
|
|
118
|
+
cleanup(socket);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
resolve(alive[idx].id);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function createAndAttach(socket, opts) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
socket.emit('create', opts, (res) => {
|
|
130
|
+
if (!res?.ok) {
|
|
131
|
+
process.stderr.write(`[myhi] 创建失败: ${res?.error || '未知错误'}\n`);
|
|
132
|
+
cleanup(socket);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
process.stdout.write(`[myhi] 已创建会话 "${res.session.title}" — ${res.session.id}\n`);
|
|
136
|
+
resolve(res.session.id);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function promptNew(socket) {
|
|
142
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
143
|
+
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
144
|
+
const title = (await ask('会话名称 [shell]: ')).trim() || 'shell';
|
|
145
|
+
const initCmd = (await ask('启动命令(可选): ')).trim() || undefined;
|
|
146
|
+
rl.close();
|
|
147
|
+
return createAndAttach(socket, { title, initCmd });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── 主入口 ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const socket = io(SERVER, {
|
|
153
|
+
transports: ['websocket'],
|
|
154
|
+
auth: { token: TOKEN },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
socket.on('connect_error', (err) => {
|
|
158
|
+
process.stderr.write(`[myhi] 连接失败: ${err.message}\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
socket.on('connect', async () => {
|
|
163
|
+
const arg = process.argv[2];
|
|
164
|
+
|
|
165
|
+
if (arg === '--new') {
|
|
166
|
+
// --new [标题] [命令] → 创建后附加
|
|
167
|
+
const title = process.argv[3];
|
|
168
|
+
const initCmd = process.argv[4] || undefined;
|
|
169
|
+
const sessionId = title
|
|
170
|
+
? await createAndAttach(socket, { title, initCmd })
|
|
171
|
+
: await promptNew(socket);
|
|
172
|
+
attach(socket, sessionId);
|
|
173
|
+
} else {
|
|
174
|
+
const sessionId = arg || await pickSession(socket);
|
|
175
|
+
attach(socket, sessionId);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 退出时恢复终端状态
|
|
180
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
181
|
+
process.on(sig, () => {
|
|
182
|
+
cleanup(socket);
|
|
183
|
+
process.exit(0);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
process.on('exit', () => {
|
|
187
|
+
try { process.stdin.setRawMode(false); } catch {}
|
|
188
|
+
});
|