diff --git a/.gitignore b/.gitignore index 618ec51..ca51287 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output dist/ +dor/src/generated-version.ts lib/dist/ *.tsbuildinfo diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index 89e1da7..fc92384 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -122,6 +122,9 @@ Invariants: - Stable ids and short refs are accepted where a surface/pane target is accepted. +- Surface targets also accept `title:`. If exactly one + visible surface has that title, it is selected. If multiple visible surfaces + match, the command fails and lists the matching surface refs. - Short refs currently use cmux-style names for implemented handles: `surface:1`, `pane:2`. - List output defaults to refs; commands that list handles accept @@ -154,5 +157,9 @@ from `command-detail`. - `dor split` [impl](../../dor/src/commands/split.ts) [docs](../../dor/test/snapshots/help/split.md) - `dor ensure` [impl](../../dor/src/commands/ensure.ts) [docs](../../dor/test/snapshots/help/ensure.md) +- `dor version` [impl](../../dor/src/commands/version.ts) [docs](../../dor/test/snapshots/help/version.md) +- `dor send` [impl](../../dor/src/commands/send.ts) [docs](../../dor/test/snapshots/help/send.md) +- `dor read` [impl](../../dor/src/commands/read.ts) [docs](../../dor/test/snapshots/help/read.md) +- `dor kill` [impl](../../dor/src/commands/kill.ts) [docs](../../dor/test/snapshots/help/kill.md) - `dor list-panes` [impl](../../dor/src/commands/list-panes.ts) [docs](../../dor/test/snapshots/help/list-panes.md) - `dor list-pane-surfaces` [impl](../../dor/src/commands/list-pane-surfaces.ts) [docs](../../dor/test/snapshots/help/list-pane-surfaces.md) diff --git a/dor/package.json b/dor/package.json index 15f4468..e7044ca 100644 --- a/dor/package.json +++ b/dor/package.json @@ -12,6 +12,7 @@ "dist" ], "scripts": { + "prebuild": "node ../scripts/generate-dor-version.mjs", "build": "tsc -p tsconfig.json && esbuild src/dor.ts --bundle --format=esm --platform=node --outfile=dist/dor.js", "test": "pnpm run build && node --test test/*.test.mjs" }, diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 26d49da..7ff13e2 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -7,9 +7,13 @@ import { type StricliProcess, } from '@stricli/core'; import { ensureCommand } from './commands/ensure.js'; +import { killCommand } from './commands/kill.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; +import { readCommand } from './commands/read.js'; +import { sendCommand } from './commands/send.js'; import { splitCommand } from './commands/split.js'; +import { versionCommand } from './commands/version.js'; import { fail } from './commands/shared.js'; import type { CliEnv, @@ -31,18 +35,30 @@ export type { EnsureSurfaceRequest, EnsureSurfaceResponse, IdFormat, + KillSurfaceConfirmation, + KillSurfaceRequest, + KillSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, + ReadSurfaceRequest, + ReadSurfaceResponse, ResolvedSplitDirection, + SendSurfaceRequest, + SendSurfaceResponse, SplitDirection, SplitSurfaceRequest, SplitSurfaceResponse, Surface, + VersionMetadata, } from './commands/types.js'; const COMMANDS = [ splitCommand, ensureCommand, + versionCommand, + sendCommand, + readCommand, + killCommand, listPanesCommand, listPaneSurfacesCommand, ] as const satisfies readonly Command[]; @@ -50,6 +66,10 @@ const COMMANDS = [ const ROUTES = { split: splitCommand.command, ensure: ensureCommand.command, + version: versionCommand.command, + send: sendCommand.command, + read: readCommand.command, + kill: killCommand.command, 'list-panes': listPanesCommand.command, 'list-pane-surfaces': listPaneSurfacesCommand.command, }; diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts new file mode 100644 index 0000000..f6b0af3 --- /dev/null +++ b/dor/src/commands/kill.ts @@ -0,0 +1,104 @@ +/** Private `surface.kill` command wiring and confirmation validation. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + KillSurfaceConfirmation, + KillSurfaceResponse, + ParseResult, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface KillFlags { + readonly confirmDangerously?: boolean; + readonly confirmIfRead?: string; + readonly surface: string; +} + +export const killCommand: Command = { + name: 'kill', + helpPatches: [ + { + scope: 'root', + findReplace: [ + ' dor kill [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously]', + ], + }, + { + scope: 'command-usage', + findReplace: [ + ' dor kill [--confirm-dangerously] [--confirm-if-read text] (--surface id|ref|index)', + ' dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously]', + ], + }, + ], + command: buildCommand({ + docs: { + brief: 'Kill a terminal surface.', + fullDescription: `Kills a terminal surface. One confirmation mode is required. + +--confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. + +--confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. + +Text output: + killed surface:3`, + }, + parameters: { + flags: { + confirmDangerously: { kind: 'boolean', brief: 'Kill without further confirmation.', optional: true, withNegated: false }, + confirmIfRead: { kind: 'parsed', parse: stringParser, brief: 'Kill only if dor read contains this text.', optional: true, placeholder: 'text' }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to kill.', placeholder: 'id|ref|index' }, + }, + }, + func: runKillCommand, + }), +}; + +async function runKillCommand(this: DorCommandContext, flags: KillFlags): Promise { + const confirmation = parseConfirmation(flags); + if (!confirmation.ok) return new Error(confirmation.message); + + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.killSurface({ + confirmation: confirmation.value, + surface: flags.surface, + }); + writeStdout(this, renderKillResponse(response)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +function parseConfirmation(flags: KillFlags): ParseResult { + const confirmations = [ + flags.confirmDangerously === true, + flags.confirmIfRead !== undefined, + ].filter(Boolean).length; + + if (confirmations !== 1) { + return { ok: false, message: 'dor kill requires exactly one confirmation mode' }; + } + + if (flags.confirmDangerously) return { ok: true, value: { mode: 'dangerously' } }; + + const text = flags.confirmIfRead?.trim() ?? ''; + if (text.replace(/\s/g, '').length < 4) { + return { ok: false, message: 'dor kill --confirm-if-read requires at least 4 non-whitespace characters' }; + } + return { ok: true, value: { mode: 'if-read', text } }; +} + +function renderKillResponse(response: KillSurfaceResponse): string { + return `${response.status} ${response.surfaceRef}\n`; +} diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts new file mode 100644 index 0000000..33a53ee --- /dev/null +++ b/dor/src/commands/read.ts @@ -0,0 +1,89 @@ +/** Private `surface.read` command wiring and response rendering. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ReadSurfaceResponse, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface ReadFlags { + readonly json?: boolean; + readonly lines?: number; + readonly scrollback?: boolean; + readonly surface?: string; +} + +export const readCommand: Command = { + name: 'read', + command: buildCommand({ + docs: { + brief: 'Read terminal text from a surface.', + fullDescription: `By default, reads the visible screen text from the target surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Text mode prints terminal text directly. + +JSON output: + { + "workspace_ref": "workspace:1", + "surface_id": "...", + "surface_ref": "surface:3", + "text": "..." + }`, + }, + parameters: { + flags: { + json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, + lines: { kind: 'parsed', parse: parseLineCount, brief: 'Maximum number of lines to return.', optional: true, placeholder: 'count' }, + scrollback: { kind: 'boolean', brief: 'Include terminal scrollback/history instead of only the visible screen.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to read.', optional: true, placeholder: 'id|ref|index' }, + }, + }, + func: runReadCommand, + }), +}; + +async function runReadCommand(this: DorCommandContext, flags: ReadFlags): Promise { + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.readSurface({ + ...(flags.lines !== undefined ? { lines: flags.lines } : {}), + scrollback: flags.scrollback === true, + surface: flags.surface, + }); + writeStdout(this, renderReadResponse(response, flags.json === true)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +function parseLineCount(input: string): number { + const value = Number(input); + if (!Number.isInteger(value) || value <= 0) { + throw new SyntaxError(`invalid --lines '${input}'`); + } + return value; +} + +function renderReadResponse(response: ReadSurfaceResponse, json: boolean): string { + if (json) { + return `${JSON.stringify({ + workspace_ref: response.workspaceRef, + ...(response.surfaceId ? { surface_id: response.surfaceId } : {}), + surface_ref: response.surfaceRef, + text: response.text, + }, null, 2)}\n`; + } + + return response.text; +} diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts new file mode 100644 index 0000000..2081bd5 --- /dev/null +++ b/dor/src/commands/send.ts @@ -0,0 +1,224 @@ +/** Private `surface.send` command wiring and input-byte rendering. */ + +import { buildCommand } from '@stricli/core'; +import type { + Command, + DorCommandContext, + ParseResult, + SendSurfaceResponse, +} from './types.js'; +import { + resolveControlClient, + stringParser, + writeStdout, +} from './shared.js'; + +interface SendFlags { + readonly key?: string; + readonly raw?: boolean; + readonly sequence?: string; + readonly stdin?: boolean; + readonly surface?: string; + readonly text?: string; +} + +type SendInput = + | { kind: 'text'; text: string } + | { kind: 'key'; key: string }; + +export const sendCommand: Command = { + name: 'send', + command: buildCommand({ + docs: { + brief: 'Send text or key input to a terminal surface.', + fullDescription: `By default, a positional argument is sent as text. Special keys must be sent with --key so values like "enter" are never confused with literal text. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Exactly one input source is required: TEXT, --text, --key, --stdin, or --sequence. + +Text input interprets backslash escapes for \\n, \\r, \\t, and \\\\ unless --raw is set. Prefer --key enter when submitting a prompt. + +Supported keys: enter, escape, esc, tab, backspace, delete, up, down, left, right, ctrl-a through ctrl-z. + +Sequence input is an ordered JSON array of {"text":"..."} and {"key":"..."} objects. + +Examples: + dor send "echo hello" + dor send --key enter + dor send --surface surface:3 --key ctrl-c + cat script.sh | dor send --surface surface:3 --stdin + dor send --surface surface:3 --sequence '[{"text":"npm test"},{"key":"enter"}]'`, + }, + parameters: { + flags: { + key: { kind: 'parsed', parse: stringParser, brief: 'Send a named key or chord.', optional: true }, + raw: { kind: 'boolean', brief: 'Do not interpret backslash escapes in text input.', optional: true, withNegated: false }, + sequence: { kind: 'parsed', parse: stringParser, brief: 'Send an ordered JSON sequence of text and key events.', optional: true, placeholder: 'json' }, + stdin: { kind: 'boolean', brief: 'Read text from standard input and send it as text.', optional: true, withNegated: false }, + surface: { kind: 'parsed', parse: stringParser, brief: 'Target surface.', optional: true, placeholder: 'id|ref|index' }, + text: { kind: 'parsed', parse: stringParser, brief: 'Send literal text.', optional: true }, + }, + positional: { + kind: 'tuple', + parameters: [ + { parse: stringParser, brief: 'Text to send.', optional: true, placeholder: 'text' }, + ], + }, + }, + func: runSendCommand, + }), +}; + +async function runSendCommand(this: DorCommandContext, flags: SendFlags, text?: string): Promise { + const inputs = await collectSendInputs(flags, text, this.options.readStdin); + if (!inputs.ok) return new Error(inputs.message); + + const encoded = encodeSendInputs(inputs.value, flags.raw === true); + if (!encoded.ok) return new Error(encoded.message); + if (encoded.value.input.length === 0) return new Error('input cannot be empty'); + + const clientResult = resolveControlClient(this.options); + if (!clientResult.ok) return new Error(clientResult.message); + + try { + const response = await clientResult.value.sendSurface({ + surface: flags.surface, + input: encoded.value.input, + inputCount: encoded.value.inputCount, + }); + writeStdout(this, renderSendResponse(response)); + return undefined; + } catch (error) { + return new Error(error instanceof Error ? error.message : String(error)); + } +} + +async function collectSendInputs( + flags: SendFlags, + positionalText: string | undefined, + readStdin: (() => Promise) | undefined, +): Promise> { + const sources = [ + positionalText !== undefined, + flags.text !== undefined, + flags.key !== undefined, + flags.stdin === true, + flags.sequence !== undefined, + ].filter(Boolean).length; + + if (sources !== 1) { + return { ok: false, message: 'dor send requires exactly one input source' }; + } + + if (positionalText !== undefined) return { ok: true, value: [{ kind: 'text', text: positionalText }] }; + if (flags.text !== undefined) return { ok: true, value: [{ kind: 'text', text: flags.text }] }; + if (flags.key !== undefined) return { ok: true, value: [{ kind: 'key', key: flags.key }] }; + if (flags.stdin === true) { + if (!readStdin) return { ok: false, message: 'stdin is not available' }; + return { ok: true, value: [{ kind: 'text', text: await readStdin() }] }; + } + return parseSendSequence(flags.sequence ?? ''); +} + +function parseSendSequence(input: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (error) { + return { ok: false, message: `invalid --sequence JSON: ${error instanceof Error ? error.message : String(error)}` }; + } + + if (!Array.isArray(parsed)) { + return { ok: false, message: '--sequence must be a JSON array' }; + } + + const inputs: SendInput[] = []; + for (const [index, item] of parsed.entries()) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return { ok: false, message: `--sequence item ${index + 1} must be an object` }; + } + const text = 'text' in item ? (item as { text: unknown }).text : undefined; + const key = 'key' in item ? (item as { key: unknown }).key : undefined; + if ((text === undefined) === (key === undefined)) { + return { ok: false, message: `--sequence item ${index + 1} must contain exactly one of text or key` }; + } + if (text !== undefined) { + if (typeof text !== 'string') return { ok: false, message: `--sequence item ${index + 1} text must be a string` }; + inputs.push({ kind: 'text', text }); + } else { + if (typeof key !== 'string') return { ok: false, message: `--sequence item ${index + 1} key must be a string` }; + inputs.push({ kind: 'key', key }); + } + } + + return { ok: true, value: inputs }; +} + +function encodeSendInputs(inputs: SendInput[], raw: boolean): ParseResult<{ input: string; inputCount: number }> { + let input = ''; + for (const item of inputs) { + if (item.kind === 'text') { + input += raw ? item.text : interpretTextEscapes(item.text); + } else { + const key = encodeKey(item.key); + if (!key.ok) return key; + input += key.value; + } + } + return { ok: true, value: { input, inputCount: inputs.length } }; +} + +function interpretTextEscapes(input: string): string { + return input.replace(/\\([nrt\\])/g, (_match, escape: string) => { + switch (escape) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + case '\\': + return '\\'; + default: + return escape; + } + }); +} + +function encodeKey(input: string): ParseResult { + const key = input.trim().toLowerCase(); + switch (key) { + case 'enter': + return { ok: true, value: '\r' }; + case 'escape': + case 'esc': + return { ok: true, value: '\x1b' }; + case 'tab': + return { ok: true, value: '\t' }; + case 'backspace': + return { ok: true, value: '\x7f' }; + case 'delete': + return { ok: true, value: '\x1b[3~' }; + case 'up': + return { ok: true, value: '\x1b[A' }; + case 'down': + return { ok: true, value: '\x1b[B' }; + case 'right': + return { ok: true, value: '\x1b[C' }; + case 'left': + return { ok: true, value: '\x1b[D' }; + } + + const ctrl = /^ctrl-([a-z])$/.exec(key); + if (ctrl) { + return { ok: true, value: String.fromCharCode(ctrl[1].charCodeAt(0) - 96) }; + } + + return { ok: false, message: `unsupported key '${input}'` }; +} + +function renderSendResponse(response: SendSurfaceResponse): string { + const noun = response.inputCount === 1 ? 'input' : 'inputs'; + return `${response.status} ${response.surfaceRef} [${response.inputCount} ${noun}]\n`; +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index e6ea567..99315bf 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -64,10 +64,54 @@ export interface EnsureSurfaceResponse { minimized: boolean; } +export interface SendSurfaceRequest { + surface?: string; + input: string; + inputCount: number; +} + +export interface SendSurfaceResponse { + status: 'sent'; + surfaceId?: string; + surfaceRef: string; + inputCount: number; +} + +export interface ReadSurfaceRequest { + lines?: number; + scrollback: boolean; + surface?: string; +} + +export interface ReadSurfaceResponse { + workspaceRef: string; + surfaceId?: string; + surfaceRef: string; + text: string; +} + +export type KillSurfaceConfirmation = + | { mode: 'if-read'; text: string } + | { mode: 'dangerously' }; + +export interface KillSurfaceRequest { + confirmation: KillSurfaceConfirmation; + surface: string; +} + +export interface KillSurfaceResponse { + status: 'killed'; + surfaceId?: string; + surfaceRef: string; +} + export interface ControlClient { listSurfaces(request: ListSurfacesRequest): Promise; splitSurface(request: SplitSurfaceRequest): Promise; ensureSurface(request: EnsureSurfaceRequest): Promise; + sendSurface(request: SendSurfaceRequest): Promise; + readSurface(request: ReadSurfaceRequest): Promise; + killSurface(request: KillSurfaceRequest): Promise; } export interface CliEnv { @@ -77,6 +121,8 @@ export interface CliEnv { export interface CliOptions { env?: CliEnv; client?: ControlClient; + readStdin?: () => Promise; + versionMetadata?: VersionMetadata; } export interface CliResult { @@ -95,6 +141,12 @@ export interface Command { helpPatches?: readonly HelpPatch[]; } +export interface VersionMetadata { + version: string; + commit: string; + commitsSinceVersion: number; +} + export interface HelpPatch { scope: 'root' | 'command-usage' | 'command-detail'; /** Ordered template-pattern find/replace pairs. Tokens: , , . */ diff --git a/dor/src/commands/version.ts b/dor/src/commands/version.ts new file mode 100644 index 0000000..44bbdf9 --- /dev/null +++ b/dor/src/commands/version.ts @@ -0,0 +1,36 @@ +/** Render stamped build metadata for the bundled `dor` CLI. */ + +import { buildCommand } from '@stricli/core'; +import { DOR_VERSION_METADATA } from '../generated-version.js'; +import type { + Command, + DorCommandContext, + VersionMetadata, +} from './types.js'; +import { writeStdout } from './shared.js'; + +export const versionCommand: Command = { + name: 'version', + command: buildCommand<{}, [], DorCommandContext>({ + docs: { + brief: 'Print the dor CLI version.', + fullDescription: `Prints the latest released Dormouse version from CHANGELOG.md, the build commit, and a prerelease-style build suffix when the build contains commits after that version tag. + +Text output: + dor 0.11.0 [1a2b3c4d] (0.11.0+12)`, + }, + parameters: {}, + func: runVersionCommand, + }), +}; + +function runVersionCommand(this: DorCommandContext): void { + writeStdout(this, renderVersion(this.options.versionMetadata ?? DOR_VERSION_METADATA)); +} + +export function renderVersion(metadata: VersionMetadata): string { + const suffix = metadata.commitsSinceVersion > 0 + ? ` (${metadata.version}+${metadata.commitsSinceVersion})` + : ''; + return `dor ${metadata.version} [${metadata.commit}]${suffix}\n`; +} diff --git a/dor/src/control-client.ts b/dor/src/control-client.ts index c420c13..24e77b1 100644 --- a/dor/src/control-client.ts +++ b/dor/src/control-client.ts @@ -3,8 +3,14 @@ import type { ControlClient, EnsureSurfaceRequest, EnsureSurfaceResponse, + KillSurfaceRequest, + KillSurfaceResponse, ListSurfacesRequest, ListSurfacesResponse, + ReadSurfaceRequest, + ReadSurfaceResponse, + SendSurfaceRequest, + SendSurfaceResponse, SplitSurfaceRequest, SplitSurfaceResponse, } from './commands/types.js'; @@ -48,6 +54,18 @@ export class SocketControlClient implements ControlClient { return this.request('surface.ensure', request); } + sendSurface(request: SendSurfaceRequest): Promise { + return this.request('surface.send', request); + } + + readSurface(request: ReadSurfaceRequest): Promise { + return this.request('surface.read', request); + } + + killSurface(request: KillSurfaceRequest): Promise { + return this.request('surface.kill', request); + } + private request(method: string, params: unknown): Promise { const requestId = `dor-${this.idBase}-${++this.nextRequestId}`; return new Promise((resolve, reject) => { diff --git a/dor/src/dor.ts b/dor/src/dor.ts index 005f6c0..dfc58b7 100644 --- a/dor/src/dor.ts +++ b/dor/src/dor.ts @@ -8,11 +8,18 @@ type ProcessLike = { exitCode?: number; stdout: { write(chunk: string): void }; stderr: { write(chunk: string): void }; + stdin: { + setEncoding?(encoding: string): void; + on(event: 'data', listener: (chunk: string) => void): void; + on(event: 'end', listener: () => void): void; + on(event: 'error', listener: (error: Error) => void): void; + resume?(): void; + }; }; declare const process: ProcessLike; -runCli(process.argv.slice(2), { env: process.env }).then( +runCli(process.argv.slice(2), { env: process.env, readStdin }).then( (result) => { process.stdout.write(result.stdout); process.stderr.write(result.stderr); @@ -23,3 +30,14 @@ runCli(process.argv.slice(2), { env: process.env }).then( process.exitCode = 1; }, ); + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: string[] = []; + process.stdin.setEncoding?.('utf8'); + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => resolve(chunks.join(''))); + process.stdin.on('error', reject); + process.stdin.resume?.(); + }); +} diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8f4b337..6457ab7 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -80,6 +80,42 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { minimized: request.minimized, }; }, + async sendSurface(request) { + this.requests.push({ method: 'sendSurface', request }); + return { + status: 'sent', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface ?? 'surface:1', + inputCount: request.inputCount, + }; + }, + async readSurface(request) { + this.requests.push({ method: 'readSurface', request }); + const text = request.scrollback + ? 'first line\nsecond line\nthird line\nfourth line' + : 'visible one\nvisible two'; + const limited = request.lines ? text.split('\n').slice(-request.lines).join('\n') : text; + return { + workspaceRef: 'workspace:1', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface ?? 'surface:1', + text: limited, + }; + }, + async killSurface(request) { + this.requests.push({ method: 'killSurface', request }); + return { + status: 'killed', + surfaceId: request.surface === 'surface:2' + ? '22222222-2222-4222-8222-222222222222' + : '11111111-1111-4111-8111-111111111111', + surfaceRef: request.surface, + }; + }, }; } @@ -175,6 +211,144 @@ test('ensure json output', async () => { ); }); +test('version output', async () => { + await snapshot( + 'version', + await runCli(['version'], { + versionMetadata: { + version: '0.12.0', + commit: '6e86b3ba', + commitsSinceVersion: 89, + }, + }), + ); +}); + +test('send text output', async () => { + await snapshot( + 'send-text', + await runCli(['send', '--surface', 'surface:2', 'echo hello\\n'], { client: fixtureClient() }), + ); +}); + +test('send sends escaped text to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--text', 'echo hello\\n'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'echo hello\n', + inputCount: 1, + }, + }]); +}); + +test('send sends key input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--surface', 'surface:2', '--key', 'ctrl-c'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: 'surface:2', + input: '\x03', + inputCount: 1, + }, + }]); +}); + +test('send sequence sends ordered input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--sequence', '[{"text":"npm test"},{"key":"enter"}]'], { client }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'npm test\r', + inputCount: 2, + }, + }]); +}); + +test('send stdin sends standard input to the host', async () => { + const client = fixtureClient(); + await runCli(['send', '--stdin'], { client, readStdin: async () => 'cat from stdin\n' }); + assert.deepEqual(client.requests, [{ + method: 'sendSurface', + request: { + surface: undefined, + input: 'cat from stdin\n', + inputCount: 1, + }, + }]); +}); + +test('send missing input output', async () => { + await snapshot('send-missing-input', await runCli(['send'], { client: fixtureClient() })); +}); + +test('send unsupported key output', async () => { + await snapshot('send-unsupported-key', await runCli(['send', '--key', 'cmd-k'], { client: fixtureClient() })); +}); + +test('read text output', async () => { + await snapshot('read-text', await runCli(['read'], { client: fixtureClient() })); +}); + +test('read sends request to the host', async () => { + const client = fixtureClient(); + await runCli(['read', '--surface', 'title:repo "watch"', '--scrollback', '--lines', '2'], { client }); + assert.deepEqual(client.requests, [{ + method: 'readSurface', + request: { + lines: 2, + scrollback: true, + surface: 'title:repo "watch"', + }, + }]); +}); + +test('read json output', async () => { + await snapshot( + 'read-json', + await runCli(['read', '--json', '--surface', 'surface:2', '--scrollback', '--lines', '2'], { client: fixtureClient() }), + ); +}); + +test('read invalid lines output', async () => { + await snapshot('read-invalid-lines', await runCli(['read', '--lines', '0'], { client: fixtureClient() })); +}); + +test('kill text output', async () => { + await snapshot( + 'kill-text', + await runCli(['kill', '--surface', 'surface:2', '--confirm-dangerously'], { client: fixtureClient() }), + ); +}); + +test('kill sends confirmation to the host', async () => { + const client = fixtureClient(); + await runCli(['kill', '--surface', 'title:repo "watch"', '--confirm-if-read', 'done'], { client }); + assert.deepEqual(client.requests, [{ + method: 'killSurface', + request: { + confirmation: { mode: 'if-read', text: 'done' }, + surface: 'title:repo "watch"', + }, + }]); +}); + +test('kill missing confirmation output', async () => { + await snapshot('kill-missing-confirmation', await runCli(['kill', '--surface', 'surface:2'], { client: fixtureClient() })); +}); + +test('kill short confirm-if-read output', async () => { + await snapshot( + 'kill-short-confirm-if-read', + await runCli(['kill', '--surface', 'surface:2', '--confirm-if-read', 'abc'], { client: fixtureClient() }), + ); +}); + test('list-panes text output', async () => { await snapshot('list-panes-text', await runCli(['list-panes'], { client: fixtureClient() })); }); diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index ab80cec..2600808 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -6,6 +6,10 @@ Invocation: `dor --help` USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- ...] dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... + dor version + dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] + dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] + dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously] dor list-panes [--id-format refs|uuids|both] [--json] dor list-pane-surfaces [--id-format refs|uuids|both] [--json] [--pane id|ref|index] dor --help @@ -19,6 +23,10 @@ FLAGS COMMANDS split Create a new terminal surface by splitting an existing surface. ensure Ensure one surface exists for a user-enforced title. + version Print the dor CLI version. + send Send text or key input to a terminal surface. + read Read terminal text from a surface. + kill Kill a terminal surface. list-panes List visible panes. list-pane-surfaces List surfaces in a pane. diff --git a/dor/test/snapshots/help/kill.md b/dor/test/snapshots/help/kill.md new file mode 100644 index 0000000..98603b8 --- /dev/null +++ b/dor/test/snapshots/help/kill.md @@ -0,0 +1,26 @@ +# dor kill + +Invocation: `dor kill --help` + +```text +USAGE + dor kill --surface id|ref|index [--confirm-if-read text|--confirm-dangerously] + dor kill --help + +Kills a terminal surface. One confirmation mode is required. + +--confirm-if-read kills only if dor read --surface would return visible text containing the provided text. The text must contain at least 4 non-whitespace characters. + +--confirm-dangerously kills without further confirmation. Use only when automation has already validated the target. + +Text output: + killed surface:3 + +FLAGS + [--confirm-dangerously] Kill without further confirmation. + [--confirm-if-read] Kill only if dor read contains this text. + --surface Surface to kill. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/help/read.md b/dor/test/snapshots/help/read.md new file mode 100644 index 0000000..3480a41 --- /dev/null +++ b/dor/test/snapshots/help/read.md @@ -0,0 +1,32 @@ +# dor read + +Invocation: `dor read --help` + +```text +USAGE + dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] + dor read --help + +By default, reads the visible screen text from the target surface. Use --scrollback to include terminal history, and --lines to limit how much text is returned. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Text mode prints terminal text directly. + +JSON output: + { + "workspace_ref": "workspace:1", + "surface_id": "...", + "surface_ref": "surface:3", + "text": "..." + } + +FLAGS + [--json] Print JSON output. + [--lines] Maximum number of lines to return. + [--scrollback] Include terminal scrollback/history instead of only the visible screen. + [--surface] Surface to read. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/help/send.md b/dor/test/snapshots/help/send.md new file mode 100644 index 0000000..d6ed815 --- /dev/null +++ b/dor/test/snapshots/help/send.md @@ -0,0 +1,42 @@ +# dor send + +Invocation: `dor send --help` + +```text +USAGE + dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] + dor send --help + +By default, a positional argument is sent as text. Special keys must be sent with --key so values like "enter" are never confused with literal text. + +If --surface is omitted, Dormouse uses the caller surface from DORMOUSE_SURFACE_ID, then the focused surface. + +Exactly one input source is required: TEXT, --text, --key, --stdin, or --sequence. + +Text input interprets backslash escapes for \n, \r, \t, and \\ unless --raw is set. Prefer --key enter when submitting a prompt. + +Supported keys: enter, escape, esc, tab, backspace, delete, up, down, left, right, ctrl-a through ctrl-z. + +Sequence input is an ordered JSON array of {"text":"..."} and {"key":"..."} objects. + +Examples: + dor send "echo hello" + dor send --key enter + dor send --surface surface:3 --key ctrl-c + cat script.sh | dor send --surface surface:3 --stdin + dor send --surface surface:3 --sequence '[{"text":"npm test"},{"key":"enter"}]' + +FLAGS + [--key] Send a named key or chord. + [--raw] Do not interpret backslash escapes in text input. + [--sequence] Send an ordered JSON sequence of text and key events. + [--stdin] Read text from standard input and send it as text. + [--surface] Target surface. + [--text] Send literal text. + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +ARGUMENTS + [text] Text to send. + +``` diff --git a/dor/test/snapshots/help/version.md b/dor/test/snapshots/help/version.md new file mode 100644 index 0000000..b913b5c --- /dev/null +++ b/dor/test/snapshots/help/version.md @@ -0,0 +1,19 @@ +# dor version + +Invocation: `dor version --help` + +```text +USAGE + dor version + dor version --help + +Prints the latest released Dormouse version from CHANGELOG.md, the build commit, and a prerelease-style build suffix when the build contains commits after that version tag. + +Text output: + dor 0.11.0 [1a2b3c4d] (0.11.0+12) + +FLAGS + -h --help Print help information and exit + -- All subsequent inputs should be interpreted as arguments + +``` diff --git a/dor/test/snapshots/kill-missing-confirmation.snap b/dor/test/snapshots/kill-missing-confirmation.snap new file mode 100644 index 0000000..15c5443 --- /dev/null +++ b/dor/test/snapshots/kill-missing-confirmation.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor kill requires exactly one confirmation mode diff --git a/dor/test/snapshots/kill-short-confirm-if-read.snap b/dor/test/snapshots/kill-short-confirm-if-read.snap new file mode 100644 index 0000000..697d717 --- /dev/null +++ b/dor/test/snapshots/kill-short-confirm-if-read.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor kill --confirm-if-read requires at least 4 non-whitespace characters diff --git a/dor/test/snapshots/kill-text.snap b/dor/test/snapshots/kill-text.snap new file mode 100644 index 0000000..79a2ca7 --- /dev/null +++ b/dor/test/snapshots/kill-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +killed surface:2 + +stderr: diff --git a/dor/test/snapshots/read-invalid-lines.snap b/dor/test/snapshots/read-invalid-lines.snap new file mode 100644 index 0000000..f3571d2 --- /dev/null +++ b/dor/test/snapshots/read-invalid-lines.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: Failed to parse "0" for lines: invalid --lines '0' diff --git a/dor/test/snapshots/read-json.snap b/dor/test/snapshots/read-json.snap new file mode 100644 index 0000000..f880371 --- /dev/null +++ b/dor/test/snapshots/read-json.snap @@ -0,0 +1,10 @@ +exitCode: 0 +stdout: +{ + "workspace_ref": "workspace:1", + "surface_id": "22222222-2222-4222-8222-222222222222", + "surface_ref": "surface:2", + "text": "third line\nfourth line" +} + +stderr: diff --git a/dor/test/snapshots/read-text.snap b/dor/test/snapshots/read-text.snap new file mode 100644 index 0000000..2ee5598 --- /dev/null +++ b/dor/test/snapshots/read-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +visible one +visible two +stderr: diff --git a/dor/test/snapshots/send-missing-input.snap b/dor/test/snapshots/send-missing-input.snap new file mode 100644 index 0000000..9ce3d5d --- /dev/null +++ b/dor/test/snapshots/send-missing-input.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: dor send requires exactly one input source diff --git a/dor/test/snapshots/send-text.snap b/dor/test/snapshots/send-text.snap new file mode 100644 index 0000000..529b150 --- /dev/null +++ b/dor/test/snapshots/send-text.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +sent surface:2 [1 input] + +stderr: diff --git a/dor/test/snapshots/send-unsupported-key.snap b/dor/test/snapshots/send-unsupported-key.snap new file mode 100644 index 0000000..12fd82a --- /dev/null +++ b/dor/test/snapshots/send-unsupported-key.snap @@ -0,0 +1,5 @@ +exitCode: 1 +stdout: + +stderr: +Error: unsupported key 'cmd-k' diff --git a/dor/test/snapshots/version.snap b/dor/test/snapshots/version.snap new file mode 100644 index 0000000..fecf270 --- /dev/null +++ b/dor/test/snapshots/version.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +dor 0.12.0 [6e86b3ba] (0.12.0+89) + +stderr: diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 41e163f..e655662 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,6 +24,7 @@ import { getActivitySnapshot, isUntouched, getOrCreateTerminal, + getTerminalInstance, isReservedUserTitle, setTerminalUserTitle, UNNAMED_PANEL_TITLE, @@ -36,7 +37,7 @@ import { resolveDisplayPrimary, } from '../lib/terminal-state'; import { orchestrateKill } from '../lib/kill-animation'; -import { PLATFORM_STRING } from '../lib/platform'; +import { getPlatform, PLATFORM_STRING } from '../lib/platform'; import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol'; import type { Surface as DorSurface, @@ -87,13 +88,18 @@ type ShellSpawnNoticeState = { type DorControlParams = { command?: unknown; + confirmation?: unknown; direction?: unknown; + input?: unknown; + inputCount?: unknown; + lines?: unknown; minimized?: unknown; pane?: string; surface?: unknown; title?: unknown; workspace?: string; window?: string; + scrollback?: unknown; }; // The webview view of a control request: the shared wire payload, but with @@ -163,6 +169,14 @@ function matchesDorPaneTarget(target: string | undefined, surface: DorSurface): return Number.isInteger(numeric) && numeric >= 1 && surface.index === numeric - 1; } +function surfaceTitleTarget(target: string): string | null { + return target.startsWith('title:') ? target.slice('title:'.length) : null; +} + +function renderSurfaceForError(surface: DorSurface): string { + return `${surface.ref} ${JSON.stringify(surface.title)}`; +} + function stringParam(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } @@ -171,6 +185,41 @@ function booleanParam(value: unknown): boolean { return value === true; } +function numberParam(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function limitLines(text: string, lines: number | undefined): string { + if (lines === undefined) return text; + const parts = text.split('\n'); + return parts.slice(-lines).join('\n'); +} + +function readVisibleSurfaceText(surfaceId: string, lines: number | undefined): string { + const terminal = getTerminalInstance(surfaceId); + if (!terminal) return ''; + + const buffer = terminal.buffer.active; + const start = Math.max(0, buffer.viewportY); + const end = start + terminal.rows; + const visibleLines: string[] = []; + for (let row = start; row < end; row += 1) { + visibleLines.push(buffer.getLine(row)?.translateToString(true) ?? ''); + } + + return limitLines(visibleLines.join('\n').replace(/\n+$/, ''), lines); +} + +function killConfirmationParam(value: unknown): { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const confirmation = value as { mode?: unknown; text?: unknown }; + if (confirmation.mode === 'dangerously') return { mode: 'dangerously' }; + if (confirmation.mode === 'if-read' && typeof confirmation.text === 'string') { + return { mode: 'if-read', text: confirmation.text }; + } + return null; +} + function parseDorSplitDirection(value: unknown): DorSplitDirection | null { if (value === undefined || value === null) return 'auto'; if (value === 'left' || value === 'right' || value === 'up' || value === 'down' || value === 'auto') return value; @@ -686,15 +735,30 @@ export function Wall({ return id; }, []); - const findVisibleSurface = useCallback(( + const resolveVisibleSurface = useCallback(( api: DockviewApi, target: string | undefined, callerSurfaceId: string | undefined, - ): DorSurface | null => { + ): ParseResult => { const surfaces = buildDorSurfaces(api); const resolvedTarget = target ?? callerSurfaceId ?? 'focused'; - return surfaces.find((surface) => matchesDorPaneTarget(resolvedTarget, surface)) + const titleTarget = surfaceTitleTarget(resolvedTarget); + if (titleTarget !== null) { + const matches = surfaces.filter((surface) => surface.title === titleTarget); + if (matches.length === 1) return { ok: true, value: matches[0] }; + if (matches.length > 1) { + return { + ok: false, + message: `surface target '${resolvedTarget}' matched multiple surfaces: ${matches.map(renderSurfaceForError).join(', ')}`, + }; + } + return { ok: false, message: `surface target '${resolvedTarget}' was not found` }; + } + + const matched = surfaces.find((surface) => matchesDorPaneTarget(resolvedTarget, surface)) ?? (!target && !callerSurfaceId ? (surfaces[0] ?? null) : null); + if (matched) return { ok: true, value: matched }; + return { ok: false, message: `surface '${resolvedTarget}' was not found` }; }, [buildDorSurfaces]); const findSurfaceIdByUserTitle = useCallback((title: string): string | null => { @@ -854,7 +918,7 @@ export function Wall({ }, [generatePaneId, selectPane, showShellSpawnNotice]); useEffect(() => { - const handler = (event: Event) => { + const handler = async (event: Event) => { const detail = (event as CustomEvent).detail; if (!detail) return; @@ -877,17 +941,17 @@ export function Wall({ // Resolve the split reference surface and its live panel, responding with // the appropriate error and returning null when either is unavailable. const resolveSplitTarget = () => { - const target = findVisibleSurface(api, stringParam(params.surface), detail.surfaceId); - if (!target) { - detail.respond({ ok: false, error: `surface '${stringParam(params.surface) ?? detail.surfaceId ?? 'focused'}' was not found` }); + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); return null; } - const panel = api.getPanel(target.id); + const panel = api.getPanel(target.value.id); if (!panel) { - detail.respond({ ok: false, error: `surface '${target.ref}' is not visible` }); + detail.respond({ ok: false, error: `surface '${target.value.ref}' is not visible` }); return null; } - return { target, panel }; + return { target: target.value, panel }; }; if (detail.method === 'surface.list') { @@ -999,12 +1063,94 @@ export function Wall({ return; } + if (detail.method === 'surface.send') { + const input = stringParam(params.input); + if (input === undefined) { + detail.respond({ ok: false, error: 'input is required' }); + return; + } + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + getPlatform().writePty(target.value.id, input); + detail.respond({ + ok: true, + result: { + status: 'sent', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + inputCount: typeof params.inputCount === 'number' ? params.inputCount : 1, + }, + }); + return; + } + + if (detail.method === 'surface.read') { + const target = resolveVisibleSurface(api, stringParam(params.surface), detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + const lines = numberParam(params.lines); + const scrollback = booleanParam(params.scrollback); + const text = scrollback + ? limitLines((await getPlatform().getScrollback(target.value.id)) ?? '', lines) + : readVisibleSurfaceText(target.value.id, lines); + detail.respond({ + ok: true, + result: { + workspaceRef: 'workspace:1', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + text, + }, + }); + return; + } + + if (detail.method === 'surface.kill') { + const confirmation = killConfirmationParam(params.confirmation); + if (!confirmation) { + detail.respond({ ok: false, error: 'invalid kill confirmation' }); + return; + } + const surface = stringParam(params.surface); + if (!surface) { + detail.respond({ ok: false, error: 'surface is required' }); + return; + } + const target = resolveVisibleSurface(api, surface, detail.surfaceId); + if (!target.ok) { + detail.respond({ ok: false, error: target.message }); + return; + } + if (confirmation.mode === 'if-read') { + const text = readVisibleSurfaceText(target.value.id, undefined); + if (!text.includes(confirmation.text)) { + detail.respond({ ok: false, error: `surface '${target.value.ref}' read text did not contain confirmation text` }); + return; + } + } + killPaneImmediately(target.value.id); + detail.respond({ + ok: true, + result: { + status: 'killed', + surfaceId: target.value.id, + surfaceRef: target.value.ref, + }, + }); + return; + } + detail.respond({ ok: false, error: `unsupported Dormouse control method '${detail.method}'` }); }; window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, findVisibleSurface, surfaceRefForId]); + }, [buildDorSurfaces, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, diff --git a/scripts/generate-dor-version.mjs b/scripts/generate-dor-version.mjs new file mode 100644 index 0000000..a6a6e07 --- /dev/null +++ b/scripts/generate-dor-version.mjs @@ -0,0 +1,40 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = fileURLToPath(import.meta.url); +const repoRoot = resolve(dirname(scriptPath), '..'); + +const changelog = await readFile(resolve(repoRoot, 'CHANGELOG.md'), 'utf8'); +const version = latestChangelogVersion(changelog); +const commit = git(['rev-parse', '--short=8', 'HEAD']) || 'unknown'; +const commitsSinceVersion = Number(git(['rev-list', '--count', `v${version}..HEAD`]) || '0'); +const outPath = resolve(repoRoot, 'dor/src/generated-version.ts'); + +await mkdir(dirname(outPath), { recursive: true }); +await writeFile( + outPath, + `// Generated by scripts/generate-dor-version.mjs. Do not edit.\n` + + `export const DOR_VERSION_METADATA = ${JSON.stringify({ + version, + commit, + commitsSinceVersion: Number.isFinite(commitsSinceVersion) ? commitsSinceVersion : 0, + }, null, 2)} as const;\n`, +); + +function latestChangelogVersion(input) { + for (const line of input.split('\n')) { + const match = /^## \[([0-9]+\.[0-9]+\.[0-9]+)\]/.exec(line); + if (match) return match[1]; + } + return '0.0.0'; +} + +function git(args) { + try { + return execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} diff --git a/standalone/sidecar/dor-control-server.js b/standalone/sidecar/dor-control-server.js index 1206c94..bc9f4b6 100644 --- a/standalone/sidecar/dor-control-server.js +++ b/standalone/sidecar/dor-control-server.js @@ -23,6 +23,17 @@ function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) // down the long-lived sidecar/pty-host. socket.on('error', () => {}); + // If the client disconnects (timeout/Ctrl-C) before the webview answers, + // release any entries owned by this socket right away rather than letting + // them linger until their own timeout fires against a dead socket. + socket.on('close', () => { + for (const [requestId, entry] of pending) { + if (entry.socket !== socket) continue; + if (entry.timeout) clearTimeout(entry.timeout); + pending.delete(requestId); + } + }); + socket.on('data', (chunk) => { buffer += chunk; const newlineIndex = buffer.indexOf('\n');