@kkutysllb/dsh-terminal 1.0.0 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +74 -1
  2. package/client.js +144 -9
  3. package/entry.js +315 -28
  4. package/package.json +13 -1
package/README.md CHANGED
@@ -4,10 +4,78 @@
4
4
 
5
5
  自 KCoder 内置包独立发布的 dsh 插件(v1.0.0 起独立版本线)。
6
6
 
7
+ ## QiLin 双通道适配(v1.1.0 起)
8
+
9
+ manifest 同时声明 `qilin` 与 `dsh` 两个通道的 `bundle.patch` / `client`:
10
+ QiLin(dsh alpha.2 合并后)的插件管理器只认原生键 `qilin.bundle.patch`
11
+ (缺失会报"没有声明组合包"),DSH 宿主仍读 `dsh.*`;两通道指向同一份
12
+ `cordis.patch.yml` 与 client 交付物,行为完全一致。
13
+
14
+ ## QiLin 引擎安装
15
+
16
+ ```bash
17
+ # npm registry(推荐:版本可被插件管理检测,用户手动更新)
18
+ # npm registry (recommended: version detection with manual updates)
19
+ qilin plugin --profile qilin add @kkutysllb/dsh-terminal
20
+
21
+ # GitHub 直装 / install straight from GitHub
22
+ qilin plugin --profile qilin add github:kkutysllb/dsh-terminal
23
+ ```
24
+
25
+ 装完在 QiLin 设置 → 内置插件里可见、可启停;终端面板经标题栏
26
+ "切换内嵌终端"开关展开。
27
+
28
+ ### 注意事项(QiLin)
29
+
30
+ - **必须经 `qilin plugin add` 装进 profile**:包会落到 profile 私有的
31
+ `~/.qilin/profiles/<name>/node_modules`——裸包名原生解析的第一跳。
32
+ **不要**手工把包目录放进共享的 `~/.qilin/profiles/node_modules`:
33
+ dsh alpha.2 合并后的 runtime+enforce 解析把该目录划为安装保留区,
34
+ 放那里的 bundle 层包激活时直接 `failed to import`。
35
+ - **引擎版本**:运行需要带 dsh 兼容层的 QiLin 3.0.0+;插件**管理**
36
+ (设置页展示/启停)要求 3.0.2+(alpha.2 合并后只认
37
+ `qilin.bundle.patch` 原生键)。
38
+ - **node-pty 依赖**:契约 `^1.1.0`,与引擎生态共享同一物理包;不可解
39
+ 析时插件保持挂载并渲染降级卡(`/dsh-terminal/api/deps` 给出 cause
40
+ 与可粘贴修复命令),不拖垮宿主。
41
+ - **OpenKylin 桌面端**:内置终端由产品启动脚本自动物化(vendor 直提 +
42
+ bundle 注册,同样落 profile 私有锚),无需手动安装。
43
+
44
+ ## 真实终端语义(模式平移自 dsh-coding-sidebar 的 pty-deps / pty-manager)
45
+
46
+ - **内核级伪终端**:node-pty `spawn`(macOS/Linux 走 forkpty,Windows 走
47
+ ConPTY),`TERM=xterm-256color` + `COLORTERM=truecolor`——vim/htop/
48
+ 配色/交互程序完整可用;
49
+ - **依赖降级**:node-pty 懒加载永不抛错;缺失/损坏时插件保持挂载,
50
+ `GET /dsh-terminal/api/deps` 返回 cause + 可粘贴修复命令,终端面板
51
+ 渲染降级卡(复制 + 重试),不再无声失败;
52
+ - **spawn-helper 修复**:插件激活时幂等补回包管理器剥掉的 macOS prebuilt
53
+ 助手可执行位(缺失时每个 spawn 都会 `posix_spawnp failed`);
54
+ - **transcript 回放**:每标签服务端维护 1MB 环形缓冲;页面刷新/面板重建
55
+ 后经 `snapshot` RPC 回放历史,回放在途的新输出由 client 侧 pending
56
+ 队列保序(不重不漏);restart 清空历史;
57
+ - **shell 解析链**:POSIX `$SHELL` → passwd 登录 shell → `/bin/bash`;
58
+ Windows `DSH_TERMINAL_SHELL` → pwsh 探测链(PATH + 已知安装目录)→
59
+ `powershell.exe`;POSIX 以登录 shell 启动(读 profile 文件)。
60
+
61
+ node-pty 版本契约:`^1.1.0`(v1.1.1 起声明于 dependencies),与 DSH core
62
+ (`@deepseek-ai/dsh-subprocess-local`)同 range——同 range 同 integrity 让
63
+ pnpm 两侧解析到同一物理包(一份 native 绑定,无漂移)。dsh 0.1.6-alpha.2
64
+ 起依赖解析默认运行时模式且共享保留区(`profiles/node_modules`)被排除,
65
+ 未声明的提升副本不再可解析——声明依赖是唯一稳定入口。
66
+
7
67
  ## 安装
8
68
 
9
69
  ```bash
10
- dsh plugin install @kkutysllb/dsh-terminal
70
+ # npm registry(推荐:版本可被插件管理检测,用户手动更新)
71
+ # npm registry (recommended: version detection with manual updates)
72
+ dsh plugin --profile web add @kkutysllb/dsh-terminal
73
+
74
+ # GitHub 直装 / install straight from GitHub
75
+ dsh plugin --profile web add github:kkutysllb/dsh-terminal
76
+
77
+ # 或从 dsh-plugins 真源仓 / or from the dsh-plugins monorepo
78
+ dsh plugin --profile web add github:kkutysllb/dsh-plugins#dsh-terminal
11
79
  ```
12
80
 
13
81
  ## 形态
@@ -18,7 +86,12 @@ dsh plugin install @kkutysllb/dsh-terminal
18
86
  ## 开发
19
87
 
20
88
  - 本仓为开发真源;改动后跑 `node scripts/sync-to-dsh-plugins.mjs` 同步 dsh-plugins 镜像并提交推送。
89
+ - 本地跑真 pty 集成用例需 `node-pty` 可解析(开发目录可
90
+ `ln -sfn ~/.dsh/profiles/web/node_modules/node-pty node_modules/node-pty`);
91
+ 沙箱环境会拦 forkpty/exec(`posix_spawnp failed`),需在无沙箱终端跑。
21
92
  - `pnpm smoke`(prepack 自动)做契约形态校验;`node scripts/create-github-releases.mjs` 同步 release/ 到 GitHub Releases。
93
+ - 单测:`node tests/run-tests.mjs`(依赖层 / shell 链 / transcript 回放 /
94
+ 桶管理真 pty 用例)。
22
95
 
23
96
  ## 许可
24
97
 
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) return
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') p.tabs.get(msg.id)?.term.write(msg.chunk)
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
@@ -715,18 +830,38 @@ body[data-ds-dark-theme] #${BTN_ID}{color:rgba(232,234,237,.8)}
715
830
  /* ---- 窗口 resize → 重排(view 时代 win.on('resize') 的等价物) ---- */
716
831
  window.addEventListener('resize', () => layout())
717
832
 
718
- /* ---- 右侧栏宽度探针(better-sidebar 浮层;面板右边界让位) ---- */
719
- // better-sidebar 把面板实时宽度写在根变量 --dsh-sidebar-width
720
- //(展开/拖宽 setProperty、收起 removeProperty)。监听 style 属性
721
- // 变化即得右边界,无变量时视作收起(0)。
833
+ /* ---- 右侧栏宽度探针(面板右边界让位) ---- */
834
+ // KCoder better-sidebar 把面板实时宽度写在根变量 --dsh-sidebar-width
835
+ //(展开/拖宽 setProperty、收起 removeProperty),监听 style 属性变
836
+ // 化即得右边界。QiLin AppFrame 右栏不写该变量 → 回退为直接量
837
+ // [data-rightbar-col] 列宽,开合/拖宽由 ResizeObserver 驱动重排,
838
+ // 终端面板右边界精确停在右侧栏左缘,不侵占其下方区域。
722
839
  let rightPanelW = 0
840
+ let rightbarWatched = null
841
+ const armRightbarWatcher = () => {
842
+ const el = document.querySelector('[data-rightbar-col]')
843
+ if (el === null) return null
844
+ if (rightbarRo !== null && rightbarWatched !== el) { rightbarRo.observe(el); rightbarWatched = el }
845
+ return el
846
+ }
723
847
  const readRightPanel = () => {
724
- const w = Number.parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
848
+ let w = Number.parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
849
+ if (!Number.isFinite(w) || w <= 0) {
850
+ const el = armRightbarWatcher()
851
+ w = el !== null ? el.getBoundingClientRect().width : 0
852
+ }
725
853
  const next = Number.isFinite(w) && w > 0 ? Math.round(w) : 0
726
854
  if (next !== rightPanelW) { rightPanelW = next; layout() }
727
855
  }
856
+ const rightbarRo = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(readRightPanel) : null
728
857
  readRightPanel()
729
858
  new MutationObserver(readRightPanel).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
859
+ // 右栏列晚于本脚本初始化(React 挂载在后):DOM 变化兜底重探,
860
+ // 探到即交棒 ResizeObserver 并停表,避免常驻监听
861
+ const rightbarMountMo = new MutationObserver(() => {
862
+ if (armRightbarWatcher() !== null) { readRightPanel(); rightbarMountMo.disconnect() }
863
+ })
864
+ if (rightbarWatched === null) rightbarMountMo.observe(document.body, { childList: true, subtree: true })
730
865
 
731
866
  /* ---- 标题栏按钮(theme-watcher 注入宿主,时序不保证 → 轮询等待) ---- */
732
867
  const injectBtn = () => {
package/entry.js CHANGED
@@ -4,32 +4,45 @@
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 同款),经 createRequire 从运行时
17
- * node_modules 解析(bundle 物化在 profiles/web/node_modules/@kcoder/
18
- * terminal/,向上可达 profiles/node_modules/node-pty——dsh-tool-bash
19
- * 已带)。延迟加载:模块导入不触发 native 绑定加载,首次 create 才
20
- * require,环境异常时报错仅影响终端功能不拖垮宿主。
18
+ * pty 引擎:node-pty(VS Code 同款,内核级伪终端 openpty/ConPTY),
19
+ * package.json dependencies 声明 ^1.1.0——profile 安装图内的合法包,
20
+ * createRequire 从插件自身解析(dsh 0.1.6-alpha.2 起运行时解析
21
+ * enforce:候选在共享保留区 profiles/node_modules 前截断,向上的
22
+ * 提升副本不再是解析来源;声明依赖即入 generation,隔离/hoisted
23
+ * 布局都解析得到)。真实终端健壮性(模式平移自 dsh-coding-sidebar
24
+ * 的 pty-deps/pty-manager):
25
+ * - 懒加载永不抛错:模块导入不触发 native 绑定加载;加载失败缓存原因,
26
+ * 插件保持挂载(降级态),deps 路由给出修复命令,不拖垮宿主;
27
+ * - ensureSpawnHelper:补回包管理器剥掉的 spawn-helper 可执行位(缺失
28
+ * 时每个 spawn 都会 posix_spawnp failed),激活时幂等执行;
29
+ * - transcript 环形缓冲(1MB/标签):页面刷新 / 面板重建后经 snapshot
30
+ * RPC 回放历史,回放在途期间的新输出由 client 侧 pending 队列保序;
31
+ * - shell 解析链:$SHELL → passwd 登录 shell → bash 兜底;Windows 上
32
+ * DSH_TERMINAL_SHELL env → pwsh 探测链 → powershell.exe 兜底。
21
33
  *
22
- * 纯逻辑(桶管理/参数校验)导出供 tests/run-tests.mjs node 直跑;
23
- * spawn 集成用例走真 pty。
34
+ * 纯逻辑(桶管理/参数校验/依赖层/shell 链)导出供 tests/run-tests.mjs
35
+ * node 直跑;spawn 集成用例走真 pty。
24
36
  *
25
37
  * @module @kkutysllb/dsh-terminal/entry
26
38
  */
27
39
 
28
40
  import { createRequire } from 'node:module'
29
41
  import { EventEmitter } from 'node:events'
30
- import { statSync } from 'node:fs'
31
- import { homedir } from 'node:os'
32
- import { basename } from 'node:path'
42
+ import { chmodSync, existsSync, realpathSync, statSync } from 'node:fs'
43
+ import { homedir, userInfo } from 'node:os'
44
+ import { basename, dirname, join } from 'node:path'
45
+ import { fileURLToPath } from 'node:url'
33
46
  import { readFile } from 'node:fs/promises'
34
47
 
35
48
  /** client-modules 集成对应的 cordis 依赖:RPC 注册必需 webServer。 */
@@ -53,19 +66,251 @@ export const PANEL_MIN_H = 140
53
66
  export const PANEL_MAX_H = 620
54
67
  export const PANEL_DEFAULT_H = 280
55
68
 
56
- /** 延迟解析 node-pty(native 模块,导入期不加载)。 */
57
- let ptyModule = null
58
- function getPty() {
59
- if (ptyModule === null) {
60
- ptyModule = createRequire(import.meta.url)('node-pty')
69
+ /* ---------------------------------------------------------------- *
70
+ * node-pty 依赖层(模式平移自 dsh-coding-sidebar src/pty-deps.ts)
71
+ * ---------------------------------------------------------------- */
72
+
73
+ /**
74
+ * node-pty 版本契约:须与 DSH core(@deepseek-ai/dsh-subprocess-local)
75
+ * 声明同 range——同 range 同 integrity 让 pnpm 把两侧解析到同一物理包
76
+ * (一份 native 绑定,无漂移)。换 fork 或改 range 前先核对 core 声明。
77
+ */
78
+ export const DSH_NODE_PTY_RANGE = '^1.1.0'
79
+
80
+ /** 懒加载缓存:undefined=未加载;{ok:true,module} | {ok:false,cause}。 */
81
+ let ptyLoadCache
82
+
83
+ /**
84
+ * 加载 node-pty(同步、单次、永不抛错)。模块缺失、native 绑定损坏、
85
+ * pnpm 11 strict-dep-builds 跳过 install script 等都只会被记进缓存——
86
+ * 插件保持挂载(降级态),由 deps 路由给出修复命令。
87
+ */
88
+ export function loadNodePty() {
89
+ if (ptyLoadCache === undefined) {
90
+ try {
91
+ ptyLoadCache = { ok: true, module: createRequire(import.meta.url)('node-pty') }
92
+ } catch (cause) {
93
+ ptyLoadCache = { ok: false, cause }
94
+ }
95
+ }
96
+ return ptyLoadCache.ok ? ptyLoadCache.module : null
97
+ }
98
+
99
+ /** 已记录的加载失败原因(成功或未加载时 undefined)。 */
100
+ export function nodePtyLoadCause() {
101
+ return ptyLoadCache !== undefined && !ptyLoadCache.ok ? ptyLoadCache.cause : undefined
102
+ }
103
+
104
+ /** 加载失败的一行人读描述。 */
105
+ function describeCause(cause) {
106
+ if (cause instanceof Error) return cause.message
107
+ return String(cause)
108
+ }
109
+
110
+ /** 加载 node-pty 或抛规范错误(create/restart 路径;消息指向 deps 路由)。 */
111
+ function loadRequiredNodePty() {
112
+ const m = loadNodePty()
113
+ if (m === null) {
114
+ throw new Error(
115
+ 'node-pty (' + DSH_NODE_PTY_RANGE + ') failed to load: ' + describeCause(nodePtyLoadCause())
116
+ + ' -- see GET ' + RPC_PREFIX + '/deps for the repair command',
117
+ )
118
+ }
119
+ return m
120
+ }
121
+
122
+ /** 解析到物理位置(symlink/link 安装),供 walk-up 探测。 */
123
+ function realDir(file) {
124
+ try { return dirname(realpathSync(file)) } catch { return dirname(file) }
125
+ }
126
+
127
+ /** 从 dir 向上找满足条件的祖先根(限深 16 层)。 */
128
+ function walkUp(dir, isRoot) {
129
+ let current = dir
130
+ for (let depth = 0; depth < 16; depth += 1) {
131
+ if (isRoot(current)) return current
132
+ const parent = dirname(current)
133
+ if (parent === current) break
134
+ current = parent
135
+ }
136
+ return null
137
+ }
138
+
139
+ /** 是否 DSH profile 根(package.json + pnpm-workspace.yaml 双证)。 */
140
+ function isProfileRoot(dir) {
141
+ return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
142
+ }
143
+
144
+ /**
145
+ * 探测本插件安装所在的 profile 目录:插件模块的最近双证祖先(profile
146
+ * 根,插件从其 node_modules 解析);探测不到回退 <宿主 home>/profiles/web
147
+ * ($QILIN_HOME → $DSH_HOME → ~/.dsh,标准 web profile);仍不像则 null。
148
+ */
149
+ export function findProfileDir(fromFile = fileURLToPath(import.meta.url)) {
150
+ const detected = walkUp(realDir(fromFile), isProfileRoot)
151
+ if (detected !== null) return detected
152
+ const configured = process.env.QILIN_HOME ?? process.env.DSH_HOME
153
+ const home = configured !== undefined && configured.trim() !== ''
154
+ ? configured
155
+ : join(homedir(), '.dsh')
156
+ const web = join(home, 'profiles', 'web')
157
+ return isProfileRoot(web) ? realpathSync(web) : null
158
+ }
159
+
160
+ /**
161
+ * node-pty 安装损坏时的可粘贴修复命令。本插件不随包发 install 脚本
162
+ * (依赖 DSH core 侧的 node-pty),因此始终走 dsh plugin 重装兜底,
163
+ * 并附 allowBuilds 提示(pnpm 11 拦 native 构建 scripts 的常见病因)。
164
+ */
165
+ export function buildRepairCommand(options = {}) {
166
+ const profileDir = options.profileDir !== undefined ? options.profileDir : findProfileDir()
167
+ const name = profileDir !== null ? basename(profileDir) : 'web'
168
+ return {
169
+ command: 'dsh plugin --profile "' + name + '" add @kkutysllb/dsh-terminal@latest',
170
+ note: "If pnpm 11 blocked node-pty's build script, ensure allowBuilds: node-pty: true"
171
+ + " in the profile's pnpm-workspace.yaml, then rerun the install command.",
61
172
  }
62
- return ptyModule
173
+ }
174
+
175
+ /** deps 路由的响应形态:{ok:true} | {ok:false, cause, command, profile, note?}。 */
176
+ export function depsStatus() {
177
+ const m = loadNodePty()
178
+ if (m !== null) return { ok: true }
179
+ const profileDir = findProfileDir()
180
+ const { command, note } = buildRepairCommand({ profileDir })
181
+ return {
182
+ ok: false,
183
+ cause: describeCause(nodePtyLoadCause()),
184
+ command,
185
+ profile: profileDir !== null ? basename(profileDir) : null,
186
+ note,
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 补回包管理器剥掉的 spawn-helper 可执行位(平移自 dsh-coding-sidebar
192
+ * ensureSpawnHelper):spawn-helper 是 macOS 侧 fork 并装配 pty 的
193
+ * prebuilt 助手,丢执行位时每个 spawn 都报 posix_spawnp failed。幂等;
194
+ * 解析或 chmod 失败静默——由 spawn 自身的报错暴露问题。
195
+ */
196
+ export function ensureSpawnHelper() {
197
+ if (process.platform === 'win32') return
198
+ try {
199
+ const require = createRequire(import.meta.url)
200
+ const entry = require.resolve('node-pty')
201
+ const packageRoot = dirname(dirname(entry))
202
+ const candidates = [
203
+ join(packageRoot, 'prebuilds', process.platform + '-' + process.arch, 'spawn-helper'),
204
+ join(packageRoot, 'build', 'Release', 'spawn-helper'),
205
+ ]
206
+ for (const helper of candidates) {
207
+ if (existsSync(helper)) chmodSync(helper, 0o755)
208
+ }
209
+ } catch {
210
+ // 解析失败(依赖缺失)留给 deps 降级路径;chmod 失败由 spawn 报错暴露。
211
+ }
212
+ }
213
+
214
+ /* ---------------------------------------------------------------- *
215
+ * shell 解析链(平移自 dsh-coding-sidebar defaultShell 系)
216
+ * ---------------------------------------------------------------- */
217
+
218
+ /**
219
+ * Windows 侧可能装着 pwsh.exe 的目录:PATH 条目优先,其后机器/用户级
220
+ * 已知安装位置(含 preview 通道与 per-user 布局)。机器级同时读
221
+ * ProgramW6432 与 ProgramFiles(32 位 Node 的 ProgramFiles 指向 (x86),
222
+ * 仍要能找到 64 位 PowerShell 7)。去重且保持优先序。
223
+ */
224
+ function windowsPwshCandidateDirs(env) {
225
+ const dirs = []
226
+ const pathEntries = env.PATH
227
+ if (pathEntries !== undefined) {
228
+ // win32 分支固定用 Windows PATH 分隔符;硬编码保证跨平台可测。
229
+ for (const entry of pathEntries.split(';')) {
230
+ const trimmed = entry.trim()
231
+ if (trimmed !== '') dirs.push(trimmed)
232
+ }
233
+ }
234
+ for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
235
+ if (programFiles === undefined || programFiles.trim() === '') continue
236
+ dirs.push(join(programFiles, 'PowerShell', '7'))
237
+ dirs.push(join(programFiles, 'PowerShell', '7-preview'))
238
+ }
239
+ const localAppData = env.LOCALAPPDATA
240
+ if (localAppData !== undefined && localAppData.trim() !== '') {
241
+ dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
242
+ dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
243
+ dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
244
+ dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
245
+ }
246
+ return [...new Set(dirs)]
247
+ }
248
+
249
+ /**
250
+ * 交互 shell 解析(像终端模拟器那样):
251
+ * - POSIX:$SHELL → passwd 登录 shell(userInfo().shell;服务管理器/
252
+ * 容器 init 常不带 SHELL 启动 dsh,这一步让标签仍开用户的登录 shell
253
+ * 而非静默降级 bash)→ /bin/bash 兜底;
254
+ * - Windows:DSH_TERMINAL_SHELL env → PATH/已知目录的 pwsh.exe 探测链
255
+ * (PowerShell 7 用户不再拿到老 5.1)→ powershell.exe 兜底。
256
+ * options 显式传参仅供测试注入平台/env/存在性探测。
257
+ */
258
+ export function defaultShell(options = {}) {
259
+ const platform = options.platform ?? process.platform
260
+ const env = options.env ?? process.env
261
+ const exists = options.exists ?? existsSync
262
+ if (platform === 'win32') {
263
+ const envShell = env.DSH_TERMINAL_SHELL
264
+ if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
265
+ for (const dir of windowsPwshCandidateDirs(env)) {
266
+ const candidate = join(dir, 'pwsh.exe')
267
+ if (exists(candidate)) return candidate
268
+ }
269
+ return 'powershell.exe'
270
+ }
271
+ const envShell = env.SHELL
272
+ if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
273
+ // uid 无 passwd 条目时 userInfo() 会抛(罕见 chroot):兜底 /bin/bash。
274
+ try {
275
+ const loginShell = userInfo().shell
276
+ if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
277
+ } catch { /* 无 passwd 条目:落到 bash 兜底 */ }
278
+ return '/bin/bash'
279
+ }
280
+
281
+ /**
282
+ * spawn 参数:POSIX 走登录 shell(读 ~/.profile ~/.zprofile 等 profile
283
+ * 文件);Windows PowerShell 不收登录旗标。显式传入时整体替换默认。
284
+ */
285
+ export function shellSpawnArgs(configured = []) {
286
+ if (configured.length > 0) return [...configured]
287
+ return process.platform === 'win32' ? [] : ['-l']
288
+ }
289
+
290
+ /** 标签标题用的 shell 短名:/bin/zsh → zsh,C:\...\pwsh.exe → pwsh。 */
291
+ export function shellDisplayName(shell) {
292
+ const normalized = shell.replace(/\\/g, '/')
293
+ const base = normalized.slice(normalized.lastIndexOf('/') + 1)
294
+ if (base === '') return shell
295
+ return base.replace(/\.(exe|cmd|bat)$/i, '')
296
+ }
297
+
298
+ /* ---------------------------------------------------------------- *
299
+ * transcript(回放缓冲;平移自 dsh-coding-sidebar 的环形裁剪语义)
300
+ * ---------------------------------------------------------------- */
301
+
302
+ /** 单标签 transcript 上限(字符;超出丢头保尾)。 */
303
+ export const TRANSCRIPT_LIMIT = 1 << 20
304
+
305
+ /** 追加一块输出并裁剪到上限(纯函数,供测试)。 */
306
+ export function appendTranscript(prev, chunk) {
307
+ const next = prev + chunk
308
+ return next.length > TRANSCRIPT_LIMIT ? next.slice(next.length - TRANSCRIPT_LIMIT) : next
63
309
  }
64
310
 
65
311
  /** 标签信息(tabs/rpc 响应;与宿主 TerminalTab 契约一致)。 */
66
312
  function tabOf(s) {
67
- const shell = process.platform === 'win32' ? 'powershell.exe' : (process.env.SHELL || '/bin/zsh')
68
- return { id: s.id, alive: !s.exited, cwd: s.cwd, title: basename(shell) }
313
+ return { id: s.id, alive: !s.exited, cwd: s.cwd, title: shellDisplayName(s.shell ?? defaultShell()) }
69
314
  }
70
315
 
71
316
  function killPty(s) {
@@ -102,6 +347,8 @@ export function clampH(h) {
102
347
  * (buckets = Map<cwd, Map<id, session>>),不同工作区互不干扰;
103
348
  * id 全局唯一跨桶可寻址;面板关闭仅隐藏不杀进程,restart 销毁重建
104
349
  * 同 id,close 关单标签。事件:data(chunk, id, bucket) / exit(id, bucket)。
350
+ * 每会话维护 transcript 环形缓冲(snapshot RPC 供 client 回放历史,
351
+ * 退出会话仍可回放);spawn 失败时 create 回滚入桶、restart 如实标退出。
105
352
  */
106
353
  export class PtyHost {
107
354
  constructor() {
@@ -151,12 +398,17 @@ export class PtyHost {
151
398
  return null
152
399
  }
153
400
 
154
- /** 新建标签到指定工作区桶(shell 进程立即启动)。 */
401
+ /** 新建标签到指定工作区桶(shell 进程立即启动;spawn 失败回滚入桶)。 */
155
402
  create(cwd) {
156
403
  const bucket = this.bucketOf(cwd)
157
- const s = { id: this.nextId++, pty: null, cwd: usableDir(cwd) ?? homedir(), bucket, exited: false }
404
+ const s = { id: this.nextId++, pty: null, cwd: usableDir(cwd) ?? homedir(), bucket, exited: false, transcript: '', shell: null }
158
405
  this.bucket(bucket).set(s.id, s)
159
- this.spawn(s)
406
+ try {
407
+ this.spawn(s)
408
+ } catch (error) {
409
+ this.bucket(bucket).delete(s.id)
410
+ throw error
411
+ }
160
412
  return tabOf(s)
161
413
  }
162
414
 
@@ -177,9 +429,17 @@ export class PtyHost {
177
429
  s.cwd = cwd
178
430
  s.bucket = bucket
179
431
  s.exited = false
432
+ s.transcript = ''
180
433
  for (const m of this.buckets.values()) m.delete(id)
181
434
  this.bucket(bucket).set(id, s)
182
- this.spawn(s)
435
+ try {
436
+ this.spawn(s)
437
+ } catch (error) {
438
+ // 重建失败:标签如实标退出并发 exit(前端显示退出条),错误上抛给 rpc。
439
+ s.exited = true
440
+ this.events.emit('exit', s.id, s.bucket)
441
+ throw error
442
+ }
183
443
  return tabOf(s)
184
444
  }
185
445
 
@@ -197,6 +457,12 @@ export class PtyHost {
197
457
  }
198
458
  }
199
459
 
460
+ /** 标签的回放缓冲(退出会话仍可回放;未知 id 返回 null)。 */
461
+ snapshot(id) {
462
+ const s = this.find(id)
463
+ return s === null ? null : s.transcript
464
+ }
465
+
200
466
  /** 关闭单个标签(仅限 cwd 桶内:跨桶 id 一律不动),返回剩余标签。 */
201
467
  close(id, cwd) {
202
468
  const bucket = this.bucketOf(cwd)
@@ -215,9 +481,10 @@ export class PtyHost {
215
481
  }
216
482
 
217
483
  spawn(s) {
218
- const pty = getPty()
219
- const shell = process.platform === 'win32' ? 'powershell.exe' : (process.env.SHELL || '/bin/zsh')
220
- const args = process.platform === 'win32' ? [] : ['--login']
484
+ const pty = loadRequiredNodePty()
485
+ const shell = defaultShell()
486
+ const args = shellSpawnArgs()
487
+ s.shell = shell
221
488
  s.pty = pty.spawn(shell, args, {
222
489
  name: 'xterm-256color',
223
490
  cwd: s.cwd,
@@ -225,7 +492,10 @@ export class PtyHost {
225
492
  cols: this.cols,
226
493
  rows: this.rows,
227
494
  })
228
- s.pty.onData(chunk => { this.events.emit('data', chunk, s.id, s.bucket) })
495
+ s.pty.onData(chunk => {
496
+ s.transcript = appendTranscript(s.transcript, chunk)
497
+ this.events.emit('data', chunk, s.id, s.bucket)
498
+ })
229
499
  s.pty.onExit(() => {
230
500
  s.exited = true
231
501
  s.pty = null
@@ -302,12 +572,19 @@ export function dispatchRpc(host, body) {
302
572
  if (typeof body.id !== 'number') return { ok: false, error: 'bad id' }
303
573
  return { ok: true, tabs: host.close(body.id, cwd) }
304
574
  }
575
+ case 'snapshot': {
576
+ if (typeof body.id !== 'number') return { ok: false, error: 'bad id' }
577
+ return { ok: true, chunk: host.snapshot(body.id) }
578
+ }
305
579
  default: return { ok: false, error: 'unknown op' }
306
580
  }
307
581
  }
308
582
 
309
- /** dsh web 插件入口:注册 RPC + SSE + vendor 托管(effect 包裹随 dispose 摘除)。 */
583
+ /** dsh web 插件入口:注册 RPC + SSE + vendor + deps(effect 包裹随 dispose 摘除)。 */
310
584
  export function apply(ctx) {
585
+ // 真实终端前置换修:macOS 侧 prebuilt spawn-helper 的可执行位可能被
586
+ // 包管理器剥掉(幂等,失败静默,由 spawn 自身报错兜底暴露)。
587
+ ensureSpawnHelper()
311
588
  const host = new PtyHost()
312
589
  /** 活跃 SSE 客户端(res 集;断开即摘除)。 */
313
590
  const sseClients = new Set()
@@ -381,6 +658,16 @@ export function apply(ctx) {
381
658
  return
382
659
  }
383
660
 
661
+ // node-pty 依赖状态(降级模式:cause + 可粘贴修复命令)
662
+ if (tail === 'deps') {
663
+ if (req.method !== 'GET') {
664
+ writeJson(res, 405, { ok: false, error: 'method not allowed' })
665
+ return
666
+ }
667
+ writeJson(res, 200, depsStatus())
668
+ return
669
+ }
670
+
384
671
  // pty 操作 RPC(写操作 POST-only)
385
672
  if (tail === 'rpc') {
386
673
  if (req.method !== 'POST') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kkutysllb/dsh-terminal",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -42,5 +42,17 @@
42
42
  },
43
43
  "publishConfig": {
44
44
  "access": "public"
45
+ },
46
+ "qilin": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "inject": [],
52
+ "platform": "web"
53
+ }
54
+ },
55
+ "dependencies": {
56
+ "node-pty": "^1.1.0"
45
57
  }
46
58
  }