Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions dor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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 { errorMessage, fail } from './commands/shared.js';
import type {
CliEnv,
CliOptions,
Expand Down Expand Up @@ -295,7 +295,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult<void> {
if (arg === '--json' || arg === '--minimize') {
continue;
}
if (arg === '--title' || arg === '--surface') {
if (arg === '--cwd' || arg === '--surface') {
const value = args[index + 1];
if (!value || value.startsWith('-') || index + 1 >= delimiterIndex) {
return { ok: false, message: `${arg} requires a value` };
Expand Down Expand Up @@ -363,7 +363,3 @@ function normalizeExitCode(exitCode: number | string | null | undefined): number
: 0;
return numeric === 0 || Number.isNaN(numeric) ? 0 : 1;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
86 changes: 43 additions & 43 deletions dor/src/commands/ensure.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
/** Private `surface.ensure` wiring for title-based idempotency. */
/** Private `surface.ensure` wiring for command+cwd idempotency. */

import { resolve as resolvePath } from 'node:path';
import { buildCommand } from '@stricli/core';
import type {
CliEnv,
Command,
DorCommandContext,
EnsureSurfaceResponse,
} from './types.js';
import {
resolveControlClient,
errorMessage,
renderJson,
requireControlClient,
stringParser,
writeStdout,
} from './shared.js';
import { buildShellCommand } from './shell-command.js';

interface EnsureFlags {
readonly json?: boolean;
readonly minimize?: boolean;
readonly surface?: string;
readonly title?: string;
readonly cwd?: string;
}

export const ensureCommand: Command = {
Expand All @@ -26,15 +29,15 @@ export const ensureCommand: Command = {
{
scope: 'root',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- <command>...\n',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
scope: 'command-usage',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- <command>...\n',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
Expand All @@ -44,36 +47,32 @@ export const ensureCommand: Command = {
],
command: buildCommand<EnsureFlags, string[], DorCommandContext>({
docs: {
brief: 'Ensure one surface exists for a user-enforced title.',
fullDescription: `Ensures one surface exists in the current workspace for a user-enforced title. The idempotency key is always the user-enforced title.
brief: 'Ensure one surface is running a command.',
fullDescription: `Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command.

If --title is omitted, Dormouse derives the title from the command after --.
Matching uses the command each shell reports it is running via Dormouse shell integration, not process inspection. This captures the typed command (\`npm run dev\`), not the forked child process (\`node .../vite\`), and works for shells the user started by hand as well as shells Dormouse started. The match is exact: \`npm run dev\` and \`npm run dev --host\` are different commands and get separate surfaces. Shells without the integration don't report their command, so ensure can't match them and starts a new surface every time.

If a surface in the current workspace already has the enforced title, Dormouse returns that surface and does not start another command.
A surface matches only while the command is live. Once the command exits and the shell returns to its prompt, the surface no longer matches; the next ensure causes a fresh split rather than reusing the idle shell. Minimized surfaces participate in matching. Closed/killed surfaces do not.

If no surface has that enforced title, Dormouse creates a split, starts the command, marks the surface title as user-enforced, and returns the new surface.
Two surfaces running the same command in different working directories are distinct (e.g. the same dev server in two worktrees). Both keep running; ensure never collapses them.

A user-enforced title is visible in the UI and must not be overwritten by terminal title escape sequences from the running process.

Matching uses Dormouse metadata, not process inspection. Minimized surfaces participate in matching. Closed/killed surfaces do not participate in matching.
--cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is resolved to an absolute path and matched exactly; symlinks are not resolved, so two routes to the same directory are treated as distinct.

--minimize applies only when creating a new surface; it does not minimize an existing match.

--surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split.

No workspace argument exists until Dormouse supports multiple workspaces.

Text output:
created surface:3 "dev server"
existing surface:3 "dev server"
created surface:3 "npm run dev"
existing surface:3 "npm run dev"

JSON output:
{
"status": "created",
"surface_id": "pane-def",
"surface_ref": "surface:3",
"title": "dev server",
"command": "pnpm dev:workspace",
"command": "npm run dev",
"cwd": "/Users/me/projects/site",
"minimized": false
}`,
},
Expand All @@ -82,7 +81,7 @@ JSON output:
json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false },
minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false },
surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' },
title: { kind: 'parsed', parse: stringParser, brief: 'User-enforced surface title.', optional: true },
cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' },
},
positional: {
kind: 'array',
Expand All @@ -95,47 +94,48 @@ JSON output:
};

async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...commandArgs: string[]): Promise<void | Error> {
const command = buildShellCommand(commandArgs, this.options.env);
if (!command) {
if (commandArgs.length === 0) {
return new Error('dor ensure requires a command after --');
}

let title = flags.title;
if (title !== undefined) {
title = title.trim();
if (title === '') {
return new Error('dor ensure --title must not be empty');
}
}

const clientResult = resolveControlClient(this.options);
if (!clientResult.ok) return new Error(clientResult.message);
const client = requireControlClient(this.options);
if (client instanceof Error) return client;

try {
const response = await clientResult.value.ensureSurface({
command,
const response = await client.ensureSurface({
command: commandArgs,
minimized: flags.minimize === true,
surface: flags.surface,
title,
cwd: callerWorkingDirectory(flags.cwd, this.options.env),
});
writeStdout(this, renderEnsureResponse(response, flags.json === true));
return undefined;
} catch (error) {
return new Error(error instanceof Error ? error.message : String(error));
return new Error(errorMessage(error));
}
}

// The host has no idea where `dor` was launched, so the caller's directory must
// travel in the request. Prefer the shell's PWD (injectable, matches what the
// user sees) and fall back to the process cwd. resolvePath canonicalizes both
// the default and a relative/absolute --cwd into one absolute path the host can
// key on with an exact compare.
function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string {
const base = env?.PWD ?? process.cwd();
return resolvePath(base, flag ?? '.');
}

function renderEnsureResponse(response: EnsureSurfaceResponse, json: boolean): string {
if (json) {
return `${JSON.stringify({
return renderJson({
status: response.status,
...(response.surfaceId ? { surface_id: response.surfaceId } : {}),
surface_ref: response.surfaceRef,
title: response.title,
command: response.command,
cwd: response.cwd,
minimized: response.minimized,
}, null, 2)}\n`;
});
}

return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.title)}\n`;
return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.command)}\n`;
}
8 changes: 4 additions & 4 deletions dor/src/commands/iframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
IframeSurfaceResponse,
} from './types.js';
import {
resolveControlClient,
requireControlClient,
stringParser,
writeStdout,
} from './shared.js';
Expand Down Expand Up @@ -64,11 +64,11 @@ JSON output:
};

async function runIframeCommand(this: DorCommandContext, flags: IframeFlags, url: string): Promise<void | Error> {
const clientResult = resolveControlClient(this.options);
if (!clientResult.ok) return new Error(clientResult.message);
const client = requireControlClient(this.options);
if (client instanceof Error) return client;

try {
const response = await clientResult.value.iframeSurface({
const response = await client.iframeSurface({
minimized: flags.minimize === true,
surface: flags.surface,
url,
Expand Down
8 changes: 4 additions & 4 deletions dor/src/commands/kill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
ParseResult,
} from './types.js';
import {
resolveControlClient,
requireControlClient,
stringParser,
writeStdout,
} from './shared.js';
Expand Down Expand Up @@ -65,11 +65,11 @@ async function runKillCommand(this: DorCommandContext, flags: KillFlags): Promis
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);
const client = requireControlClient(this.options);
if (client instanceof Error) return client;

try {
const response = await clientResult.value.killSurface({
const response = await client.killSurface({
confirmation: confirmation.value,
surface: flags.surface,
});
Expand Down
16 changes: 9 additions & 7 deletions dor/src/commands/list-surfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import type {
Surface,
} from './types.js';
import {
errorMessage,
parseIdFormat,
renderHandle,
resolveControlClient,
renderJson,
requireControlClient,
stringParser,
wantsIds,
wantsRefs,
Expand Down Expand Up @@ -48,18 +50,18 @@ export async function runListCommand(
flags: ListFlags,
context: DorCommandContext,
): Promise<void | Error> {
const clientResult = resolveControlClient(context.options);
if (!clientResult.ok) return new Error(clientResult.message);
const client = requireControlClient(context.options);
if (client instanceof Error) return client;

try {
const pane = mode === 'pane-surfaces' ? (flags.pane ?? 'focused') : undefined;
const response = await clientResult.value.listSurfaces({ pane });
const response = await client.listSurfaces({ pane });
const idFormat = flags.idFormat ?? 'refs';
const stdout = renderListResponse(response, mode, idFormat, flags.json === true);
writeStdout(context, stdout);
return undefined;
} catch (error) {
return new Error(error instanceof Error ? error.message : String(error));
return new Error(errorMessage(error));
}
}

Expand Down Expand Up @@ -170,7 +172,7 @@ function renderPanesJson(response: ListSurfacesResponse, panes: Pane[], idFormat
panes: panes.map((pane) => renderPaneJson(pane, idFormat)),
...(wantsRefs(idFormat) ? { window_ref: response.windowRef, workspace_ref: response.workspaceRef } : {}),
};
return `${JSON.stringify(payload, null, 2)}\n`;
return renderJson(payload);
}

function renderPaneJson(pane: Pane, idFormat: IdFormat): Record<string, unknown> {
Expand All @@ -195,7 +197,7 @@ function renderPaneSurfacesJson(response: ListSurfacesResponse, idFormat: IdForm
surfaces: response.surfaces.map((surface) => renderPaneSurfaceJson(surface, idFormat)),
...(wantsRefs(idFormat) ? { window_ref: response.windowRef, workspace_ref: response.workspaceRef } : {}),
};
return `${JSON.stringify(payload, null, 2)}\n`;
return renderJson(payload);
}

function renderPaneSurfaceJson(surface: Surface, idFormat: IdFormat): Record<string, unknown> {
Expand Down
8 changes: 4 additions & 4 deletions dor/src/commands/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
ReadSurfaceResponse,
} from './types.js';
import {
resolveControlClient,
requireControlClient,
stringParser,
writeStdout,
} from './shared.js';
Expand Down Expand Up @@ -51,11 +51,11 @@ JSON output:
};

async function runReadCommand(this: DorCommandContext, flags: ReadFlags): Promise<void | Error> {
const clientResult = resolveControlClient(this.options);
if (!clientResult.ok) return new Error(clientResult.message);
const client = requireControlClient(this.options);
if (client instanceof Error) return client;

try {
const response = await clientResult.value.readSurface({
const response = await client.readSurface({
...(flags.lines !== undefined ? { lines: flags.lines } : {}),
scrollback: flags.scrollback === true,
surface: flags.surface,
Expand Down
8 changes: 4 additions & 4 deletions dor/src/commands/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
SendSurfaceResponse,
} from './types.js';
import {
resolveControlClient,
requireControlClient,
stringParser,
writeStdout,
} from './shared.js';
Expand Down Expand Up @@ -78,11 +78,11 @@ async function runSendCommand(this: DorCommandContext, flags: SendFlags, text?:
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);
const client = requireControlClient(this.options);
if (client instanceof Error) return client;

try {
const response = await clientResult.value.sendSurface({
const response = await client.sendSurface({
surface: flags.surface,
input: encoded.value.input,
inputCount: encoded.value.inputCount,
Expand Down
15 changes: 14 additions & 1 deletion dor/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,18 @@ import type {

export const stringParser = (input: string): string => input;

export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

export function fail(message: string): CliResult {
return { exitCode: 1, stdout: '', stderr: `Error: ${message}\n` };
}

export function renderJson(payload: unknown): string {
return `${JSON.stringify(payload, null, 2)}\n`;
}

export function isIdFormat(value: string): value is IdFormat {
return value === 'refs' || value === 'uuids' || value === 'both';
}
Expand All @@ -23,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat {
throw new SyntaxError(`invalid --id-format '${value}'`);
}

export function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
if (options.client) return { ok: true, value: options.client };

const env = options.env ?? {};
Expand All @@ -43,6 +51,11 @@ export function resolveControlClient(options: CliOptions): ParseResult<ControlCl
};
}

export function requireControlClient(options: CliOptions): ControlClient | Error {
const result = resolveControlClient(options);
return result.ok ? result.value : new Error(result.message);
}

export function renderHandle(handle: { ref: string; id: string }, idFormat: IdFormat): string {
switch (idFormat) {
case 'refs':
Expand Down
Loading
Loading