Skip to content

Commit 2cef80a

Browse files
committed
fix(hub): run .cmd/.bat shims in PTY sessions on Windows
1 parent 0e4a49c commit 2cef80a

4 files changed

Lines changed: 186 additions & 9 deletions

File tree

‎packages/hub/src/node/__tests__/host-terminals.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const itPty = hasNative ? it : it.skip
3131
// doesn't reliably provide (echo timing); skip there, mirroring the terminals
3232
// plugin's own `itPosixPty` gate.
3333
const itPosixPty = (hasNative && process.platform !== 'win32') ? it : it.skip
34+
const itWindowsPty = (hasNative && process.platform === 'win32') ? it : it.skip
3435

3536
interface FakeSink {
3637
write: ReturnType<typeof vi.fn>
@@ -613,6 +614,29 @@ describe('devframeTerminalHost interactive PTY sessions', () => {
613614
expect(result.killed).toBe(false)
614615
})
615616

617+
itWindowsPty('runs .cmd shims, which ConPTY cannot spawn directly', async () => {
618+
const dir = mkdtempSync(join(tmpdir(), 'devframe-pty-shim-'))
619+
try {
620+
const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'
621+
writeFileSync(join(dir, 'echo-args.cmd'), `@"${NODE}" -e "process.stdout.write(JSON.stringify(process.argv.slice(1)))" %*\r\n`)
622+
const { host } = createTerminalHost()
623+
624+
const session = await host.startPtySession({
625+
command: 'echo-args',
626+
args: ['a&b', 'c d'],
627+
// Reuse the inherited key: Windows env blocks are case-insensitive.
628+
env: { [pathKey]: `${dir};${process.env[pathKey]}` },
629+
}, { id: 'pty-cmd-shim', title: 'PTY .cmd shim' })
630+
const output = await session.getResult()
631+
632+
expect(output.output).toContain('["a&b","c d"]')
633+
expect(output.exitCode).toBe(0)
634+
}
635+
finally {
636+
rmSync(dir, { recursive: true, force: true })
637+
}
638+
})
639+
616640
itPty('getResult() preserves a non-zero PTY exit code', async () => {
617641
expect.assertions(3)
618642

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterAll, describe, expect, it } from 'vitest'
5+
import { resolvePtyCommand } from '../pty-command'
6+
7+
const bin = mkdtempSync(join(tmpdir(), 'devframe-pty-command-'))
8+
writeFileSync(join(bin, 'pnpm.cmd'), '')
9+
writeFileSync(join(bin, 'legacy.bat'), '')
10+
writeFileSync(join(bin, 'node.exe'), '')
11+
12+
afterAll(() => rmSync(bin, { recursive: true, force: true }))
13+
14+
const win32 = { cwd: tmpdir(), platform: 'win32' as const, env: { Path: bin, ComSpec: 'C:\\Windows\\system32\\cmd.exe' } }
15+
16+
describe('resolvePtyCommand', () => {
17+
it('runs .cmd shims through ComSpec on win32', () => {
18+
expect(resolvePtyCommand('pnpm', ['exec', 'storybook', 'dev', '-p', '6006'], win32)).toEqual({
19+
command: 'C:\\Windows\\system32\\cmd.exe',
20+
args: ['/d', '/c', 'pnpm', 'exec', 'storybook', 'dev', '-p', '6006'],
21+
})
22+
})
23+
24+
it('runs .bat files through ComSpec on win32', () => {
25+
expect(resolvePtyCommand('legacy', [], win32).command).toBe('C:\\Windows\\system32\\cmd.exe')
26+
})
27+
28+
it('falls back to cmd.exe without ComSpec', () => {
29+
expect(resolvePtyCommand('pnpm', [], { ...win32, env: { PATH: bin } }).command).toBe('cmd.exe')
30+
})
31+
32+
it('runs unresolved commands through cmd.exe so a typo fails like a shell would', () => {
33+
expect(resolvePtyCommand('does-not-exist', ['x'], win32).args).toEqual(['/d', '/c', 'does-not-exist', 'x'])
34+
})
35+
36+
it('spawns real executables directly on win32', () => {
37+
expect(resolvePtyCommand('node', ['-v'], win32)).toEqual({ command: 'node', args: ['-v'] })
38+
expect(resolvePtyCommand(join(bin, 'node.exe'), ['-v'], win32)).toEqual({ command: join(bin, 'node.exe'), args: ['-v'] })
39+
})
40+
41+
it('does not wrap cmd.exe itself', () => {
42+
expect(resolvePtyCommand('cmd.exe', ['/c', 'dir'], win32)).toEqual({ command: 'cmd.exe', args: ['/c', 'dir'] })
43+
expect(resolvePtyCommand('C:\\Windows\\system32\\cmd.exe', [], win32).args).toEqual([])
44+
})
45+
46+
it('escapes cmd metacharacters in bare arguments, twice for batch shims', () => {
47+
expect(resolvePtyCommand('does-not-exist', ['a&b', 'x(y)'], win32).args).toEqual(['/d', '/c', 'does-not-exist', 'a^&b', 'x^(y^)'])
48+
expect(resolvePtyCommand('pnpm', ['a&b', '50%'], win32).args).toEqual(['/d', '/c', 'pnpm', 'a^^^&b', '50^^^%'])
49+
})
50+
51+
it('leaves arguments zigpty quotes untouched', () => {
52+
expect(resolvePtyCommand('pnpm', ['a b&c', '', 'say "hi"'], win32).args).toEqual(['/d', '/c', 'pnpm', 'a b&c', '', 'say "hi"'])
53+
})
54+
55+
it('skips the cwd when NoDefaultCurrentDirectoryInExePath is set', () => {
56+
const env = { NoDefaultCurrentDirectoryInExePath: '1', ComSpec: 'cmd.exe' }
57+
expect(resolvePtyCommand('node', [], { ...win32, cwd: bin, env }).command).toBe('cmd.exe')
58+
expect(resolvePtyCommand('node', [], { ...win32, cwd: bin, env: { ComSpec: 'cmd.exe' } }).command).toBe('node')
59+
})
60+
61+
it('leaves the command unchanged on other platforms', () => {
62+
for (const platform of ['linux', 'darwin'] as const)
63+
expect(resolvePtyCommand('pnpm', ['a&b'], { ...win32, platform })).toEqual({ command: 'pnpm', args: ['a&b'] })
64+
})
65+
})

‎packages/hub/src/node/host-terminals.ts‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import process from 'node:process'
2020
import { createEventEmitter } from 'devframe/utils/events'
2121
import { HUB_EVENTS } from '../events'
2222
import { diagnostics } from './diagnostics'
23+
import { resolvePtyCommand } from './pty-command'
2324

2425
type PartialWithoutId<T extends { id: string }> = Partial<T> & { id: string }
2526

@@ -443,18 +444,21 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
443444

444445
const spawnPty = (): IPty => {
445446
const currentRun = ++runId
446-
const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], {
447+
const cwd = executeOptions.cwd ?? process.cwd()
448+
const env = {
449+
...process.env,
450+
TERM: PTY_TERM_NAME,
451+
COLORTERM: 'truecolor',
452+
FORCE_COLOR: '1',
453+
...(executeOptions.env ?? {}),
454+
}
455+
const { command, args } = resolvePtyCommand(executeOptions.command, executeOptions.args ?? [], { cwd, env })
456+
const ptyProcess = spawn(command, args, {
447457
name: PTY_TERM_NAME,
448458
cols,
449459
rows,
450-
cwd: executeOptions.cwd ?? process.cwd(),
451-
env: {
452-
...process.env,
453-
TERM: PTY_TERM_NAME,
454-
COLORTERM: 'truecolor',
455-
FORCE_COLOR: '1',
456-
...(executeOptions.env ?? {}),
457-
},
460+
cwd,
461+
env,
458462
})
459463
const outputChunks: string[] = []
460464
let killed = false
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { existsSync } from 'node:fs'
2+
import { extname, resolve } from 'node:path'
3+
import process from 'node:process'
4+
5+
export interface PtyCommand {
6+
command: string
7+
args: string[]
8+
}
9+
10+
export interface ResolvePtyCommandOptions {
11+
cwd: string
12+
env: Record<string, string | undefined>
13+
platform?: NodeJS.Platform
14+
}
15+
16+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD'
17+
// Same set cross-spawn escapes for `cmd.exe`.
18+
const CMD_META_CHARS = /[()[\]%!^"`<>&|;, *?]/g
19+
20+
export function resolvePtyCommand(
21+
command: string,
22+
args: string[],
23+
{ cwd, env, platform = process.platform }: ResolvePtyCommandOptions,
24+
): PtyCommand {
25+
if (platform !== 'win32')
26+
return { command, args }
27+
28+
const comSpec = getEnv(env, 'ComSpec') || 'cmd.exe'
29+
if (isCmd(command, comSpec))
30+
return { command, args }
31+
32+
const resolved = findExecutable(command, cwd, env)
33+
const ext = resolved ? extname(resolved).toLowerCase() : ''
34+
if (ext === '.exe' || ext === '.com')
35+
return { command, args }
36+
37+
// `.cmd`/`.bat` shims re-parse their arguments
38+
// metachar need a second round of escaping to survive both passes
39+
const isBatch = ext === '.cmd' || ext === '.bat'
40+
return {
41+
command: comSpec,
42+
args: ['/d', '/c', escapeCmd(command, false), ...args.map(arg => escapeCmd(arg, isBatch))],
43+
}
44+
}
45+
46+
function getEnv(env: ResolvePtyCommandOptions['env'], name: string): string | undefined {
47+
const key = Object.keys(env).findLast(key => key.toLowerCase() === name.toLowerCase())
48+
return key ? env[key] : undefined
49+
}
50+
51+
function isCmd(command: string, comSpec: string): boolean {
52+
const name = command.toLowerCase()
53+
return name === comSpec.toLowerCase() || name === 'cmd' || name === 'cmd.exe'
54+
}
55+
56+
function findExecutable(command: string, cwd: string, env: ResolvePtyCommandOptions['env']): string | undefined {
57+
const extensions = (getEnv(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean)
58+
const candidates = extname(command) ? [command, ...extensions.map(ext => command + ext)] : extensions.map(ext => command + ext)
59+
60+
const searchCwd = getEnv(env, 'NoDefaultCurrentDirectoryInExePath') === undefined
61+
const dirs = /[\\/]/.test(command)
62+
? [cwd]
63+
: [...(searchCwd ? [cwd] : []), ...(getEnv(env, 'PATH') || '').split(';').filter(Boolean)]
64+
for (const dir of dirs) {
65+
for (const candidate of candidates) {
66+
const file = resolve(dir, candidate)
67+
if (existsSync(file))
68+
return file
69+
}
70+
}
71+
return undefined
72+
}
73+
74+
/**
75+
* Escape `cmd.exe` metacharacters in an argument zigpty passes unquoted.
76+
* zigpty already double-quotes arguments with whitespace or quotes, and
77+
* `cmd.exe` keeps quoted metacharacters literal, so those are left alone.
78+
*/
79+
function escapeCmd(arg: string, doubleEscape: boolean): string {
80+
if (!arg || /[\s"]/.test(arg))
81+
return arg
82+
const escaped = arg.replace(CMD_META_CHARS, '^$&')
83+
return doubleEscape ? escaped.replace(CMD_META_CHARS, '^$&') : escaped
84+
}

0 commit comments

Comments
 (0)