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
2 changes: 1 addition & 1 deletion dor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult<void> {

for (let index = 0; index < delimiterIndex; index += 1) {
const arg = args[index];
if (arg === '--json' || arg === '--minimize') {
if (arg === '--json' || arg === '--minimize' || arg === '--restart') {
continue;
}
if (arg === '--cwd' || arg === '--surface') {
Expand Down
46 changes: 38 additions & 8 deletions dor/src/commands/ensure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,37 @@ import {
interface EnsureFlags {
readonly json?: boolean;
readonly minimize?: boolean;
readonly restart?: boolean;
readonly surface?: string;
readonly cwd?: string;
}

// `--restart` makes the host block until a server is interrupted and respawned,
// which can outlast the client's default 5s request timeout. Give that one
// command plenty of headroom.
const RESTART_TIMEOUT_MS = 60_000;

// When ensure *creates* a surface, the host waits for the new shell to report OSC
// 633 integration so it can warn if the command won't be trackable. That wait can
// run to ~15s on a shell that never integrates, outlasting the default 5s. Matched
// surfaces still respond instantly; this only raises the ceiling for the slow case.
const ENSURE_TIMEOUT_MS = 20_000;

export const ensureCommand: Command = {
name: 'ensure',
helpPatches: [
{
scope: 'root',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
scope: 'command-usage',
findReplace: [
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
],
},
{
Expand All @@ -50,7 +62,9 @@ export const ensureCommand: Command = {
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.

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.
Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), 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.

ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell.

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.

Expand All @@ -60,11 +74,14 @@ Two surfaces running the same command in different working directories are disti

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

--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one.

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

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

JSON output:
{
Expand All @@ -80,6 +97,7 @@ JSON output:
flags: {
json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false },
minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false },
restart: { kind: 'boolean', brief: 'Restart a matching surface in place.', optional: true, withNegated: false },
surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' },
cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' },
},
Expand All @@ -98,13 +116,14 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...
return new Error('dor ensure requires a command after --');
}

const client = requireControlClient(this.options);
const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : ENSURE_TIMEOUT_MS);
if (client instanceof Error) return client;

try {
const response = await client.ensureSurface({
command: commandArgs,
minimized: flags.minimize === true,
restart: flags.restart === true,
surface: flags.surface,
cwd: callerWorkingDirectory(flags.cwd, this.options.env),
});
Expand All @@ -115,13 +134,24 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...
}
}

// Git Bash exports PWD as a POSIX path (`/c/Users/...`). On Windows, resolvePath
// reads the leading `/c` as a folder under the current drive's root and mangles it
// to `C:\c\Users\...`, which then matches no surface. Fold the MSYS drive form to a
// native Windows drive first. No-op off win32 and for paths that already carry a
// drive letter (e.g. `C:/Users/...`, which some MSYS builds export instead).
export function msysToWindowsCwd(pwd: string, platform: string): string {
if (platform !== 'win32') return pwd;
const match = pwd.match(/^\/([A-Za-z])\/(.*)$/);
return match ? `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}` : pwd;
}

// 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.
// key on.
function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string {
const base = env?.PWD ?? process.cwd();
const base = msysToWindowsCwd(env?.PWD ?? process.cwd(), process.platform);
return resolvePath(base, flag ?? '.');
}

Expand Down
10 changes: 7 additions & 3 deletions dor/src/commands/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat {
throw new SyntaxError(`invalid --id-format '${value}'`);
}

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

const env = options.env ?? {};
Expand All @@ -47,12 +47,16 @@ function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
socketPath,
token,
surfaceId: env.DORMOUSE_SURFACE_ID,
...(timeoutMs === undefined ? {} : { timeoutMs }),
}),
};
}

export function requireControlClient(options: CliOptions): ControlClient | Error {
const result = resolveControlClient(options);
// `timeoutMs` overrides the client's default request timeout for commands that
// intentionally block the host (e.g. `dor ensure --restart` waits for a server
// to die and respawn). Ignored when a client is injected (tests).
export function requireControlClient(options: CliOptions, timeoutMs?: number): ControlClient | Error {
const result = resolveControlClient(options, timeoutMs);
return result.ok ? result.value : new Error(result.message);
}

Expand Down
4 changes: 3 additions & 1 deletion dor/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export interface EnsureSurfaceRequest {
/** Raw argv for the command; the host quotes it for the target shell. */
command: string[];
minimized: boolean;
/** Interrupt and re-run a matching surface in place instead of reusing it. */
restart: boolean;
surface?: string;
/** Working directory for matching and for the new command; part of the idempotency key. */
cwd: string;
}

export interface EnsureSurfaceResponse {
status: 'created' | 'existing';
status: 'created' | 'existing' | 'restarted';
surfaceId?: string;
surfaceRef: string;
command: string;
Expand Down
40 changes: 39 additions & 1 deletion dor/test/cli-output.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCli } from '../dist/cli.js';
import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js';
import { msysToWindowsCwd } from '../dist/commands/ensure.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const snapshotsDir = join(__dirname, 'snapshots');
Expand Down Expand Up @@ -73,8 +74,9 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) {
// Mirror the host: quote the argv for the target shell, and key on the
// command so the fixture can exercise both the created and existing paths.
const command = buildShellCommandForKind('posix', request.command);
const isExisting = command === 'pnpm dev:workspace';
return {
status: command === 'pnpm dev:workspace' ? 'existing' : 'created',
status: isExisting ? (request.restart ? 'restarted' : 'existing') : 'created',
surfaceId: '33333333-3333-4333-8333-333333333333',
surfaceRef: 'surface:3',
command,
Expand Down Expand Up @@ -239,12 +241,48 @@ test('ensure sends command argv and caller cwd to the host', async () => {
request: {
command: ['pnpm', 'dev'],
minimized: false,
restart: false,
surface: undefined,
cwd: '/work/site',
},
}]);
});

test('ensure --restart restarts a matching surface in place', async () => {
const client = fixtureClient();
await snapshot(
'ensure-restart',
await runCli(['ensure', '--restart', '--', 'pnpm', 'dev:workspace'], {
client,
env: { PWD: '/work/site' },
}),
);
assert.equal(client.requests[0].request.restart, true);
});

test('ensure surfaces a host error (no integration) to stderr with exit 1', async () => {
const client = {
requests: [],
async ensureSurface(request) {
this.requests.push({ method: 'ensureSurface', request });
throw new Error('dor ensure requires OSC 633 shell integration, which cmd.exe does not provide. Run it from a shell with Dormouse integration, such as Git Bash or PowerShell.');
},
};
const result = await runCli(['ensure', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } });
assert.equal(result.exitCode, 1);
assert.match(result.stderr, /^Error: dor ensure requires OSC 633 shell integration, which cmd\.exe does not provide/);
assert.equal(result.stdout, '');
});

test('msysToWindowsCwd folds a Git Bash POSIX PWD to a Windows drive on win32', () => {
assert.equal(msysToWindowsCwd('/c/Users/me/site', 'win32'), 'C:\\Users\\me\\site');
assert.equal(msysToWindowsCwd('/d/work', 'win32'), 'D:\\work');
// Already-native paths (some MSYS builds export `C:/...`) and non-win32
// platforms are left for resolvePath to handle.
assert.equal(msysToWindowsCwd('C:/Users/me/site', 'win32'), 'C:/Users/me/site');
assert.equal(msysToWindowsCwd('/c/Users/me/site', 'linux'), '/c/Users/me/site');
});

test('ensure json output', async () => {
await snapshot(
'ensure-json',
Expand Down
5 changes: 5 additions & 0 deletions dor/test/snapshots/ensure-restart.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
exitCode: 0
stdout:
restarted surface:3 "pnpm dev:workspace"

stderr:
2 changes: 1 addition & 1 deletion dor/test/snapshots/help/dor.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Invocation: `dor --help`
```text
USAGE
dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- <command>...]
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
dor version
dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [<text>]
dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index]
Expand Down
10 changes: 8 additions & 2 deletions dor/test/snapshots/help/ensure.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ Invocation: `dor ensure --help`

```text
USAGE
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
dor ensure --help

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.

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.
Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), 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.

ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell.

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.

Expand All @@ -19,11 +21,14 @@ Two surfaces running the same command in different working directories are disti

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

--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one.

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

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

JSON output:
{
Expand All @@ -38,6 +43,7 @@ JSON output:
FLAGS
[--json] Print JSON output.
[--minimize] Create the surface minimized.
[--restart] Restart a matching surface in place.
[--surface] Surface to split when creating.
[--cwd] Working directory for matching and for the new command.
-h --help Print help information and exit
Expand Down
Loading
Loading