@kkutysllb/dsh-terminal 1.0.0 → 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/README.md +26 -0
- package/client.js +119 -4
- package/entry.js +312 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,27 @@
|
|
|
4
4
|
|
|
5
5
|
自 KCoder 内置包独立发布的 dsh 插件(v1.0.0 起独立版本线)。
|
|
6
6
|
|
|
7
|
+
## 真实终端语义(模式平移自 dsh-coding-sidebar 的 pty-deps / pty-manager)
|
|
8
|
+
|
|
9
|
+
- **内核级伪终端**:node-pty `spawn`(macOS/Linux 走 forkpty,Windows 走
|
|
10
|
+
ConPTY),`TERM=xterm-256color` + `COLORTERM=truecolor`——vim/htop/
|
|
11
|
+
配色/交互程序完整可用;
|
|
12
|
+
- **依赖降级**:node-pty 懒加载永不抛错;缺失/损坏时插件保持挂载,
|
|
13
|
+
`GET /dsh-terminal/api/deps` 返回 cause + 可粘贴修复命令,终端面板
|
|
14
|
+
渲染降级卡(复制 + 重试),不再无声失败;
|
|
15
|
+
- **spawn-helper 修复**:插件激活时幂等补回包管理器剥掉的 macOS prebuilt
|
|
16
|
+
助手可执行位(缺失时每个 spawn 都会 `posix_spawnp failed`);
|
|
17
|
+
- **transcript 回放**:每标签服务端维护 1MB 环形缓冲;页面刷新/面板重建
|
|
18
|
+
后经 `snapshot` RPC 回放历史,回放在途的新输出由 client 侧 pending
|
|
19
|
+
队列保序(不重不漏);restart 清空历史;
|
|
20
|
+
- **shell 解析链**:POSIX `$SHELL` → passwd 登录 shell → `/bin/bash`;
|
|
21
|
+
Windows `DSH_TERMINAL_SHELL` → pwsh 探测链(PATH + 已知安装目录)→
|
|
22
|
+
`powershell.exe`;POSIX 以登录 shell 启动(读 profile 文件)。
|
|
23
|
+
|
|
24
|
+
node-pty 版本契约:`^1.1.0`,与 DSH core(`@deepseek-ai/dsh-subprocess-local`)
|
|
25
|
+
同 range——同 range 同 integrity 让 pnpm 两侧解析到同一物理包(一份
|
|
26
|
+
native 绑定,无漂移)。
|
|
27
|
+
|
|
7
28
|
## 安装
|
|
8
29
|
|
|
9
30
|
```bash
|
|
@@ -18,7 +39,12 @@ dsh plugin install @kkutysllb/dsh-terminal
|
|
|
18
39
|
## 开发
|
|
19
40
|
|
|
20
41
|
- 本仓为开发真源;改动后跑 `node scripts/sync-to-dsh-plugins.mjs` 同步 dsh-plugins 镜像并提交推送。
|
|
42
|
+
- 本地跑真 pty 集成用例需 `node-pty` 可解析(开发目录可
|
|
43
|
+
`ln -sfn ~/.dsh/profiles/web/node_modules/node-pty node_modules/node-pty`);
|
|
44
|
+
沙箱环境会拦 forkpty/exec(`posix_spawnp failed`),需在无沙箱终端跑。
|
|
21
45
|
- `pnpm smoke`(prepack 自动)做契约形态校验;`node scripts/create-github-releases.mjs` 同步 release/ 到 GitHub Releases。
|
|
46
|
+
- 单测:`node tests/run-tests.mjs`(依赖层 / shell 链 / transcript 回放 /
|
|
47
|
+
桶管理真 pty 用例)。
|
|
22
48
|
|
|
23
49
|
## 许可
|
|
24
50
|
|
package/client.js
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* (client 直设,无需主进程 executeJavaScript);
|
|
14
14
|
* - 多标签:每标签一个 shell(server PtyHost 会话),"+" 新建、× 关闭、
|
|
15
15
|
* restart 重建;隐藏标签持续收数据(SSE 全量广播按桶路由);
|
|
16
|
+
* - 真实终端回放(模式平移自 dsh-coding-sidebar):注册标签即拉服务端
|
|
17
|
+
* transcript(snapshot RPC)回放历史——页面刷新/面板重建不再丢输出;
|
|
18
|
+
* 回放在途期间到达的 SSE 新帧进 pending 队列,历史写完再 flush(严格
|
|
19
|
+
* 保序,不重不漏);
|
|
20
|
+
* - 依赖降级:开面板前探 /api/deps,node-pty 缺失/损坏时渲染降级卡
|
|
21
|
+
* (原因 + 可复制的修复命令 + 重试),不再无声失败;
|
|
16
22
|
* - header 上缘 4px 拖条调高(clamp + localStorage 持久化);
|
|
17
23
|
* - 右键菜单:复制/粘贴/清屏/新建/关闭(navigator.clipboard,页面
|
|
18
24
|
* 同源权限;宿主 bridge IPC 不再存在)。
|
|
@@ -285,6 +291,16 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
285
291
|
.kt-menu button:hover{background:rgba(128,128,128,.18)}
|
|
286
292
|
.kt-menu button:disabled{opacity:.35;cursor:default}
|
|
287
293
|
.kt-menu .kt-sep{height:1px;margin:4px 6px}
|
|
294
|
+
.kt-deps{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;padding:16px 20px;overflow:auto}
|
|
295
|
+
.kt-deps-title{font-weight:700;font-size:13px}
|
|
296
|
+
.kt-deps-cause{opacity:.75;font-size:12px;word-break:break-all}
|
|
297
|
+
.kt-deps-cmd{display:flex;align-items:center;gap:8px}
|
|
298
|
+
.kt-deps-cmd code{flex:0 1 auto;max-width:100%;overflow-x:auto;padding:4px 8px;border-radius:6px;background:rgba(128,128,128,.14);font:500 12px Menlo,Monaco,monospace;white-space:nowrap}
|
|
299
|
+
.kt-deps-cmd button{all:unset;box-sizing:border-box;cursor:pointer;padding:3px 10px;border-radius:6px;font-weight:600;flex:none}
|
|
300
|
+
.kt-deps-cmd button:hover{background:rgba(128,128,128,.25)}
|
|
301
|
+
.kt-deps-note{opacity:.6;font-size:11px;max-width:640px}
|
|
302
|
+
.kt-deps-retry{all:unset;box-sizing:border-box;cursor:pointer;align-self:flex-start;padding:5px 14px;border-radius:7px;font-weight:600;background:rgba(77,107,254,.15);color:#4D6BFE}
|
|
303
|
+
.kt-deps-retry:hover{background:rgba(77,107,254,.28)}
|
|
288
304
|
`
|
|
289
305
|
document.head.append(style)
|
|
290
306
|
|
|
@@ -299,6 +315,23 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
299
315
|
return res.json()
|
|
300
316
|
}
|
|
301
317
|
|
|
318
|
+
/* ---- node-pty 依赖状态(ok:true 缓存;不 ok 每次重查,修复后自然恢复) ---- */
|
|
319
|
+
let depsOk = null // null = 尚未查询
|
|
320
|
+
const fetchDeps = async () => {
|
|
321
|
+
if (depsOk === true) return { ok: true }
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(`${API}/deps`)
|
|
324
|
+
const body = res.ok ? await res.json() : null
|
|
325
|
+
depsOk = body !== null && body !== undefined && body.ok === true
|
|
326
|
+
if (depsOk) return { ok: true }
|
|
327
|
+
return body != null && body.ok === false
|
|
328
|
+
? body
|
|
329
|
+
: { ok: false, cause: 'deps ' + res.status, command: '' }
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return { ok: false, cause: String(error?.message ?? error), command: '' }
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
302
335
|
/* ---- 单工作区面板(平移 WorkspaceView + mountTerminal) ---- */
|
|
303
336
|
const panels = new Map() // bucket → PanelState
|
|
304
337
|
const xtermReady = ensureXterm()
|
|
@@ -334,6 +367,50 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
334
367
|
for (const st of panel.tabs.values()) st.term.options.theme = p.xterm
|
|
335
368
|
}
|
|
336
369
|
|
|
370
|
+
/** 依赖降级面板(panel-shaped:与 layout/applyPalette/开合协议兼容)。 */
|
|
371
|
+
const ensureDepsPanel = (bucket, deps) => {
|
|
372
|
+
const root = el('div', 'kt-panel')
|
|
373
|
+
root.id = bucket === NO_WORKSPACE_KEY ? '__dsh_kc_term_panel' : '__dsh_kc_term_panel_deps_' + panels.size
|
|
374
|
+
const grip = el('div', 'kt-grip')
|
|
375
|
+
const header = el('div', 'kt-header')
|
|
376
|
+
const termHost = el('div', 'kt-term')
|
|
377
|
+
const exitBar = el('div', 'kt-exit')
|
|
378
|
+
const menu = el('div', 'kt-menu')
|
|
379
|
+
menu.style.display = 'none'
|
|
380
|
+
const card = el('div', 'kt-deps')
|
|
381
|
+
card.append(el('div', 'kt-deps-title', '终端依赖不可用(降级模式)'))
|
|
382
|
+
card.append(el('div', 'kt-deps-cause', deps.cause || 'node-pty 加载失败'))
|
|
383
|
+
const cmdRow = el('div', 'kt-deps-cmd')
|
|
384
|
+
const code = el('code', '', deps.command || 'dsh plugin install')
|
|
385
|
+
const copy = el('button', '', '复制')
|
|
386
|
+
copy.type = 'button'
|
|
387
|
+
copy.onclick = () => { void navigator.clipboard?.writeText(code.textContent ?? '').catch(() => {}) }
|
|
388
|
+
cmdRow.append(code, copy)
|
|
389
|
+
card.append(cmdRow)
|
|
390
|
+
if (deps.note) card.append(el('div', 'kt-deps-note', deps.note))
|
|
391
|
+
const retry = el('button', 'kt-deps-retry', '重试')
|
|
392
|
+
retry.type = 'button'
|
|
393
|
+
retry.onclick = () => {
|
|
394
|
+
depsOk = null
|
|
395
|
+
panels.delete(bucket)
|
|
396
|
+
root.remove()
|
|
397
|
+
show(bucket)
|
|
398
|
+
}
|
|
399
|
+
card.append(retry)
|
|
400
|
+
termHost.append(card)
|
|
401
|
+
root.append(grip, header, termHost, exitBar, menu)
|
|
402
|
+
// 挂 documentElement:上游 SPA 重渲染重写 body 不清外部节点(同教训)。
|
|
403
|
+
document.documentElement.append(root)
|
|
404
|
+
const panel = {
|
|
405
|
+
bucket, root, grip, header, termHost, exitBar, menu,
|
|
406
|
+
tabs: new Map(), activeId: -1, open: false, shown: false, loaded: true,
|
|
407
|
+
palette: null, depsMode: true,
|
|
408
|
+
resyncTabs: () => {},
|
|
409
|
+
}
|
|
410
|
+
panels.set(bucket, panel)
|
|
411
|
+
return panel
|
|
412
|
+
}
|
|
413
|
+
|
|
337
414
|
/** 懒建指定工作区面板 DOM(vendor 就绪后;已存在直接返回)。 */
|
|
338
415
|
const ensurePanel = async (bucket) => {
|
|
339
416
|
let panel = panels.get(bucket)
|
|
@@ -344,6 +421,10 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
344
421
|
panel = undefined
|
|
345
422
|
}
|
|
346
423
|
if (panel !== undefined) return panel
|
|
424
|
+
// 依赖预检:node-pty 缺失/损坏时渲染降级卡(原因 + 修复命令 + 重试),
|
|
425
|
+
// 不再无声失败;修复后重试自然恢复。
|
|
426
|
+
const deps = await fetchDeps()
|
|
427
|
+
if (!deps.ok) return ensureDepsPanel(bucket, deps)
|
|
347
428
|
// 现取而非用预热常量:预热失败置空 vendorPromise 后,这里重拉新 promise
|
|
348
429
|
const { Terminal, FitAddon } = await ensureXterm()
|
|
349
430
|
const root = el('div', 'kt-panel')
|
|
@@ -413,13 +494,29 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
413
494
|
x.innerHTML = '<svg viewBox="0 0 16 16" width="10" height="10" fill="none"><path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
|
|
414
495
|
tabEl.append(label, x)
|
|
415
496
|
tabsBar.append(tabEl)
|
|
416
|
-
const st = { id: tab.id, term, fit, host, el: tabEl, exited: !tab.alive }
|
|
497
|
+
const st = { id: tab.id, term, fit, host, el: tabEl, exited: !tab.alive, replaying: true, pending: [], disposed: false }
|
|
417
498
|
tabEl.dataset.exited = st.exited ? '1' : '0'
|
|
418
499
|
tabEl.onclick = () => setActive(panel, st.id)
|
|
419
500
|
x.onclick = e => { e.stopPropagation(); void closeTab(panel, st.id) }
|
|
420
501
|
term.onData(data => { void rpc({ op: 'write', id: st.id, data }).catch(() => {}) })
|
|
421
502
|
term.onResize(({ cols, rows }) => { void rpc({ op: 'resize', id: st.id, cols, rows }).catch(() => {}) })
|
|
422
503
|
panel.tabs.set(st.id, st)
|
|
504
|
+
// 真实终端回放:注册即拉服务端 transcript 写历史(页面刷新/面板
|
|
505
|
+
// 重建后不再丢输出)。回放在途期间到达的 SSE 新帧进 pending,
|
|
506
|
+
// 历史写完统一 flush——严格保序,不重不漏。
|
|
507
|
+
void rpc({ op: 'snapshot', id: st.id }).then(out => {
|
|
508
|
+
if (st.disposed) return
|
|
509
|
+
if (out != null && out.ok && typeof out.chunk === 'string' && out.chunk !== '') {
|
|
510
|
+
try { st.term.write(out.chunk) } catch { /* term 已释放 */ }
|
|
511
|
+
}
|
|
512
|
+
}).catch(() => {}).finally(() => {
|
|
513
|
+
st.replaying = false
|
|
514
|
+
if (st.pending.length > 0) {
|
|
515
|
+
const queued = st.pending
|
|
516
|
+
st.pending = []
|
|
517
|
+
for (const c of queued) { try { st.term.write(c) } catch { break } }
|
|
518
|
+
}
|
|
519
|
+
})
|
|
423
520
|
return st
|
|
424
521
|
}
|
|
425
522
|
|
|
@@ -453,6 +550,7 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
453
550
|
if (st === undefined) return
|
|
454
551
|
const out = await rpc({ op: 'close', id, cwd: p.bucket === NO_WORKSPACE_KEY ? '' : p.bucket }).catch(() => null)
|
|
455
552
|
if (out === null) return
|
|
553
|
+
st.disposed = true
|
|
456
554
|
st.term.dispose()
|
|
457
555
|
st.host.remove()
|
|
458
556
|
st.el.remove()
|
|
@@ -460,7 +558,7 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
460
558
|
// 对齐服务端剩余标签(防御:本地与远端应一致)
|
|
461
559
|
const ids = new Set((out.tabs ?? []).map(t => t.id))
|
|
462
560
|
for (const [kid, kst] of [...p.tabs]) {
|
|
463
|
-
if (!ids.has(kid)) { kst.term.dispose(); kst.host.remove(); kst.el.remove(); p.tabs.delete(kid) }
|
|
561
|
+
if (!ids.has(kid)) { kst.disposed = true; kst.term.dispose(); kst.host.remove(); kst.el.remove(); p.tabs.delete(kid) }
|
|
464
562
|
}
|
|
465
563
|
if (p.tabs.size === 0) {
|
|
466
564
|
// 全部关闭 → 收起面板(下次打开 ensureFirst 新建全新会话)
|
|
@@ -476,7 +574,17 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
476
574
|
|
|
477
575
|
const newTab = async (p) => {
|
|
478
576
|
const out = await rpc({ op: 'new', cwd: p.bucket === NO_WORKSPACE_KEY ? '' : p.bucket }).catch(() => null)
|
|
479
|
-
if (out === null || out.tab === undefined)
|
|
577
|
+
if (out === null || out.tab === undefined) {
|
|
578
|
+
// 新建失败可能是 node-pty 缺失/损坏(spawn 加载失败):探测 deps,
|
|
579
|
+
// 确认缺失则把面板替换为降级卡(修复命令 + 重试)。
|
|
580
|
+
const deps = await fetchDeps().catch(() => null)
|
|
581
|
+
if (deps !== null && !deps.ok) {
|
|
582
|
+
panels.delete(p.bucket)
|
|
583
|
+
if (p.root.isConnected) p.root.remove()
|
|
584
|
+
show(p.bucket)
|
|
585
|
+
}
|
|
586
|
+
return
|
|
587
|
+
}
|
|
480
588
|
const st = registerTab(out.tab)
|
|
481
589
|
setActive(p, st.id)
|
|
482
590
|
st.term.focus()
|
|
@@ -491,6 +599,7 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
491
599
|
const freshIds = new Set(fresh.map(t => t.id))
|
|
492
600
|
for (const [kid, kst] of [...p.tabs]) {
|
|
493
601
|
if (!freshIds.has(kid)) {
|
|
602
|
+
kst.disposed = true
|
|
494
603
|
kst.term.dispose(); kst.host.remove(); kst.el.remove(); p.tabs.delete(kid)
|
|
495
604
|
}
|
|
496
605
|
}
|
|
@@ -692,7 +801,13 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
|
|
|
692
801
|
if (msg === null || msg === undefined) return
|
|
693
802
|
const p = panels.get(msg.bucket)
|
|
694
803
|
if (p === undefined) return
|
|
695
|
-
if (msg.type === 'data')
|
|
804
|
+
if (msg.type === 'data') {
|
|
805
|
+
const st = p.tabs.get(msg.id)
|
|
806
|
+
if (st === undefined || st.disposed) return
|
|
807
|
+
// 回放在途:新帧先入 pending 队列,snapshot 历史写完再 flush。
|
|
808
|
+
if (st.replaying) st.pending.push(msg.chunk)
|
|
809
|
+
else st.term.write(msg.chunk)
|
|
810
|
+
}
|
|
696
811
|
else if (msg.type === 'exit') {
|
|
697
812
|
const st = p.tabs.get(msg.id)
|
|
698
813
|
if (st === undefined) return
|
package/entry.js
CHANGED
|
@@ -4,32 +4,43 @@
|
|
|
4
4
|
* 嵌入式终端 RPC(逻辑平移自退役宿主 desktop/main/pty-host.ts +
|
|
5
5
|
* terminal-panel.ts,语义保持一致):
|
|
6
6
|
* - POST /dsh-terminal/api/rpc pty 操作(tabs/new/write/resize/
|
|
7
|
-
* restart/close,cwd 为工作区桶键)
|
|
7
|
+
* restart/close/snapshot,cwd 为工作区桶键)
|
|
8
8
|
* - GET /dsh-terminal/api/stream SSE 输出流(data/exit 事件带 bucket,
|
|
9
9
|
* 全局一条广播,client 按当前桶路由;15s 心跳防代理断连)
|
|
10
10
|
* - GET /dsh-terminal/api/vendor/<name> xterm vendor 静态托管
|
|
11
11
|
* (client.js 自包含无 import,运行时懒拉 + eval;白名单三件)
|
|
12
|
+
* - GET /dsh-terminal/api/deps node-pty 依赖状态(降级模式:缺失时
|
|
13
|
+
* 返回 cause + 可粘贴的修复命令,前端渲染降级卡)
|
|
12
14
|
*
|
|
13
15
|
* 安全边界与 dsh-git-panel 同款:isTrusted(loopback 放行 +
|
|
14
16
|
* webRuntime.trustedHosts);写操作 POST-only;SSE 为只读推送。
|
|
15
17
|
*
|
|
16
|
-
* pty 引擎:node-pty(VS Code
|
|
17
|
-
* node_modules 解析(bundle 物化在
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* pty 引擎:node-pty(VS Code 同款,内核级伪终端 openpty/ConPTY),经
|
|
19
|
+
* createRequire 从运行时 node_modules 解析(bundle 物化在 profile 的
|
|
20
|
+
* node_modules 下,向上可达 profiles/node_modules/node-pty——dsh-tool-bash
|
|
21
|
+
* 已带)。真实终端健壮性(模式平移自 dsh-coding-sidebar 的 pty-deps/
|
|
22
|
+
* pty-manager):
|
|
23
|
+
* - 懒加载永不抛错:模块导入不触发 native 绑定加载;加载失败缓存原因,
|
|
24
|
+
* 插件保持挂载(降级态),deps 路由给出修复命令,不拖垮宿主;
|
|
25
|
+
* - ensureSpawnHelper:补回包管理器剥掉的 spawn-helper 可执行位(缺失
|
|
26
|
+
* 时每个 spawn 都会 posix_spawnp failed),激活时幂等执行;
|
|
27
|
+
* - transcript 环形缓冲(1MB/标签):页面刷新 / 面板重建后经 snapshot
|
|
28
|
+
* RPC 回放历史,回放在途期间的新输出由 client 侧 pending 队列保序;
|
|
29
|
+
* - shell 解析链:$SHELL → passwd 登录 shell → bash 兜底;Windows 上
|
|
30
|
+
* DSH_TERMINAL_SHELL env → pwsh 探测链 → powershell.exe 兜底。
|
|
21
31
|
*
|
|
22
|
-
*
|
|
23
|
-
* spawn 集成用例走真 pty。
|
|
32
|
+
* 纯逻辑(桶管理/参数校验/依赖层/shell 链)导出供 tests/run-tests.mjs
|
|
33
|
+
* node 直跑;spawn 集成用例走真 pty。
|
|
24
34
|
*
|
|
25
35
|
* @module @kkutysllb/dsh-terminal/entry
|
|
26
36
|
*/
|
|
27
37
|
|
|
28
38
|
import { createRequire } from 'node:module'
|
|
29
39
|
import { EventEmitter } from 'node:events'
|
|
30
|
-
import { statSync } from 'node:fs'
|
|
31
|
-
import { homedir } from 'node:os'
|
|
32
|
-
import { basename } from 'node:path'
|
|
40
|
+
import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs'
|
|
41
|
+
import { homedir, userInfo } from 'node:os'
|
|
42
|
+
import { basename, dirname, join } from 'node:path'
|
|
43
|
+
import { fileURLToPath } from 'node:url'
|
|
33
44
|
import { readFile } from 'node:fs/promises'
|
|
34
45
|
|
|
35
46
|
/** client-modules 集成对应的 cordis 依赖:RPC 注册必需 webServer。 */
|
|
@@ -53,19 +64,250 @@ export const PANEL_MIN_H = 140
|
|
|
53
64
|
export const PANEL_MAX_H = 620
|
|
54
65
|
export const PANEL_DEFAULT_H = 280
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
/* ---------------------------------------------------------------- *
|
|
68
|
+
* node-pty 依赖层(模式平移自 dsh-coding-sidebar src/pty-deps.ts)
|
|
69
|
+
* ---------------------------------------------------------------- */
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* node-pty 版本契约:须与 DSH core(@deepseek-ai/dsh-subprocess-local)
|
|
73
|
+
* 声明同 range——同 range 同 integrity 让 pnpm 把两侧解析到同一物理包
|
|
74
|
+
* (一份 native 绑定,无漂移)。换 fork 或改 range 前先核对 core 声明。
|
|
75
|
+
*/
|
|
76
|
+
export const DSH_NODE_PTY_RANGE = '^1.1.0'
|
|
77
|
+
|
|
78
|
+
/** 懒加载缓存:undefined=未加载;{ok:true,module} | {ok:false,cause}。 */
|
|
79
|
+
let ptyLoadCache
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 加载 node-pty(同步、单次、永不抛错)。模块缺失、native 绑定损坏、
|
|
83
|
+
* pnpm 11 strict-dep-builds 跳过 install script 等都只会被记进缓存——
|
|
84
|
+
* 插件保持挂载(降级态),由 deps 路由给出修复命令。
|
|
85
|
+
*/
|
|
86
|
+
export function loadNodePty() {
|
|
87
|
+
if (ptyLoadCache === undefined) {
|
|
88
|
+
try {
|
|
89
|
+
ptyLoadCache = { ok: true, module: createRequire(import.meta.url)('node-pty') }
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
ptyLoadCache = { ok: false, cause }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return ptyLoadCache.ok ? ptyLoadCache.module : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 已记录的加载失败原因(成功或未加载时 undefined)。 */
|
|
98
|
+
export function nodePtyLoadCause() {
|
|
99
|
+
return ptyLoadCache !== undefined && !ptyLoadCache.ok ? ptyLoadCache.cause : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 加载失败的一行人读描述。 */
|
|
103
|
+
function describeCause(cause) {
|
|
104
|
+
if (cause instanceof Error) return cause.message
|
|
105
|
+
return String(cause)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 加载 node-pty 或抛规范错误(create/restart 路径;消息指向 deps 路由)。 */
|
|
109
|
+
function loadRequiredNodePty() {
|
|
110
|
+
const m = loadNodePty()
|
|
111
|
+
if (m === null) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
'node-pty (' + DSH_NODE_PTY_RANGE + ') failed to load: ' + describeCause(nodePtyLoadCause())
|
|
114
|
+
+ ' -- see GET ' + RPC_PREFIX + '/deps for the repair command',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return m
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 解析到物理位置(symlink/link 安装),供 walk-up 探测。 */
|
|
121
|
+
function realDir(file) {
|
|
122
|
+
try { return dirname(realpathSync(file)) } catch { return dirname(file) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 从 dir 向上找满足条件的祖先根(限深 16 层)。 */
|
|
126
|
+
function walkUp(dir, isRoot) {
|
|
127
|
+
let current = dir
|
|
128
|
+
for (let depth = 0; depth < 16; depth += 1) {
|
|
129
|
+
if (isRoot(current)) return current
|
|
130
|
+
const parent = dirname(current)
|
|
131
|
+
if (parent === current) break
|
|
132
|
+
current = parent
|
|
133
|
+
}
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 是否 DSH profile 根(package.json + pnpm-workspace.yaml 双证)。 */
|
|
138
|
+
function isProfileRoot(dir) {
|
|
139
|
+
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 探测本插件安装所在的 profile 目录:插件模块的最近双证祖先(profile
|
|
144
|
+
* 根,插件从其 node_modules 解析);探测不到回退 $DSH_HOME/profiles/web
|
|
145
|
+
* (标准 web profile);仍不像则 null。
|
|
146
|
+
*/
|
|
147
|
+
export function findProfileDir(fromFile = fileURLToPath(import.meta.url)) {
|
|
148
|
+
const detected = walkUp(realDir(fromFile), isProfileRoot)
|
|
149
|
+
if (detected !== null) return detected
|
|
150
|
+
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
|
|
151
|
+
? process.env.DSH_HOME
|
|
152
|
+
: join(homedir(), '.dsh')
|
|
153
|
+
const web = join(home, 'profiles', 'web')
|
|
154
|
+
return isProfileRoot(web) ? realpathSync(web) : null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* node-pty 安装损坏时的可粘贴修复命令。本插件不随包发 install 脚本
|
|
159
|
+
* (依赖 DSH core 侧的 node-pty),因此始终走 dsh plugin 重装兜底,
|
|
160
|
+
* 并附 allowBuilds 提示(pnpm 11 拦 native 构建 scripts 的常见病因)。
|
|
161
|
+
*/
|
|
162
|
+
export function buildRepairCommand(options = {}) {
|
|
163
|
+
const profileDir = options.profileDir !== undefined ? options.profileDir : findProfileDir()
|
|
164
|
+
const name = profileDir !== null ? basename(profileDir) : 'web'
|
|
165
|
+
return {
|
|
166
|
+
command: 'dsh plugin --profile "' + name + '" install',
|
|
167
|
+
note: "If pnpm 11 blocked node-pty's build script, ensure allowBuilds: node-pty: true"
|
|
168
|
+
+ " in the profile's pnpm-workspace.yaml, then rerun the install command.",
|
|
61
169
|
}
|
|
62
|
-
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** deps 路由的响应形态:{ok:true} | {ok:false, cause, command, profile, note?}。 */
|
|
173
|
+
export function depsStatus() {
|
|
174
|
+
const m = loadNodePty()
|
|
175
|
+
if (m !== null) return { ok: true }
|
|
176
|
+
const profileDir = findProfileDir()
|
|
177
|
+
const { command, note } = buildRepairCommand({ profileDir })
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
cause: describeCause(nodePtyLoadCause()),
|
|
181
|
+
command,
|
|
182
|
+
profile: profileDir !== null ? basename(profileDir) : null,
|
|
183
|
+
note,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 补回包管理器剥掉的 spawn-helper 可执行位(平移自 dsh-coding-sidebar
|
|
189
|
+
* ensureSpawnHelper):spawn-helper 是 macOS 侧 fork 并装配 pty 的
|
|
190
|
+
* prebuilt 助手,丢执行位时每个 spawn 都报 posix_spawnp failed。幂等;
|
|
191
|
+
* 解析或 chmod 失败静默——由 spawn 自身的报错暴露问题。
|
|
192
|
+
*/
|
|
193
|
+
export function ensureSpawnHelper() {
|
|
194
|
+
if (process.platform === 'win32') return
|
|
195
|
+
try {
|
|
196
|
+
const require = createRequire(import.meta.url)
|
|
197
|
+
const entry = require.resolve('node-pty')
|
|
198
|
+
const packageRoot = dirname(dirname(entry))
|
|
199
|
+
const candidates = [
|
|
200
|
+
join(packageRoot, 'prebuilds', process.platform + '-' + process.arch, 'spawn-helper'),
|
|
201
|
+
join(packageRoot, 'build', 'Release', 'spawn-helper'),
|
|
202
|
+
]
|
|
203
|
+
for (const helper of candidates) {
|
|
204
|
+
if (existsSync(helper)) chmodSync(helper, 0o755)
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// 解析失败(依赖缺失)留给 deps 降级路径;chmod 失败由 spawn 报错暴露。
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ---------------------------------------------------------------- *
|
|
212
|
+
* shell 解析链(平移自 dsh-coding-sidebar defaultShell 系)
|
|
213
|
+
* ---------------------------------------------------------------- */
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Windows 侧可能装着 pwsh.exe 的目录:PATH 条目优先,其后机器/用户级
|
|
217
|
+
* 已知安装位置(含 preview 通道与 per-user 布局)。机器级同时读
|
|
218
|
+
* ProgramW6432 与 ProgramFiles(32 位 Node 的 ProgramFiles 指向 (x86),
|
|
219
|
+
* 仍要能找到 64 位 PowerShell 7)。去重且保持优先序。
|
|
220
|
+
*/
|
|
221
|
+
function windowsPwshCandidateDirs(env) {
|
|
222
|
+
const dirs = []
|
|
223
|
+
const pathEntries = env.PATH
|
|
224
|
+
if (pathEntries !== undefined) {
|
|
225
|
+
// win32 分支固定用 Windows PATH 分隔符;硬编码保证跨平台可测。
|
|
226
|
+
for (const entry of pathEntries.split(';')) {
|
|
227
|
+
const trimmed = entry.trim()
|
|
228
|
+
if (trimmed !== '') dirs.push(trimmed)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
|
|
232
|
+
if (programFiles === undefined || programFiles.trim() === '') continue
|
|
233
|
+
dirs.push(join(programFiles, 'PowerShell', '7'))
|
|
234
|
+
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
|
|
235
|
+
}
|
|
236
|
+
const localAppData = env.LOCALAPPDATA
|
|
237
|
+
if (localAppData !== undefined && localAppData.trim() !== '') {
|
|
238
|
+
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
|
|
239
|
+
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
|
|
240
|
+
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
|
|
241
|
+
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
|
|
242
|
+
}
|
|
243
|
+
return [...new Set(dirs)]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 交互 shell 解析(像终端模拟器那样):
|
|
248
|
+
* - POSIX:$SHELL → passwd 登录 shell(userInfo().shell;服务管理器/
|
|
249
|
+
* 容器 init 常不带 SHELL 启动 dsh,这一步让标签仍开用户的登录 shell
|
|
250
|
+
* 而非静默降级 bash)→ /bin/bash 兜底;
|
|
251
|
+
* - Windows:DSH_TERMINAL_SHELL env → PATH/已知目录的 pwsh.exe 探测链
|
|
252
|
+
* (PowerShell 7 用户不再拿到老 5.1)→ powershell.exe 兜底。
|
|
253
|
+
* options 显式传参仅供测试注入平台/env/存在性探测。
|
|
254
|
+
*/
|
|
255
|
+
export function defaultShell(options = {}) {
|
|
256
|
+
const platform = options.platform ?? process.platform
|
|
257
|
+
const env = options.env ?? process.env
|
|
258
|
+
const exists = options.exists ?? existsSync
|
|
259
|
+
if (platform === 'win32') {
|
|
260
|
+
const envShell = env.DSH_TERMINAL_SHELL
|
|
261
|
+
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
|
262
|
+
for (const dir of windowsPwshCandidateDirs(env)) {
|
|
263
|
+
const candidate = join(dir, 'pwsh.exe')
|
|
264
|
+
if (exists(candidate)) return candidate
|
|
265
|
+
}
|
|
266
|
+
return 'powershell.exe'
|
|
267
|
+
}
|
|
268
|
+
const envShell = env.SHELL
|
|
269
|
+
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
|
|
270
|
+
// uid 无 passwd 条目时 userInfo() 会抛(罕见 chroot):兜底 /bin/bash。
|
|
271
|
+
try {
|
|
272
|
+
const loginShell = userInfo().shell
|
|
273
|
+
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
|
|
274
|
+
} catch { /* 无 passwd 条目:落到 bash 兜底 */ }
|
|
275
|
+
return '/bin/bash'
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* spawn 参数:POSIX 走登录 shell(读 ~/.profile ~/.zprofile 等 profile
|
|
280
|
+
* 文件);Windows PowerShell 不收登录旗标。显式传入时整体替换默认。
|
|
281
|
+
*/
|
|
282
|
+
export function shellSpawnArgs(configured = []) {
|
|
283
|
+
if (configured.length > 0) return [...configured]
|
|
284
|
+
return process.platform === 'win32' ? [] : ['-l']
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 标签标题用的 shell 短名:/bin/zsh → zsh,C:\...\pwsh.exe → pwsh。 */
|
|
288
|
+
export function shellDisplayName(shell) {
|
|
289
|
+
const normalized = shell.replace(/\\/g, '/')
|
|
290
|
+
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
|
|
291
|
+
if (base === '') return shell
|
|
292
|
+
return base.replace(/\.(exe|cmd|bat)$/i, '')
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ---------------------------------------------------------------- *
|
|
296
|
+
* transcript(回放缓冲;平移自 dsh-coding-sidebar 的环形裁剪语义)
|
|
297
|
+
* ---------------------------------------------------------------- */
|
|
298
|
+
|
|
299
|
+
/** 单标签 transcript 上限(字符;超出丢头保尾)。 */
|
|
300
|
+
export const TRANSCRIPT_LIMIT = 1 << 20
|
|
301
|
+
|
|
302
|
+
/** 追加一块输出并裁剪到上限(纯函数,供测试)。 */
|
|
303
|
+
export function appendTranscript(prev, chunk) {
|
|
304
|
+
const next = prev + chunk
|
|
305
|
+
return next.length > TRANSCRIPT_LIMIT ? next.slice(next.length - TRANSCRIPT_LIMIT) : next
|
|
63
306
|
}
|
|
64
307
|
|
|
65
308
|
/** 标签信息(tabs/rpc 响应;与宿主 TerminalTab 契约一致)。 */
|
|
66
309
|
function tabOf(s) {
|
|
67
|
-
|
|
68
|
-
return { id: s.id, alive: !s.exited, cwd: s.cwd, title: basename(shell) }
|
|
310
|
+
return { id: s.id, alive: !s.exited, cwd: s.cwd, title: shellDisplayName(s.shell ?? defaultShell()) }
|
|
69
311
|
}
|
|
70
312
|
|
|
71
313
|
function killPty(s) {
|
|
@@ -102,6 +344,8 @@ export function clampH(h) {
|
|
|
102
344
|
* (buckets = Map<cwd, Map<id, session>>),不同工作区互不干扰;
|
|
103
345
|
* id 全局唯一跨桶可寻址;面板关闭仅隐藏不杀进程,restart 销毁重建
|
|
104
346
|
* 同 id,close 关单标签。事件:data(chunk, id, bucket) / exit(id, bucket)。
|
|
347
|
+
* 每会话维护 transcript 环形缓冲(snapshot RPC 供 client 回放历史,
|
|
348
|
+
* 退出会话仍可回放);spawn 失败时 create 回滚入桶、restart 如实标退出。
|
|
105
349
|
*/
|
|
106
350
|
export class PtyHost {
|
|
107
351
|
constructor() {
|
|
@@ -151,12 +395,17 @@ export class PtyHost {
|
|
|
151
395
|
return null
|
|
152
396
|
}
|
|
153
397
|
|
|
154
|
-
/** 新建标签到指定工作区桶(shell
|
|
398
|
+
/** 新建标签到指定工作区桶(shell 进程立即启动;spawn 失败回滚入桶)。 */
|
|
155
399
|
create(cwd) {
|
|
156
400
|
const bucket = this.bucketOf(cwd)
|
|
157
|
-
const s = { id: this.nextId++, pty: null, cwd: usableDir(cwd) ?? homedir(), bucket, exited: false }
|
|
401
|
+
const s = { id: this.nextId++, pty: null, cwd: usableDir(cwd) ?? homedir(), bucket, exited: false, transcript: '', shell: null }
|
|
158
402
|
this.bucket(bucket).set(s.id, s)
|
|
159
|
-
|
|
403
|
+
try {
|
|
404
|
+
this.spawn(s)
|
|
405
|
+
} catch (error) {
|
|
406
|
+
this.bucket(bucket).delete(s.id)
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
160
409
|
return tabOf(s)
|
|
161
410
|
}
|
|
162
411
|
|
|
@@ -177,9 +426,17 @@ export class PtyHost {
|
|
|
177
426
|
s.cwd = cwd
|
|
178
427
|
s.bucket = bucket
|
|
179
428
|
s.exited = false
|
|
429
|
+
s.transcript = ''
|
|
180
430
|
for (const m of this.buckets.values()) m.delete(id)
|
|
181
431
|
this.bucket(bucket).set(id, s)
|
|
182
|
-
|
|
432
|
+
try {
|
|
433
|
+
this.spawn(s)
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// 重建失败:标签如实标退出并发 exit(前端显示退出条),错误上抛给 rpc。
|
|
436
|
+
s.exited = true
|
|
437
|
+
this.events.emit('exit', s.id, s.bucket)
|
|
438
|
+
throw error
|
|
439
|
+
}
|
|
183
440
|
return tabOf(s)
|
|
184
441
|
}
|
|
185
442
|
|
|
@@ -197,6 +454,12 @@ export class PtyHost {
|
|
|
197
454
|
}
|
|
198
455
|
}
|
|
199
456
|
|
|
457
|
+
/** 标签的回放缓冲(退出会话仍可回放;未知 id 返回 null)。 */
|
|
458
|
+
snapshot(id) {
|
|
459
|
+
const s = this.find(id)
|
|
460
|
+
return s === null ? null : s.transcript
|
|
461
|
+
}
|
|
462
|
+
|
|
200
463
|
/** 关闭单个标签(仅限 cwd 桶内:跨桶 id 一律不动),返回剩余标签。 */
|
|
201
464
|
close(id, cwd) {
|
|
202
465
|
const bucket = this.bucketOf(cwd)
|
|
@@ -215,9 +478,10 @@ export class PtyHost {
|
|
|
215
478
|
}
|
|
216
479
|
|
|
217
480
|
spawn(s) {
|
|
218
|
-
const pty =
|
|
219
|
-
const shell =
|
|
220
|
-
const args =
|
|
481
|
+
const pty = loadRequiredNodePty()
|
|
482
|
+
const shell = defaultShell()
|
|
483
|
+
const args = shellSpawnArgs()
|
|
484
|
+
s.shell = shell
|
|
221
485
|
s.pty = pty.spawn(shell, args, {
|
|
222
486
|
name: 'xterm-256color',
|
|
223
487
|
cwd: s.cwd,
|
|
@@ -225,7 +489,10 @@ export class PtyHost {
|
|
|
225
489
|
cols: this.cols,
|
|
226
490
|
rows: this.rows,
|
|
227
491
|
})
|
|
228
|
-
s.pty.onData(chunk => {
|
|
492
|
+
s.pty.onData(chunk => {
|
|
493
|
+
s.transcript = appendTranscript(s.transcript, chunk)
|
|
494
|
+
this.events.emit('data', chunk, s.id, s.bucket)
|
|
495
|
+
})
|
|
229
496
|
s.pty.onExit(() => {
|
|
230
497
|
s.exited = true
|
|
231
498
|
s.pty = null
|
|
@@ -302,12 +569,19 @@ export function dispatchRpc(host, body) {
|
|
|
302
569
|
if (typeof body.id !== 'number') return { ok: false, error: 'bad id' }
|
|
303
570
|
return { ok: true, tabs: host.close(body.id, cwd) }
|
|
304
571
|
}
|
|
572
|
+
case 'snapshot': {
|
|
573
|
+
if (typeof body.id !== 'number') return { ok: false, error: 'bad id' }
|
|
574
|
+
return { ok: true, chunk: host.snapshot(body.id) }
|
|
575
|
+
}
|
|
305
576
|
default: return { ok: false, error: 'unknown op' }
|
|
306
577
|
}
|
|
307
578
|
}
|
|
308
579
|
|
|
309
|
-
/** dsh web 插件入口:注册 RPC + SSE + vendor
|
|
580
|
+
/** dsh web 插件入口:注册 RPC + SSE + vendor + deps(effect 包裹随 dispose 摘除)。 */
|
|
310
581
|
export function apply(ctx) {
|
|
582
|
+
// 真实终端前置换修:macOS 侧 prebuilt spawn-helper 的可执行位可能被
|
|
583
|
+
// 包管理器剥掉(幂等,失败静默,由 spawn 自身报错兜底暴露)。
|
|
584
|
+
ensureSpawnHelper()
|
|
311
585
|
const host = new PtyHost()
|
|
312
586
|
/** 活跃 SSE 客户端(res 集;断开即摘除)。 */
|
|
313
587
|
const sseClients = new Set()
|
|
@@ -381,6 +655,16 @@ export function apply(ctx) {
|
|
|
381
655
|
return
|
|
382
656
|
}
|
|
383
657
|
|
|
658
|
+
// node-pty 依赖状态(降级模式:cause + 可粘贴修复命令)
|
|
659
|
+
if (tail === 'deps') {
|
|
660
|
+
if (req.method !== 'GET') {
|
|
661
|
+
writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
writeJson(res, 200, depsStatus())
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
|
|
384
668
|
// pty 操作 RPC(写操作 POST-only)
|
|
385
669
|
if (tail === 'rpc') {
|
|
386
670
|
if (req.method !== 'POST') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kkutysllb/dsh-terminal",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "dsh-external in-box dsh bundle: embedded terminal panel (node-pty backed multi-tab shells, per-workspace buckets, xterm.js UI) as a bottom dock inside the dsh web page, replacing the retired Electron host terminal-panel.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|