diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts new file mode 100644 index 00000000..80e3fae2 --- /dev/null +++ b/src/tui/tui-client.ts @@ -0,0 +1,997 @@ +/** + * @fileoverview Everything `codeman tui` needs from the outside world. + * + * The TUI is a CLIENT of the running server, never a second brain (see + * docs/tui-plan.md §4): states, approvals and history all come from the same + * API the web UI uses, so the two surfaces can never disagree. This module is + * the only place in `src/tui/` that does IO. It covers four jobs: + * + * 1. **Discovery + auth** — find the instance's server (`CODEMAN_API_URL`, else + * loopback on `CODEMAN_PORT`), accepting the self-signed cert `--https` + * generates, and read credentials the way `codeman attach` already does + * (env, then the data dir's `.env`). + * 2. **Typed API calls** that unwrap the `{success,data}` envelope and throw a + * `TuiApiError` carrying the status and `errorCode` on failure. + * 3. **Live updates** over SSE, decoded by `tui-sse.ts`, with a staleness + * watchdog and capped backoff. The TUI does not patch rows from payloads: an + * interesting event means "resync", and the app layer debounces the refetch. + * 4. **Degraded mode** — when nothing answers, sessions are enumerated straight + * from tmux plus a read-only peek at `state.json`, which keeps the "the + * server died, get me to my sessions" path that `sc` has today. + * + * IMPORT-SAFE: no probing, no timers and no tmux at import time. Everything a + * `TuiClient` starts is owned by it and released by `close()`; a leaked SSE + * socket or watchdog interval would keep the process alive after the TUI exits. + * + * LIMITATIONS (server surfaces that do not exist, worked around here rather + * than by touching `src/web/`): + * - There is no endpoint that reports the server's hostname, so the header's + * hostname is this machine's (`os.hostname()`) unless `CODEMAN_API_URL` + * points somewhere non-loopback, in which case that host is used. + * - Plan usage has no route of its own: the last-known snapshot rides + * `GET /api/status` as `planUsage` (`web/plan-usage-latest.ts`) and updates + * arrive as `session:statusTelemetry` SSE frames. + * - The unified list carries no token counters or turn-start stamp + * (`TuiSessionRow.lastSubmitAt`), so those stay unset here; the app layer + * merges them from live session state when it wants them. + * + * @module tui/tui-client + */ + +import { execFile as execFileCb } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import { hostname as osHostname } from 'node:os'; +import { promisify } from 'node:util'; +import { CODEMAN_INSTANCE, dataPath, resolveTmuxSocketName } from '../config/instance.js'; +import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; +import { probeServer } from '../daemon-control.js'; +import { getErrorMessage } from '../types/api.js'; +import { + SseFrameParser, + SSE_BASE_BACKOFF_MS, + SSE_MAX_BACKOFF_MS, + SSE_STALE_TIMEOUT_MS, + approvalEventKind, + classifySseEvent, + sseBackoffDelay, +} from './tui-sse.js'; +import type { UnifiedSessionItem } from '../services/unified-session-service.js'; +import type { CaseInfo } from '../types/api.js'; +import type { SearchResponseData } from '../types/search.js'; +import type { StatusTelemetry } from '../usage-telemetry.js'; +import type { ApprovalItem, ApprovalResolvedInfo } from '../web/approval-inbox.js'; +import type { AwayDigestResponse } from '../web/away-digest.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** A non-2xx answer, or a `success:false` envelope. */ +export class TuiApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly errorCode?: string + ) { + super(message); + this.name = 'TuiApiError'; + } +} + +export interface TuiServerInfo { + /** Origin only, no trailing slash: `https://127.0.0.1:3000`. */ + baseUrl: string; + version?: string; + hostname?: string; + /** `CODEMAN_INSTANCE`, empty string for the production layout. */ + instance: string; + /** A server answered but rejected our credentials. */ + authRequired?: boolean; +} + +export interface TuiClientOptions { + /** Skip discovery and talk to this origin. */ + baseUrl?: string; + /** Loopback port to probe. Outranks `CODEMAN_API_URL`, so a caller that names a port cannot be redirected by the ambient environment. */ + port?: number | string; + username?: string; + password?: string; + /** Where credentials come from when none are passed. Defaults to the instance's `.env`. */ + envFilePath?: string; + /** Per-request timeout. Discovery probes use `probeTimeoutMs`. */ + timeoutMs?: number; + probeTimeoutMs?: number; + /** Injected for tests; the default shells out to `tmux` via execFile. */ + exec?: TuiExecFile; + /** Read-only source of names/dirs in degraded mode. Defaults to the instance's. */ + statePath?: string; +} + +/** Plan-usage snapshot as the server broadcasts it (telemetry plus its source). */ +export type TuiPlanUsage = StatusTelemetry & { sessionId?: string }; + +export type TuiApprovalAnswer = + | { action: 'approve' } + | { action: 'deny' } + | { action: 'option'; option: number } + | { action: 'text'; text: string }; + +/** + * Answering is a conversation with a live terminal, so refusal is a normal + * outcome, not an exception: the server re-captures the pane first and 409s + * when the dialog is gone (someone answered it in tmux a second ago). + */ +export type TuiAnswerResult = + | { ok: true; id: string; sessionId: string; action: string } + | { ok: false; reason: 'gone' | 'not-found' | 'rejected' | 'failed'; message: string }; + +export interface TuiQuickStartOptions { + caseName: string; + mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi'; + sessionName?: string; + /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ + parentSessionId?: string; +} + +export interface TuiQuickStartResult { + sessionId: string; + casePath?: string; + caseName?: string; +} + +/** Init snapshot, narrowed to the two facts the dashboard header shows. */ +export interface TuiInitState { + version?: string; + planUsage?: TuiPlanUsage | null; +} + +export type TuiApprovalEvent = + | { kind: 'pending'; item: ApprovalItem } + | { kind: 'updated'; item: ApprovalItem } + | { kind: 'resolved'; info: ApprovalResolvedInfo }; + +export type TuiSseStatus = 'connected' | 'reconnecting'; + +export interface TuiSseStatusDetail { + /** Consecutive failed connects; 0 while connected. */ + attempt: number; + /** + * SSE has failed often enough that the app should poll + * `fetchUnifiedSessions()` instead of waiting for events. + */ + recommendPolling: boolean; + message?: string; +} + +export interface TuiEventHandlers { + onInit?(state: TuiInitState): void; + /** Something session-shaped changed; the argument is the event name. */ + onResync?(event: string): void; + onApproval?(event: TuiApprovalEvent): void; + onPlanUsage?(usage: TuiPlanUsage): void; + onStatus?(status: TuiSseStatus, detail: TuiSseStatusDetail): void; +} + +export interface TuiSubscribeOptions { + /** + * Sessions whose `session:terminal` frames this stream wants. The default is + * a sentinel that matches no session id, which suppresses the terminal + * stream (by far the bulk of the wire) without suppressing anything else: + * the `?sessions=` filter applies to terminal frames ONLY, lifecycle and + * hook events still reach every client. The preview pane pulls its own tail + * over HTTP, so the TUI never needs those bytes. + */ + sessionIds?: readonly string[]; + staleTimeoutMs?: number; + /** How often the staleness watchdog fires. Defaults to a third of the timeout. */ + checkIntervalMs?: number; + baseBackoffMs?: number; + maxBackoffMs?: number; + /** Consecutive failed connects before `recommendPolling` flips on. */ + pollingAfterFailures?: number; +} + +export interface TuiEventStream { + close(): void; + readonly status: TuiSseStatus; + readonly recommendPolling: boolean; +} + +export type TuiExecFile = (file: string, args: readonly string[]) => Promise<{ stdout: string; stderr: string }>; + +/** One tmux session as degraded mode sees it. */ +export interface TuiTmuxSession { + muxName: string; + /** The `codeman-` fragment; mux names carry only the first 8 chars of the id. */ + sessionIdPrefix: string; + /** Full id, when `state.json` had exactly one session starting with the prefix. */ + sessionId?: string; + name?: string; + workingDir?: string; + mode?: string; + attached: boolean; + /** Epoch ms from tmux's `session_created` (which reports seconds). */ + createdAt?: number; + windows?: number; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Discovery + credentials (pure halves, exported for tests) +// ───────────────────────────────────────────────────────────────────────────── + +const DEFAULT_PORT = 3000; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_PROBE_TIMEOUT_MS = 1_500; +/** Ceiling on a single response body. A tail request asks for far less. */ +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +/** Trim a trailing slash so `new URL(path, base)` never doubles it. */ +function normalizeOrigin(url: string): string { + return url.trim().replace(/\/+$/, ''); +} + +/** + * Origins to probe, in preference order. `CODEMAN_API_URL` (the variable hooks + * already get) wins outright; otherwise both schemes on loopback, https first + * because a production install is HTTPS-only and a plain-http server rejects a + * TLS handshake immediately rather than hanging. + */ +export function tuiServerCandidates(env: { apiUrl?: string; port?: string | number } = {}): string[] { + if (env.apiUrl && env.apiUrl.trim()) return [normalizeOrigin(env.apiUrl)]; + const parsed = typeof env.port === 'number' ? env.port : Number.parseInt(String(env.port ?? ''), 10); + const port = Number.isSafeInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : DEFAULT_PORT; + return [`https://127.0.0.1:${port}`, `http://127.0.0.1:${port}`]; +} + +/** + * Parse a `KEY=value` env file. Mirrors `readCodemanEnv()` in `cli.ts`: blank + * lines and `#` comments skipped, one layer of matching quotes stripped. + */ +export function parseEnvFile(text: string): Record { + const result: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + result[match[1]] = value; + } + return result; +} + +export interface TuiCredentials { + username: string; + password?: string; +} + +/** + * Credentials for the API, env first and the data dir's `.env` as the fallback, + * exactly like the `codeman attach` path. No password means no auth is + * configured (or the user has it only in the server's environment, in which + * case the API answers 401 and `connect()` reports `authRequired`). + */ +export function readCodemanCredentials(envFilePath = dataPath('.env')): TuiCredentials { + let fileEnv: Record = {}; + try { + fileEnv = parseEnvFile(readFileSync(envFilePath, 'utf-8')); + } catch { + /* absent or unreadable: env-only */ + } + const username = process.env.CODEMAN_USERNAME || fileEnv.CODEMAN_USERNAME || 'admin'; + const password = process.env.CODEMAN_PASSWORD || fileEnv.CODEMAN_PASSWORD; + return password ? { username, password } : { username }; +} + +export function basicAuthHeader(credentials: TuiCredentials): string | undefined { + if (!credentials.password) return undefined; + return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Degraded mode +// ───────────────────────────────────────────────────────────────────────────── + +const execFileAsync = promisify(execFileCb); + +const defaultExecFile: TuiExecFile = async (file, args) => { + const { stdout, stderr } = await execFileAsync(file, [...args], { + timeout: EXEC_TIMEOUT_MS, + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + }); + return { stdout, stderr }; +}; + +/** + * Session names Codeman owns on this socket. Remote (`codeman-ssh-…`) and + * docker (`codeman-dkr-…`) names deliberately fail this pattern (they live on + * their own sockets and must never be adopted), and the letters they use are + * outside `[a-f0-9-]`, so this is the same fence `tmux-manager.ts` draws. + */ +const MUX_NAME_PATTERN = /^(?:codeman|claudeman)-([a-f0-9-]+)$/; + +/** Field separator for `list-sessions -F`. Session names cannot contain a tab. */ +const TMUX_FIELD_SEPARATOR = '\t'; + +const TMUX_LIST_FORMAT = ['#{session_name}', '#{session_attached}', '#{session_created}', '#{session_windows}'].join( + TMUX_FIELD_SEPARATOR +); + +/** Names/dirs from `state.json`, keyed by full session id. Read-only, tolerant. */ +function readStateSessions(statePath: string): Map { + const sessions = new Map(); + try { + const parsed = JSON.parse(readFileSync(statePath, 'utf-8')) as { + sessions?: Record; + }; + for (const [id, value] of Object.entries(parsed.sessions ?? {})) { + if (value && typeof value === 'object') sessions.set(id, value); + } + } catch { + /* no state file, or mid-write garbage: degraded mode is best-effort */ + } + return sessions; +} + +/** Parse `list-sessions -F` output. Pure, so the format string is unit-testable. */ +export function parseTmuxSessionList(stdout: string): TuiTmuxSession[] { + const rows: TuiTmuxSession[] = []; + for (const line of stdout.split('\n')) { + if (!line.trim()) continue; + const [muxName = '', attached = '', created = '', windows = ''] = line.split(TMUX_FIELD_SEPARATOR); + const match = MUX_NAME_PATTERN.exec(muxName); + if (!match) continue; + const createdSeconds = Number.parseInt(created, 10); + const windowCount = Number.parseInt(windows, 10); + rows.push({ + muxName, + sessionIdPrefix: match[1], + attached: attached.trim() !== '' && attached.trim() !== '0', + ...(Number.isSafeInteger(createdSeconds) && createdSeconds > 0 ? { createdAt: createdSeconds * 1000 } : {}), + ...(Number.isSafeInteger(windowCount) && windowCount > 0 ? { windows: windowCount } : {}), + }); + } + return rows; +} + +/** + * List Codeman's tmux sessions without a server, decorating them with whatever + * `state.json` remembers. Attach is all this supports: there are no states, no + * approvals and no previews when nothing is running the classification. + * + * The tmux call is `execFile` with an argv array (never a shell string), and + * the socket comes from the instance config, so a beta TUI sees only the beta + * instance's sessions. + */ +export async function enumerateTmuxSessions( + options: { exec?: TuiExecFile; socket?: string; statePath?: string } = {} +): Promise { + const exec = options.exec ?? defaultExecFile; + const socket = options.socket ?? resolveTmuxSocketName(); + let stdout = ''; + try { + ({ stdout } = await exec('tmux', ['-L', socket, 'list-sessions', '-F', TMUX_LIST_FORMAT])); + } catch { + // "no server running on ..." exits non-zero, which is simply an empty list. + return []; + } + + const rows = parseTmuxSessionList(stdout); + if (rows.length === 0) return rows; + + const state = readStateSessions(options.statePath ?? dataPath('state.json')); + for (const row of rows) { + const matches = [...state.entries()].filter(([id]) => id.startsWith(row.sessionIdPrefix)); + // Ambiguity is meaningless here: two ids sharing an 8-char prefix cannot both + // own one mux name, and guessing would put the wrong name on a row. + if (matches.length !== 1) continue; + const [id, entry] = matches[0]; + row.sessionId = id; + if (entry.name) row.name = entry.name; + if (entry.workingDir) row.workingDir = entry.workingDir; + if (entry.mode) row.mode = entry.mode; + } + return rows; +} + +// ───────────────────────────────────────────────────────────────────────────── +// The client +// ───────────────────────────────────────────────────────────────────────────── + +interface RawResponse { + status: number; + body: string; +} + +export class TuiClient { + private base: string | null; + private readonly credentials: TuiCredentials; + private readonly timeoutMs: number; + private readonly probeTimeoutMs: number; + private readonly exec: TuiExecFile; + private readonly statePath: string; + private readonly streams = new Set(); + private readonly clientId = `codeman-tui-${process.pid}`; + private seq = 0; + private server: TuiServerInfo | null = null; + + constructor(private readonly options: TuiClientOptions = {}) { + this.base = options.baseUrl ? normalizeOrigin(options.baseUrl) : null; + const credentials = readCodemanCredentials(options.envFilePath ?? dataPath('.env')); + this.credentials = { + username: options.username ?? credentials.username, + ...((options.password ?? credentials.password) ? { password: options.password ?? credentials.password } : {}), + }; + this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + this.exec = options.exec ?? defaultExecFile; + this.statePath = options.statePath ?? dataPath('state.json'); + } + + /** The origin in use, or null before a successful `connect()`. */ + get baseUrl(): string | null { + return this.base; + } + + get serverInfo(): TuiServerInfo | null { + return this.server; + } + + /** + * Find the server and read its identity. Returns null when nothing answers, + * which is the app's cue to fall back to `enumerateTmuxSessions()`. + */ + async connect(): Promise { + // An explicit port outranks the ambient `CODEMAN_API_URL` (which every + // Codeman-managed session exports): a caller that named a port must not be + // silently redirected at whatever server happens to own this shell. + const candidates = this.base + ? [this.base] + : this.options.port !== undefined + ? tuiServerCandidates({ port: this.options.port }) + : tuiServerCandidates({ apiUrl: process.env.CODEMAN_API_URL, port: process.env.CODEMAN_PORT }); + + const probes = await Promise.all( + candidates.map((origin) => probeServer(`${origin}/api/status`, this.probeTimeoutMs)) + ); + const index = probes.findIndex((probe) => probe.up); + if (index === -1) { + this.server = null; + return null; + } + + this.base = candidates[index]; + const info: TuiServerInfo = { + baseUrl: this.base, + instance: CODEMAN_INSTANCE, + hostname: this.resolveHostname(this.base), + }; + if (probes[index].version) info.version = probes[index].version; + + // The probe is unauthenticated, so behind a password it learns nothing but + // "something is there". Ask again with credentials for the version. + try { + const status = await this.requestData<{ version?: string; planUsage?: TuiPlanUsage | null }>( + 'GET', + '/api/status' + ); + if (status?.version) info.version = status.version; + } catch (err) { + if (err instanceof TuiApiError && (err.status === 401 || err.status === 403)) { + info.authRequired = true; + } + } + + this.server = info; + return info; + } + + // ── API ──────────────────────────────────────────────────────────────────── + + async fetchUnifiedSessions(limit?: number): Promise { + const query = limit !== undefined ? `?limit=${encodeURIComponent(String(Math.max(1, Math.trunc(limit))))}` : ''; + const data = await this.requestData<{ sessions?: UnifiedSessionItem[] }>('GET', `/api/sessions/unified${query}`); + return data?.sessions ?? []; + } + + async fetchApprovals(): Promise { + const data = await this.requestData<{ approvals?: ApprovalItem[] }>('GET', '/api/approvals'); + return data?.approvals ?? []; + } + + /** + * Answer a pending prompt. Every refusal the server can reasonably give + * (dialog gone, item already resolved, digit not among the parsed options) + * comes back as a typed result: a human answering a dialog that just closed + * is normal operation, not an error condition. + */ + async answerApproval(id: string, answer: TuiApprovalAnswer): Promise { + try { + const data = await this.requestData<{ id: string; sessionId: string; action: string }>( + 'POST', + `/api/approvals/${encodeURIComponent(id)}/answer`, + answer + ); + return { ok: true, id: data?.id ?? id, sessionId: data?.sessionId ?? '', action: data?.action ?? answer.action }; + } catch (err) { + if (!(err instanceof TuiApiError)) throw err; + return { ok: false, reason: answerFailureReason(err), message: err.message }; + } + } + + /** Raw terminal bytes (ANSI intact) for the preview pane. */ + async fetchTerminalTail(sessionId: string, bytes: number): Promise { + const tail = Math.max(1, Math.trunc(bytes)); + const data = await this.requestData<{ terminalBuffer?: string }>( + 'GET', + `/api/sessions/${encodeURIComponent(sessionId)}/terminal?tail=${tail}` + ); + return data?.terminalBuffer ?? ''; + } + + /** + * Type one line at a session's composer. + * + * Two hard rules from CLAUDE.md, both enforced here so no caller can get them + * wrong: the payload must END with `\r` or the server never issues Enter and + * the text sits unsubmitted, and embedded newlines are stripped rather than + * sent (multi-line input breaks Ink, and the server would join the lines). + * The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a + * dropped connection cannot type the prompt twice. + */ + async sendInput(sessionId: string, text: string): Promise { + const line = text.replace(/[\r\n]+/g, ' ').trim(); + this.seq += 1; + await this.requestData('POST', `/api/sessions/${encodeURIComponent(sessionId)}/input`, { + input: `${line}\r`, + useMux: true, + clientId: this.clientId, + seq: this.seq, + }); + } + + /** Last `seq` sent. Monotonic per process; exposed for tests and diagnostics. */ + get lastInputSeq(): number { + return this.seq; + } + + /** + * Start a session. Always `quick-start`, never `POST /api/sessions`: only + * this route resolves a case NAME, and it is what routes remote/docker cases + * to the right host instead of stat-ing the path locally. + */ + async quickStart(options: TuiQuickStartOptions): Promise { + const data = await this.requestData('POST', '/api/quick-start', { + caseName: options.caseName, + ...(options.mode ? { mode: options.mode } : {}), + ...(options.sessionName ? { sessionName: options.sessionName } : {}), + ...(options.parentSessionId ? { parentSessionId: options.parentSessionId } : {}), + }); + if (!data?.sessionId) throw new TuiApiError('quick-start returned no session id', 502); + return data; + } + + async fetchCases(): Promise { + const data = await this.requestData('GET', '/api/cases'); + return Array.isArray(data) ? data : []; + } + + async deleteSession(sessionId: string): Promise { + await this.requestData('DELETE', `/api/sessions/${encodeURIComponent(sessionId)}`); + } + + async search(query: string, limit?: number): Promise { + const params = new URLSearchParams({ q: query }); + if (limit !== undefined) params.set('limit', String(Math.max(1, Math.trunc(limit)))); + const data = await this.requestData('GET', `/api/search?${params.toString()}`); + return data ?? { query, groups: [], totalResults: 0, truncated: false }; + } + + /** + * The away digest. This route predates the envelope and answers + * `{success:true, digest}` with the payload at the TOP level, so it reads the + * raw body rather than `data` (see CLAUDE.md, away-digest). + */ + async fetchAwayDigest(range?: string): Promise { + const query = range ? `?range=${encodeURIComponent(range)}` : ''; + const body = await this.requestJson<{ digest?: AwayDigestResponse }>('GET', `/api/away-digest${query}`); + const digest = body?.digest; + if (!digest) throw new TuiApiError('away-digest returned no digest', 502); + return digest; + } + + /** Last-known plan-usage snapshot, or null when the account reports none. */ + async fetchPlanUsage(): Promise { + const data = await this.requestData<{ planUsage?: TuiPlanUsage | null }>('GET', '/api/status'); + return data?.planUsage ?? null; + } + + // ── Degraded mode ────────────────────────────────────────────────────────── + + /** Sessions straight from tmux, for when no server answered. */ + enumerateTmuxSessions(): Promise { + return enumerateTmuxSessions({ exec: this.exec, statePath: this.statePath }); + } + + // ── Live updates ─────────────────────────────────────────────────────────── + + /** + * Subscribe to `/api/events`. The returned stream owns its socket, its + * watchdog and its backoff timer; `close()` (or the client's) releases all + * three. Handlers are called synchronously as frames decode. + */ + subscribeEvents(handlers: TuiEventHandlers, options: TuiSubscribeOptions = {}): TuiEventStream { + if (!this.base) throw new Error('subscribeEvents() needs a connected client: call connect() first'); + const stream = new SseStream(this.base, this.authHeader(), handlers, options, () => this.streams.delete(stream)); + this.streams.add(stream); + stream.start(); + return stream; + } + + /** Tear down every stream this client opened. Safe to call twice. */ + close(): void { + for (const stream of [...this.streams]) stream.close(); + this.streams.clear(); + } + + // ── Internals ────────────────────────────────────────────────────────────── + + private authHeader(): string | undefined { + return basicAuthHeader(this.credentials); + } + + /** + * The header's hostname. No endpoint reports the server's own, so a loopback + * origin means "this machine" and anything else is named by its URL. + */ + private resolveHostname(origin: string): string { + try { + const host = new URL(origin).hostname; + if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]') return osHostname(); + return host; + } catch { + return osHostname(); + } + } + + /** Unwrap `{success:true,data}`; throw `TuiApiError` on `success:false`. */ + private async requestData(method: string, path: string, body?: unknown): Promise { + const payload = await this.requestJson<{ success?: boolean; data?: T }>(method, path, body); + if (payload && typeof payload === 'object' && payload.success === true) return payload.data; + return payload as unknown as T; + } + + /** The parsed response body, envelope and all. */ + private async requestJson(method: string, path: string, body?: unknown): Promise { + const response = await this.raw(method, path, body); + let parsed: unknown; + if (response.body.trim()) { + try { + parsed = JSON.parse(response.body); + } catch { + if (response.status >= 400) { + throw new TuiApiError(httpErrorMessage(response), response.status); + } + throw new TuiApiError(`${method} ${path} returned a non-JSON body`, response.status); + } + } + + const envelope = parsed as { success?: boolean; error?: string; errorCode?: string } | undefined; + if (envelope && typeof envelope === 'object' && envelope.success === false) { + throw new TuiApiError(envelope.error || 'request failed', response.status, envelope.errorCode); + } + if (response.status >= 400) { + throw new TuiApiError(httpErrorMessage(response), response.status); + } + return parsed as T | undefined; + } + + private raw(method: string, path: string, body?: unknown): Promise { + if (!this.base) throw new Error('the TUI client is not connected to a server'); + const url = new URL(path, `${this.base}/`); + const payload = body === undefined ? undefined : JSON.stringify(body); + const transport = url.protocol === 'https:' ? https : http; + const auth = this.authHeader(); + + return new Promise((resolve, reject) => { + const headers: Record = { Accept: 'application/json' }; + if (payload !== undefined) { + headers['Content-Type'] = 'application/json'; + headers['Content-Length'] = Buffer.byteLength(payload); + } + if (auth) headers.Authorization = auth; + + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method, + path: `${url.pathname}${url.search}`, + // Loopback with the self-signed cert `--https` generates: verifying it + // would fail every local request. Same call the CLI already makes. + rejectUnauthorized: false, + timeout: this.timeoutMs, + headers, + }, + (res) => { + let text = ''; + let overflowed = false; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + if (overflowed) return; + if (text.length + chunk.length > MAX_RESPONSE_BYTES) { + overflowed = true; + res.destroy(); + reject(new TuiApiError(`${method} ${path} response exceeded ${MAX_RESPONSE_BYTES} bytes`, 507)); + return; + } + text += chunk; + }); + res.on('end', () => { + if (!overflowed) resolve({ status: res.statusCode ?? 0, body: text }); + }); + res.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0))); + } + ); + req.on('timeout', () => { + req.destroy(); + reject(new TuiApiError(`${method} ${path} timed out after ${this.timeoutMs}ms`, 0)); + }); + req.on('error', (err) => reject(new TuiApiError(getErrorMessage(err), 0))); + if (payload !== undefined) req.write(payload); + req.end(); + }); + } +} + +function httpErrorMessage(response: RawResponse): string { + const detail = response.body.trim().slice(0, 200); + return detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`; +} + +/** Map an answer failure onto the outcomes the dashboard renders differently. */ +function answerFailureReason(err: TuiApiError): 'gone' | 'not-found' | 'rejected' | 'failed' { + if (err.errorCode === 'CONFLICT' || err.status === 409) return 'gone'; + if (err.errorCode === 'NOT_FOUND' || err.status === 404) return 'not-found'; + if (err.errorCode === 'INVALID_INPUT' || err.status === 400) return 'rejected'; + return 'failed'; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SSE connection +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A session id can never look like this, so passing it as the `?sessions=` + * filter drops every `session:terminal` frame while leaving lifecycle, hook and + * approval events untouched. + */ +const NO_TERMINAL_FILTER = 'tui-no-terminal'; + +const DEFAULT_POLLING_AFTER_FAILURES = 2; + +class SseStream implements TuiEventStream { + private req: http.ClientRequest | null = null; + private res: http.IncomingMessage | null = null; + private parser = new SseFrameParser(); + private watchdog: NodeJS.Timeout | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private lastTraffic = 0; + private attempt = 0; + private closed = false; + /** Bumped per connection so a late `error`/`close` cannot fail a newer socket. */ + private generation = 0; + private _status: TuiSseStatus = 'reconnecting'; + private _recommendPolling = false; + + private readonly staleTimeoutMs: number; + private readonly checkIntervalMs: number; + private readonly baseBackoffMs: number; + private readonly maxBackoffMs: number; + private readonly pollingAfterFailures: number; + private readonly sessionsParam: string; + + constructor( + private readonly baseUrl: string, + private readonly auth: string | undefined, + private readonly handlers: TuiEventHandlers, + options: TuiSubscribeOptions, + private readonly onClosed: () => void + ) { + this.staleTimeoutMs = options.staleTimeoutMs ?? SSE_STALE_TIMEOUT_MS; + this.checkIntervalMs = options.checkIntervalMs ?? Math.max(250, Math.floor(this.staleTimeoutMs / 3)); + this.baseBackoffMs = options.baseBackoffMs ?? SSE_BASE_BACKOFF_MS; + this.maxBackoffMs = options.maxBackoffMs ?? SSE_MAX_BACKOFF_MS; + this.pollingAfterFailures = options.pollingAfterFailures ?? DEFAULT_POLLING_AFTER_FAILURES; + this.sessionsParam = options.sessionIds?.length ? options.sessionIds.join(',') : NO_TERMINAL_FILTER; + } + + get status(): TuiSseStatus { + return this._status; + } + + get recommendPolling(): boolean { + return this._recommendPolling; + } + + start(): void { + this.open(); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.teardown(); + this.onClosed(); + } + + private open(): void { + if (this.closed) return; + const generation = ++this.generation; + const url = new URL('/api/events', `${this.baseUrl}/`); + url.searchParams.set('sessions', this.sessionsParam); + const transport = url.protocol === 'https:' ? https : http; + + const headers: Record = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' }; + if (this.auth) headers.Authorization = this.auth; + + const req = transport.request( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + method: 'GET', + path: `${url.pathname}${url.search}`, + rejectUnauthorized: false, + headers, + }, + (res) => { + if (generation !== this.generation || this.closed) { + res.destroy(); + return; + } + if (res.statusCode !== 200) { + res.resume(); + this.fail(generation, `event stream refused with HTTP ${res.statusCode ?? 0}`); + return; + } + this.res = res; + this.attempt = 0; + this._recommendPolling = false; + this._status = 'connected'; + this.touch(); + this.armWatchdog(); + this.handlers.onStatus?.('connected', { attempt: 0, recommendPolling: false }); + + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + if (generation !== this.generation) return; + // Any inbound bytes are liveness, comments and padding included. + this.touch(); + for (const frame of this.parser.feed(chunk)) this.dispatch(frame.event, frame.data); + }); + res.on('end', () => this.fail(generation, 'event stream ended')); + res.on('close', () => this.fail(generation, 'event stream closed')); + res.on('error', (err) => this.fail(generation, getErrorMessage(err))); + } + ); + req.on('error', (err) => this.fail(generation, getErrorMessage(err))); + this.req = req; + req.end(); + } + + private dispatch(event: string, data: string): void { + switch (classifySseEvent(event)) { + case 'heartbeat': + return; + case 'init': { + const state = parseJson<{ version?: string; planUsage?: TuiPlanUsage | null }>(data); + if (state) this.handlers.onInit?.({ version: state.version, planUsage: state.planUsage ?? null }); + return; + } + case 'approval': { + const payload = parseJson(data); + const kind = approvalEventKind(event); + if (!payload || !kind) return; + if (kind === 'resolved') { + this.handlers.onApproval?.({ kind, info: payload as ApprovalResolvedInfo }); + } else { + this.handlers.onApproval?.({ kind, item: payload as ApprovalItem }); + } + // An approval landing or clearing also changes how its row is grouped. + this.handlers.onResync?.(event); + return; + } + case 'plan-usage': { + const usage = parseJson(data); + if (usage) this.handlers.onPlanUsage?.(usage); + return; + } + case 'resync': + this.handlers.onResync?.(event); + return; + case 'ignore': + return; + } + } + + private touch(): void { + this.lastTraffic = Date.now(); + } + + private armWatchdog(): void { + if (this.watchdog) return; + this.watchdog = setInterval(() => { + if (this.closed) return; + if (Date.now() - this.lastTraffic <= this.staleTimeoutMs) return; + // The socket never errored, it just went quiet: the server heartbeats + // every 15s, so this is a dead stream that would otherwise freeze every + // SSE-driven surface until the user restarted the TUI. + this.fail(this.generation, `no traffic for ${this.staleTimeoutMs}ms`); + }, this.checkIntervalMs); + } + + private fail(generation: number, message: string): void { + if (this.closed || generation !== this.generation) return; + this.generation++; + this.teardown(); + + this.attempt++; + this._status = 'reconnecting'; + this._recommendPolling = this.attempt >= this.pollingAfterFailures; + this.handlers.onStatus?.('reconnecting', { + attempt: this.attempt, + recommendPolling: this._recommendPolling, + message, + }); + + const delay = sseBackoffDelay(this.attempt, this.baseBackoffMs, this.maxBackoffMs); + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.open(); + }, delay); + } + + /** Drop the socket and both timers. Never touches `closed`, so `fail()` can reuse it. */ + private teardown(): void { + if (this.watchdog) { + clearInterval(this.watchdog); + this.watchdog = null; + } + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + // The no-op error listeners are not decoration: destroying a socket can + // emit ECONNRESET, and an `error` event with no listener is an uncaught + // exception that would take the TUI down on a routine reconnect. + if (this.res) { + this.res.removeAllListeners(); + this.res.on('error', () => {}); + this.res.destroy(); + this.res = null; + } + if (this.req) { + this.req.removeAllListeners(); + this.req.on('error', () => {}); + this.req.destroy(); + this.req = null; + } + this.parser.reset(); + } +} + +function parseJson(text: string): T | null { + try { + return JSON.parse(text) as T; + } catch { + return null; + } +} diff --git a/test/tui/tui-client-sse.test.ts b/test/tui/tui-client-sse.test.ts new file mode 100644 index 00000000..a811b91c --- /dev/null +++ b/test/tui/tui-client-sse.test.ts @@ -0,0 +1,206 @@ +/** + * @fileoverview Integration tests for the TUI's live-update stream. + * + * These run against a real loopback `text/event-stream` endpoint rather than a + * mocked socket, because the behaviours that matter here are all socket-level: + * a stream that ENDS, a stream that goes SILENT without erroring (the failure + * mode `EventSource` cannot see, which is why the server heartbeats), and a + * teardown that must leave no timer behind. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import http from 'node:http'; +import { TuiClient, type TuiApprovalEvent, type TuiSseStatusDetail } from '../../src/tui/tui-client.js'; + +const PORT = 3242; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +interface Connection { + url: string; + headers: http.IncomingHttpHeaders; + res: http.ServerResponse; +} + +const connections: Connection[] = []; +/** Flipped by a test that wants every connect attempt to fail. */ +let refuse = false; + +let server: http.Server; +let client: TuiClient | null = null; + +function frame(event: string, data: unknown): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function until(predicate: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('condition not met before the deadline'); +} + +beforeAll(async () => { + server = http.createServer((req, res) => { + if (!req.url?.startsWith('/api/events')) { + res.writeHead(404).end(); + return; + } + if (refuse) { + res.writeHead(503).end('busy'); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + // Node holds headers back until the first body write; the real server sends + // an `init` frame immediately, so flush to match it. Without this the + // client never sees a response and every test here waits forever. + res.flushHeaders(); + connections.push({ url: req.url, headers: req.headers, res }); + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + connections.length = 0; + refuse = false; +}); + +afterEach(() => { + client?.close(); + client = null; + for (const connection of connections) connection.res.end(); +}); + +describe('subscribeEvents', () => { + it('routes each frame to the handler that owns it', async () => { + const resyncs: string[] = []; + const approvals: TuiApprovalEvent[] = []; + let planUsage: unknown = null; + let init: unknown = null; + + client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client.subscribeEvents({ + onInit: (state) => { + init = state; + }, + onResync: (event) => resyncs.push(event), + onApproval: (event) => approvals.push(event), + onPlanUsage: (usage) => { + planUsage = usage; + }, + }); + + await until(() => connections.length === 1); + const { res } = connections[0]; + res.write(frame('init', { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } })); + res.write(frame('session:created', { id: 'a' })); + // Split across writes on purpose: the parser must not need frame-aligned reads. + res.write('event: approval:pending\ndata: {"id":"a:1","sessionId":"a",'); + res.write('"kind":"permission","createdAt":7}\n\n'); + res.write(frame('session:terminal', { id: 'a', data: 'noise' })); + res.write(frame('sse:heartbeat', { t: 1 })); + res.write(frame('session:statusTelemetry', { sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } })); + + await until(() => planUsage !== null); + expect(init).toEqual({ version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 5, resetAt: 1 } } }); + expect(approvals).toEqual([ + { kind: 'pending', item: { id: 'a:1', sessionId: 'a', kind: 'permission', createdAt: 7 } }, + ]); + expect(planUsage).toEqual({ sessionId: 'a', fiveHour: { usedPercentage: 41, resetAt: 2 } }); + // The approval also regrouped a row, so it resyncs too. Terminal and + // heartbeat frames never do. + expect(resyncs).toEqual(['session:created', 'approval:pending']); + }); + + it('suppresses the terminal firehose by default and carries the auth header', async () => { + client = new TuiClient({ baseUrl: BASE_URL, password: 's3cret' }); + client.subscribeEvents({}); + await until(() => connections.length === 1); + expect(connections[0].url).toBe('/api/events?sessions=tui-no-terminal'); + expect(connections[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`); + expect(connections[0].headers.accept).toBe('text/event-stream'); + }); + + it('subscribes to the terminal stream of named sessions when asked', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}, { sessionIds: ['a', 'b'] }); + await until(() => connections.length === 1); + expect(connections[0].url).toBe('/api/events?sessions=a%2Cb'); + }); + + it('reconnects when the stream ends', async () => { + const statuses: Array<[string, TuiSseStatusDetail]> = []; + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents( + { onStatus: (status, detail) => statuses.push([status, detail]) }, + { baseBackoffMs: 10, maxBackoffMs: 20 } + ); + + await until(() => stream.status === 'connected'); + connections[0].res.end(); + await until(() => connections.length === 2 && stream.status === 'connected'); + expect(statuses.map(([status]) => status)).toEqual(['connected', 'reconnecting', 'connected']); + expect(statuses[1][1].message).toBeTruthy(); + }); + + it('reconnects when a live stream goes silent, which no socket error reports', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}, { staleTimeoutMs: 150, checkIntervalMs: 25, baseBackoffMs: 10, maxBackoffMs: 20 }); + + await until(() => connections.length === 1); + // The server holds the connection open and says nothing: exactly the case + // the watchdog exists for. + await until(() => connections.length === 2); + expect(connections).toHaveLength(2); + }); + + it('recommends polling once connecting keeps failing', async () => { + refuse = true; + const details: TuiSseStatusDetail[] = []; + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents( + { onStatus: (_status, detail) => details.push(detail) }, + { baseBackoffMs: 10, maxBackoffMs: 20, pollingAfterFailures: 2 } + ); + + await until(() => details.length >= 2); + expect(details[0]).toMatchObject({ attempt: 1, recommendPolling: false }); + expect(details[1]).toMatchObject({ attempt: 2, recommendPolling: true }); + expect(stream.recommendPolling).toBe(true); + expect(stream.status).toBe('reconnecting'); + }); + + it('stops reconnecting after close, so the process can exit', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + const stream = client.subscribeEvents({}, { baseBackoffMs: 10, maxBackoffMs: 20 }); + await until(() => connections.length === 1); + + connections[0].res.end(); + stream.close(); + const seen = connections.length; + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(connections.length).toBe(seen); + }); + + it('closes every stream the client opened', async () => { + client = new TuiClient({ baseUrl: BASE_URL }); + client.subscribeEvents({}); + client.subscribeEvents({}); + await until(() => connections.length === 2); + + client.close(); + await until(() => connections.every((connection) => connection.res.socket === null || connection.res.destroyed)); + const seen = connections.length; + await new Promise((resolve) => setTimeout(resolve, 120)); + expect(connections.length).toBe(seen); + }); + + it('refuses to subscribe before the client knows where the server is', () => { + const disconnected = new TuiClient({ port: 3999 }); + expect(() => disconnected.subscribeEvents({})).toThrow(/connect\(\)/); + }); +}); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts new file mode 100644 index 00000000..5e3e4029 --- /dev/null +++ b/test/tui/tui-client.test.ts @@ -0,0 +1,465 @@ +/** + * @fileoverview Unit tests for the TUI's IO layer: discovery, credentials, the + * typed API surface and degraded-mode tmux enumeration. + * + * The API calls run against a real loopback HTTP server that answers in the + * shapes the routes really produce (the `{success,data}` envelope, plus + * away-digest's legacy top-level `digest`), so an envelope change breaks these + * tests rather than the dashboard. tmux is never executed: the exec function is + * injected, and `test/setup.ts` gives this file its own HOME, so the state file + * it reads is a fixture of its own making. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import http from 'node:http'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { dataPath } from '../../src/config/instance.js'; +import { + TuiApiError, + TuiClient, + basicAuthHeader, + enumerateTmuxSessions, + parseEnvFile, + parseTmuxSessionList, + readCodemanCredentials, + tuiServerCandidates, + type TuiExecFile, +} from '../../src/tui/tui-client.js'; + +const PORT = 3241; +/** Nothing ever listens here: the "no server" path. */ +const DEAD_PORT = 3243; +const BASE_URL = `http://127.0.0.1:${PORT}`; + +interface Recorded { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +const recorded: Recorded[] = []; +type Responder = (req: http.IncomingMessage, res: http.ServerResponse, body: string) => void; + +/** Per-test override; falls back to `defaultResponder`. */ +let responder: Responder | null = null; + +function sendJson(res: http.ServerResponse, status: number, payload: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +const defaultResponder: Responder = (req, res) => { + const url = req.url ?? ''; + if (url.startsWith('/api/status')) { + return sendJson(res, 200, { + success: true, + data: { version: '9.9.9', planUsage: { fiveHour: { usedPercentage: 32, resetAt: 1000 } } }, + }); + } + if (url.startsWith('/api/sessions/unified')) { + return sendJson(res, 200, { + success: true, + data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 }, + }); + } + if (url.startsWith('/api/approvals')) { + return sendJson(res, 200, { + success: true, + data: { approvals: [{ id: 'abc:1', sessionId: 'abc', sessionName: 'w1', kind: 'permission', createdAt: 5 }] }, + }); + } + if (url.includes('/terminal')) { + return sendJson(res, 200, { success: true, data: { terminalBuffer: 'tail bytes', status: 'idle' } }); + } + if (url.endsWith('/input')) { + return sendJson(res, 200, { success: true, data: {} }); + } + if (url.startsWith('/api/quick-start')) { + return sendJson(res, 200, { success: true, data: { sessionId: 'new-1', casePath: '/cases/x', caseName: 'x' } }); + } + if (url.startsWith('/api/cases')) { + return sendJson(res, 200, { success: true, data: [{ name: 'x', path: '/cases/x', location: 'local' }] }); + } + if (url.startsWith('/api/search')) { + return sendJson(res, 200, { + success: true, + data: { query: 'foo', groups: [], totalResults: 0, truncated: false }, + }); + } + if (url.startsWith('/api/away-digest')) { + // Legacy shape: the payload sits at the TOP level, not under `data`. + return sendJson(res, 200, { success: true, digest: { totals: { activeSessions: 2 } } }); + } + if (req.method === 'DELETE') { + return sendJson(res, 200, { success: true, data: {} }); + } + return sendJson(res, 404, { success: false, error: 'no route', errorCode: 'NOT_FOUND' }); +}; + +function client(overrides: Record = {}): TuiClient { + return new TuiClient({ baseUrl: BASE_URL, timeoutMs: 4000, ...overrides }); +} + +let server: http.Server; +const originalApiUrl = process.env.CODEMAN_API_URL; +const originalPort = process.env.CODEMAN_PORT; + +beforeAll(async () => { + // This suite runs inside a Codeman-managed session, which exports + // CODEMAN_API_URL pointing at the LIVE server. Discovery consults it, so it + // has to be out of the way before any test calls connect(). + delete process.env.CODEMAN_API_URL; + delete process.env.CODEMAN_PORT; + + server = http.createServer((req, res) => { + let body = ''; + req.setEncoding('utf-8'); + req.on('data', (chunk: string) => { + body += chunk; + }); + req.on('end', () => { + recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body }); + (responder ?? defaultResponder)(req, res, body); + }); + }); + await new Promise((resolve) => server.listen(PORT, '127.0.0.1', resolve)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + if (originalApiUrl !== undefined) process.env.CODEMAN_API_URL = originalApiUrl; + if (originalPort !== undefined) process.env.CODEMAN_PORT = originalPort; +}); + +beforeEach(() => { + recorded.length = 0; + responder = null; +}); + +describe('parseEnvFile', () => { + it('reads plain assignments and skips comments and blanks', () => { + expect(parseEnvFile('# comment\n\nCODEMAN_USERNAME=bob\nCODEMAN_PASSWORD=hunter2\n')).toEqual({ + CODEMAN_USERNAME: 'bob', + CODEMAN_PASSWORD: 'hunter2', + }); + }); + + it('strips one layer of matching quotes', () => { + expect(parseEnvFile('A="quoted"\nB=\'single\'\nC="mismatched\'')).toEqual({ + A: 'quoted', + B: 'single', + C: '"mismatched\'', + }); + }); + + it('ignores lines that are not assignments', () => { + expect(parseEnvFile('not an assignment\n1BAD=x\nGOOD=y')).toEqual({ GOOD: 'y' }); + }); +}); + +describe('readCodemanCredentials', () => { + it('falls back to the data dir .env when the environment has nothing', () => { + const envPath = dataPath('.env'); + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8'); + expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'filepass' }); + }); + + it('lets the environment win over the file', () => { + const envPath = dataPath('.env'); + writeFileSync(envPath, 'CODEMAN_USERNAME=fileuser\nCODEMAN_PASSWORD=filepass\n', 'utf-8'); + process.env.CODEMAN_PASSWORD = 'envpass'; + try { + expect(readCodemanCredentials()).toEqual({ username: 'fileuser', password: 'envpass' }); + } finally { + delete process.env.CODEMAN_PASSWORD; + } + }); + + it('reports admin with no password when nothing is configured', () => { + expect(readCodemanCredentials('/nonexistent/codeman/.env')).toEqual({ username: 'admin' }); + }); +}); + +describe('basicAuthHeader', () => { + it('is absent without a password and base64 with one', () => { + expect(basicAuthHeader({ username: 'admin' })).toBeUndefined(); + expect(basicAuthHeader({ username: 'admin', password: 's3cret' })).toBe( + `Basic ${Buffer.from('admin:s3cret').toString('base64')}` + ); + }); +}); + +describe('tuiServerCandidates', () => { + it('prefers an explicit API url and trims its trailing slash', () => { + expect(tuiServerCandidates({ apiUrl: 'https://box:8443/' })).toEqual(['https://box:8443']); + }); + + it('probes both schemes on loopback, https first', () => { + expect(tuiServerCandidates({ port: 5000 })).toEqual(['https://127.0.0.1:5000', 'http://127.0.0.1:5000']); + }); + + it('falls back to port 3000 for junk', () => { + expect(tuiServerCandidates({ port: 'not-a-port' })).toEqual(['https://127.0.0.1:3000', 'http://127.0.0.1:3000']); + }); +}); + +describe('TuiClient envelope handling', () => { + it('unwraps the sessions list', async () => { + const sessions = await client().fetchUnifiedSessions(10); + expect(sessions).toEqual([{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }]); + expect(recorded[0].url).toBe('/api/sessions/unified?limit=10'); + }); + + it('unwraps pending approvals', async () => { + const approvals = await client().fetchApprovals(); + expect(approvals).toHaveLength(1); + expect(approvals[0].id).toBe('abc:1'); + }); + + it('turns a success:false envelope into a typed error carrying the code', async () => { + responder = (_req, res) => + sendJson(res, 404, { success: false, error: 'Session not found', errorCode: 'NOT_FOUND' }); + await expect(client().fetchTerminalTail('gone', 1000)).rejects.toMatchObject({ + name: 'TuiApiError', + status: 404, + errorCode: 'NOT_FOUND', + message: 'Session not found', + }); + }); + + it('turns a non-JSON failure into a typed error too', async () => { + responder = (_req, res) => { + res.writeHead(502, { 'Content-Type': 'text/plain' }); + res.end('bad gateway'); + }; + const err = await client() + .fetchApprovals() + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(TuiApiError); + expect((err as TuiApiError).status).toBe(502); + }); + + it('sends Basic auth when a password is configured, and none when it is not', async () => { + await client({ username: 'admin', password: 's3cret' }).fetchApprovals(); + expect(recorded[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:s3cret').toString('base64')}`); + + recorded.length = 0; + await client({ envFilePath: '/nonexistent/codeman/.env' }).fetchApprovals(); + expect(recorded[0].headers.authorization).toBeUndefined(); + }); +}); + +describe('TuiClient.answerApproval', () => { + it('reports success', async () => { + responder = (_req, res) => + sendJson(res, 200, { success: true, data: { id: 'abc:1', sessionId: 'abc', action: 'approve' } }); + await expect(client().answerApproval('abc:1', { action: 'approve' })).resolves.toEqual({ + ok: true, + id: 'abc:1', + sessionId: 'abc', + action: 'approve', + }); + }); + + it('reports a 409 as a typed "gone" result, not an exception', async () => { + responder = (_req, res) => + sendJson(res, 409, { success: false, error: 'The dialog is no longer on screen', errorCode: 'CONFLICT' }); + const result = await client().answerApproval('abc:1', { action: 'option', option: 2 }); + expect(result).toEqual({ ok: false, reason: 'gone', message: 'The dialog is no longer on screen' }); + }); + + it('separates "already resolved" from "digit rejected"', async () => { + responder = (_req, res) => sendJson(res, 404, { success: false, error: 'gone', errorCode: 'NOT_FOUND' }); + expect((await client().answerApproval('x', { action: 'deny' })).ok).toBe(false); + expect(await client().answerApproval('x', { action: 'deny' })).toMatchObject({ reason: 'not-found' }); + + responder = (_req, res) => sendJson(res, 400, { success: false, error: 'bad option', errorCode: 'INVALID_INPUT' }); + expect(await client().answerApproval('x', { action: 'option', option: 9 })).toMatchObject({ reason: 'rejected' }); + }); + + it('posts the answer body verbatim', async () => { + responder = (_req, res) => sendJson(res, 200, { success: true, data: { id: 'a', sessionId: 'b', action: 'text' } }); + await client().answerApproval('a b/c', { action: 'text', text: 'yes please' }); + expect(recorded[0].method).toBe('POST'); + expect(recorded[0].url).toBe('/api/approvals/a%20b%2Fc/answer'); + expect(JSON.parse(recorded[0].body)).toEqual({ action: 'text', text: 'yes please' }); + }); +}); + +describe('TuiClient.sendInput', () => { + it('always terminates with a carriage return and never sends a bare newline', async () => { + await client().sendInput('abc', 'hello world'); + expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true }); + }); + + it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => { + await client().sendInput('abc', 'echo A\necho B\r\nline three '); + expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r'); + }); + + it('tags every send with a stable clientId and a monotonic seq', async () => { + const c = client(); + await c.sendInput('abc', 'one'); + await c.sendInput('abc', 'two'); + await c.sendInput('def', 'three'); + const bodies = recorded.map((entry) => JSON.parse(entry.body) as { clientId: string; seq: number }); + expect(bodies.map((b) => b.seq)).toEqual([1, 2, 3]); + expect(new Set(bodies.map((b) => b.clientId)).size).toBe(1); + expect(bodies[0].clientId).toMatch(/^codeman-tui-\d+$/); + expect(c.lastInputSeq).toBe(3); + }); +}); + +describe('TuiClient remaining API surface', () => { + it('fetches a terminal tail by byte count', async () => { + await expect(client().fetchTerminalTail('abc', 4096)).resolves.toBe('tail bytes'); + expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096'); + }); + + it('starts sessions through quick-start', async () => { + const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' }); + expect(result.sessionId).toBe('new-1'); + expect(recorded[0].url).toBe('/api/quick-start'); + expect(JSON.parse(recorded[0].body)).toEqual({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' }); + }); + + it('lists cases', async () => { + await expect(client().fetchCases()).resolves.toEqual([{ name: 'x', path: '/cases/x', location: 'local' }]); + }); + + it('deletes a session by exact id', async () => { + await client().deleteSession('abc'); + expect(recorded[0].method).toBe('DELETE'); + expect(recorded[0].url).toBe('/api/sessions/abc'); + }); + + it('searches with an encoded query', async () => { + await client().search('a b', 5); + expect(recorded[0].url).toBe('/api/search?q=a+b&limit=5'); + }); + + it('reads the away digest from its legacy top-level shape', async () => { + await expect(client().fetchAwayDigest('24h')).resolves.toEqual({ totals: { activeSessions: 2 } }); + expect(recorded[0].url).toBe('/api/away-digest?range=24h'); + }); + + it('reads plan usage off the status snapshot', async () => { + await expect(client().fetchPlanUsage()).resolves.toEqual({ fiveHour: { usedPercentage: 32, resetAt: 1000 } }); + }); + + it('reports a missing plan-usage snapshot as null rather than throwing', async () => { + responder = (_req, res) => sendJson(res, 200, { success: true, data: { version: '1.0.0', planUsage: null } }); + await expect(client().fetchPlanUsage()).resolves.toBeNull(); + }); +}); + +describe('TuiClient.connect', () => { + it('discovers the loopback server and reports its identity', async () => { + const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(BASE_URL); + expect(info?.version).toBe('9.9.9'); + expect(info?.hostname).toBeTruthy(); + expect(info?.authRequired).toBeUndefined(); + }); + + it('reports a server that rejects our credentials instead of calling it down', async () => { + responder = (_req, res) => { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="codeman"' }); + res.end('Unauthorized'); + }; + const info = await new TuiClient({ port: PORT, probeTimeoutMs: 1000 }).connect(); + expect(info?.baseUrl).toBe(BASE_URL); + expect(info?.authRequired).toBe(true); + }); + + it('returns null when nothing answers', async () => { + await expect(new TuiClient({ port: DEAD_PORT, probeTimeoutMs: 500 }).connect()).resolves.toBeNull(); + }); + + it('refuses to talk to an unconnected client', async () => { + await expect(new TuiClient({ port: DEAD_PORT }).fetchApprovals()).rejects.toThrow(/not connected/); + }); +}); + +describe('degraded-mode tmux enumeration', () => { + const listing = [ + 'codeman-1a2b3c4d\t1\t1700000000\t1', + 'codeman-deadbeef\t0\t1700000100\t2', + 'claudeman-cafe0001\t0\t1700000200\t1', + 'my-own-tmux-session\t1\t1700000300\t1', + 'codeman-ssh-abc\t0\t1700000400\t1', + ].join('\n'); + + it('parses the list format and keeps only Codeman-owned names', () => { + const rows = parseTmuxSessionList(listing); + expect(rows.map((row) => row.muxName)).toEqual(['codeman-1a2b3c4d', 'codeman-deadbeef', 'claudeman-cafe0001']); + expect(rows[0]).toMatchObject({ sessionIdPrefix: '1a2b3c4d', attached: true, createdAt: 1_700_000_000_000 }); + expect(rows[1]).toMatchObject({ attached: false, windows: 2 }); + }); + + it('never shells out: the tmux call is an argv array on the instance socket', async () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const exec: TuiExecFile = async (file, args) => { + calls.push({ file, args }); + return { stdout: listing, stderr: '' }; + }; + await enumerateTmuxSessions({ exec, socket: 'codeman-beta', statePath: '/nonexistent/state.json' }); + expect(calls).toHaveLength(1); + expect(calls[0].file).toBe('tmux'); + expect(calls[0].args.slice(0, 4)).toEqual(['-L', 'codeman-beta', 'list-sessions', '-F']); + }); + + it('decorates rows with names and dirs from state.json, matching on the id prefix', async () => { + const statePath = dataPath('state.json'); + writeFileSync( + statePath, + JSON.stringify({ + sessions: { + '1a2b3c4d-1111-2222-3333-444444444444': { + name: 'w1-codeman', + workingDir: '/home/dev/codeman', + mode: 'claude', + }, + }, + }), + 'utf-8' + ); + const exec: TuiExecFile = async () => ({ stdout: listing, stderr: '' }); + const rows = await enumerateTmuxSessions({ exec, statePath }); + expect(rows[0]).toMatchObject({ + sessionId: '1a2b3c4d-1111-2222-3333-444444444444', + name: 'w1-codeman', + workingDir: '/home/dev/codeman', + mode: 'claude', + }); + // No state entry: the row still exists, it just has no decoration. + expect(rows[1].sessionId).toBeUndefined(); + expect(rows[1].name).toBeUndefined(); + }); + + it('refuses to guess when two ids share a prefix', async () => { + const statePath = dataPath('ambiguous-state.json'); + writeFileSync( + statePath, + JSON.stringify({ + sessions: { + '1a2b3c4d-aaaa': { name: 'first' }, + '1a2b3c4d-bbbb': { name: 'second' }, + }, + }), + 'utf-8' + ); + const exec: TuiExecFile = async () => ({ stdout: 'codeman-1a2b3c4d\t0\t1700000000\t1', stderr: '' }); + const rows = await enumerateTmuxSessions({ exec, statePath }); + expect(rows[0].name).toBeUndefined(); + }); + + it('treats a dead tmux server as an empty list', async () => { + const exec: TuiExecFile = async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman'); + }; + await expect(enumerateTmuxSessions({ exec })).resolves.toEqual([]); + }); +});