diff --git a/README.md b/README.md index 8e4dae11..34ad614d 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt ## More Features - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files -- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. CLIs are found the same way the Run menu finds them (including `~/.local/bin` and npm/nvm prefixes), so a service with a minimal `PATH` still reports them correctly. Admin only in multi-user mode. +- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. Besides the `PATH`, it also looks in each CLI's usual install directories (`~/.local/bin`, `~/.npm-global/bin` and the like), so most installs are found under a service with a minimal `PATH`. Admin only in multi-user mode. - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions. - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index c7b96cdc..c40b15c2 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -41,7 +41,11 @@ export interface PathResolver { searchDirs?: string[]; } -/** Expand a leading `~` (the only form registry `searchDirs` use). */ +/** + * Expand a leading `~` (the only form registry `searchDirs` use). Twin of `expandHome()` in + * src/utils/cli-resolver.ts, copied rather than imported because importing it from config/ + * would pull in the whole resolver chain; keep the two in step. + */ function expandSearchDir(dir: string): string { if (dir === '~') return homedir(); if (dir.startsWith('~/')) return join(homedir(), dir.slice(2)); diff --git a/src/utils/cli-executable-resolver.ts b/src/utils/cli-executable-resolver.ts index 81eb78c6..80ec0e35 100644 --- a/src/utils/cli-executable-resolver.ts +++ b/src/utils/cli-executable-resolver.ts @@ -122,7 +122,8 @@ export interface ProductionCliResolverHostOptions { allowRealIoUnderVitest?: boolean; } -function isExecutableRegularFile(path: string): boolean { +/** An executable regular file. Exported for `codeman doctor`, which must judge a candidate the same way. */ +export function isExecutableRegularFile(path: string): boolean { try { if (!statSync(path).isFile()) return false; accessSync(path, constants.X_OK); diff --git a/src/utils/dependency-checker.ts b/src/utils/dependency-checker.ts index 6081078a..63ad9804 100644 --- a/src/utils/dependency-checker.ts +++ b/src/utils/dependency-checker.ts @@ -8,7 +8,9 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { isExecutableRegularFile } from './cli-executable-resolver.js'; import type { ProbeEnvironment, ToolCategory, ToolDependency } from '../config/dependency-registry.js'; export interface EnvDetectionInputs { @@ -62,6 +64,8 @@ export interface ProbeHost { environment: ProbeEnvironment; which(bin: string): string | null; fileExists(path: string): boolean; + /** An executable regular file, the run mode's own test for a `searchDirs` candidate. */ + isExecutableFile(path: string): boolean; runVersion(bin: string, args: string[]): string | null; windowsProgramRoots(): string[]; windowsFileVersion(winPath: string): string | null; @@ -96,28 +100,31 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult { if (spec.resolver.kind === 'path') { const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver; for (const bin of bins) { - // `which` first (the PATH), then the registry's search dirs: under a service the PATH is - // minimal and the run mode finds the CLI through those dirs, so the doctor must too. - let resolved = host.which(bin); - if (!resolved && searchDirs) { - for (const dir of searchDirs) { - const candidate = `${dir.replace(/\/+$/, '')}/${bin}`; - if (host.fileExists(candidate)) { - resolved = candidate; - break; - } - } + // The same candidate order and the same per-candidate test as the run mode's resolver + // (createCliExecutableResolver): the `which` hit (the PATH), then each search dir. Under + // a service the PATH is minimal and the run mode finds the CLI through those dirs, so + // the doctor must too. A search-dir candidate counts only as an absolute path to an + // executable regular file, so a relative dir from a custom clis.json or a file without + // the x bit reads as missing here exactly as it does in the Run menu. + const candidates: string[] = []; + const onPath = host.which(bin); + if (onPath && isAbsolute(onPath)) candidates.push(onPath); + for (const dir of searchDirs ?? []) { + const candidate = join(dir, bin); + if (candidates.includes(candidate)) continue; // a search dir that is also on the PATH + if (isAbsolute(candidate) && host.isExecutableFile(candidate)) candidates.push(candidate); } - if (resolved) { + for (const candidate of candidates) { // Run the RESOLVED path: a bare name would miss the same binary `which` just missed. - const out = host.runVersion(resolved, [versionArg ?? '--version']); + const out = host.runVersion(candidate, [versionArg ?? '--version']); const version = out ? extractVersion(out, versionRegex) : undefined; // A generic binary name that prints the wrong thing is some OTHER program (see - // PathResolver.requireVersionMatch). Keep looking, then report MISSING; the - // alternative is claiming a tool is installed that the feature's own resolver - // rejects, which reads as "the mode is broken" rather than "install it". + // PathResolver.requireVersionMatch). Try the next candidate, then report MISSING; + // the alternative is claiming a tool is installed that the feature's own resolver + // rejects, or missing one it accepts (an npm squatter on the PATH in front of the + // real grok in ~/.grok/bin), which reads as "the mode is broken". if (requireVersionMatch && !version) continue; - return finalize(base, tool, resolved, version); + return finalize(base, tool, candidate, version); } } return { ...base, status: 'missing', installHint }; @@ -144,11 +151,16 @@ export function checkAll(registry: ToolDependency[], host: ProbeHost): ToolResul return registry.map((tool) => checkTool(tool, host)); } +// SIGKILL on every probe below: execFileSync's `timeout` only SENDS the kill signal and then +// keeps waiting for the child, so a `--version` that ignores the default SIGTERM would hold +// the doctor (now a Settings button) until GET /api/doctor's own timeout, then be orphaned. +// Same reasoning as the resolver host in cli-executable-resolver.ts. function safeWhich(bin: string): string | null { try { const out = execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', }).trim(); const first = out.split(/\r?\n/)[0]?.trim(); return first && existsSync(first) ? first : null; @@ -163,6 +175,7 @@ function safeRunVersion(bin: string, args: string[]): string | null { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'], + killSignal: 'SIGKILL', }); } catch (err: unknown) { // Some tools (e.g. ffmpeg) exit non-zero on -version but still print to stdout @@ -199,11 +212,12 @@ function readWindowsFileVersion(winPath: string): string | null { const windowsPath = execFileSync('wslpath', ['-w', winPath], { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, + killSignal: 'SIGKILL', }).trim(); const out = execFileSync( 'powershell.exe', ['-NoProfile', '-Command', `(Get-Item '${windowsPath.replace(/'/g, "''")}').VersionInfo.ProductVersion`], - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, killSignal: 'SIGKILL' } ).trim(); return out || null; } catch { @@ -221,6 +235,7 @@ export function createRealHost(): ProbeHost { environment, which: safeWhich, fileExists: existsSync, + isExecutableFile: isExecutableRegularFile, runVersion: safeRunVersion, windowsProgramRoots: listWindowsProgramRoots, windowsFileVersion: readWindowsFileVersion, diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index eb33dcdf..e8fed034 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1409,7 +1409,9 @@ Object.assign(CodemanApp.prototype, { for (const t of tools) { const li = document.createElement('li'); const strong = document.createElement('b'); - strong.textContent = `${glyph[t.status] || '?'} ${t.label}`; + // As the terminal doctor marks it: a missing OPTIONAL tool is ○, only a required one ✗. + const mark = t.status === 'missing' && !t.required ? '○' : glyph[t.status] || '?'; + strong.textContent = `${mark} ${t.label}`; li.append(strong); const bits = [t.status]; if (t.version) bits.push(t.version); diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 978bbc62..d5421e09 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { dependencyRegistry } from '../src/config/dependency-registry.js'; import { detectEnvironment, @@ -129,6 +132,7 @@ function fakeHost(env: ProbeEnvironment, over: Partial = {}): ProbeHo environment: env, which: () => null, fileExists: () => false, + isExecutableFile: () => false, runVersion: () => null, windowsProgramRoots: () => [], windowsFileVersion: () => null, @@ -274,7 +278,7 @@ describe('checkTool with searchDirs (service PATH is minimal)', () => { it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => { const runVersion = vi.fn(() => 'claude 2.1.0'); - const host = fakeHost('linux', { fileExists: (p) => p === '/opt/npm/bin/claude', runVersion }); + const host = fakeHost('linux', { isExecutableFile: (p) => p === '/opt/npm/bin/claude', runVersion }); expect(checkTool(claudeLike, host)).toMatchObject({ status: 'ok', path: '/opt/npm/bin/claude', @@ -290,12 +294,80 @@ describe('checkTool with searchDirs (service PATH is minimal)', () => { it('prefers the PATH hit over a search dir', () => { const host = fakeHost('linux', { which: () => '/usr/bin/claude', - fileExists: () => true, + isExecutableFile: () => true, runVersion: () => '1.0.0', }); expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' }); }); + // The run mode's resolver (createCliExecutableResolver) accepts a search-dir candidate only + // as an absolute path to an executable regular file. A file that merely exists is not one. + it('skips a search-dir file that exists but is not executable', () => { + const runVersion = vi.fn(() => 'claude 2.1.0'); + const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => false, runVersion }); + expect(checkTool(claudeLike, host)).toMatchObject({ status: 'missing' }); + expect(runVersion).not.toHaveBeenCalled(); + }); + + it('ignores a relative search dir (a custom clis.json entry) as the resolver does', () => { + const relative: ToolDependency = { + ...claudeLike, + resolvers: [{ match: ['linux'], resolver: { kind: 'path', bins: ['claude'], searchDirs: ['tools/bin'] } }], + }; + const runVersion = vi.fn(() => 'claude 2.1.0'); + const host = fakeHost('linux', { fileExists: () => true, isExecutableFile: () => true, runVersion }); + expect(checkTool(relative, host)).toMatchObject({ status: 'missing' }); + expect(runVersion).not.toHaveBeenCalled(); + }); + + // The grok case: an npm squatter answers on the PATH while the real CLI sits in ~/.grok/bin. + // The Run menu's resolver rejects the squatter and moves on to the search dirs; the doctor + // used to stop at the PATH hit and report MISSING. + const squatted: ToolDependency = { + ...claudeLike, + id: 'pi', + label: 'Pi CLI', + resolvers: [ + { + match: ['linux'], + resolver: { + kind: 'path', + bins: ['pi'], + versionRegex: PI_VERSION_REGEX, + requireVersionMatch: true, + searchDirs: ['/home/u/.local/bin', '/home/u/.npm-global/bin'], + }, + }, + ], + }; + + it('finds the right binary in a search dir when a wrong one is on the PATH', () => { + const host = fakeHost('linux', { + which: () => '/usr/bin/pi', + isExecutableFile: (p) => p === '/home/u/.npm-global/bin/pi', + runVersion: (bin) => (bin === '/usr/bin/pi' ? 'Raspberry Pi utility\n' : '0.84.3\n'), + }); + expect(checkTool(squatted, host)).toMatchObject({ + status: 'ok', + path: '/home/u/.npm-global/bin/pi', + version: '0.84.3', + }); + }); + + it('version-checks each search-dir candidate and moves past one that fails', () => { + const runVersion = vi.fn((bin: string) => (bin === '/home/u/.local/bin/pi' ? 'something else\n' : '0.84.3\n')); + const host = fakeHost('linux', { isExecutableFile: () => true, runVersion }); + expect(checkTool(squatted, host)).toMatchObject({ status: 'ok', path: '/home/u/.npm-global/bin/pi' }); + expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']); + }); + + it('probes a search dir that is also on the PATH only once', () => { + const runVersion = vi.fn(() => 'Raspberry Pi utility\n'); + const host = fakeHost('linux', { which: () => '/home/u/.local/bin/pi', isExecutableFile: () => true, runVersion }); + expect(checkTool(squatted, host)).toMatchObject({ status: 'missing' }); + expect(runVersion.mock.calls.map(([bin]) => bin)).toEqual(['/home/u/.local/bin/pi', '/home/u/.npm-global/bin/pi']); + }); + it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => { const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver)); const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length); @@ -320,4 +392,20 @@ describe('createRealHost', () => { expect(typeof host.which).toBe('function'); expect(Array.isArray(host.windowsProgramRoots())).toBe(true); }); + + it('counts only an executable regular file as a search-dir candidate', () => { + const dir = mkdtempSync(join(tmpdir(), 'doctor-exec-')); + try { + const file = join(dir, 'tool'); + writeFileSync(file, '#!/bin/sh\necho 1.0.0\n', { mode: 0o644 }); + const host = createRealHost(); + expect(host.isExecutableFile(file)).toBe(false); + chmodSync(file, 0o755); + expect(host.isExecutableFile(file)).toBe(true); + expect(host.isExecutableFile(dir)).toBe(false); + expect(host.isExecutableFile(join(dir, 'absent'))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/test/doctor-cli-json.test.ts b/test/doctor-cli-json.test.ts index 64c04881..c5ccd8ed 100644 --- a/test/doctor-cli-json.test.ts +++ b/test/doctor-cli-json.test.ts @@ -2,76 +2,108 @@ // The contract GET /api/doctor's default runner relies on: the same entry script, given // `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits // non-zero because something required is missing. +// +// Hermetic: the doctor runs `--version` on every CLI it finds, and a suite must never execute +// whatever happens to be installed on the machine running it (cli-executable-resolver.ts +// @fileoverview). Each run gets a temp HOME and a PATH holding only `which` and `node`, and a +// clis.json in that HOME's data dir drops the registry's absolute search dirs +// (`/usr/local/bin`), so the only CLI the doctor can find is a fixture this file wrote. import { execFile, execFileSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { isAbsolute, join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; const ROOT = join(import.meta.dirname, '..'); +interface DoctorRun { + report: { tools: Array<{ id: string; status: string; path?: string; category: string }> } & Record; + stderr: string; +} + +function hermeticDoctorEnv(): { home: string; bare: string; env: NodeJS.ProcessEnv; cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), 'doctor-home-')); + const bare = mkdtempSync(join(tmpdir(), 'doctor-path-')); + symlinkSync(execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(), join(bare, 'which')); + symlinkSync(process.execPath, join(bare, 'node')); + // Overrides deep-merge by id and arrays replace wholesale, so this keeps every stock entry + // and only narrows its search dirs to the `~` ones, which resolve inside the temp HOME. + const clis = Object.fromEntries( + STOCK_CLIS.map((e) => [e.id, { discovery: { searchDirs: e.discovery.searchDirs.filter((d) => !isAbsolute(d)) } }]) + ); + mkdirSync(join(home, '.codeman'), { recursive: true }); + // 0600 or the registry ignores the file (isUnsafePermissions). + writeFileSync(join(home, '.codeman', 'clis.json'), JSON.stringify({ schemaVersion: 1, clis }), { mode: 0o600 }); + const env: NodeJS.ProcessEnv = { ...process.env, HOME: home, PATH: bare }; + delete env.CODEMAN_DATA_DIR; + delete env.CODEMAN_INSTANCE; + return { + home, + bare, + env, + cleanup: () => { + rmSync(home, { recursive: true, force: true }); + rmSync(bare, { recursive: true, force: true }); + }, + }; +} + +function runDoctor(env: NodeJS.ProcessEnv): Promise { + return new Promise((resolve, reject) => { + execFile( + process.execPath, + [ + join(ROOT, 'node_modules/tsx/dist/cli.mjs'), + join(ROOT, 'src/index.ts'), + 'doctor', + '--json', + '--category', + 'core', + ], + { timeout: 60_000, cwd: ROOT, env }, + (err, out, stderr) => (out ? resolve({ report: JSON.parse(out), stderr }) : reject(err ?? new Error('no output'))) + ); + }); +} + describe('codeman doctor --json', () => { it('prints a report that includes Node and a summary, whatever the exit code', async () => { - const stdout = await new Promise((resolve, reject) => { - execFile( - process.execPath, - [ - join(ROOT, 'node_modules/tsx/dist/cli.mjs'), - join(ROOT, 'src/index.ts'), - 'doctor', - '--json', - '--category', - 'core', - ], - { timeout: 60_000, cwd: ROOT }, - (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output'))) - ); - }); - const report = JSON.parse(stdout); - expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/); - expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) })); - const node = report.tools.find((t: { id: string }) => t.id === 'node'); - expect(node?.status).toBe('ok'); - expect(report.tools.every((t: { category: string }) => t.category === 'core')).toBe(true); + const h = hermeticDoctorEnv(); + try { + const { report, stderr } = await runDoctor(h.env); + expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/); + expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) })); + const node = report.tools.find((t) => t.id === 'node'); + expect(node?.status).toBe('ok'); + expect(report.tools.every((t) => t.category === 'core')).toBe(true); + // The override was accepted (an ignored or invalid clis.json warns on stderr), and nothing + // the doctor found, and so ran, lives outside this test's own temp dirs. + expect(stderr).not.toContain('[cli-registry]'); + for (const t of report.tools.filter((t) => t.path)) { + expect(t.path!.startsWith(h.bare) || t.path!.startsWith(h.home)).toBe(true); + } + } finally { + h.cleanup(); + } }, 90_000); // The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI // installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's - // searchDirs) found it. The PATH here holds nothing but `which`. + // searchDirs) found it. it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => { - const home = mkdtempSync(join(tmpdir(), 'doctor-home-')); - const bare = mkdtempSync(join(tmpdir(), 'doctor-path-')); + const h = hermeticDoctorEnv(); try { - mkdirSync(join(home, '.local/bin'), { recursive: true }); - const fake = join(home, '.local/bin/claude'); + mkdirSync(join(h.home, '.local/bin'), { recursive: true }); + const fake = join(h.home, '.local/bin/claude'); writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n'); chmodSync(fake, 0o755); - symlinkSync(execFileSyncWhich(), join(bare, 'which')); - const stdout = await new Promise((resolve, reject) => { - execFile( - process.execPath, - [ - join(ROOT, 'node_modules/tsx/dist/cli.mjs'), - join(ROOT, 'src/index.ts'), - 'doctor', - '--json', - '--category', - 'core', - ], - { timeout: 60_000, cwd: ROOT, env: { ...process.env, HOME: home, PATH: bare } }, - (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output'))) - ); - }); - const claude = JSON.parse(stdout).tools.find((t: { id: string }) => t.id === 'claude'); + const { report } = await runDoctor(h.env); + const claude = report.tools.find((t) => t.id === 'claude'); expect(claude).toMatchObject({ status: 'ok', path: fake }); } finally { - rmSync(home, { recursive: true, force: true }); - rmSync(bare, { recursive: true, force: true }); + h.cleanup(); } }, 90_000); }); - -function execFileSyncWhich(): string { - return execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(); -} diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts index e1dc8ff8..d33ff17c 100644 --- a/test/doctor-settings.browser.test.ts +++ b/test/doctor-settings.browser.test.ts @@ -73,6 +73,8 @@ describe('Diagnostics panel in a real browser', () => { expect(text).toContain('✓ Node.js ok · 22.1.0'); expect(text).toContain('/usr/bin/node'); expect(text).toContain('✗ tmux missing · required'); + // A missing OPTIONAL tool is not an error: ○, as the terminal doctor marks it. + expect(text).toContain('○ missing · optional'); expect(text).toContain('Install: apt install tmux'); expect(text).toContain(''); // shown literally expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();