From 8d9ab1cd2f853622bcb7d401e7be8fb4228dfa39 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Mon, 17 Aug 2026 00:06:20 +0800 Subject: [PATCH] Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140) --- README.md | 13 +- README_EN.md | 15 +- .../2026-08-14-issue-42-verify-design.md | 2 + dsh.plugin.json | 2 +- package.json | 2 +- scripts/install.ps1 | 136 ++++++---- scripts/install.sh | 129 ++++++---- src/agent-pty.ts | 13 +- src/client/TerminalView.tsx | 79 +++++- src/client/api.ts | 20 ++ src/client/locales.ts | 6 + src/client/service.ts | 2 +- src/client/sidebar.module.css | 59 +++++ src/index.ts | 75 ++++-- src/pty-deps.ts | 240 ++++++++++++++++++ src/pty-manager.ts | 9 +- src/wire.ts | 1 + tests/pty-deps.spec.ts | 205 +++++++++++++++ tests/terminal-deps-banner.spec.tsx | 82 ++++++ 19 files changed, 956 insertions(+), 134 deletions(-) create mode 100644 src/pty-deps.ts create mode 100644 tests/pty-deps.spec.ts create mode 100644 tests/terminal-deps-banner.spec.tsx diff --git a/README.md b/README.md index 43ee559..d376c45 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ## 🆕 最近更新 -v0.12.2 +v0.12.3 > 📝 **说明**:本版新增「位置兼容模式」——为 Windows 右上角原生标题栏预留顶部空间,侧边栏整体下移,距离可在齿轮弹窗自定义(0–120px)。随 0.12.0 起的服务化基座一同发布:能力探测、状态订阅、tab 角标、生命周期回调、外链认领(`urlTarget`)与插件自有设置均已就绪,第三方插件可深度接入;设置页新增「添加插件」推荐目录。详细条目见下表。 @@ -71,10 +71,10 @@ irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/ ```sh # macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart +curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.3 --restart # Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.3 -Restart ``` 不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。 @@ -100,7 +100,7 @@ minimumReleaseAgeExclude: - dsh-better-sidebar EOF -# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.2) +# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.12.3) npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar ``` @@ -131,7 +131,7 @@ npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sideba 3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载; 4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。 -`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.2` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。 +`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.12.3` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。 @@ -156,6 +156,7 @@ dsh plugin --profile web add dsh-better-sidebar | 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 | | 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 | | Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 | +| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(POSIX 为 `bash "<插件路径>/scripts/install.sh" --repair`,Windows 为对应 `install.ps1 -Repair`;或直接重跑一键安装脚本),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 | | Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 | @@ -177,7 +178,7 @@ dsh plugin --profile web add dsh-better-sidebar 5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) ``` -更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.2"` 再 `pnpm install`。 +更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.3"` 再 `pnpm install`。 diff --git a/README_EN.md b/README_EN.md index df6dacf..a3662f6 100644 --- a/README_EN.md +++ b/README_EN.md @@ -36,7 +36,7 @@ ## 🆕 Recent Updates -v0.12.2 +v0.12.3 > 📝 **Note**: this release adds "Position compat mode" — reserves top space for the native Windows title bar (top-right), shifting the sidebar buttons and content below it (0–120px, customizable in the gear popup). It ships together with the service API base introduced since v0.12.0: capability detection, state subscription, tab badges, lifecycle callbacks, external-link claiming (`urlTarget`) and plugin-owned settings are all in place for deep third-party integration; settings gained the "Add Plugins" recommended catalog. See the table below for details. @@ -71,10 +71,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot ```sh # macOS / Linux -curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.2 --restart +curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.12.3 --restart # Windows PowerShell -& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.2 -Restart +& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.3 -Restart ``` Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running. @@ -100,7 +100,7 @@ minimumReleaseAgeExclude: - dsh-better-sidebar EOF -# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.2) +# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.12.3) npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar ``` @@ -131,7 +131,7 @@ The one-click script does four things, all idempotent (safe to re-run): 3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`; 4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page). -`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.2` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified. +`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.12.3` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified. @@ -142,7 +142,7 @@ The one-click script does four things, all idempotent (safe to re-run): dsh plugin --profile web add dsh-better-sidebar ``` -or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.2"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. +or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. @@ -156,6 +156,7 @@ or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/pack | "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. | | Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). | | Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. | +| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (POSIX: `bash "/scripts/install.sh" --repair`; Windows: the matching `install.ps1 -Repair`; or re-run the one-click installer), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. | | No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. | @@ -177,7 +178,7 @@ To debug local changes or track the dev branch, point the dependency at a local 5. Restart DSH and hard-refresh ``` -Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.2"` and re-run `pnpm install`. +Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.3"` and re-run `pnpm install`. diff --git a/docs/plans/2026-08-14-issue-42-verify-design.md b/docs/plans/2026-08-14-issue-42-verify-design.md index c6b073d..6badb71 100644 --- a/docs/plans/2026-08-14-issue-42-verify-design.md +++ b/docs/plans/2026-08-14-issue-42-verify-design.md @@ -46,6 +46,8 @@ - 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道; - 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。 +> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。 + ## 4. 验证 - 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例); diff --git a/dsh.plugin.json b/dsh.plugin.json index 254357f..16298fa 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.12.2", + "version": "0.12.3", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index e6b7ca9..365826d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.12.2", + "version": "0.12.3", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 70e14e0..e07c333 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,4 +1,4 @@ -# ============================================================================= +## ============================================================================= # dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh) # # 通过 DSH 官方插件命令安装 npm 包并自动挂载: @@ -16,9 +16,15 @@ # & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart # # 本地保存后运行 # powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun +# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140) +# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair # # 参数: # -Version npm 版本号/范围,缺省 latest(自动解析为最新)。 +# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml +# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild +# node-pty。 +# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。 # -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。 # -DryRun 只打印将要执行的操作,不写任何文件。 # @@ -39,7 +45,9 @@ param( [string]$Version = '', [switch]$Restart, - [switch]$DryRun + [switch]$DryRun, + [switch]$Repair, + [string]$Profile = 'web' ) $PKG = 'dsh-better-sidebar' @@ -53,7 +61,7 @@ if ($env:DSH_HOME) { } else { $DSH_HOME = Join-Path $HOME '.dsh' } -$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web' +$PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile" $WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml' $PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml' @@ -88,35 +96,10 @@ function Get-DshCli { return $null } -# 前置校验 -if (-not (Get-Command node -ErrorAction SilentlyContinue)) { - Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。' -} -if (-not (Test-Path $PROFILE_DIR)) { - Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)" -} -if (-not (Test-Path $WS_YML)) { - Die "找不到 $WS_YML(请先初始化 web profile)" -} - -$SPEC = Resolve-Spec $Version -$CLI = Get-DshCli -if (-not $CLI) { - Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。' -} -Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)" - -if ($DryRun) { - Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)" - Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)" - Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG" - Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)" - if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' } - exit 0 -} - -# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本 -$wsScript = @' +# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截 +# node-pty/protobufjs 构建脚本、放行本插件新版本 +function Ensure-WorkspaceSettings { + $wsScript = @' const fs = require("fs"); const p = process.argv[2]; let t = fs.readFileSync(p, "utf8"); @@ -141,29 +124,84 @@ if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { if (t !== before) fs.writeFileSync(p, t); console.log(t === before ? "unchanged" : "updated"); '@ -# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被 -# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。 -# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。 -$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js") -Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8 -$wsOut = node $wsJs "$WS_YML" 2>&1 -$wsCode = $LASTEXITCODE -Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue -$wsResult = (($wsOut | Out-String)).Trim() -if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" } -if ($wsResult -eq 'updated') { - Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)" -} else { - Say 'workspace 设置已就绪,跳过' + # PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被 + # Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。 + # 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。 + $wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js") + Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8 + $wsOut = node $wsJs "$WS_YML" 2>&1 + $wsCode = $LASTEXITCODE + Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue + $wsResult = (($wsOut | Out-String)).Trim() + if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" } + if ($wsResult -eq 'updated') { + Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)" + } else { + Say 'workspace 设置已就绪,跳过' + } } +# 前置校验 +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。' +} +if (-not (Test-Path $PROFILE_DIR)) { + Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)" +} +if (-not (Test-Path $WS_YML)) { + Die "找不到 $WS_YML(请先初始化 $Profile profile)" +} + +# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ──────────── +# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑 +# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。 +if ($Repair) { + if ($DryRun) { + Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)" + Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty" + exit 0 + } + Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..." + Ensure-WorkspaceSettings + Push-Location $PROFILE_DIR + try { + pnpm install + if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" } + pnpm rebuild node-pty + if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" } + } finally { + Pop-Location + } + Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。' + exit 0 +} + +$SPEC = Resolve-Spec $Version +$CLI = Get-DshCli +if (-not $CLI) { + Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。' +} +Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)" + +if ($DryRun) { + Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)" + Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)" + Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG" + Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)" + if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' } + exit 0 +} + +# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本 +Ensure-WorkspaceSettings + # 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载) if ($CLI -eq 'dsh') { - $cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC") + $cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC") } else { - $cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC") + $cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC") } -Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..." +Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..." $addOut = & $CLI @cliArgs 2>&1 $addCode = $LASTEXITCODE $addOut | ForEach-Object { $_ } diff --git a/scripts/install.sh b/scripts/install.sh index 4a76d4c..f43672d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -12,9 +12,14 @@ # # 用法: # bash scripts/install.sh [版本] [--restart] [--dry-run] +# bash scripts/install.sh --repair [--profile <名>] [--dry-run] # # 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。 # 示例:0.10.2、^0.10.2、~0.10.2、latest +# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml +# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild +# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。 +# --profile 目标 profile 名(缺省 web);安装与修复模式均适用。 # --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。 # 注意:重启会断开当前 DSH 页面会话,默认不自动重启。 # --dry-run 只打印将要执行的操作,不写任何文件。 @@ -42,11 +47,15 @@ set -euo pipefail for arg in "$@"; do if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then cat <<'EOF' -dsh-better-sidebar 一键安装脚本 +dsh-better-sidebar 一键安装 / 依赖修复脚本 -用法:bash scripts/install.sh [版本] [--restart] [--dry-run] +用法: + bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>] + bash scripts/install.sh --repair [--profile <名>] [--dry-run] 版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest + --repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用) + --profile 目标 profile 名(缺省 web) --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示) --dry-run 只打印将要执行的操作,不写任何文件 @@ -57,30 +66,73 @@ EOF done DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}" -PROFILE_DIR="$DSH_HOME/profiles/web" -WS_YML="$PROFILE_DIR/pnpm-workspace.yaml" -PATCH_YML="$PROFILE_DIR/cordis.patch.yml" REGISTRY="${REGISTRY:-https://registry.npmjs.org}" PKG="dsh-better-sidebar" DSH_CMD="${DSH_CMD:-dsh}" RESTART=false DRY_RUN=false +REPAIR=false VERSION_SPEC="" -for arg in "$@"; do - case "$arg" in +PROFILE_NAME="web" +while [ $# -gt 0 ]; do + case "$1" in --restart) RESTART=true ;; --dry-run) DRY_RUN=true ;; + --repair) REPAIR=true ;; + --profile) + if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi + PROFILE_NAME="$2"; shift ;; -h|--help) : ;; # 已在上面处理 - -*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;; - *) VERSION_SPEC="$arg" ;; + -*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;; + *) VERSION_SPEC="$1" ;; esac + shift done +PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME" +WS_YML="$PROFILE_DIR/pnpm-workspace.yaml" +PATCH_YML="$PROFILE_DIR/cordis.patch.yml" + say() { printf '\033[32m[install]\033[0m %s\n' "$*"; } warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } +# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截 +# node-pty/protobufjs 构建脚本、放行本插件新版本 +ensure_workspace_settings() { + WS_RESULT="$(node -e ' +const fs = require("fs"); +const p = process.argv[1]; +let t = fs.readFileSync(p, "utf8"); +const before = t; +// allowBuilds:把 node-pty/protobufjs 归位为 true +t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); +if (!/^\s*allowBuilds:\s*$/m.test(t)) { + t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; +} else { + for (const k of ["node-pty", "protobufjs"]) { + if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { + t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); + } + } +} +// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒 +if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { + if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { + t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); + } else { + t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; + } +} +if (t !== before) fs.writeFileSync(p, t); +console.log(t === before ? "unchanged" : "updated"); +' "$WS_YML")" + [ "$WS_RESULT" = "updated" ] \ + && say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \ + || say "workspace 设置已就绪,跳过" +} + # 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest) resolve_spec() { local given="${1:-latest}" @@ -120,15 +172,33 @@ dsh_cli() { command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。" [ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)" -[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)" +[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)" + +# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ──────────── +# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑 +# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。 +if [ "$REPAIR" = true ]; then + if [ "$DRY_RUN" = true ]; then + say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)" + say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty" + exit 0 + fi + say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..." + ensure_workspace_settings + if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then + die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。" + fi + say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。" + exit 0 +fi SPEC="$(resolve_spec "$VERSION_SPEC")" CLI="$(dsh_cli)" -say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})" +say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})" if [ "$DRY_RUN" = true ]; then say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})" - say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)" + say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)" say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG" say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)" if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi @@ -136,40 +206,11 @@ if [ "$DRY_RUN" = true ]; then fi # 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略 -WS_RESULT="$(node -e ' -const fs = require("fs"); -const p = process.argv[1]; -let t = fs.readFileSync(p, "utf8"); -const before = t; -// allowBuilds:把 node-pty/protobufjs 归位为 true -t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); -if (!/^\s*allowBuilds:\s*$/m.test(t)) { - t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; -} else { - for (const k of ["node-pty", "protobufjs"]) { - if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { - t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); - } - } -} -// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒 -if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { - if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { - t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); - } else { - t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; - } -} -if (t !== before) fs.writeFileSync(p, t); -console.log(t === before ? "unchanged" : "updated"); -' "$WS_YML")" -[ "$WS_RESULT" = "updated" ] \ - && say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \ - || say "workspace 设置已就绪,跳过" +ensure_workspace_settings # 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载) -say "执行 $CLI plugin --profile web add $PKG@$SPEC ..." -if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then +say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..." +if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:" warn " - 网络/登录问题:npm registry 不可达或需要登录。" warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。" diff --git a/src/agent-pty.ts b/src/agent-pty.ts index 23cc28f..32555b2 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -12,8 +12,9 @@ * Both registries share the same shell resolver and spawn-helper fix. */ import { randomUUID } from 'node:crypto' -import * as nodePty from 'node-pty' +import type { IPty } from 'node-pty' import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts' +import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts' import { SidebarError } from './wire.ts' /** Per-agent-terminal transcript bound (bytes kept for replay and reads). */ @@ -106,7 +107,7 @@ export interface AgentTerminalHandle { /** The working directory the process was spawned with. */ cwd: string /** The live pty process. */ - pty: nodePty.IPty + pty: IPty /** Output accumulated since spawn (bounded; head dropped when over the limit). */ transcript: string /** Whether the top-level process exited (transcript stays replayable). */ @@ -189,7 +190,11 @@ export class AgentPtyRegistry { private readonly sessions = new Map() private readonly changeListeners = new Set<() => void>() - constructor(private readonly shell: string) { + constructor( + private readonly shell: string, + /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */ + private readonly nodePty: NodePtyModule = loadRequiredNodePty(), + ) { ensureSpawnHelper() } @@ -211,7 +216,7 @@ export class AgentPtyRegistry { ): string { const uuid = randomUUID() const dims = clampDims(cols, rows) - const pty = nodePty.spawn(this.shell, shellSpawnArgs(), { + const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), { name: 'xterm-256color', cols: dims.cols, rows: dims.rows, diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 30c0fac..2fcbdfa 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -22,10 +22,11 @@ import { useEffect, useRef, useState } from 'react' import { Terminal, type ITheme } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' +import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import '@xterm/xterm/css/xterm.css' import { t } from './locales.ts' import { openWhenSized } from './open-when-sized.ts' -import type { SessionScope } from './api.ts' +import { api, type SessionScope, type TerminalDepsStatus } from './api.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts' import { resolveTerminalFont } from './terminal-font.ts' @@ -34,6 +35,17 @@ import css from './sidebar.module.css' /** How many consecutive unreasoned failures before showing the error banner. */ const FAILURE_LIMIT = 3 +/** + * The WS close-code-1011 reason the host sends when node-pty is unavailable + * (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so + * the two sides keep the literal in lockstep). The view then fetches the + * full repair details from /sidebar/api/terminal.deps. + */ +const PTY_DEPS_MISSING = 'pty-deps-missing' + +/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */ +type TerminalDepsInfo = Extract + /** * Curated ANSI palettes for the terminal. The surface colors (background, * foreground, cursor, selection) ride the theme tokens so the terminal @@ -84,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: const hostRef = useRef(null) const [connected, setConnected] = useState(false) const [fatal, setFatal] = useState(null) + const [depsFatal, setDepsFatal] = useState(null) const [lastUrl, setLastUrl] = useState(null) const connectRef = useRef<(() => void) | null>(null) @@ -157,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: } socket.onclose = (event) => { setConnected(false) + // node-pty dependency missing/broken (issue #140): the host closed + // with the short marker. Fetch the full repair details over HTTP — + // a WS close reason is capped at 123 bytes, too small for the + // pasteable command. A failed fetch falls back to the plain banner. + if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) { + void api.terminalDeps().then((status) => { + if (status.ok) { + // The host recovered between the close and the fetch — the + // plain banner with a retry is the honest state. + setFatal(t('terminalDepsFailed')) + return + } + setFatal(null) + setDepsFatal(status) + }).catch(() => { + setFatal(t('terminalDepsFailed')) + }) + return + } // A server-side refusal carries a close code + reason; retrying it // forever would only spin the banner, so surface it with a retry. if (event.code === 1011 && event.reason !== '') { @@ -263,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: return (
+ {depsFatal !== null && ( + { setDepsFatal(null); connectRef.current?.() }} /> + )} {fatal !== null && (
{t('terminalError')}: {fatal} @@ -276,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
)} - {fatal === null && !connected &&
{t('disconnected')}
} + {fatal === null && depsFatal === null && !connected &&
{t('disconnected')}
}
) } + +/** + * The node-pty dependency failure banner (issue #140): explains that the + * terminal's native dependency failed to load and shows the PASTEABLE repair + * command (bash / cmd / PowerShell) with a copy button — the user pastes it + * into a terminal where their DSH profile lives and runs it, then retries. + * Extracted as a standalone component for direct testing. + */ +export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) { + const { deps, onRetry } = props + const [copied, setCopied] = useState(false) + const copy = async (): Promise => { + const written = await writeClipboard(deps.command) + if (written) { + setCopied(true) + window.setTimeout(() => setCopied(false), 2000) + } + } + return ( +
+
{t('terminalDepsFailed')}
+
+ {t('terminalDepsHint')} + {deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''} +
+
+
{deps.command}
+ +
+ {deps.note !== undefined &&
{deps.note}
} +
+ +
+
+ ) +} diff --git a/src/client/api.ts b/src/client/api.ts index bfab3de..7dbdda1 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -79,6 +79,21 @@ export interface JobOutputResult { read: boolean } +/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */ +export type TerminalDepsStatus = + | { ok: true } + | { + ok: false + /** The require-time error message (module missing, native binding broken…). */ + cause: string + /** The pasteable repair command (terminal/cmd). */ + command: string + /** The detected profile name (null when undetected → the command defaults to web). */ + profile: string | null + /** Optional supplementary hint (fallback command only). */ + note?: string + } + async function call(method: string, payload: Record, signal?: AbortSignal): Promise { let response: Response try { @@ -164,6 +179,11 @@ export const api = { /** Release an agent terminal by uuid (tab closed while WS was down). */ agentPtyClose: (uuid: string) => call<{ ok: true }>('agent-pty.close', { uuid }), + /** Terminal dependency status (issue #140): after a WS close 1011 with + * reason `pty-deps-missing` the view fetches the full repair details here + * (the close reason itself is capped at 123 bytes). */ + terminalDeps: () => + call('terminal.deps', {}), /** * The output the model has read so far for one background job (replayed * from the owner session's event log — never the model's job_output diff --git a/src/client/locales.ts b/src/client/locales.ts index 1ada329..c4221d6 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -29,6 +29,9 @@ export const zh = { terminalError: '终端连接失败', terminalConnectFailed: '终端多次连接失败', terminalRetry: '重试', + terminalDepsFailed: '终端依赖 node-pty 加载失败', + terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):', + terminalDepsProfile: '(检测到 profile:{profile})', preview: '预览', edit: '编辑', refresh: '刷新', @@ -253,6 +256,9 @@ export const en: Record = { terminalError: 'Terminal connection failed', terminalConnectFailed: 'Terminal failed to connect repeatedly', terminalRetry: 'Retry', + terminalDepsFailed: 'Terminal dependency node-pty failed to load', + terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):', + terminalDepsProfile: ' (detected profile: {profile})', preview: 'Preview', edit: 'Edit', refresh: 'Refresh', diff --git a/src/client/service.ts b/src/client/service.ts index ee333bd..bc66f51 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -420,7 +420,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes * The plugin version this service instance reports. Keep in lockstep with * `package.json`'s version — `tests/service.spec.ts` asserts the pair. */ -export const SIDEBAR_SERVICE_VERSION = '0.12.2' +export const SIDEBAR_SERVICE_VERSION = '0.12.3' /** * Monotonic capability list consumers use to gate new API usage (features diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 570b532..7b6680e 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1305,6 +1305,65 @@ color: var(--dsw-alias-label-primary); } +/* The node-pty dependency failure banner (issue #140): a taller warn block + carrying the pasteable repair command (bash / cmd / PowerShell). */ +.terminalDepsBanner { + flex: none; + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); +} + +.terminalDepsTitle { + font: var(--dsw-font-xxs-strong-12); + color: var(--dsw-alias-state-warn-primary); +} + +.terminalDepsHint { + opacity: 0.9; +} + +.terminalDepsCommandRow { + display: flex; + align-items: flex-start; + gap: 8px; +} + +/* The command the user pastes into a terminal / cmd. Selectable, wrapping, + and scrollable when long (Windows paths can get very long). */ +.terminalRepairCommand { + flex: 1; + min-width: 0; + margin: 0; + padding: 6px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + user-select: text; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-2); + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 4px; + max-height: 160px; + overflow: auto; +} + +.terminalDepsNote { + opacity: 0.85; +} + +.terminalDepsActions { + display: flex; + align-items: center; + gap: 8px; +} + /* The PER-TAB error strip: fills one tab's content area (unlike .boundaryError, the full-height fixed rail for shell-level crashes), so a crashing viewer/editor never hides the toggle cluster or the other tabs. */ diff --git a/src/index.ts b/src/index.ts index 3933f4f..a6cf980 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,12 @@ import * as git from './git.ts' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' +import { + DSH_NODE_PTY_RANGE, + depsStatus, + loadNodePty, + PTY_DEPS_MISSING, +} from './pty-deps.ts' import { registerTools } from './tools.ts' import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' @@ -179,8 +185,8 @@ export interface SidebarSettingsFace { /** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */ function buildApi( ctx: Context, - ptyManager: PtyManager, - agentPtyRegistry: AgentPtyRegistry, + ptyManager: PtyManager | null, + agentPtyRegistry: AgentPtyRegistry | null, resolved: ResolvedSidebarConfig, getSettings: () => SidebarSettingsFace | undefined, ): Record { @@ -313,7 +319,9 @@ function buildApi( 'pty.close': (payload) => { const sessionId = requireString(payload, 'sessionId') const tab = requireString(payload, 'tab') - ptyManager.close(`${sessionId}:${tab}`) + // Degraded mode (node-pty unavailable): no live pty can exist, so a + // no-op ok is the honest answer — never an error the client must show. + ptyManager?.close(`${sessionId}:${tab}`) return { ok: true } }, // Release an agent terminal by uuid. The WS close frame already does @@ -322,9 +330,14 @@ function buildApi( // tab never leaves a zombie pty behind. Idempotent. 'agent-pty.close': (payload) => { const uuid = requireString(payload, 'uuid') - agentPtyRegistry.close(uuid) + agentPtyRegistry?.close(uuid) return { ok: true } }, + // Terminal dependency status (issue #140): after a WS close 1011 with + // reason `pty-deps-missing` the client fetches the full repair details + // here — the close reason itself is capped at 123 bytes, too small for + // the pasteable command. + 'terminal.deps': () => depsStatus(), // Background jobs: read one job's output (a REPLAY of what the model // has read so far, from the owner session's event log — the model's // job_output cursor is never touched, so the human pane can never steal @@ -436,13 +449,25 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // gateway fence derives its list from. Read per request from the live // service value; a replaced list takes effect without a plugin restart. const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts) - const ptyManager = new PtyManager(terminalShell, resolved.terminalsPerSession) + // node-pty is loaded lazily, never at module top level (issue #140): a + // missing or broken install must degrade THIS plugin — terminal tab shows + // a repair command, agent terminal tools stay unregistered — instead of + // failing the plugin load and taking the whole `dsh web` server down. + const nodePty = loadNodePty() + if (nodePty === null) { + const status = depsStatus() + const detail = status.ok + ? 'unknown cause' + : `${status.cause}. Repair: ${status.command}` + ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`) + } + const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null // The agent-owned terminal registry: parallel to the UI-tab ptyManager, // keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`, // uncapped, and torn down with the plugin. The model creates terminals here // through the terminal_create tool; the sidebar view attaches through the // same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=... - const agentPtyRegistry = new AgentPtyRegistry(terminalShell) + const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null // ── User-facing "Side card" preferences ────────────────────────────────── // Register the namespace with the settings provider so the Settings page @@ -461,6 +486,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const syncToolsGate = (scope: { get(): SidebarPrefs }): void => { if (scope.get().agentTerminalTools) { if (toolsDisposers === null) { + // Degraded mode (node-pty unavailable): never register the terminal + // tools — every one of them would fail at spawn time. + if (agentPtyRegistry === null) return toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId)) } } else if (toolsDisposers !== null) { @@ -469,7 +497,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // The feature is off: release every agent terminal the model created // while it was on (they are only reachable through the tools). The // registry change fires the push, so the sidebar reconciles them away. - agentPtyRegistry.disposeAll() + agentPtyRegistry?.disposeAll() } } ctx.inject(['settings'], (sctx) => { @@ -700,8 +728,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void { ctx.effect(() => () => { toolsDisposers?.() - ptyManager.disposeAll() - agentPtyRegistry.disposeAll() + ptyManager?.disposeAll() + agentPtyRegistry?.disposeAll() wss.close() agentListWss.close() }, 'dsh-better-sidebar: teardown') @@ -709,7 +737,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { /** Push the live agent-terminal list for one session to a connected sidebar view. */ async function attachAgentList( - registry: AgentPtyRegistry, + registry: AgentPtyRegistry | null, ws: WebSocket, req: SidebarHttpRequest, ): Promise { @@ -722,13 +750,15 @@ async function attachAgentList( } const send = (): void => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(registry.list(sessionId))) + // Degraded mode (node-pty unavailable): no agent terminal can exist, + // so the honest push is the empty list. + ws.send(JSON.stringify(registry?.list(sessionId) ?? [])) } } send() - const unsubscribe = registry.subscribe(send) - ws.on('close', () => { unsubscribe() }) - ws.on('error', () => { unsubscribe() }) + const unsubscribe = registry?.subscribe(send) + ws.on('close', () => { unsubscribe?.() }) + ws.on('error', () => { unsubscribe?.() }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) } @@ -748,8 +778,8 @@ async function attachAgentList( */ async function attachTerminal( ctx: Context, - ptyManager: PtyManager, - agentPtyRegistry: AgentPtyRegistry, + ptyManager: PtyManager | null, + agentPtyRegistry: AgentPtyRegistry | null, ws: WebSocket, req: SidebarHttpRequest, resolved: ResolvedSidebarConfig, @@ -758,6 +788,12 @@ async function attachTerminal( const url = new URL(req.url ?? '/', 'http://dsh.internal') const uuid = url.searchParams.get('uuid') if (uuid !== null) { + // Degraded mode (node-pty unavailable): no agent terminal can exist, + // so the lookup behaves exactly like a missing uuid. + if (agentPtyRegistry === null) { + ws.close(1011, `agent terminal "${uuid}" not found`) + return + } const handle = agentPtyRegistry.get(uuid) if (handle === undefined) { ws.close(1011, `agent terminal "${uuid}" not found`) @@ -772,6 +808,13 @@ async function attachTerminal( ws.close(1008, 'either ?uuid or ?sessionId+?tab are required') return } + if (ptyManager === null) { + // Degraded mode (issue #140): node-pty unavailable. The close reason + // is a SHORT marker — a WS close reason is capped at 123 bytes, so the + // client fetches the full repair command from /sidebar/api/terminal.deps. + ws.close(1011, PTY_DEPS_MISSING) + return + } const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24) // Replay the transcript, then follow live output. diff --git a/src/pty-deps.ts b/src/pty-deps.ts new file mode 100644 index 0000000..cfd695c --- /dev/null +++ b/src/pty-deps.ts @@ -0,0 +1,240 @@ +/** + * node-pty dependency loading for the host half (issue #140, plugin side). + * + * The terminal surfaces (UI tabs + model-facing terminal_* tools) need + * node-pty, but the package must NEVER be imported statically at module + * top level: a missing or broken install (pnpm 11's strict-dep-builds + * skipping node-pty's install script, a pruned store entry, a failed + * prebuilt-binary download…) would then fail the plugin module load and — + * because a loader entry apply failure aborts the boot — take the whole + * `dsh web` server down with it. + * + * Instead the host half loads node-pty lazily (synchronously, via + * createRequire — the same resolution `ensureSpawnHelper` already uses in + * production). When the load fails the plugin stays mounted in a degraded + * state: the terminal tab shows a friendly error carrying a pasteable + * repair command (see scripts/install.sh / install.ps1 `--repair`), and the + * agent terminal tools are simply not registered. + * + * Version contract: the plugin must stay in sync with DSH core — + * `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in + * its `dependencies`. Both sides then resolve the SAME pnpm store entry + * (same range, same integrity → one native binding, no drift). Do NOT + * switch to a fork (e.g. @lydell/node-pty) or a different range without + * re-checking the core declaration. + */ +import { existsSync, readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { homedir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type * as nodePtyNs from 'node-pty' +import { SidebarError } from './wire.ts' + +/** The node-pty module surface the registries consume (spawn/kill/resize/…). */ +export type NodePtyModule = typeof nodePtyNs + +/** + * The node-pty version range this plugin ships. MUST stay identical to the + * range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same + * range keeps pnpm resolving both to one physical package. + */ +export const DSH_NODE_PTY_RANGE = '^1.1.0' + +/** + * The WebSocket close-code-1011 reason the host sends when node-pty is + * unavailable. The client recognizes this exact marker and fetches the full + * repair details from `/sidebar/api/terminal.deps` (a WS close reason is + * capped at 123 bytes, so the command itself cannot ride the close frame). + */ +export const PTY_DEPS_MISSING = 'pty-deps-missing' + +/** A require-compatible loader, injectable for tests. */ +export type NodePtyRequire = (id: string) => unknown + +const defaultRequire: NodePtyRequire = createRequire(import.meta.url) + +type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown } + +let cached: LoadResult | undefined + +/** + * Load node-pty once (synchronously) and cache the outcome. Returns null + * when the package or its native binding cannot be loaded; the cause stays + * queryable through {@link nodePtyLoadCause}. Never throws. + */ +export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null { + if (cached === undefined) { + try { + cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule } + } catch (cause) { + cached = { ok: false, cause } + } + } + return cached.ok ? cached.module : null +} + +/** The recorded load failure (undefined when the load succeeded or never ran). */ +export function nodePtyLoadCause(): unknown { + return cached !== undefined && !cached.ok ? cached.cause : undefined +} + +/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */ +export function resetNodePtyCache(): void { + cached = undefined +} + +/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */ +export function loadRequiredNodePty(): NodePtyModule { + const module = loadNodePty() + if (module === null) { + const cause = describeCause(nodePtyLoadCause()) + throw new SidebarError( + 'pty-deps-missing', + `node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`, + 503, + ) + } + return module +} + +/** Resolve a directory to its physical location (symlinked/link: installs). */ +function realDir(file: string): string { + try { + return dirname(realpathSync(file)) + } catch { + return dirname(file) + } +} + +/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */ +function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null { + let current = dir + for (let depth = 0; depth < 16; depth += 1) { + if (isRoot(current)) return current + const parent = dirname(current) + if (parent === current) break + current = parent + } + return null +} + +/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */ +function isProfileRoot(dir: string): boolean { + return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml')) +} + +/** + * Detect the DSH profile directory this plugin is installed into: the + * nearest ancestor of the plugin module that carries both `package.json` + * and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the + * profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the + * standard web profile), then null. + */ +export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null { + const detected = walkUp(realDir(fromFile), isProfileRoot) + if (detected !== null) return detected + const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== '' + ? process.env.DSH_HOME + : join(homedir(), '.dsh') + const web = join(home, 'profiles', 'web') + return isProfileRoot(web) ? realpathSync(web) : null +} + +/** Whether `dir`'s package.json declares this plugin's name. */ +function isPluginRoot(dir: string): boolean { + const file = join(dir, 'package.json') + if (!existsSync(file)) return false + try { + const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown } + return parsed.name === 'dsh-better-sidebar' + } catch { + return false + } +} + +/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */ +export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null { + return walkUp(realDir(fromFile), isPluginRoot) +} + +/** Options for {@link buildRepairCommand}. */ +export interface RepairCommandOptions { + /** The plugin package root (where scripts/install.sh / install.ps1 live). */ + pluginRoot: string | null + /** The detected profile directory (null → the standard `web` profile). */ + profileDir: string | null + /** Platform override for tests; defaults to the live process. */ + platform?: NodeJS.Platform +} + +/** + * The pasteable repair command for a broken node-pty install: rerun the + * plugin's own installer in `--repair` mode (idempotent: it re-writes the + * profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the + * dependency). Falls back to DSH's plugin command when the scripts are not + * shipped (exotic layouts). + */ +export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } { + const { pluginRoot, profileDir } = options + const platform = options.platform ?? process.platform + const profileName = profileDir !== null ? basename(profileDir) : null + const profileArg = profileName !== null + ? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`) + : '' + if (pluginRoot !== null) { + if (platform === 'win32') { + const script = join(pluginRoot, 'scripts', 'install.ps1') + if (existsSync(script)) { + return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` } + } + } else { + const script = join(pluginRoot, 'scripts', 'install.sh') + if (existsSync(script)) { + return { command: `bash "${script}" --repair${profileArg}` } + } + } + } + const name = profileName ?? 'web' + return { + command: `dsh plugin --profile "${name}" install`, + note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).', + } +} + +/** One-line human description of the recorded load cause. */ +function describeCause(cause: unknown): string { + if (cause instanceof Error) return cause.message + return String(cause) +} + +/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */ +export type NodePtyDepsStatus = + | { ok: true } + | { + ok: false + /** The require-time error message (module missing, native binding broken…). */ + cause: string + /** The pasteable repair command (terminal/cmd). */ + command: string + /** The detected profile name (null when undetected → the command defaults to web). */ + profile: string | null + /** Optional supplementary hint (fallback command only). */ + note?: string + } + +/** Current node-pty dependency status (loaded vs degraded + repair info). */ +export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus { + const module = loadNodePty() + if (module !== null) return { ok: true } + const pluginRoot = findPluginRoot(options.fromFile) + const profileDir = findProfileDir(options.fromFile) + const { command, note } = buildRepairCommand({ pluginRoot, profileDir }) + return { + ok: false, + cause: describeCause(nodePtyLoadCause()), + command, + profile: profileDir !== null ? basename(profileDir) : null, + ...(note !== undefined ? { note } : {}), + } +} diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 860145a..41a80fa 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -10,7 +10,8 @@ import { chmodSync, existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { createRequire } from 'node:module' import { userInfo } from 'node:os' -import * as nodePty from 'node-pty' +import type { IPty } from 'node-pty' +import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts' import { SidebarError } from './wire.ts' /** Per-terminal transcript bound (bytes kept for replay). */ @@ -52,7 +53,7 @@ export interface SidebarPty { * the page-load hydrate race can attach the real cwd after the first * connect, and a shell in the wrong directory must not linger). */ cwd: string - pty: nodePty.IPty + pty: IPty /** Output accumulated since spawn (bounded; head dropped when over the limit). */ transcript: string /** Whether the top-level process exited (transcript stays replayable). */ @@ -71,6 +72,8 @@ export class PtyManager { constructor( private readonly shell: string, private readonly maxPerSession: number, + /** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */ + private readonly nodePty: NodePtyModule = loadRequiredNodePty(), ) {} /** All live terminal keys of one session. */ @@ -119,7 +122,7 @@ export class PtyManager { sessionId, tabId, cwd, - pty: nodePty.spawn(this.shell, shellSpawnArgs(), { + pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), { name: 'xterm-256color', cols: Math.max(2, Math.floor(cols)), rows: Math.max(2, Math.floor(rows)), diff --git a/src/wire.ts b/src/wire.ts index 5511024..0ab939c 100644 --- a/src/wire.ts +++ b/src/wire.ts @@ -15,6 +15,7 @@ export type SidebarErrorCode = | 'fs-error' | 'git-error' | 'pty-error' + | 'pty-deps-missing' | 'job-error' | 'settings-rejected' | 'settings-conflict' diff --git a/tests/pty-deps.spec.ts b/tests/pty-deps.spec.ts new file mode 100644 index 0000000..50de8de --- /dev/null +++ b/tests/pty-deps.spec.ts @@ -0,0 +1,205 @@ +/** + * pty-deps unit tests (issue #140, plugin side): lazy node-pty loading, + * profile/plugin-root discovery, and the pasteable repair command — the + * degraded mode the terminal tab enters when node-pty cannot load, instead + * of the whole plugin (and `dsh web`) failing to boot. + */ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildRepairCommand, + depsStatus, + DSH_NODE_PTY_RANGE, + findPluginRoot, + findProfileDir, + loadNodePty, + loadRequiredNodePty, + nodePtyLoadCause, + resetNodePtyCache, +} from '../src/pty-deps.ts' +import { SidebarError } from '../src/wire.ts' + +/** A throw-only require: node-pty missing or its native binding broken. */ +const failingRequire = (): never => { throw new Error('Cannot find package node-pty') } + +describe('loadNodePty', () => { + beforeEach(() => { resetNodePtyCache() }) + + it('loads the real node-pty module in this repository', () => { + const mod = loadNodePty() + expect(mod).not.toBeNull() + expect(typeof mod?.spawn).toBe('function') + }) + + it('returns null and records the cause when the require throws', () => { + expect(loadNodePty(failingRequire)).toBeNull() + expect((nodePtyLoadCause() as Error).message).toBe('Cannot find package node-pty') + }) + + it('caches the first outcome (a later working require does not override)', () => { + expect(loadNodePty(failingRequire)).toBeNull() + expect(loadNodePty(() => ({ spawn: () => undefined }))).toBeNull() + resetNodePtyCache() + expect(loadNodePty(() => ({ spawn: () => undefined }))).not.toBeNull() + }) + + it('loadRequiredNodePty throws the canonical degraded-mode error', () => { + loadNodePty(failingRequire) + let thrown: unknown + try { + loadRequiredNodePty() + } catch (error) { + thrown = error + } + expect(thrown).toBeInstanceOf(SidebarError) + expect((thrown as SidebarError).code).toBe('pty-deps-missing') + expect((thrown as SidebarError).status).toBe(503) + }) +}) + +describe('buildRepairCommand', () => { + let root: string + let pluginRoot: string + let profileDir: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'pty-deps-')) + pluginRoot = join(root, 'plugin') + profileDir = join(root, 'profiles', 'web') + mkdirSync(join(pluginRoot, 'scripts'), { recursive: true }) + mkdirSync(profileDir, { recursive: true }) + writeFileSync(join(pluginRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n') + writeFileSync(join(pluginRoot, 'scripts', 'install.ps1'), '# ps1\n') + }) + + afterEach(() => { rmSync(root, { recursive: true, force: true }) }) + + it('posix: bash installer with --repair and the detected profile', () => { + const { command, note } = buildRepairCommand({ pluginRoot, profileDir, platform: 'darwin' }) + expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair --profile "web"`) + expect(note).toBeUndefined() + }) + + it('posix: omits --profile when the profile could not be detected', () => { + const { command } = buildRepairCommand({ pluginRoot, profileDir: null, platform: 'linux' }) + expect(command).toBe(`bash "${join(pluginRoot, 'scripts', 'install.sh')}" --repair`) + }) + + it('win32: powershell -ExecutionPolicy Bypass with -Repair and -Profile', () => { + const { command } = buildRepairCommand({ pluginRoot, profileDir, platform: 'win32' }) + const expected = join(pluginRoot, 'scripts', 'install.ps1') + expect(command).toBe(`powershell -ExecutionPolicy Bypass -File "${expected}" -Repair -Profile "web"`) + }) + + it('falls back to the dsh plugin command when the plugin scripts are unavailable', () => { + const bareRoot = join(root, 'bare') + mkdirSync(bareRoot) + const { command, note } = buildRepairCommand({ pluginRoot: bareRoot, profileDir, platform: 'darwin' }) + expect(command).toBe('dsh plugin --profile "web" install') + expect(note).toContain('allowBuilds') + }) + + it('falls back to the dsh plugin command (default web) when nothing is detectable', () => { + const { command } = buildRepairCommand({ pluginRoot: null, profileDir: null, platform: 'darwin' }) + expect(command).toBe('dsh plugin --profile "web" install') + }) +}) + +describe('findProfileDir / findPluginRoot', () => { + let root: string + let moduleFile: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'pty-deps-')) + // A fake plugin module deep inside the profile's pnpm node_modules. + const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar') + const libDir = join(pkgRoot, 'lib') + mkdirSync(libDir, { recursive: true }) + writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' })) + writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '') + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' })) + moduleFile = join(libDir, 'index.js') + writeFileSync(moduleFile, '') + }) + + afterEach(() => { rmSync(root, { recursive: true, force: true }) }) + + it('walks up from the plugin module to the profile root (pnpm-workspace.yaml)', () => { + expect(findProfileDir(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web'))) + }) + + it('walks up to the plugin package root by package.json name', () => { + expect(findPluginRoot(moduleFile)).toBe(realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar'))) + }) + + it('falls back to $DSH_HOME/profiles/web when no ancestor looks like a profile', () => { + const home = join(root, 'home') + const web = join(home, 'profiles', 'web') + mkdirSync(web, { recursive: true }) + writeFileSync(join(web, 'package.json'), JSON.stringify({ name: 'dsh-profile-web' })) + writeFileSync(join(web, 'pnpm-workspace.yaml'), '') + // A bare module path with no profile-like ancestor (the walk-up must miss). + const bareModule = join(root, 'elsewhere', 'lib', 'index.js') + mkdirSync(dirname(bareModule), { recursive: true }) + writeFileSync(bareModule, '') + const previous = process.env.DSH_HOME + process.env.DSH_HOME = home + try { + expect(findProfileDir(bareModule)).toBe(realpathSync(web)) + } finally { + if (previous === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previous + } + }) +}) + +describe('depsStatus', () => { + let root: string + let moduleFile: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'pty-deps-')) + const pkgRoot = join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar') + mkdirSync(join(pkgRoot, 'scripts'), { recursive: true }) + mkdirSync(join(pkgRoot, 'lib'), { recursive: true }) + writeFileSync(join(root, 'profiles', 'web', 'package.json'), JSON.stringify({ name: 'dsh-profile-web' })) + writeFileSync(join(root, 'profiles', 'web', 'pnpm-workspace.yaml'), '') + writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({ name: 'dsh-better-sidebar' })) + writeFileSync(join(pkgRoot, 'scripts', 'install.sh'), '#!/usr/bin/env bash\n') + moduleFile = join(pkgRoot, 'lib', 'index.js') + writeFileSync(moduleFile, '') + resetNodePtyCache() + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + resetNodePtyCache() + }) + + it('reports ok:true when node-pty loads', () => { + expect(depsStatus({ fromFile: moduleFile })).toEqual({ ok: true }) + }) + + it('reports the cause, the repair command and the profile when the load fails', () => { + loadNodePty(failingRequire) + const status = depsStatus({ fromFile: moduleFile }) + expect(status.ok).toBe(false) + if (status.ok) throw new Error('unreachable') + expect(status.cause).toBe('Cannot find package node-pty') + const script = realpathSync(join(root, 'profiles', 'web', 'node_modules', '.pnpm', 'dsh-better-sidebar@0.0.0', 'node_modules', 'dsh-better-sidebar', 'scripts', 'install.sh')) + expect(status.command).toBe(`bash "${script}" --repair --profile "web"`) + expect(status.profile).toBe('web') + }) +}) + +describe('DSH sync contract', () => { + it('the declared node-pty range stays identical to DSH core (dsh-subprocess-local ^1.1.0)', () => { + expect(DSH_NODE_PTY_RANGE).toBe('^1.1.0') + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + dependencies: Record + } + expect(pkg.dependencies['node-pty']).toBe(DSH_NODE_PTY_RANGE) + }) +}) diff --git a/tests/terminal-deps-banner.spec.tsx b/tests/terminal-deps-banner.spec.tsx new file mode 100644 index 0000000..2ab76ad --- /dev/null +++ b/tests/terminal-deps-banner.spec.tsx @@ -0,0 +1,82 @@ +/** + * TerminalDepsBanner tests (issue #140): the degraded-mode banner the + * terminal tab shows when node-pty fails to load. It must render the + * failure title, the PASTEABLE repair command (bash / cmd / PowerShell), + * a copy button that writes the command to the clipboard with a transient + * "Copied" feedback, and a retry button. + * + * The banner is tested directly (like PluginListBody): it is a pure + * presentational component, so server-side renderToString covers the + * static content and createRoot + act() covers the copy click. + */ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import * as primitives from '@deepseek-ai/dsh-client-ui-primitives' +import { TerminalDepsBanner } from '../src/client/TerminalView.tsx' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +const deps = { + ok: false as const, + cause: 'Cannot find package node-pty', + command: 'bash "/Users/u/.dsh/profiles/web/node_modules/.pnpm/dsh-better-sidebar@0.0.0/node_modules/dsh-better-sidebar/scripts/install.sh" --repair --profile "web"', + profile: 'web', +} + +describe('TerminalDepsBanner (render)', () => { + it('renders the failure title, the pasteable command and the actions', () => { + const html = renderToString(createElement(TerminalDepsBanner, { deps, onRetry: () => {} })) + // The jsdom locale is en-US, so the en dictionary applies. + expect(html).toContain('Terminal dependency node-pty failed to load') + // renderToString HTML-escapes the quotes inside the command block. + expect(html).toContain(deps.command.replaceAll('"', '"')) + expect(html).toContain('Copy') + expect(html).toContain('Retry') + // The profile the command targets is shown next to the hint. + expect(html).toContain('detected profile: web') + }) +}) + +describe('TerminalDepsBanner copy click (interactive)', () => { + it('clicking Copy writes the repair command and flashes "Copied"', async () => { + vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + const onRetry = vi.fn() + act(() => { + root.render(createElement(TerminalDepsBanner, { deps, onRetry })) + }) + const copyButton = container.querySelector('button[aria-label="Copy"]') as HTMLButtonElement + expect(copyButton).not.toBeNull() + expect(copyButton.textContent).toBe('Copy') + await act(async () => { copyButton.click() }) + expect(primitives.writeClipboard).toHaveBeenCalledWith(deps.command) + expect(copyButton.textContent).toBe('Copied') + act(() => { root.unmount() }) + container.remove() + vi.restoreAllMocks() + }) + + it('clicking Retry fires the callback (the view reconnects)', async () => { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + const onRetry = vi.fn() + act(() => { + root.render(createElement(TerminalDepsBanner, { deps, onRetry })) + }) + const buttons = [...container.querySelectorAll('button')] as HTMLButtonElement[] + const retry = buttons.find(button => button.textContent === 'Retry') + expect(retry).toBeDefined() + await act(async () => { retry!.click() }) + expect(onRetry).toHaveBeenCalledTimes(1) + act(() => { root.unmount() }) + container.remove() + }) +})