From af35b1ddcb1561684db5432a68aec86e879895d5 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Thu, 17 Sep 2026 19:37:18 -0400 Subject: [PATCH 1/6] fix(cli): support Windows runtime shims --- src/cli/mm.test.ts | 25 +++++++++++++++++++++---- src/cli/mm.ts | 38 ++++++++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index c1da26a..fb39347 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -675,13 +675,28 @@ describe('printHelp', () => { describe('resolveRuntime', () => { it('returns node for node runtime', () => { - expect(resolveRuntime('/root', 'node')).toBe('node'); + expect(resolveRuntime('/root', 'node')).toStrictEqual({ + command: 'node', + shell: false, + }); + }); + + it('returns bin path without shell when runtime exists', () => { + vi.mocked(existsSync).mockReturnValue(true); + const result = resolveRuntime('/root', 'tsx', 'linux'); + expect(result).toStrictEqual({ + command: path.join('/root', 'node_modules', '.bin', 'tsx'), + shell: false, + }); }); - it('returns bin path when runtime exists', () => { + it('returns the Windows command shim and enables a shell', () => { vi.mocked(existsSync).mockReturnValue(true); - const result = resolveRuntime('/root', 'tsx'); - expect(result).toBe(path.join('/root', 'node_modules', '.bin', 'tsx')); + + expect(resolveRuntime('/root', 'tsx', 'win32')).toStrictEqual({ + command: path.join('/root', 'node_modules', '.bin', 'tsx.cmd'), + shell: true, + }); }); it('exits when runtime binary not found', () => { @@ -2924,6 +2939,7 @@ describe('handleServe', () => { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: '/root', + shell: false, }); expect(stdoutSpy).toHaveBeenCalledWith( 'Daemon started on port 4000 (PID 456)\n', @@ -3179,6 +3195,7 @@ describe('autoStartDaemon', () => { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: '/root', + shell: false, }); expect(releaseStartupLock).toHaveBeenCalledWith('/root'); expect(result).toStrictEqual(mockState); diff --git a/src/cli/mm.ts b/src/cli/mm.ts index d558df4..e7a3b42 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -49,6 +49,11 @@ type DaemonConfig = { runtime: string; }; +type RuntimeCommand = { + command: string; + shell: boolean; +}; + /** * Extracts and consumes the `--project ` flag from argv, returning * the remaining args and the extracted project path (if any). @@ -1181,12 +1186,13 @@ export async function autoStartDaemon( } const config = await readDaemonConfig(worktreeRoot); - const runtimeBin = resolveRuntime(worktreeRoot, config.runtime); + const runtimeCommand = resolveRuntime(worktreeRoot, config.runtime); - const child = spawn(runtimeBin, [config.daemonPath], { + const child = spawn(runtimeCommand.command, [config.daemonPath], { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: worktreeRoot, + shell: runtimeCommand.shell, }); child.unref(); @@ -1219,13 +1225,14 @@ export async function handleServe( } const config = await readDaemonConfig(worktreeRoot); - const runtimeBin = resolveRuntime(worktreeRoot, config.runtime); + const runtimeCommand = resolveRuntime(worktreeRoot, config.runtime); if (background) { - const child = spawn(runtimeBin, [config.daemonPath], { + const child = spawn(runtimeCommand.command, [config.daemonPath], { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: worktreeRoot, + shell: runtimeCommand.shell, }); child.unref(); @@ -1236,9 +1243,10 @@ export async function handleServe( return; } - const child = spawn(runtimeBin, [config.daemonPath], { + const child = spawn(runtimeCommand.command, [config.daemonPath], { stdio: 'inherit', cwd: worktreeRoot, + shell: runtimeCommand.shell, }); await new Promise((resolve) => { @@ -1364,21 +1372,31 @@ export async function readDaemonConfig( * * @param worktreeRoot - The git worktree root directory. * @param runtime - The runtime name from configuration. - * @returns The absolute path to the runtime binary. + * @param platform - Platform used to resolve package-manager command shims. + * @returns Spawn metadata for the runtime binary. */ -export function resolveRuntime(worktreeRoot: string, runtime: string): string { +export function resolveRuntime( + worktreeRoot: string, + runtime: string, + platform = process.platform, +): RuntimeCommand { if (runtime === 'node') { - return 'node'; + return { command: 'node', shell: false }; } - const binPath = path.join(worktreeRoot, 'node_modules', '.bin', runtime); + const binPath = path.join( + worktreeRoot, + 'node_modules', + '.bin', + platform === 'win32' ? `${runtime}.cmd` : runtime, + ); if (!existsSync(binPath)) { process.stderr.write( `Error: Runtime '${runtime}' not found at ${binPath}. Install it or set "mm.runtime" in package.json.\n`, ); process.exit(1); } - return binPath; + return { command: binPath, shell: platform === 'win32' }; } /** From fbecc07c9360c52170d654b83f3809e773d743f5 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Thu, 17 Sep 2026 19:56:03 -0400 Subject: [PATCH 2/6] fix(cli): resolve extension paths from project root --- src/cli/mm.test.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mm.ts | 6 ++++ 2 files changed, 82 insertions(+) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index fb39347..ba41133 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -2751,6 +2751,82 @@ describe('main', () => { process.argv = origArgv; }); + it('resolves a relative extension path from the worktree root', async () => { + const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } = + await import('../server/daemon-state.js'); + const mockState = { + port: 3000, + pid: 123, + nonce: 'abc', + startedAt: '2024-01-01', + version: '1.0.0', + subPorts: { anvil: 8545, fixture: 8546, mock: 8547 }, + }; + vi.mocked(readDaemonState).mockResolvedValueOnce(mockState); + vi.mocked(isDaemonAlive).mockResolvedValueOnce(true); + vi.mocked(isDaemonVersionMatch).mockReturnValueOnce(true); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, result: 'launched' }), + } as Response); + + const originalProject = process.env.MM_PROJECT; + process.env.MM_PROJECT = '/mock/worktree'; + const origArgv = process.argv; + process.argv = ['node', 'mm', 'launch', '--extension-path', 'dist/chrome']; + + await main(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/launch', + expect.objectContaining({ + body: JSON.stringify({ + extensionPath: path.resolve('/mock/worktree', 'dist/chrome'), + }), + }), + ); + + process.argv = origArgv; + process.env.MM_PROJECT = originalProject; + }); + + it('preserves an absolute extension path', async () => { + const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } = + await import('../server/daemon-state.js'); + const mockState = { + port: 3000, + pid: 123, + nonce: 'abc', + startedAt: '2024-01-01', + version: '1.0.0', + subPorts: { anvil: 8545, fixture: 8546, mock: 8547 }, + }; + vi.mocked(readDaemonState).mockResolvedValueOnce(mockState); + vi.mocked(isDaemonAlive).mockResolvedValueOnce(true); + vi.mocked(isDaemonVersionMatch).mockReturnValueOnce(true); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, result: 'launched' }), + } as Response); + + const extensionPath = path.resolve('/custom-extension'); + const origArgv = process.argv; + process.argv = ['node', 'mm', 'launch', '--extension-path', extensionPath]; + + await main(); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:3000/launch', + expect.objectContaining({ + body: JSON.stringify({ extensionPath }), + }), + ); + + process.argv = origArgv; + }); + it('routes cleanup command through discoverDaemon', async () => { const { readDaemonState, isDaemonAlive, isDaemonVersionMatch } = await import('../server/daemon-state.js'); diff --git a/src/cli/mm.ts b/src/cli/mm.ts index e7a3b42..9627b0e 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -172,6 +172,12 @@ export async function main(): Promise { if (command === 'launch') { const launchArgs = parseLaunchArgs(args.slice(1)); + if (typeof launchArgs.extensionPath === 'string') { + launchArgs.extensionPath = path.resolve( + worktreeRoot, + launchArgs.extensionPath, + ); + } await sendRequest(daemonState.port, 'POST', '/launch', launchArgs); return; } From 9a418b7ca33cc5345848dc3697631414ee131491 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Thu, 17 Sep 2026 20:17:34 -0400 Subject: [PATCH 3/6] docs: clarify mm CLI installation modes --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cc37520..a3b2e6e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The design is **consumer-agnostic**: the core handles protocol, tooling, and kno ## Installation -As a project dependency (the CLI is available via `npx mm` or `yarn mm`): +As a project dependency, run the CLI through your package manager. It does not put a bare `mm` command on your shell `PATH`: ```bash yarn add @metamask/client-mcp-core @@ -87,6 +87,16 @@ npm install -g @metamask/client-mcp-core The global CLI can target any project via `--project` or `MM_PROJECT` (see [Project Targeting](#project-targeting)). +Use the invocation that matches your installation: + +```bash +# Project dependency +yarn mm launch + +# Global installation +mm launch +``` + ## Getting Started Consuming this package requires two things: a **daemon entry point** and a **configuration file**. @@ -146,6 +156,8 @@ mm click e3 # interact using a11y refs mm cleanup --shutdown # stop browser and daemon ``` +Use `yarn mm` in place of `mm` when the CLI is installed as a project dependency. + If running from outside the project directory (e.g., a parent folder containing multiple repos): ```bash From 50c6cfaf8e3c0a416f55cebf806e46e7562f6568 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Thu, 17 Sep 2026 21:18:44 -0400 Subject: [PATCH 4/6] fix(cli): allow slower daemon startup --- src/cli/mm.test.ts | 6 ++++-- src/cli/mm.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index ba41133..c2fa67c 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -2629,12 +2629,14 @@ describe('waitForDaemon', () => { vi.useFakeTimers(); const promise = waitForDaemon('/root').catch((error: Error) => error); - for (let i = 0; i < 55; i++) { + for (let i = 0; i < 155; i++) { await vi.advanceTimersByTimeAsync(200); } const result = await promise; expect(result).toBeInstanceOf(Error); - expect((result as Error).message).toContain('Daemon failed to start'); + expect((result as Error).message).toBe( + 'Daemon failed to start within 30 seconds', + ); vi.useRealTimers(); }); }); diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 9627b0e..7489265 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -28,7 +28,9 @@ const COMMAND_TIMEOUTS_MS: Record = { const AUTO_START_COMMANDS = new Set(['launch', 'serve']); const DAEMON_POLL_INTERVAL_MS = 200; -const DAEMON_POLL_MAX_ATTEMPTS = 50; // 50 * 200ms = 10s +const DAEMON_START_TIMEOUT_MS = 30_000; +const DAEMON_POLL_MAX_ATTEMPTS = + DAEMON_START_TIMEOUT_MS / DAEMON_POLL_INTERVAL_MS; const SEND_MAX_RETRIES = 3; const SEND_RETRY_BASE_DELAY_MS = 200; const CONFIG_MODULE_NAME = 'mm-client-cli'; @@ -1421,7 +1423,9 @@ export async function waitForDaemon( return state; } } - throw new Error('Daemon failed to start within 10 seconds'); + throw new Error( + `Daemon failed to start within ${DAEMON_START_TIMEOUT_MS / 1000} seconds`, + ); } /** From 76571a19ceda44f790997bda543762c859dd6bd6 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Thu, 17 Sep 2026 21:54:43 -0400 Subject: [PATCH 5/6] fix(cli): avoid shell for Windows runtimes --- src/cli/mm.test.ts | 95 ++++++++++++++++++++++++--- src/cli/mm.ts | 111 ++++++++++++++++++++++++-------- src/server/daemon-state.test.ts | 33 ++++++---- src/server/daemon-state.ts | 17 ++--- src/tools/interaction.test.ts | 3 + 5 files changed, 202 insertions(+), 57 deletions(-) diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index c2fa67c..a1d620a 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -54,7 +54,10 @@ vi.mock('node:child_process', () => ({ vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, existsSync: vi.fn(() => true) }; + return { + ...actual, + existsSync: vi.fn(() => true), + }; }); vi.mock('node:fs/promises', async (importOriginal) => { @@ -675,10 +678,12 @@ describe('printHelp', () => { describe('resolveRuntime', () => { it('returns node for node runtime', () => { - expect(resolveRuntime('/root', 'node')).toStrictEqual({ + const result = resolveRuntime('/root', 'node'); + expect(result).toStrictEqual({ command: 'node', - shell: false, + getArgs: expect.any(Function), }); + expect(result.getArgs('./daemon.ts')).toStrictEqual(['./daemon.ts']); }); it('returns bin path without shell when runtime exists', () => { @@ -686,17 +691,85 @@ describe('resolveRuntime', () => { const result = resolveRuntime('/root', 'tsx', 'linux'); expect(result).toStrictEqual({ command: path.join('/root', 'node_modules', '.bin', 'tsx'), - shell: false, + getArgs: expect.any(Function), }); + expect(result.getArgs('./daemon.ts')).toStrictEqual(['./daemon.ts']); }); - it('returns the Windows command shim and enables a shell', () => { + it('runs a Windows runtime shim through cmd.exe with quoted arguments', () => { vi.mocked(existsSync).mockReturnValue(true); expect(resolveRuntime('/root', 'tsx', 'win32')).toStrictEqual({ - command: path.join('/root', 'node_modules', '.bin', 'tsx.cmd'), - shell: true, + command: process.env.ComSpec ?? 'cmd.exe', + getArgs: expect.any(Function), + windowsVerbatimArguments: true, }); + expect( + resolveRuntime('/root', 'tsx', 'win32').getArgs('./daemon.ts'), + ).toStrictEqual([ + '/d', + '/s', + '/c', + '"\\root\\node_modules\\.bin\\tsx.cmd ^^^"./daemon.ts^^^""', + ]); + }); + + it('uses the configured Windows command processor', () => { + vi.mocked(existsSync).mockReturnValue(true); + const originalComSpec = process.env.ComSpec; + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + + try { + expect(resolveRuntime('/root', 'tsx', 'win32')).toMatchObject({ + command: 'C:\\Windows\\System32\\cmd.exe', + }); + } finally { + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } + } + }); + + it('preserves Windows project paths containing spaces', () => { + vi.mocked(existsSync).mockReturnValue(true); + const runtime = resolveRuntime( + 'C:\\Users\\Jane Doe\\project', + 'tsx', + 'win32', + ); + + expect(runtime.getArgs('test/e2e/daemon.ts')).toStrictEqual([ + '/d', + '/s', + '/c', + '"C:\\Users\\Jane^ Doe\\project\\node_modules\\.bin\\tsx.cmd ^^^"test/e2e/daemon.ts^^^""', + ]); + }); + + it('supports a Windows runtime alias provided by a native package', () => { + vi.mocked(existsSync).mockReturnValue(true); + + expect( + resolveRuntime('/root', 'swc-node', 'win32').getArgs('daemon.ts'), + ).toStrictEqual([ + '/d', + '/s', + '/c', + '"\\root\\node_modules\\.bin\\swc-node.cmd ^^^"daemon.ts^^^""', + ]); + }); + + it('exits when a Windows runtime command shim is missing', () => { + vi.mocked(existsSync).mockReturnValue(false); + + expect(() => resolveRuntime('/root', 'tsx', 'win32')).toThrowError( + 'process.exit', + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Runtime 'tsx' not found at"), + ); }); it('exits when runtime binary not found', () => { @@ -2790,7 +2863,11 @@ describe('main', () => { ); process.argv = origArgv; - process.env.MM_PROJECT = originalProject; + if (originalProject === undefined) { + delete process.env.MM_PROJECT; + } else { + process.env.MM_PROJECT = originalProject; + } }); it('preserves an absolute extension path', async () => { @@ -3017,7 +3094,6 @@ describe('handleServe', () => { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: '/root', - shell: false, }); expect(stdoutSpy).toHaveBeenCalledWith( 'Daemon started on port 4000 (PID 456)\n', @@ -3273,7 +3349,6 @@ describe('autoStartDaemon', () => { detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: '/root', - shell: false, }); expect(releaseStartupLock).toHaveBeenCalledWith('/root'); expect(result).toStrictEqual(mockState); diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 7489265..12c35e0 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -53,9 +53,35 @@ type DaemonConfig = { type RuntimeCommand = { command: string; - shell: boolean; + getArgs: (daemonPath: string) => string[]; + windowsVerbatimArguments?: boolean; }; +/** + * Escapes an executable path for use in a cmd.exe command string. + * + * @param command - The executable path to escape. + * @returns The escaped executable path. + */ +function escapeWindowsCommand(command: string): string { + return command.replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1'); +} + +/** + * Escapes an argument for a cmd.exe command string. + * + * @param argument - The argument to escape. + * @returns The escaped and quoted argument. + */ +function escapeWindowsArgument(argument: string): string { + const escapedArgument = argument + .replace(/(?=(\\+?)?)\1"/gu, '$1$1\\"') + .replace(/(?=(\\+?)?)\1$/gu, '$1$1'); + return `"${escapedArgument}"` + .replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1') + .replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1'); +} + /** * Extracts and consumes the `--project ` flag from argv, returning * the remaining args and the extracted project path (if any). @@ -1196,12 +1222,16 @@ export async function autoStartDaemon( const config = await readDaemonConfig(worktreeRoot); const runtimeCommand = resolveRuntime(worktreeRoot, config.runtime); - const child = spawn(runtimeCommand.command, [config.daemonPath], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - cwd: worktreeRoot, - shell: runtimeCommand.shell, - }); + const child = spawn( + runtimeCommand.command, + runtimeCommand.getArgs(config.daemonPath), + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + cwd: worktreeRoot, + windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, + }, + ); child.unref(); return await waitForDaemon(worktreeRoot); @@ -1236,12 +1266,16 @@ export async function handleServe( const runtimeCommand = resolveRuntime(worktreeRoot, config.runtime); if (background) { - const child = spawn(runtimeCommand.command, [config.daemonPath], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - cwd: worktreeRoot, - shell: runtimeCommand.shell, - }); + const child = spawn( + runtimeCommand.command, + runtimeCommand.getArgs(config.daemonPath), + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + cwd: worktreeRoot, + windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, + }, + ); child.unref(); const state = await waitForDaemon(worktreeRoot); @@ -1251,11 +1285,15 @@ export async function handleServe( return; } - const child = spawn(runtimeCommand.command, [config.daemonPath], { - stdio: 'inherit', - cwd: worktreeRoot, - shell: runtimeCommand.shell, - }); + const child = spawn( + runtimeCommand.command, + runtimeCommand.getArgs(config.daemonPath), + { + stdio: 'inherit', + cwd: worktreeRoot, + windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, + }, + ); await new Promise((resolve) => { child.on('exit', (code) => { @@ -1389,22 +1427,43 @@ export function resolveRuntime( platform = process.platform, ): RuntimeCommand { if (runtime === 'node') { - return { command: 'node', shell: false }; + return { command: 'node', getArgs: (daemonPath) => [daemonPath] }; } - const binPath = path.join( - worktreeRoot, - 'node_modules', - '.bin', - platform === 'win32' ? `${runtime}.cmd` : runtime, - ); + if (platform === 'win32') { + const binPath = path.win32.join( + worktreeRoot, + 'node_modules', + '.bin', + `${runtime}.cmd`, + ); + if (!existsSync(binPath)) { + process.stderr.write( + `Error: Runtime '${runtime}' not found at ${binPath}. Install it or set "mm.runtime" in package.json.\n`, + ); + process.exit(1); + } + + return { + command: process.env.ComSpec ?? 'cmd.exe', + getArgs: (daemonPath) => [ + '/d', + '/s', + '/c', + `"${escapeWindowsCommand(binPath)} ${escapeWindowsArgument(daemonPath)}"`, + ], + windowsVerbatimArguments: true, + }; + } + + const binPath = path.join(worktreeRoot, 'node_modules', '.bin', runtime); if (!existsSync(binPath)) { process.stderr.write( `Error: Runtime '${runtime}' not found at ${binPath}. Install it or set "mm.runtime" in package.json.\n`, ); process.exit(1); } - return { command: binPath, shell: platform === 'win32' }; + return { command: binPath, getArgs: (daemonPath) => [daemonPath] }; } /** diff --git a/src/server/daemon-state.test.ts b/src/server/daemon-state.test.ts index f8a26be..869e96e 100644 --- a/src/server/daemon-state.test.ts +++ b/src/server/daemon-state.test.ts @@ -132,11 +132,11 @@ describe('daemon-state', () => { expect(acquired).toBe(false); }); - it('reclaims a stale lock by age', async () => { + it('reclaims an old lock without a valid pid', async () => { const lockPath = path.join(tmpDir, '.mm-server.lock'); const staleTime = new Date(Date.now() - 31_000); - await fs.writeFile(lockPath, `${process.pid}\n`); + await fs.writeFile(lockPath, 'not-a-pid\n'); await fs.utimes(lockPath, staleTime, staleTime); const acquired = await acquireStartupLock(tmpDir); @@ -145,24 +145,35 @@ describe('daemon-state', () => { expect(await fs.readFile(lockPath, 'utf-8')).toBe(`${process.pid}\n`); }); - it('reclaims a stale lock for a dead pid', async () => { + it('does not reclaim an old lock held by a live process', async () => { const lockPath = path.join(tmpDir, '.mm-server.lock'); + const staleTime = new Date(Date.now() - 31_000); - await fs.writeFile(lockPath, '999999\n'); + await fs.writeFile(lockPath, `${process.pid}\n`); + await fs.utimes(lockPath, staleTime, staleTime); - const acquired = await acquireStartupLock(tmpDir); + expect(await acquireStartupLock(tmpDir)).toBe(false); + }); - expect(acquired).toBe(true); - expect(await fs.readFile(lockPath, 'utf-8')).toBe(`${process.pid}\n`); + it('reclaims an old lock with a malformed pid', async () => { + const lockPath = path.join(tmpDir, '.mm-server.lock'); + const staleTime = new Date(Date.now() - 31_000); + + await fs.writeFile(lockPath, `${process.pid}invalid\n`); + await fs.utimes(lockPath, staleTime, staleTime); + + expect(await acquireStartupLock(tmpDir)).toBe(true); }); - it('returns false when stale lock check errors', async () => { - await fs.writeFile(path.join(tmpDir, '.mm-server.lock'), '12345\n'); - await fs.chmod(path.join(tmpDir, '.mm-server.lock'), 0o000); + it('reclaims a stale lock for a dead pid', async () => { + const lockPath = path.join(tmpDir, '.mm-server.lock'); + + await fs.writeFile(lockPath, '999999\n'); const acquired = await acquireStartupLock(tmpDir); - expect(acquired).toBe(false); + expect(acquired).toBe(true); + expect(await fs.readFile(lockPath, 'utf-8')).toBe(`${process.pid}\n`); }); it('throws when lock creation fails with a non-EEXIST error', async () => { diff --git a/src/server/daemon-state.ts b/src/server/daemon-state.ts index d3cfe9a..941fd53 100644 --- a/src/server/daemon-state.ts +++ b/src/server/daemon-state.ts @@ -124,7 +124,8 @@ export function generateNonce(): string { /** * Acquires an exclusive startup lock for the worktree. * Uses O_CREAT | O_EXCL to atomically create the lock file — if it already - * exists, checks whether the lock is stale (dead PID or older than 30s) + * exists, checks whether the lock is stale (a dead PID or an old lock without + * a valid PID) * and reclaims it if so. * * @param worktreeRoot - Absolute path to the git worktree root. @@ -161,7 +162,7 @@ export async function acquireStartupLock( * Checks whether a lock file is stale by examining PID liveness and file age. * * @param lockPath - Absolute path to the lock file. - * @returns true if the lock holder is dead or the file is older than LOCK_STALE_MS. + * @returns true if the lock holder is dead or the lock is old without a valid PID. */ async function isLockStale(lockPath: string): Promise { try { @@ -170,13 +171,9 @@ async function isLockStale(lockPath: string): Promise { fs.stat(lockPath), ]); - const ageMs = Date.now() - stat.mtimeMs; - if (ageMs > LOCK_STALE_MS) { - return true; - } - - const pid = parseInt(content.trim(), 10); - if (!isNaN(pid)) { + const pidText = content.trim(); + if (/^[1-9]\d*$/u.test(pidText)) { + const pid = Number(pidText); try { process.kill(pid, 0); return false; @@ -185,7 +182,7 @@ async function isLockStale(lockPath: string): Promise { } } - return false; + return Date.now() - stat.mtimeMs > LOCK_STALE_MS; } catch { return false; } diff --git a/src/tools/interaction.test.ts b/src/tools/interaction.test.ts index 7dc2d98..0d16c0c 100644 --- a/src/tools/interaction.test.ts +++ b/src/tools/interaction.test.ts @@ -511,6 +511,9 @@ describe('interaction', () => { it('types text into element by CSS selector', async () => { const locator = createMockLocator(); const context = createMockContext(); + const nowSpy = vi.spyOn(Date, 'now'); + nowSpy.mockReturnValueOnce(1000); + nowSpy.mockReturnValueOnce(1000); vi.spyOn(discoveryModule, 'waitForTarget').mockResolvedValue( locator as any, From 8f8c60955f27bcea4c05e234395e8c14439ee814 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Mon, 21 Sep 2026 11:01:39 -0400 Subject: [PATCH 6/6] only support tsx and node --- README.md | 2 +- src/cli/mm.test.ts | 142 ++++++++++++++++++--------------------------- src/cli/mm.ts | 96 ++++++++++-------------------- 3 files changed, 87 insertions(+), 153 deletions(-) diff --git a/README.md b/README.md index a3b2e6e..637f08b 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,7 @@ export default { }; ``` -The `daemon` field tells the CLI where the daemon entry point lives. The `runtime` field specifies the TypeScript runner (defaults to `tsx`). +The `daemon` field tells the CLI where the daemon entry point lives. The `runtime` field supports `tsx` (the default) and `node`. For `tsx`, the CLI resolves the project's installed `tsx/cli` module and launches it through Node. The CLI uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for config discovery, so you can also use `mm-client-cli.config.js`, `.mm-client-clirc.json`, or other supported formats. diff --git a/src/cli/mm.test.ts b/src/cli/mm.test.ts index a1d620a..e661c51 100644 --- a/src/cli/mm.test.ts +++ b/src/cli/mm.test.ts @@ -3,8 +3,8 @@ /* eslint-disable n/no-sync */ /* eslint-disable require-atomic-updates */ import { cosmiconfig } from 'cosmiconfig'; -import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { MockInstance } from 'vitest'; @@ -52,14 +52,6 @@ vi.mock('node:child_process', () => ({ }), })); -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(() => true), - }; -}); - vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -70,6 +62,10 @@ vi.mock('node:fs/promises', async (importOriginal) => { }; }); +vi.mock('node:module', () => ({ + createRequire: vi.fn(), +})); + vi.mock('../server/daemon-state.js', () => ({ readDaemonState: vi.fn(async () => null), isDaemonAlive: vi.fn(async () => false), @@ -90,6 +86,8 @@ vi.mock('cosmiconfig', () => ({ let exitSpy: MockInstance; let stderrSpy: MockInstance; let stdoutSpy: MockInstance; +const mockCreateRequire = vi.mocked(createRequire); +const mockResolveRuntime = vi.fn(); // eslint-disable-next-line vitest/require-top-level-describe beforeEach(() => { @@ -104,6 +102,10 @@ beforeEach(() => { }) as never); stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); stdoutSpy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + mockResolveRuntime.mockReturnValue('/mock/worktree/node_modules/tsx/cli.mjs'); + mockCreateRequire.mockReturnValue({ + resolve: mockResolveRuntime, + } as never); }); // eslint-disable-next-line vitest/require-top-level-describe @@ -686,97 +688,53 @@ describe('resolveRuntime', () => { expect(result.getArgs('./daemon.ts')).toStrictEqual(['./daemon.ts']); }); - it('returns bin path without shell when runtime exists', () => { - vi.mocked(existsSync).mockReturnValue(true); - const result = resolveRuntime('/root', 'tsx', 'linux'); + it('runs tsx through Node using the project-local CLI module', () => { + const result = resolveRuntime('/root', 'tsx'); expect(result).toStrictEqual({ - command: path.join('/root', 'node_modules', '.bin', 'tsx'), + command: process.execPath, getArgs: expect.any(Function), }); - expect(result.getArgs('./daemon.ts')).toStrictEqual(['./daemon.ts']); - }); - - it('runs a Windows runtime shim through cmd.exe with quoted arguments', () => { - vi.mocked(existsSync).mockReturnValue(true); - - expect(resolveRuntime('/root', 'tsx', 'win32')).toStrictEqual({ - command: process.env.ComSpec ?? 'cmd.exe', - getArgs: expect.any(Function), - windowsVerbatimArguments: true, - }); - expect( - resolveRuntime('/root', 'tsx', 'win32').getArgs('./daemon.ts'), - ).toStrictEqual([ - '/d', - '/s', - '/c', - '"\\root\\node_modules\\.bin\\tsx.cmd ^^^"./daemon.ts^^^""', + expect(mockCreateRequire).toHaveBeenCalledWith( + path.join('/root', 'package.json'), + ); + expect(mockResolveRuntime).toHaveBeenCalledWith('tsx/cli'); + expect(result.getArgs('./daemon.ts')).toStrictEqual([ + '/mock/worktree/node_modules/tsx/cli.mjs', + './daemon.ts', ]); }); - it('uses the configured Windows command processor', () => { - vi.mocked(existsSync).mockReturnValue(true); - const originalComSpec = process.env.ComSpec; - process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; - - try { - expect(resolveRuntime('/root', 'tsx', 'win32')).toMatchObject({ - command: 'C:\\Windows\\System32\\cmd.exe', - }); - } finally { - if (originalComSpec === undefined) { - delete process.env.ComSpec; - } else { - process.env.ComSpec = originalComSpec; - } - } - }); - - it('preserves Windows project paths containing spaces', () => { - vi.mocked(existsSync).mockReturnValue(true); - const runtime = resolveRuntime( - 'C:\\Users\\Jane Doe\\project', - 'tsx', - 'win32', + it('runs tsx without a shell for Windows project paths containing spaces', () => { + mockResolveRuntime.mockReturnValue( + 'C:\\Users\\Jane Doe\\project\\node_modules\\tsx\\dist\\cli.mjs', ); - expect(runtime.getArgs('test/e2e/daemon.ts')).toStrictEqual([ - '/d', - '/s', - '/c', - '"C:\\Users\\Jane^ Doe\\project\\node_modules\\.bin\\tsx.cmd ^^^"test/e2e/daemon.ts^^^""', - ]); - }); - - it('supports a Windows runtime alias provided by a native package', () => { - vi.mocked(existsSync).mockReturnValue(true); + const runtime = resolveRuntime('C:\\Users\\Jane Doe\\project', 'tsx'); - expect( - resolveRuntime('/root', 'swc-node', 'win32').getArgs('daemon.ts'), - ).toStrictEqual([ - '/d', - '/s', - '/c', - '"\\root\\node_modules\\.bin\\swc-node.cmd ^^^"daemon.ts^^^""', + expect(runtime).toStrictEqual({ + command: process.execPath, + getArgs: expect.any(Function), + }); + expect(runtime.getArgs('test/e2e/daemon.ts')).toStrictEqual([ + 'C:\\Users\\Jane Doe\\project\\node_modules\\tsx\\dist\\cli.mjs', + 'test/e2e/daemon.ts', ]); }); - it('exits when a Windows runtime command shim is missing', () => { - vi.mocked(existsSync).mockReturnValue(false); + it('exits when tsx is not installed in the project', () => { + mockResolveRuntime.mockImplementation(() => { + throw new Error('Cannot find module'); + }); - expect(() => resolveRuntime('/root', 'tsx', 'win32')).toThrowError( - 'process.exit', - ); + expect(() => resolveRuntime('/root', 'tsx')).toThrowError('process.exit'); expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining("Runtime 'tsx' not found at"), + expect.stringContaining("Runtime 'tsx' is not installed in /root"), ); }); - it('exits when runtime binary not found', () => { - vi.mocked(existsSync).mockReturnValue(false); - expect(() => resolveRuntime('/root', 'tsx')).toThrowError('process.exit'); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining("Runtime 'tsx' not found"), + it('throws for an invalid runtime value', () => { + expect(() => resolveRuntime('/root', 'invalid' as never)).toThrowError( + 'Unsupported runtime', ); }); }); @@ -891,6 +849,21 @@ describe('readDaemonConfig', () => { expect(result.runtime).toBe('tsx'); }); + it('exits when the configured runtime is unsupported', async () => { + mockSearch.mockResolvedValue({ + config: { daemon: './daemon.ts', runtime: 'swc-node' }, + filepath: '/mock/worktree/mm-client-cli.config.ts', + isEmpty: false, + }); + + await expect(readDaemonConfig('/mock/worktree')).rejects.toThrowError( + 'process.exit', + ); + expect(stderrSpy).toHaveBeenCalledWith( + "Error: Unsupported runtime 'swc-node'. Supported runtimes are 'node' and 'tsx'.\n", + ); + }); + it('exits when no config file is found', async () => { mockSearch.mockResolvedValueOnce(null); @@ -3063,7 +3036,6 @@ describe('handleServe', () => { vi.mocked(readDaemonState).mockResolvedValueOnce(null); - vi.mocked(existsSync).mockReturnValue(true); mockSearch.mockResolvedValueOnce({ config: { daemon: './daemon.ts', runtime: 'node' }, filepath: '/root/mm-client-cli.config.ts', @@ -3113,7 +3085,6 @@ describe('handleServe', () => { vi.mocked(readDaemonState).mockResolvedValueOnce(staleState); vi.mocked(isDaemonAlive).mockResolvedValueOnce(false); - vi.mocked(existsSync).mockReturnValue(true); mockSearch.mockResolvedValueOnce({ config: { daemon: './d.ts', runtime: 'node' }, filepath: '/root/mm-client-cli.config.ts', @@ -3318,7 +3289,6 @@ describe('autoStartDaemon', () => { vi.mocked(acquireStartupLock).mockResolvedValueOnce(true); vi.mocked(readDaemonState).mockResolvedValueOnce(null); - vi.mocked(existsSync).mockReturnValue(true); mockSearch.mockResolvedValueOnce({ config: { daemon: './daemon.ts', runtime: 'node' }, filepath: '/root/mm-client-cli.config.ts', diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 12c35e0..c4a5e60 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -1,8 +1,8 @@ #!/usr/bin/env node import { cosmiconfig } from 'cosmiconfig'; import { execSync, spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; import * as fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; import * as path from 'node:path'; import pkg from '../../package.json'; @@ -34,6 +34,7 @@ const DAEMON_POLL_MAX_ATTEMPTS = const SEND_MAX_RETRIES = 3; const SEND_RETRY_BASE_DELAY_MS = 200; const CONFIG_MODULE_NAME = 'mm-client-cli'; +const SUPPORTED_RUNTIMES = new Set(['node', 'tsx']); /** * Configuration shape for mm-client-cli config files. @@ -42,46 +43,20 @@ const CONFIG_MODULE_NAME = 'mm-client-cli'; export type MmClientCliConfig = { /** Path to the daemon entry point (TypeScript or JavaScript file). */ daemon: string; - /** TypeScript runner to use. Defaults to 'tsx'. */ - runtime?: string; + /** Runtime used to start the daemon. Defaults to 'tsx'. */ + runtime?: 'node' | 'tsx'; }; type DaemonConfig = { daemonPath: string; - runtime: string; + runtime: 'node' | 'tsx'; }; type RuntimeCommand = { command: string; getArgs: (daemonPath: string) => string[]; - windowsVerbatimArguments?: boolean; }; -/** - * Escapes an executable path for use in a cmd.exe command string. - * - * @param command - The executable path to escape. - * @returns The escaped executable path. - */ -function escapeWindowsCommand(command: string): string { - return command.replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1'); -} - -/** - * Escapes an argument for a cmd.exe command string. - * - * @param argument - The argument to escape. - * @returns The escaped and quoted argument. - */ -function escapeWindowsArgument(argument: string): string { - const escapedArgument = argument - .replace(/(?=(\\+?)?)\1"/gu, '$1$1\\"') - .replace(/(?=(\\+?)?)\1$/gu, '$1$1'); - return `"${escapedArgument}"` - .replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1') - .replace(/([()\][%!^"`<>&|;, *?])/gu, '^$1'); -} - /** * Extracts and consumes the `--project ` flag from argv, returning * the remaining args and the extracted project path (if any). @@ -1229,7 +1204,6 @@ export async function autoStartDaemon( detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: worktreeRoot, - windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, }, ); child.unref(); @@ -1273,7 +1247,6 @@ export async function handleServe( detached: true, stdio: ['ignore', 'ignore', 'ignore'], cwd: worktreeRoot, - windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, }, ); child.unref(); @@ -1291,7 +1264,6 @@ export async function handleServe( { stdio: 'inherit', cwd: worktreeRoot, - windowsVerbatimArguments: runtimeCommand.windowsVerbatimArguments, }, ); @@ -1407,63 +1379,55 @@ export async function readDaemonConfig( process.exit(1); } + const runtime = config.runtime ?? 'tsx'; + if (!SUPPORTED_RUNTIMES.has(runtime)) { + process.stderr.write( + `Error: Unsupported runtime '${runtime}'. Supported runtimes are 'node' and 'tsx'.\n`, + ); + process.exit(1); + } + return { daemonPath: config.daemon, - runtime: config.runtime ?? 'tsx', + runtime, }; } /** - * Resolves the runtime binary path for spawning the daemon. + * Resolves the supported runtime command for spawning the daemon. * * @param worktreeRoot - The git worktree root directory. * @param runtime - The runtime name from configuration. - * @param platform - Platform used to resolve package-manager command shims. * @returns Spawn metadata for the runtime binary. */ export function resolveRuntime( worktreeRoot: string, - runtime: string, - platform = process.platform, + runtime: 'node' | 'tsx', ): RuntimeCommand { if (runtime === 'node') { return { command: 'node', getArgs: (daemonPath) => [daemonPath] }; } - if (platform === 'win32') { - const binPath = path.win32.join( - worktreeRoot, - 'node_modules', - '.bin', - `${runtime}.cmd`, - ); - if (!existsSync(binPath)) { + if (runtime === 'tsx') { + try { + const requireFromProject = createRequire( + path.join(worktreeRoot, 'package.json'), + ); + const tsxCli = requireFromProject.resolve('tsx/cli'); + + return { + command: process.execPath, + getArgs: (daemonPath) => [tsxCli, daemonPath], + }; + } catch { process.stderr.write( - `Error: Runtime '${runtime}' not found at ${binPath}. Install it or set "mm.runtime" in package.json.\n`, + `Error: Runtime 'tsx' is not installed in ${worktreeRoot}. Install it or set "mm.runtime" in package.json.\n`, ); process.exit(1); } - - return { - command: process.env.ComSpec ?? 'cmd.exe', - getArgs: (daemonPath) => [ - '/d', - '/s', - '/c', - `"${escapeWindowsCommand(binPath)} ${escapeWindowsArgument(daemonPath)}"`, - ], - windowsVerbatimArguments: true, - }; } - const binPath = path.join(worktreeRoot, 'node_modules', '.bin', runtime); - if (!existsSync(binPath)) { - process.stderr.write( - `Error: Runtime '${runtime}' not found at ${binPath}. Install it or set "mm.runtime" in package.json.\n`, - ); - process.exit(1); - } - return { command: binPath, getArgs: (daemonPath) => [daemonPath] }; + throw new Error('Unsupported runtime'); } /**