@hyzyn/dsh-tty 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +85 -34
- package/README.md +65 -29
- package/client.js +51 -37
- package/lib/index.d.ts +367 -1
- package/lib/index.js +420 -138
- package/lib/index.js.map +1 -1
- package/lib/known-hosts.d.ts +7 -1
- package/lib/known-hosts.js +26 -12
- package/lib/known-hosts.js.map +1 -1
- package/lib/probe.d.ts +2 -2
- package/lib/probe.js +8 -36
- package/lib/probe.js.map +1 -1
- package/lib/sftp.d.ts +63 -10
- package/lib/sftp.js +244 -44
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +33 -13
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh.d.ts +21 -7
- package/lib/ssh.js +114 -19
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +67 -13
- package/lib/tunnels.js.map +1 -1
- package/package.json +7 -2
- package/scripts/build-client.mjs +30 -0
- package/scripts/integration.mjs +1691 -0
- package/scripts/lib/test-sshd.mjs +314 -0
- package/scripts/live.mjs +68 -0
- package/scripts/preview/harness.html +75 -0
- package/scripts/preview/harness.js +875 -0
- package/scripts/preview/mock-host.js +365 -0
- package/scripts/preview.mjs +502 -0
- package/scripts/probe-route-smoke.mjs +111 -0
- package/scripts/probe-smoke.mjs +143 -0
- package/scripts/probe.mjs +240 -0
- package/scripts/sftplimits-smoke.mjs +92 -0
- package/scripts/ssh-smoke.mjs +593 -0
- package/scripts/tui-smoke.mjs +112 -0
- package/scripts/windows-smoke.mjs +179 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — TUI 冒烟:在真实实例上验证 vim / htop 可启动、可按键、可退出。
|
|
4
|
+
* 用法:node scripts/tui-smoke.mjs [wsUrl](默认 ws://127.0.0.1:3090/api/dsh-tty/ws,可用 DSH_TTY_WS_URL 覆盖)
|
|
5
|
+
* 原理:xterm.js 渲染由浏览器完成,这里验证 PTY 侧的 TUI 行为——
|
|
6
|
+
* TERM=xterm-256color 下 vim 启动会输出 ANSI 转义序列、htop 同理;
|
|
7
|
+
* 通过发送按键(:q! / q)验证输入通道。
|
|
8
|
+
* 退出码:0 = PASS,1 = FAIL。
|
|
9
|
+
*/
|
|
10
|
+
import WebSocket from 'ws'
|
|
11
|
+
|
|
12
|
+
// 默认地址可用环境变量 DSH_TTY_WS_URL 覆盖(不同 profile 的实例端口不同)
|
|
13
|
+
const url = process.argv[2] ?? process.env.DSH_TTY_WS_URL ?? 'ws://127.0.0.1:3090/api/dsh-tty/ws'
|
|
14
|
+
const client = new WebSocket(url)
|
|
15
|
+
client.setMaxListeners(0)
|
|
16
|
+
|
|
17
|
+
let text = ''
|
|
18
|
+
let passed = true
|
|
19
|
+
const results = []
|
|
20
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
21
|
+
|
|
22
|
+
function waitFor(pred, timeoutMs = 15000, label) {
|
|
23
|
+
const ok = typeof pred === 'function' ? pred : () => pred.test(text)
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const start = Date.now()
|
|
26
|
+
const timer = setInterval(() => {
|
|
27
|
+
if (ok()) {
|
|
28
|
+
clearInterval(timer)
|
|
29
|
+
resolve()
|
|
30
|
+
} else if (Date.now() - start > timeoutMs) {
|
|
31
|
+
clearInterval(timer)
|
|
32
|
+
reject(new Error(`等待「${label}」超时;尾部输出: ${JSON.stringify(text.slice(-150))}`))
|
|
33
|
+
}
|
|
34
|
+
}, 100)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
console.error('[tui] 连接超时')
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}, 10000)
|
|
42
|
+
|
|
43
|
+
client.on('open', () => {
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
client.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
client.on('message', (raw) => {
|
|
49
|
+
const msg = JSON.parse(raw.toString())
|
|
50
|
+
if (msg.t === 'data') text += String(msg.d ?? '')
|
|
51
|
+
if (msg.t === 'error') console.error('[tui] 服务端错误:' + msg.m)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
client.on('error', (error) => {
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
console.error('[tui] 连接失败:' + error.message)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
await new Promise((resolve) => client.once('open', resolve))
|
|
62
|
+
await waitFor(/[^\x00-\x08\x0e-\x1f]{2,}/, 15000, 'shell 输出')
|
|
63
|
+
// shell 就绪需要时间(zsh 启动 .zshrc 等);过早发送按键会被吞掉
|
|
64
|
+
await sleep(2000)
|
|
65
|
+
|
|
66
|
+
// vim:以发送前为基线等待全屏渲染(>500 字节增长)→ :q! 退出
|
|
67
|
+
const beforeVim = text.length
|
|
68
|
+
client.send(JSON.stringify({ t: 'input', d: 'vim --clean\n' }))
|
|
69
|
+
try {
|
|
70
|
+
await waitFor(() => text.length > beforeVim + 500, 15000, 'vim 屏幕输出')
|
|
71
|
+
results.push(['PASS', 'vim 启动并渲染 ANSI 屏幕(+' + (text.length - beforeVim) + ' 字节)'])
|
|
72
|
+
} catch (error) {
|
|
73
|
+
results.push(['FAIL', error.message])
|
|
74
|
+
passed = false
|
|
75
|
+
}
|
|
76
|
+
client.send(JSON.stringify({ t: 'input', d: ':q!\n' }))
|
|
77
|
+
await sleep(1000)
|
|
78
|
+
|
|
79
|
+
// 第二个 TUI:按可用性选择 htop → nano → top(macOS 自带 top)
|
|
80
|
+
const { execFile } = await import('node:child_process')
|
|
81
|
+
const check = (cmd) => new Promise((r) => execFile('which', [cmd], (error, stdout) => r(error === null && stdout.trim() !== '' ? cmd : null)))
|
|
82
|
+
let second = 'top'
|
|
83
|
+
for (const cmd of ['htop', 'nano', 'top']) {
|
|
84
|
+
const found = await check(cmd)
|
|
85
|
+
if (found !== null) { second = found; break }
|
|
86
|
+
}
|
|
87
|
+
console.log(`[tui] 第二个 TUI 程序:${second}`)
|
|
88
|
+
const beforeSecond = text.length
|
|
89
|
+
client.send(JSON.stringify({ t: 'input', d: `${second === 'nano' ? 'nano -l\n' : second + ' -d 1\n'}` }))
|
|
90
|
+
try {
|
|
91
|
+
await waitFor(() => text.length > beforeSecond + 500, 15000, `${second} 屏幕输出`)
|
|
92
|
+
results.push(['PASS', `${second} 启动并渲染 ANSI 屏幕(+${text.length - beforeSecond} 字节)`])
|
|
93
|
+
} catch (error) {
|
|
94
|
+
results.push(['FAIL', error.message])
|
|
95
|
+
passed = false
|
|
96
|
+
}
|
|
97
|
+
client.send(JSON.stringify({ t: 'input', d: 'q' }))
|
|
98
|
+
await sleep(600)
|
|
99
|
+
|
|
100
|
+
client.send(JSON.stringify({ t: 'kill' }))
|
|
101
|
+
await sleep(500)
|
|
102
|
+
client.close()
|
|
103
|
+
|
|
104
|
+
console.log(`==== TUI 冒烟:${results.filter(([k]) => k === 'PASS').length}/${results.length} PASS ====`)
|
|
105
|
+
for (const [kind, name] of results) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}`)
|
|
106
|
+
process.exit(passed ? 0 : 1)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
main().catch((error) => {
|
|
110
|
+
console.error('[tui] 崩溃:', error)
|
|
111
|
+
process.exit(1)
|
|
112
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-tty — Windows 端到端冒烟(DEFECTS D45 的最后一格)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么单独一个脚本:integration.mjs 的 103 个用例是 POSIX 形状的(TERM 注入的
|
|
6
|
+
* `-c` 包装层、shell 集成 OSC 桩、tmux、`stty size` 校验),在 Windows 上一条都
|
|
7
|
+
* 不适用;而 0.18.1 修的那类 bug(默认 shell 回落 /bin/zsh、POSIX 包装层让 cmd
|
|
8
|
+
* 空跑一场就退出)恰好只在「真实 cmd.exe + ConPTY」上暴露。所以这里只钉最小
|
|
9
|
+
* 但真实的一条链路:挂载真实插件 → ws 协议 → spawn → ready(pid) → 输入回显 →
|
|
10
|
+
* kill → exit,外加「跑起来的是 %COMSPEC% 而不是 POSIX 包装层」的直接证据。
|
|
11
|
+
*
|
|
12
|
+
* 非 Windows 平台**优雅跳过**(退出码 0,打印原因):本脚本是给 CI 的
|
|
13
|
+
* windows-latest 用的,在 macOS/Linux 上假装跑过没有意义。
|
|
14
|
+
*
|
|
15
|
+
* 用法:pnpm --filter @hyzyn/dsh-tty windows-smoke
|
|
16
|
+
* 退出码:0 = 全部 PASS 或跳过,1 = 任一 FAIL。
|
|
17
|
+
*/
|
|
18
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import WebServerRuntime from '@deepseek-ai/dsh-host-webserver'
|
|
20
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
21
|
+
import WebSocket from 'ws'
|
|
22
|
+
import { name, inject, apply } from '../lib/index.js'
|
|
23
|
+
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
console.log(`[windows-smoke] 跳过:当前平台 ${process.platform}——本脚本只在 Windows 上跑(真实验证 cmd.exe / ConPTY 链路),CI 的 windows-latest job 会执行它`)
|
|
26
|
+
process.exit(0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* 看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
|
|
30
|
+
const watchdog = setTimeout(() => {
|
|
31
|
+
console.error('[watchdog] 90s 看门狗触发:windows-smoke 卡死')
|
|
32
|
+
process.exit(2)
|
|
33
|
+
}, 90000)
|
|
34
|
+
watchdog.unref()
|
|
35
|
+
|
|
36
|
+
const RESULTS = []
|
|
37
|
+
function pass(label) { RESULTS.push(['PASS', label]); console.log(' ✔ PASS ' + label) }
|
|
38
|
+
function fail(label, detail) { RESULTS.push(['FAIL', label, detail]); console.error(' ✘ FAIL ' + label + (detail ? ' — ' + detail : '')) }
|
|
39
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
40
|
+
|
|
41
|
+
/** 简易 ws 会话客户端(与 integration.mjs 同形,只保留本脚本要用的部分)。 */
|
|
42
|
+
function openSession(port) {
|
|
43
|
+
const client = new WebSocket(`ws://127.0.0.1:${port}/api/dsh-tty/ws`)
|
|
44
|
+
client.setMaxListeners(0)
|
|
45
|
+
const state = { frames: [], text: '', ready: null, exited: null, errors: [], waiters: [], closed: false }
|
|
46
|
+
client.on('message', (raw) => {
|
|
47
|
+
const msg = JSON.parse(raw.toString())
|
|
48
|
+
state.frames.push(msg)
|
|
49
|
+
if (msg.t === 'data') state.text += String(msg.d ?? '')
|
|
50
|
+
if (msg.t === 'ready') state.ready = msg
|
|
51
|
+
if (msg.t === 'exit') state.exited = { sid: msg.sid, code: msg.code, signal: msg.signal }
|
|
52
|
+
if (msg.t === 'error') state.errors.push(String(msg.m ?? ''))
|
|
53
|
+
for (const w of [...state.waiters]) w()
|
|
54
|
+
})
|
|
55
|
+
client.on('close', () => {
|
|
56
|
+
state.closed = true
|
|
57
|
+
for (const w of [...state.waiters]) w()
|
|
58
|
+
})
|
|
59
|
+
const waitFor = async (pred, timeoutMs = 15000, label = '条件') => {
|
|
60
|
+
const start = Date.now()
|
|
61
|
+
while (!pred()) {
|
|
62
|
+
if (Date.now() - start > timeoutMs) {
|
|
63
|
+
const details = [`已收文本: ${JSON.stringify(state.text.slice(-300))}`]
|
|
64
|
+
if (state.errors.length > 0) details.push(`error 帧: ${JSON.stringify(state.errors.slice(-3))}`)
|
|
65
|
+
if (state.exited !== null) details.push(`进程已退出: ${JSON.stringify(state.exited)}`)
|
|
66
|
+
throw new Error(`等待${label}超时(${details.join(';')})`)
|
|
67
|
+
}
|
|
68
|
+
await Promise.race([new Promise((resolve) => state.waiters.push(resolve)), sleep(200)])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const open = async () => {
|
|
72
|
+
const start = Date.now()
|
|
73
|
+
let lastError = ''
|
|
74
|
+
while (client.readyState !== WebSocket.OPEN) {
|
|
75
|
+
if (Date.now() - start > 10000) throw new Error('ws 连接超时:' + (lastError || '无错误事件'))
|
|
76
|
+
await new Promise((resolve) => {
|
|
77
|
+
const timer = setTimeout(resolve, 100)
|
|
78
|
+
client.once('open', () => { clearTimeout(timer); resolve() })
|
|
79
|
+
client.once('error', (error) => { lastError = error.message })
|
|
80
|
+
client.once('close', () => { clearTimeout(timer); resolve() })
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { client, state, waitFor, open }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** ConPTY 里按 Enter 是 \r(顺带补 \n 兼容两种翻译),cmd 才会执行。 */
|
|
88
|
+
const ENTER = '\r\n'
|
|
89
|
+
|
|
90
|
+
async function run() {
|
|
91
|
+
const app = new Context()
|
|
92
|
+
const wsFiber = app.plugin(WebServerRuntime, { host: '127.0.0.1', port: 0 })
|
|
93
|
+
const subFiber = app.plugin(LocalSubprocessRuntime)
|
|
94
|
+
const stubFiber = app.plugin({
|
|
95
|
+
name: 'windows-smoke-stub',
|
|
96
|
+
apply: (ctx) => {
|
|
97
|
+
ctx.provide('settings', { register: () => ({ get: () => ({}), update: async () => {} }) })
|
|
98
|
+
ctx.provide('tools', { register: () => () => {} })
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
await stubFiber.await()
|
|
102
|
+
const pluginFiber = app.plugin({ name, inject, apply }, { maxSessions: 2, term: 'xterm-256color', colorTerm: 'truecolor' })
|
|
103
|
+
await wsFiber.await()
|
|
104
|
+
await subFiber.await()
|
|
105
|
+
await pluginFiber.await()
|
|
106
|
+
const port = app.webServer.port
|
|
107
|
+
console.log(`webServer on 127.0.0.1:${port},插件已挂载`)
|
|
108
|
+
await sleep(400) // 等 ctx.inject(['webServer']) 回调完成路由注册
|
|
109
|
+
|
|
110
|
+
// W1:spawn → ready(默认 shell 必须是 %COMSPEC%,且真的起来了)
|
|
111
|
+
console.log('\n[W1] spawn → ready(默认 shell = %COMSPEC%)')
|
|
112
|
+
const s = openSession(port)
|
|
113
|
+
await s.open()
|
|
114
|
+
s.client.send(JSON.stringify({ t: 'spawn', cols: 100, rows: 30 }))
|
|
115
|
+
try {
|
|
116
|
+
await s.waitFor(() => s.state.ready !== null, 20000, 'ready')
|
|
117
|
+
const pid = s.state.ready.pid
|
|
118
|
+
// ConPTY 不给本机 pid:Windows 上 node-pty 的 pid 恒为 0(首个 CI 跑就是被这条
|
|
119
|
+
// 断言卡的,而 W2/W3 早已证明会话真的活着)。所以这里只要求「是数字且非负」,
|
|
120
|
+
// 会话是否真跑起来交给 W2/W3 的输出断言。
|
|
121
|
+
if (typeof pid === 'number' && pid >= 0) pass(`W1 spawn → ready(pid=${pid}${pid === 0 ? '(ConPTY 不暴露本机 pid)' : ''})`)
|
|
122
|
+
else fail('W1 spawn → ready', `ready 帧的 pid 不是数字:${JSON.stringify(s.state.ready)}`)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
fail('W1 spawn → ready', error.message)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// W2:跑起来的是 %COMSPEC%(cmd.exe),不是 POSIX 包装层
|
|
128
|
+
console.log('\n[W2] 输入回显:跑起来的是 cmd.exe')
|
|
129
|
+
try {
|
|
130
|
+
s.client.send(JSON.stringify({ t: 'input', d: `echo IT_SHELL_%COMSPEC%${ENTER}` }))
|
|
131
|
+
await s.waitFor(() => /IT_SHELL_.*cmd\.exe/i.test(s.state.text), 15000, 'cmd.exe 回显')
|
|
132
|
+
pass('W2 %COMSPEC% 生效(cmd.exe 在跑,说明没有 POSIX 包装层把命令吃掉)')
|
|
133
|
+
} catch (error) {
|
|
134
|
+
fail('W2 %COMSPEC% 生效', error.message)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// W3:命令真的被执行(不是只回显了输入)——靠 %OS% 展开证明
|
|
138
|
+
console.log('\n[W3] 命令执行(%OS% 由 shell 展开)')
|
|
139
|
+
try {
|
|
140
|
+
s.client.send(JSON.stringify({ t: 'input', d: `echo IT_WIN_%OS%${ENTER}` }))
|
|
141
|
+
await s.waitFor(() => /IT_WIN_Windows_NT/.test(s.state.text), 15000, '%OS% 展开')
|
|
142
|
+
pass('W3 命令执行生效(IT_WIN_Windows_NT —— %OS% 由 cmd 展开,回显里只有 %OS% 字面量)')
|
|
143
|
+
} catch (error) {
|
|
144
|
+
fail('W3 命令执行生效', error.message)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// W4:kill 帧 → exit,会话名额释放
|
|
148
|
+
console.log('\n[W4] kill → exit')
|
|
149
|
+
try {
|
|
150
|
+
s.client.send(JSON.stringify({ t: 'kill' }))
|
|
151
|
+
await s.waitFor(() => s.state.exited !== null, 15000, 'exit 帧')
|
|
152
|
+
pass('W4 kill 帧结束会话(收到 exit)')
|
|
153
|
+
} catch (error) {
|
|
154
|
+
fail('W4 kill 帧结束会话', error.message)
|
|
155
|
+
}
|
|
156
|
+
try { s.client.close() } catch { /* 已关闭 */ }
|
|
157
|
+
|
|
158
|
+
// W5:kill 之后还能再开一个(名额与清理都正确)
|
|
159
|
+
console.log('\n[W5] kill 后重新 spawn')
|
|
160
|
+
const s2 = openSession(port)
|
|
161
|
+
try {
|
|
162
|
+
await s2.open()
|
|
163
|
+
s2.client.send(JSON.stringify({ t: 'spawn', cols: 80, rows: 24 }))
|
|
164
|
+
await s2.waitFor(() => s2.state.ready !== null, 20000, '第二次 ready')
|
|
165
|
+
pass('W5 kill 后可重新 spawn(会话清理与名额释放正确)')
|
|
166
|
+
s2.client.send(JSON.stringify({ t: 'kill' }))
|
|
167
|
+
await s2.waitFor(() => s2.state.exited !== null, 15000, '第二次 exit')
|
|
168
|
+
} catch (error) {
|
|
169
|
+
fail('W5 kill 后可重新 spawn', error.message)
|
|
170
|
+
}
|
|
171
|
+
try { s2.client.close() } catch { /* 已关闭 */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await run()
|
|
175
|
+
const failed = RESULTS.filter((row) => row[0] === 'FAIL')
|
|
176
|
+
console.log('\n==== Windows 冒烟:' + String(RESULTS.length - failed.length) + '/' + String(RESULTS.length) + ' PASS ====')
|
|
177
|
+
for (const row of failed) console.error(' ✘ ' + row[1] + (row[2] ? ' — ' + row[2] : ''))
|
|
178
|
+
clearTimeout(watchdog)
|
|
179
|
+
process.exit(failed.length === 0 ? 0 : 1)
|