feat: enhance PersistentShell to support shell detection and path normalization
- Implemented shell detection for POSIX, MSYS, and WSL environments. - Added functions for quoting strings and converting paths for Bash compatibility. - Updated command execution to handle different shell types and ensure proper path handling. - Improved initialization by sourcing ~/.bashrc when available and managing file paths for stdout, stderr, and cwd.
This commit is contained in:
parent
3d963fb4a6
commit
6362d58c13
@ -37,6 +37,115 @@ const SHELL_CONFIGS: Record<string, string> = {
|
|||||||
'/bin/zsh': '.zshrc',
|
'/bin/zsh': '.zshrc',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DetectedShell = {
|
||||||
|
bin: string
|
||||||
|
args: string[]
|
||||||
|
type: 'posix' | 'msys' | 'wsl'
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteForBash(str: string): string {
|
||||||
|
return `'${str.replace(/'/g, "'\\''")}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBashPath(pathStr: string, type: 'posix' | 'msys' | 'wsl'): string {
|
||||||
|
// Already POSIX absolute path
|
||||||
|
if (pathStr.startsWith('/')) return pathStr
|
||||||
|
if (type === 'posix') return pathStr
|
||||||
|
|
||||||
|
// Normalize backslashes
|
||||||
|
const normalized = pathStr.replace(/\\/g, '/').replace(/\\\\/g, '/')
|
||||||
|
const driveMatch = /^[A-Za-z]:/.exec(normalized)
|
||||||
|
if (driveMatch) {
|
||||||
|
const drive = normalized[0].toLowerCase()
|
||||||
|
const rest = normalized.slice(2)
|
||||||
|
if (type === 'msys') {
|
||||||
|
return `/` + drive + (rest.startsWith('/') ? rest : `/${rest}`)
|
||||||
|
}
|
||||||
|
// wsl
|
||||||
|
return `/mnt/` + drive + (rest.startsWith('/') ? rest : `/${rest}`)
|
||||||
|
}
|
||||||
|
// Relative path: just convert slashes
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExists(p: string | undefined): p is string {
|
||||||
|
return !!p && existsSync(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectShell(): DetectedShell {
|
||||||
|
const isWin = process.platform === 'win32'
|
||||||
|
if (!isWin) {
|
||||||
|
const bin = process.env.SHELL || '/bin/bash'
|
||||||
|
return { bin, args: ['-l'], type: 'posix' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Respect SHELL if it points to a bash.exe that exists
|
||||||
|
if (process.env.SHELL && /bash\.exe$/i.test(process.env.SHELL) && existsSync(process.env.SHELL)) {
|
||||||
|
return { bin: process.env.SHELL, args: ['-l'], type: 'msys' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.1) Explicit override
|
||||||
|
if (process.env.KODE_BASH && existsSync(process.env.KODE_BASH)) {
|
||||||
|
return { bin: process.env.KODE_BASH, args: ['-l'], type: 'msys' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Common Git Bash/MSYS2 locations
|
||||||
|
const programFiles = [
|
||||||
|
process.env['ProgramFiles'],
|
||||||
|
process.env['ProgramFiles(x86)'],
|
||||||
|
process.env['ProgramW6432'],
|
||||||
|
].filter(Boolean) as string[]
|
||||||
|
|
||||||
|
const localAppData = process.env['LocalAppData']
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
for (const base of programFiles) {
|
||||||
|
candidates.push(
|
||||||
|
join(base, 'Git', 'bin', 'bash.exe'),
|
||||||
|
join(base, 'Git', 'usr', 'bin', 'bash.exe'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (localAppData) {
|
||||||
|
candidates.push(
|
||||||
|
join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
|
||||||
|
join(localAppData, 'Programs', 'Git', 'usr', 'bin', 'bash.exe'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// MSYS2 default
|
||||||
|
candidates.push('C:/msys64/usr/bin/bash.exe')
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (existsSync(c)) {
|
||||||
|
return { bin: c, args: ['-l'], type: 'msys' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.1) Search in PATH for bash.exe
|
||||||
|
const pathEnv = process.env.PATH || process.env.Path || process.env.path || ''
|
||||||
|
const pathEntries = pathEnv.split(';').filter(Boolean)
|
||||||
|
for (const p of pathEntries) {
|
||||||
|
const candidate = join(p, 'bash.exe')
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return { bin: candidate, args: ['-l'], type: 'msys' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) WSL
|
||||||
|
try {
|
||||||
|
// Quick probe to ensure WSL+bash exists
|
||||||
|
execSync('wsl.exe -e bash -lc "echo KODE_OK"', { stdio: 'ignore', timeout: 1500 })
|
||||||
|
return { bin: 'wsl.exe', args: ['-e', 'bash', '-l'], type: 'wsl' }
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 4) Last resort: meaningful error
|
||||||
|
const hint = [
|
||||||
|
'无法找到可用的 bash。请安装 Git for Windows 或启用 WSL。',
|
||||||
|
'推荐安装 Git: https://git-scm.com/download/win',
|
||||||
|
'或启用 WSL 并安装 Ubuntu: https://learn.microsoft.com/windows/wsl/install',
|
||||||
|
].join('\n')
|
||||||
|
throw new Error(hint)
|
||||||
|
}
|
||||||
|
|
||||||
export class PersistentShell {
|
export class PersistentShell {
|
||||||
private commandQueue: QueuedCommand[] = []
|
private commandQueue: QueuedCommand[] = []
|
||||||
private isExecuting: boolean = false
|
private isExecuting: boolean = false
|
||||||
@ -49,10 +158,20 @@ export class PersistentShell {
|
|||||||
private cwdFile: string
|
private cwdFile: string
|
||||||
private cwd: string
|
private cwd: string
|
||||||
private binShell: string
|
private binShell: string
|
||||||
|
private shellArgs: string[]
|
||||||
|
private shellType: 'posix' | 'msys' | 'wsl'
|
||||||
|
private statusFileBashPath: string
|
||||||
|
private stdoutFileBashPath: string
|
||||||
|
private stderrFileBashPath: string
|
||||||
|
private cwdFileBashPath: string
|
||||||
|
|
||||||
constructor(cwd: string) {
|
constructor(cwd: string) {
|
||||||
this.binShell = process.env.SHELL || '/bin/bash'
|
const { bin, args, type } = detectShell()
|
||||||
this.shell = spawn(this.binShell, ['-l'], {
|
this.binShell = bin
|
||||||
|
this.shellArgs = args
|
||||||
|
this.shellType = type
|
||||||
|
|
||||||
|
this.shell = spawn(this.binShell, this.shellArgs, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
cwd,
|
cwd,
|
||||||
env: {
|
env: {
|
||||||
@ -98,13 +217,15 @@ export class PersistentShell {
|
|||||||
}
|
}
|
||||||
// Initialize CWD file with initial directory
|
// Initialize CWD file with initial directory
|
||||||
fs.writeFileSync(this.cwdFile, cwd)
|
fs.writeFileSync(this.cwdFile, cwd)
|
||||||
const configFile = SHELL_CONFIGS[this.binShell]
|
|
||||||
if (configFile) {
|
// Compute bash-visible paths for redirections
|
||||||
const configFilePath = join(homedir(), configFile)
|
this.statusFileBashPath = toBashPath(this.statusFile, this.shellType)
|
||||||
if (existsSync(configFilePath)) {
|
this.stdoutFileBashPath = toBashPath(this.stdoutFile, this.shellType)
|
||||||
this.sendToShell(`source ${configFilePath}`)
|
this.stderrFileBashPath = toBashPath(this.stderrFile, this.shellType)
|
||||||
}
|
this.cwdFileBashPath = toBashPath(this.cwdFile, this.shellType)
|
||||||
}
|
|
||||||
|
// Source ~/.bashrc when available (works for bash on POSIX/MSYS/WSL)
|
||||||
|
this.sendToShell('[ -f ~/.bashrc ] && source ~/.bashrc || true')
|
||||||
}
|
}
|
||||||
|
|
||||||
private static instance: PersistentShell | null = null
|
private static instance: PersistentShell | null = null
|
||||||
@ -232,10 +353,17 @@ export class PersistentShell {
|
|||||||
|
|
||||||
// Check the syntax of the command
|
// Check the syntax of the command
|
||||||
try {
|
try {
|
||||||
execSync(`${this.binShell} -n -c ${quotedCommand}`, {
|
if (this.shellType === 'wsl') {
|
||||||
stdio: 'ignore',
|
execSync(`wsl.exe -e bash -n -c ${quotedCommand}`, {
|
||||||
timeout: 1000,
|
stdio: 'ignore',
|
||||||
})
|
timeout: 1000,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
execSync(`${this.binShell} -n -c ${quotedCommand}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
timeout: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
} catch (stderr) {
|
} catch (stderr) {
|
||||||
// If there's a syntax error, return an error and log it
|
// If there's a syntax error, return an error and log it
|
||||||
const errorStr =
|
const errorStr =
|
||||||
@ -264,17 +392,17 @@ export class PersistentShell {
|
|||||||
|
|
||||||
// 1. Execute the main command with redirections
|
// 1. Execute the main command with redirections
|
||||||
commandParts.push(
|
commandParts.push(
|
||||||
`eval ${quotedCommand} < /dev/null > ${this.stdoutFile} 2> ${this.stderrFile}`,
|
`eval ${quotedCommand} < /dev/null > ${quoteForBash(this.stdoutFileBashPath)} 2> ${quoteForBash(this.stderrFileBashPath)}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Capture exit code immediately after command execution to avoid losing it
|
// 2. Capture exit code immediately after command execution to avoid losing it
|
||||||
commandParts.push(`EXEC_EXIT_CODE=$?`)
|
commandParts.push(`EXEC_EXIT_CODE=$?`)
|
||||||
|
|
||||||
// 3. Update CWD file
|
// 3. Update CWD file
|
||||||
commandParts.push(`pwd > ${this.cwdFile}`)
|
commandParts.push(`pwd > ${quoteForBash(this.cwdFileBashPath)}`)
|
||||||
|
|
||||||
// 4. Write the preserved exit code to status file to avoid race with pwd
|
// 4. Write the preserved exit code to status file to avoid race with pwd
|
||||||
commandParts.push(`echo $EXEC_EXIT_CODE > ${this.statusFile}`)
|
commandParts.push(`echo $EXEC_EXIT_CODE > ${quoteForBash(this.statusFileBashPath)}`)
|
||||||
|
|
||||||
// Send the combined commands as a single operation to maintain atomicity
|
// Send the combined commands as a single operation to maintain atomicity
|
||||||
this.sendToShell(commandParts.join('\n'))
|
this.sendToShell(commandParts.join('\n'))
|
||||||
@ -363,7 +491,8 @@ export class PersistentShell {
|
|||||||
if (!existsSync(resolved)) {
|
if (!existsSync(resolved)) {
|
||||||
throw new Error(`Path "${resolved}" does not exist`)
|
throw new Error(`Path "${resolved}" does not exist`)
|
||||||
}
|
}
|
||||||
await this.exec(`cd ${resolved}`)
|
const bashPath = toBashPath(resolved, this.shellType)
|
||||||
|
await this.exec(`cd ${quoteForBash(bashPath)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user