Skip to content
Open
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
24 changes: 24 additions & 0 deletions packages/hub/src/node/__tests__/host-terminals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const itPty = hasNative ? it : it.skip
// doesn't reliably provide (echo timing); skip there, mirroring the terminals
// plugin's own `itPosixPty` gate.
const itPosixPty = (hasNative && process.platform !== 'win32') ? it : it.skip
const itWindowsPty = (hasNative && process.platform === 'win32') ? it : it.skip

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

itWindowsPty('runs .cmd shims, which ConPTY cannot spawn directly', async () => {
const dir = mkdtempSync(join(tmpdir(), 'devframe-pty-shim-'))
try {
const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH'
writeFileSync(join(dir, 'echo-args.cmd'), `@"${NODE}" -e "process.stdout.write(JSON.stringify(process.argv.slice(1)))" %*\r\n`)
const { host } = createTerminalHost()

const session = await host.startPtySession({
command: 'echo-args',
args: ['a&b', 'c d'],
// Reuse the inherited key: Windows env blocks are case-insensitive.
env: { [pathKey]: `${dir};${process.env[pathKey]}` },
}, { id: 'pty-cmd-shim', title: 'PTY .cmd shim' })
const output = await session.getResult()

expect(output.output).toContain('["a&b","c d"]')
expect(output.exitCode).toBe(0)
}
finally {
rmSync(dir, { recursive: true, force: true })
}
})

itPty('getResult() preserves a non-zero PTY exit code', async () => {
expect.assertions(3)

Expand Down
65 changes: 65 additions & 0 deletions packages/hub/src/node/__tests__/pty-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { resolvePtyCommand } from '../pty-command'

const bin = mkdtempSync(join(tmpdir(), 'devframe-pty-command-'))
writeFileSync(join(bin, 'pnpm.cmd'), '')
writeFileSync(join(bin, 'legacy.bat'), '')
writeFileSync(join(bin, 'node.exe'), '')

afterAll(() => rmSync(bin, { recursive: true, force: true }))

const win32 = { cwd: tmpdir(), platform: 'win32' as const, env: { Path: bin, ComSpec: 'C:\\Windows\\system32\\cmd.exe' } }

describe('resolvePtyCommand', () => {
it('runs .cmd shims through ComSpec on win32', () => {
expect(resolvePtyCommand('pnpm', ['exec', 'storybook', 'dev', '-p', '6006'], win32)).toEqual({
command: 'C:\\Windows\\system32\\cmd.exe',
args: ['/d', '/c', 'pnpm', 'exec', 'storybook', 'dev', '-p', '6006'],
})
})

it('runs .bat files through ComSpec on win32', () => {
expect(resolvePtyCommand('legacy', [], win32).command).toBe('C:\\Windows\\system32\\cmd.exe')
})

it('falls back to cmd.exe without ComSpec', () => {
expect(resolvePtyCommand('pnpm', [], { ...win32, env: { PATH: bin } }).command).toBe('cmd.exe')
})

it('runs unresolved commands through cmd.exe so a typo fails like a shell would', () => {
expect(resolvePtyCommand('does-not-exist', ['x'], win32).args).toEqual(['/d', '/c', 'does-not-exist', 'x'])
})

it('spawns real executables directly on win32', () => {
expect(resolvePtyCommand('node', ['-v'], win32)).toEqual({ command: 'node', args: ['-v'] })
expect(resolvePtyCommand(join(bin, 'node.exe'), ['-v'], win32)).toEqual({ command: join(bin, 'node.exe'), args: ['-v'] })
})

it('does not wrap cmd.exe itself', () => {
expect(resolvePtyCommand('cmd.exe', ['/c', 'dir'], win32)).toEqual({ command: 'cmd.exe', args: ['/c', 'dir'] })
expect(resolvePtyCommand('C:\\Windows\\system32\\cmd.exe', [], win32).args).toEqual([])
})

it('escapes cmd metacharacters in bare arguments, twice for batch shims', () => {
expect(resolvePtyCommand('does-not-exist', ['a&b', 'x(y)'], win32).args).toEqual(['/d', '/c', 'does-not-exist', 'a^&b', 'x^(y^)'])
expect(resolvePtyCommand('pnpm', ['a&b', '50%'], win32).args).toEqual(['/d', '/c', 'pnpm', 'a^^^&b', '50^^^%'])
})

it('leaves arguments zigpty quotes untouched', () => {
expect(resolvePtyCommand('pnpm', ['a b&c', '', 'say "hi"'], win32).args).toEqual(['/d', '/c', 'pnpm', 'a b&c', '', 'say "hi"'])
})

it('skips the cwd when NoDefaultCurrentDirectoryInExePath is set', () => {
const env = { NoDefaultCurrentDirectoryInExePath: '1', ComSpec: 'cmd.exe' }
expect(resolvePtyCommand('node', [], { ...win32, cwd: bin, env }).command).toBe('cmd.exe')
expect(resolvePtyCommand('node', [], { ...win32, cwd: bin, env: { ComSpec: 'cmd.exe' } }).command).toBe('node')
})

it('leaves the command unchanged on other platforms', () => {
for (const platform of ['linux', 'darwin'] as const)
expect(resolvePtyCommand('pnpm', ['a&b'], { ...win32, platform })).toEqual({ command: 'pnpm', args: ['a&b'] })
})
})
22 changes: 13 additions & 9 deletions packages/hub/src/node/host-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import process from 'node:process'
import { createEventEmitter } from 'devframe/utils/events'
import { HUB_EVENTS } from '../events'
import { diagnostics } from './diagnostics'
import { resolvePtyCommand } from './pty-command'

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

Expand Down Expand Up @@ -443,18 +444,21 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {

const spawnPty = (): IPty => {
const currentRun = ++runId
const ptyProcess = spawn(executeOptions.command, executeOptions.args ?? [], {
const cwd = executeOptions.cwd ?? process.cwd()
const env = {
...process.env,
TERM: PTY_TERM_NAME,
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
...(executeOptions.env ?? {}),
}
const { command, args } = resolvePtyCommand(executeOptions.command, executeOptions.args ?? [], { cwd, env })
const ptyProcess = spawn(command, args, {
name: PTY_TERM_NAME,
cols,
rows,
cwd: executeOptions.cwd ?? process.cwd(),
env: {
...process.env,
TERM: PTY_TERM_NAME,
COLORTERM: 'truecolor',
FORCE_COLOR: '1',
...(executeOptions.env ?? {}),
},
cwd,
env,
})
const outputChunks: string[] = []
let killed = false
Expand Down
84 changes: 84 additions & 0 deletions packages/hub/src/node/pty-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { existsSync } from 'node:fs'
import { extname, resolve } from 'node:path'
import process from 'node:process'

export interface PtyCommand {
command: string
args: string[]
}

export interface ResolvePtyCommandOptions {
cwd: string
env: Record<string, string | undefined>
platform?: NodeJS.Platform
}

const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD'
// Same set cross-spawn escapes for `cmd.exe`.
const CMD_META_CHARS = /[()[\]%!^"`<>&|;, *?]/g

export function resolvePtyCommand(
command: string,
args: string[],
{ cwd, env, platform = process.platform }: ResolvePtyCommandOptions,
): PtyCommand {
if (platform !== 'win32')
return { command, args }

const comSpec = getEnv(env, 'ComSpec') || 'cmd.exe'
if (isCmd(command, comSpec))
return { command, args }

const resolved = findExecutable(command, cwd, env)
const ext = resolved ? extname(resolved).toLowerCase() : ''
if (ext === '.exe' || ext === '.com')
return { command, args }

// `.cmd`/`.bat` shims re-parse their arguments
// metachar need a second round of escaping to survive both passes
const isBatch = ext === '.cmd' || ext === '.bat'
return {
command: comSpec,
args: ['/d', '/c', escapeCmd(command, false), ...args.map(arg => escapeCmd(arg, isBatch))],
}
}

function getEnv(env: ResolvePtyCommandOptions['env'], name: string): string | undefined {
const key = Object.keys(env).findLast(key => key.toLowerCase() === name.toLowerCase())
return key ? env[key] : undefined
}

function isCmd(command: string, comSpec: string): boolean {
const name = command.toLowerCase()
return name === comSpec.toLowerCase() || name === 'cmd' || name === 'cmd.exe'
}

function findExecutable(command: string, cwd: string, env: ResolvePtyCommandOptions['env']): string | undefined {
const extensions = (getEnv(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean)
const candidates = extname(command) ? [command, ...extensions.map(ext => command + ext)] : extensions.map(ext => command + ext)

const searchCwd = getEnv(env, 'NoDefaultCurrentDirectoryInExePath') === undefined
const dirs = /[\\/]/.test(command)
? [cwd]
: [...(searchCwd ? [cwd] : []), ...(getEnv(env, 'PATH') || '').split(';').filter(Boolean)]
for (const dir of dirs) {
for (const candidate of candidates) {
const file = resolve(dir, candidate)
if (existsSync(file))
return file
}
}
return undefined
}

/**
* Escape `cmd.exe` metacharacters in an argument zigpty passes unquoted.
* zigpty already double-quotes arguments with whitespace or quotes, and
* `cmd.exe` keeps quoted metacharacters literal, so those are left alone.
*/
function escapeCmd(arg: string, doubleEscape: boolean): string {
if (!arg || /[\s"]/.test(arg))
return arg
const escaped = arg.replace(CMD_META_CHARS, '^$&')
return doubleEscape ? escaped.replace(CMD_META_CHARS, '^$&') : escaped
}
Loading