Skip to content

Commit 53e02bf

Browse files
authored
dor ensure: add --restart to interrupt and re-run a matching surface (#158)
2 parents 59b1a11 + aee4ac0 commit 53e02bf

20 files changed

Lines changed: 523 additions & 49 deletions

dor/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult<void> {
319319

320320
for (let index = 0; index < delimiterIndex; index += 1) {
321321
const arg = args[index];
322-
if (arg === '--json' || arg === '--minimize') {
322+
if (arg === '--json' || arg === '--minimize' || arg === '--restart') {
323323
continue;
324324
}
325325
if (arg === '--cwd' || arg === '--surface') {

dor/src/commands/ensure.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,37 @@ import {
1919
interface EnsureFlags {
2020
readonly json?: boolean;
2121
readonly minimize?: boolean;
22+
readonly restart?: boolean;
2223
readonly surface?: string;
2324
readonly cwd?: string;
2425
}
2526

27+
// `--restart` makes the host block until a server is interrupted and respawned,
28+
// which can outlast the client's default 5s request timeout. Give that one
29+
// command plenty of headroom.
30+
const RESTART_TIMEOUT_MS = 60_000;
31+
32+
// When ensure *creates* a surface, the host waits for the new shell to report OSC
33+
// 633 integration so it can warn if the command won't be trackable. That wait can
34+
// run to ~15s on a shell that never integrates, outlasting the default 5s. Matched
35+
// surfaces still respond instantly; this only raises the ceiling for the slow case.
36+
const ENSURE_TIMEOUT_MS = 20_000;
37+
2638
export const ensureCommand: Command = {
2739
name: 'ensure',
2840
helpPatches: [
2941
{
3042
scope: 'root',
3143
findReplace: [
32-
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
33-
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
44+
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
45+
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
3446
],
3547
},
3648
{
3749
scope: 'command-usage',
3850
findReplace: [
39-
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]<TO-EOL>',
40-
' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...\n',
51+
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]<TO-EOL>',
52+
' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...\n',
4153
],
4254
},
4355
{
@@ -50,7 +62,9 @@ export const ensureCommand: Command = {
5062
brief: 'Ensure one surface is running a command.',
5163
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.
5264
53-
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.
65+
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.
66+
67+
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.
5468
5569
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.
5670
@@ -60,11 +74,14 @@ Two surfaces running the same command in different working directories are disti
6074
6175
--minimize applies only when creating a new surface; it does not minimize an existing match.
6276
77+
--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.
78+
6379
--surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split.
6480
6581
Text output:
6682
created surface:3 "npm run dev"
6783
existing surface:3 "npm run dev"
84+
restarted surface:3 "npm run dev"
6885
6986
JSON output:
7087
{
@@ -80,6 +97,7 @@ JSON output:
8097
flags: {
8198
json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false },
8299
minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false },
100+
restart: { kind: 'boolean', brief: 'Restart a matching surface in place.', optional: true, withNegated: false },
83101
surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' },
84102
cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' },
85103
},
@@ -98,13 +116,14 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...
98116
return new Error('dor ensure requires a command after --');
99117
}
100118

101-
const client = requireControlClient(this.options);
119+
const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : ENSURE_TIMEOUT_MS);
102120
if (client instanceof Error) return client;
103121

104122
try {
105123
const response = await client.ensureSurface({
106124
command: commandArgs,
107125
minimized: flags.minimize === true,
126+
restart: flags.restart === true,
108127
surface: flags.surface,
109128
cwd: callerWorkingDirectory(flags.cwd, this.options.env),
110129
});
@@ -115,13 +134,24 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...
115134
}
116135
}
117136

137+
// Git Bash exports PWD as a POSIX path (`/c/Users/...`). On Windows, resolvePath
138+
// reads the leading `/c` as a folder under the current drive's root and mangles it
139+
// to `C:\c\Users\...`, which then matches no surface. Fold the MSYS drive form to a
140+
// native Windows drive first. No-op off win32 and for paths that already carry a
141+
// drive letter (e.g. `C:/Users/...`, which some MSYS builds export instead).
142+
export function msysToWindowsCwd(pwd: string, platform: string): string {
143+
if (platform !== 'win32') return pwd;
144+
const match = pwd.match(/^\/([A-Za-z])\/(.*)$/);
145+
return match ? `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}` : pwd;
146+
}
147+
118148
// The host has no idea where `dor` was launched, so the caller's directory must
119149
// travel in the request. Prefer the shell's PWD (injectable, matches what the
120150
// user sees) and fall back to the process cwd. resolvePath canonicalizes both
121151
// the default and a relative/absolute --cwd into one absolute path the host can
122-
// key on with an exact compare.
152+
// key on.
123153
function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string {
124-
const base = env?.PWD ?? process.cwd();
154+
const base = msysToWindowsCwd(env?.PWD ?? process.cwd(), process.platform);
125155
return resolvePath(base, flag ?? '.');
126156
}
127157

dor/src/commands/shared.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat {
3131
throw new SyntaxError(`invalid --id-format '${value}'`);
3232
}
3333

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

3737
const env = options.env ?? {};
@@ -47,12 +47,16 @@ function resolveControlClient(options: CliOptions): ParseResult<ControlClient> {
4747
socketPath,
4848
token,
4949
surfaceId: env.DORMOUSE_SURFACE_ID,
50+
...(timeoutMs === undefined ? {} : { timeoutMs }),
5051
}),
5152
};
5253
}
5354

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

dor/src/commands/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,15 @@ export interface EnsureSurfaceRequest {
5353
/** Raw argv for the command; the host quotes it for the target shell. */
5454
command: string[];
5555
minimized: boolean;
56+
/** Interrupt and re-run a matching surface in place instead of reusing it. */
57+
restart: boolean;
5658
surface?: string;
5759
/** Working directory for matching and for the new command; part of the idempotency key. */
5860
cwd: string;
5961
}
6062

6163
export interface EnsureSurfaceResponse {
62-
status: 'created' | 'existing';
64+
status: 'created' | 'existing' | 'restarted';
6365
surfaceId?: string;
6466
surfaceRef: string;
6567
command: string;

dor/test/cli-output.test.mjs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
55
import { fileURLToPath } from 'node:url';
66
import { runCli } from '../dist/cli.js';
77
import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js';
8+
import { msysToWindowsCwd } from '../dist/commands/ensure.js';
89

910
const __dirname = dirname(fileURLToPath(import.meta.url));
1011
const snapshotsDir = join(__dirname, 'snapshots');
@@ -73,8 +74,9 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) {
7374
// Mirror the host: quote the argv for the target shell, and key on the
7475
// command so the fixture can exercise both the created and existing paths.
7576
const command = buildShellCommandForKind('posix', request.command);
77+
const isExisting = command === 'pnpm dev:workspace';
7678
return {
77-
status: command === 'pnpm dev:workspace' ? 'existing' : 'created',
79+
status: isExisting ? (request.restart ? 'restarted' : 'existing') : 'created',
7880
surfaceId: '33333333-3333-4333-8333-333333333333',
7981
surfaceRef: 'surface:3',
8082
command,
@@ -239,12 +241,48 @@ test('ensure sends command argv and caller cwd to the host', async () => {
239241
request: {
240242
command: ['pnpm', 'dev'],
241243
minimized: false,
244+
restart: false,
242245
surface: undefined,
243246
cwd: '/work/site',
244247
},
245248
}]);
246249
});
247250

251+
test('ensure --restart restarts a matching surface in place', async () => {
252+
const client = fixtureClient();
253+
await snapshot(
254+
'ensure-restart',
255+
await runCli(['ensure', '--restart', '--', 'pnpm', 'dev:workspace'], {
256+
client,
257+
env: { PWD: '/work/site' },
258+
}),
259+
);
260+
assert.equal(client.requests[0].request.restart, true);
261+
});
262+
263+
test('ensure surfaces a host error (no integration) to stderr with exit 1', async () => {
264+
const client = {
265+
requests: [],
266+
async ensureSurface(request) {
267+
this.requests.push({ method: 'ensureSurface', request });
268+
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.');
269+
},
270+
};
271+
const result = await runCli(['ensure', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } });
272+
assert.equal(result.exitCode, 1);
273+
assert.match(result.stderr, /^Error: dor ensure requires OSC 633 shell integration, which cmd\.exe does not provide/);
274+
assert.equal(result.stdout, '');
275+
});
276+
277+
test('msysToWindowsCwd folds a Git Bash POSIX PWD to a Windows drive on win32', () => {
278+
assert.equal(msysToWindowsCwd('/c/Users/me/site', 'win32'), 'C:\\Users\\me\\site');
279+
assert.equal(msysToWindowsCwd('/d/work', 'win32'), 'D:\\work');
280+
// Already-native paths (some MSYS builds export `C:/...`) and non-win32
281+
// platforms are left for resolvePath to handle.
282+
assert.equal(msysToWindowsCwd('C:/Users/me/site', 'win32'), 'C:/Users/me/site');
283+
assert.equal(msysToWindowsCwd('/c/Users/me/site', 'linux'), '/c/Users/me/site');
284+
});
285+
248286
test('ensure json output', async () => {
249287
await snapshot(
250288
'ensure-json',
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
exitCode: 0
2+
stdout:
3+
restarted surface:3 "pnpm dev:workspace"
4+
5+
stderr:

dor/test/snapshots/help/dor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Invocation: `dor --help`
55
```text
66
USAGE
77
dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- <command>...]
8-
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
8+
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
99
dor version
1010
dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [<text>]
1111
dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index]

dor/test/snapshots/help/ensure.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ Invocation: `dor ensure --help`
44

55
```text
66
USAGE
7-
dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- <command>...
7+
dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- <command>...
88
dor ensure --help
99
1010
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.
1111
12-
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.
12+
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.
13+
14+
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.
1315
1416
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.
1517
@@ -19,11 +21,14 @@ Two surfaces running the same command in different working directories are disti
1921
2022
--minimize applies only when creating a new surface; it does not minimize an existing match.
2123
24+
--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.
25+
2226
--surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split.
2327
2428
Text output:
2529
created surface:3 "npm run dev"
2630
existing surface:3 "npm run dev"
31+
restarted surface:3 "npm run dev"
2732
2833
JSON output:
2934
{
@@ -38,6 +43,7 @@ JSON output:
3843
FLAGS
3944
[--json] Print JSON output.
4045
[--minimize] Create the surface minimized.
46+
[--restart] Restart a matching surface in place.
4147
[--surface] Surface to split when creating.
4248
[--cwd] Working directory for matching and for the new command.
4349
-h --help Print help information and exit

0 commit comments

Comments
 (0)