/** * @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles. * * Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here * is STRICTER than either, and deliberately so: `dsh` is not merely a short name * with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and * Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`), * which like nearly every Unix tool prints a version-shaped string of its own. * A version-token probe alone (which is all pi and grok need) would * therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn * line, so every candidate must additionally prove its identity by printing the * harness's own help banner. * * Two probes per candidate, both bounded and both cached behind the shared * resolver's positive/negative caching: * 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`) * 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`) * Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the * cheaper, more discriminating signal and never contributes a version number. * * `dsh` is a profile LAUNCHER, not an agent: `dsh --profile ` boots an * ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web` * (browser UI), `headless` (one-shot) and `base` (no app). The interactive * terminal agent Codeman actually drives is a THIRD-PARTY profile the user * installs. That is why this module resolves two independent things — a binary * AND a profile inventory — and why "available" for the deepseek run mode means * both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts * for the actionable per-half message). * * @module utils/deepseek-cli-resolver */ import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { createCliExecutableResolver, formatCliNotFoundMessage, type CliResolverHost, } from './cli-executable-resolver.js'; /** * Common directories where the `dsh` binary may be installed. * * `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no * vendor-owned install dir to lead with: the global npm bin is wherever the * user's prefix points. `~/.local/bin` heads the list because it is the default * for a prefix-relocated npm (and is where this box's install landed). */ const DEEPSEEK_SEARCH_DIRS = [ join(homedir(), '.local', 'bin'), '/usr/local/bin', join(homedir(), '.npm-global', 'bin'), join(homedir(), 'bin'), ]; /** * A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so * the prerelease suffix is part of the token — truncating it to `0.1.1` would * misreport a release-candidate as a release in `codeman doctor`. * * Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`, * so the doctor and the run mode cannot disagree about what counts as an * installed dsh (the same single-source rule as PI_VERSION_REGEX / * GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()` * (first capture group, whole-output scan): hence a capturing group and a * leading boundary instead of `^`. No `g` flag, so there is no shared * `lastIndex` to reset. */ export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/; /** * The identity marker that separates DeepSeek's `dsh` from Debian's dancer's * shell. The real launcher's `--help` banner reads: * * dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle … * * Matched case-insensitively against the help output. This is the check that * makes the resolver safe to point a spawn line at; see the module header. */ export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i; const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'; /** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */ export function resolveDshHome(): string { const fromEnv = process.env.DSH_HOME?.trim(); return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh'); } /** * What a profile is FOR, inferred from the bundles it composes. * * `interactive` is the only kind a tmux pane can drive: `web` serves a browser * UI and would occupy the pane with a logging server, `headless` answers one * task and exits (which reads as an instantly-dead pane). `unknown` is treated * as interactive-capable on purpose — the whole point of the harness is that * anyone can publish an app bundle, so an unrecognized third-party profile must * not be hidden from the picker just because this list has not heard of it. */ export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown'; export interface DeepSeekProfile { /** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */ name: string; /** Bundle package names composed by the profile, in order. */ bundles: string[]; kind: DeepSeekProfileKind; } /** Bundles that positively identify a non-interactive profile. */ const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i; const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i; /** * Bundles that positively identify a terminal app. Intentionally a loose * community-wide pattern rather than one blessed package: the terminal front * door is third-party by construction (DeepSeek ships none), and a dozen * scoped `dsh-tui` packages from a dozen different authors compete. Anything * matching is a TUI; anything unmatched is `unknown`, which still counts as * launchable. */ const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|tui/i; /** Profile directory names that are not profiles. */ const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']); function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind { const haystack = [name, ...bundles].join(' '); // Order matters: a profile that composes BOTH a web app and a tui bundle is a // web profile as far as a tmux pane is concerned, because the web app owns the // process and blocks. if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web'; if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless'; if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive'; return 'unknown'; } /** * Read a single profile directory's `package.json` and return its bundle list. * Returns null for anything that is not a readable dsh profile, so a stray * directory under `profiles/` cannot break the inventory. */ function readProfile(profilesDir: string, name: string): DeepSeekProfile | null { try { const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8'); const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } }; const rawBundles = parsed?.dsh?.profile?.bundles; const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : []; return { name, bundles, kind: classifyProfile(name, bundles) }; } catch { return null; } } /** * Inventory the profiles installed under `$DSH_HOME/profiles`. * * Never throws: a missing DSH_HOME (dsh installed but never run) is an empty * list, which the callers render as "no profile yet" rather than an error. * Deliberately un-cached — a user can create a profile at any moment (including * through Codeman's own bootstrap), and the directory scan is cheap next to the * two process spawns the binary probe already costs. */ export function listDeepSeekProfiles(): DeepSeekProfile[] { const profilesDir = join(resolveDshHome(), 'profiles'); let entries: string[]; try { entries = readdirSync(profilesDir, { withFileTypes: true }) .filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.')) .map((e) => e.name); } catch { return []; } return entries .map((name) => readProfile(profilesDir, name)) .filter((p): p is DeepSeekProfile => p !== null) .sort((a, b) => a.name.localeCompare(b.name)); } /** * The profile a session should boot when the user picked none. * * Prefers a positively-identified terminal profile, then an unrecognized one * (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall * back to `web`/`headless`, which cannot drive a pane. Returns null when nothing * launchable is installed, which is what makes the mode report unavailable * instead of spawning a pane that dies on arrival. */ export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null { return ( profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null ); } /** True when the profile can occupy a tmux pane as an interactive agent. */ export function isLaunchableProfile(profile: DeepSeekProfile): boolean { return profile.kind === 'interactive' || profile.kind === 'unknown'; } /** * Run the two-stage identity+version probe on a candidate path. * * Returns the version token only when the binary proves it is the DeepSeek * Harness launcher. Returns null for anything else: a missing binary, a * non-zero exit, a hang (timeout), a help banner without the harness marker * (this is the dancer's-shell rejection), or output with no version-shaped * token. * * Never runs under vitest: the suites must stay hermetic and must not depend on * whether the dev box happens to have dsh installed — and since `dsh` names a * real Debian program, this probe would EXECUTE whatever binary of that name the * machine carries. The shared resolver host is already inert under vitest, so * this gate is defense in depth for any opted-in host that still carries the * default probe; tests drive resolution via `createDeepSeekResolverForTest`, * whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts. */ function probeDeepSeekVersion(binPath: string): string | null { if (process.env.VITEST) return null; const run = (args: string[]): string | null => { try { return execFileSync(binPath, args, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], // A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout // and block the server (execFileSync keeps waiting after the signal). killSignal: 'SIGKILL', }).trim(); } catch (err) { console.warn( `[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})` ); return null; } }; // Identity first — the discriminating signal, and the one that keeps Debian's // dancer's shell out of a spawn line. const help = run(['--help']); if (help === null) return null; if (!DEEPSEEK_IDENTITY_REGEX.test(help)) { console.warn( `[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` + `(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` + `dancer's shell) is earlier on PATH.` ); return null; } const out = run(['--version']); if (out === null) return null; const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1]; if (candidate) return candidate; console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`); return null; } type DeepSeekVersionProbe = (binPath: string) => string | null; function createDeepSeekResolver( host?: CliResolverHost, versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion, now?: () => number ) { return createCliExecutableResolver( { binary: 'dsh', searchDirs: DEEPSEEK_SEARCH_DIRS, validateCandidate: (binPath) => { const version = versionProbe(binPath); return version ? { accepted: true, metadata: version } : { accepted: false }; }, now, }, host ); } /** * Creates an isolated DeepSeek wrapper around an injected host, version probe * and clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, * which is exactly what the hermeticity test exercises. */ export function createDeepSeekResolverForTest( host: CliResolverHost, versionProbe?: DeepSeekVersionProbe, now?: () => number ) { return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now); } const deepSeekResolver = createDeepSeekResolver(); /** * Finds the directory containing a verified `dsh` binary. * Checks the server PATH first, then the common install locations. Every * candidate must pass the identity+version probe before it is accepted. * * @returns Directory path, or null if not found */ export function resolveDeepSeekDir(): string | null { return deepSeekResolver.resolve()?.directory ?? null; } /** * Whether the `dsh` BINARY is installed. Note this is deliberately weaker than * what the run mode needs: a dsh with no launchable profile cannot start a * session. Callers gating the Run button want `isDeepSeekRunnable()`. */ export function isDeepSeekAvailable(): boolean { return resolveDeepSeekDir() !== null; } /** Binary present AND at least one profile that can occupy a pane. */ export function isDeepSeekRunnable(): boolean { return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null; } export function getDeepSeekNotFoundMessage(): string { return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics()); } /** * Version reported by the resolved `dsh` binary, or null when dsh is * unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution * is diagnosable from the UI. */ export function getDeepSeekCliVersion(): string | null { return deepSeekResolver.resolve()?.metadata ?? null; } /** Does the named profile exist and can it drive a pane? */ export function profileExists(name: string): boolean { return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json')); }