@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.
Files changed (4) hide show
  1. package/README.md +26 -0
  2. package/client.js +119 -4
  3. package/entry.js +312 -28
  4. 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) 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
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 同款),经 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
+ * 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
- * 纯逻辑(桶管理/参数校验)导出供 tests/run-tests.mjs node 直跑;
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
- /** 延迟解析 node-pty(native 模块,导入期不加载)。 */
57
- let ptyModule = null
58
- function getPty() {
59
- if (ptyModule === null) {
60
- ptyModule = createRequire(import.meta.url)('node-pty')
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
- return ptyModule
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
- 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) }
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
- this.spawn(s)
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
- this.spawn(s)
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 = getPty()
219
- const shell = process.platform === 'win32' ? 'powershell.exe' : (process.env.SHELL || '/bin/zsh')
220
- const args = process.platform === 'win32' ? [] : ['--login']
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 => { this.events.emit('data', chunk, s.id, s.bucket) })
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 托管(effect 包裹随 dispose 摘除)。 */
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.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",