241 lines
9.4 KiB
TypeScript
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 } : {}),
|
|
}
|
|
}
|