Files
apes-sidebar/src/pty-deps.ts
T
Atlas Zeng 8d9ab1cd2f Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair
fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
2026-08-17 00:06:20 +08:00

241 lines
9.4 KiB
TypeScript

/**
* 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 } : {}),
}
}