| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { execFile, spawn, type ChildProcess } from 'child_process'; |
| import { promisify } from 'util'; |
| import { homedir } from 'os'; |
| import { join } from 'path'; |
| import { access } from 'fs/promises'; |
| import type { TerminalInfo } from '@automaker/types'; |
|
|
| const execFileAsync = promisify(execFile); |
|
|
| |
| const isWindows = process.platform === 'win32'; |
| const isMac = process.platform === 'darwin'; |
| const isLinux = process.platform === 'linux'; |
|
|
| |
| let cachedTerminals: TerminalInfo[] | null = null; |
| let cacheTimestamp: number = 0; |
| const CACHE_TTL_MS = 5 * 60 * 1000; |
|
|
| |
| |
| |
| function isCacheValid(): boolean { |
| return cachedTerminals !== null && Date.now() - cacheTimestamp < CACHE_TTL_MS; |
| } |
|
|
| |
| |
| |
| |
| export function clearTerminalCache(): void { |
| cachedTerminals = null; |
| cacheTimestamp = 0; |
| } |
|
|
| |
| |
| |
| |
| async function commandExists(cmd: string): Promise<boolean> { |
| try { |
| const whichCmd = isWindows ? 'where' : 'which'; |
| await execFileAsync(whichCmd, [cmd]); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| |
| async function findMacApp(appName: string): Promise<string | null> { |
| if (!isMac) return null; |
|
|
| |
| const appPath = join('/Applications', `${appName}.app`); |
| try { |
| await access(appPath); |
| return appPath; |
| } catch { |
| |
| } |
|
|
| |
| const systemAppPath = join('/System/Applications', `${appName}.app`); |
| try { |
| await access(systemAppPath); |
| return systemAppPath; |
| } catch { |
| |
| } |
|
|
| |
| const userAppPath = join(homedir(), 'Applications', `${appName}.app`); |
| try { |
| await access(userAppPath); |
| return userAppPath; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| async function windowsPathExists(path: string): Promise<boolean> { |
| if (!isWindows) return false; |
|
|
| try { |
| await access(path); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| interface TerminalDefinition { |
| id: string; |
| name: string; |
| |
| cliCommand?: string; |
| |
| cliAliases?: readonly string[]; |
| |
| macAppName?: string; |
| |
| windowsPaths?: readonly string[]; |
| |
| linuxPaths?: readonly string[]; |
| |
| platform?: 'darwin' | 'win32' | 'linux'; |
| } |
|
|
| |
| |
| |
| const SUPPORTED_TERMINALS: TerminalDefinition[] = [ |
| |
| { |
| id: 'iterm2', |
| name: 'iTerm2', |
| cliCommand: 'iterm2', |
| macAppName: 'iTerm', |
| platform: 'darwin', |
| }, |
| { |
| id: 'warp', |
| name: 'Warp', |
| cliCommand: 'warp-cli', |
| cliAliases: ['warp-terminal', 'warp'], |
| macAppName: 'Warp', |
| }, |
| { |
| id: 'ghostty', |
| name: 'Ghostty', |
| cliCommand: 'ghostty', |
| macAppName: 'Ghostty', |
| }, |
| { |
| id: 'rio', |
| name: 'Rio', |
| cliCommand: 'rio', |
| macAppName: 'Rio', |
| }, |
| { |
| id: 'alacritty', |
| name: 'Alacritty', |
| cliCommand: 'alacritty', |
| macAppName: 'Alacritty', |
| }, |
| { |
| id: 'wezterm', |
| name: 'WezTerm', |
| cliCommand: 'wezterm', |
| macAppName: 'WezTerm', |
| }, |
| { |
| id: 'kitty', |
| name: 'Kitty', |
| cliCommand: 'kitty', |
| macAppName: 'kitty', |
| }, |
| { |
| id: 'hyper', |
| name: 'Hyper', |
| cliCommand: 'hyper', |
| macAppName: 'Hyper', |
| }, |
| { |
| id: 'tabby', |
| name: 'Tabby', |
| cliCommand: 'tabby', |
| macAppName: 'Tabby', |
| }, |
| { |
| id: 'terminal-macos', |
| name: 'System Terminal', |
| macAppName: 'Utilities/Terminal', |
| platform: 'darwin', |
| }, |
|
|
| |
| { |
| id: 'windows-terminal', |
| name: 'Windows Terminal', |
| cliCommand: 'wt', |
| windowsPaths: [join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WindowsApps', 'wt.exe')], |
| platform: 'win32', |
| }, |
| { |
| id: 'powershell', |
| name: 'PowerShell', |
| cliCommand: 'pwsh', |
| cliAliases: ['powershell'], |
| windowsPaths: [ |
| join( |
| process.env.SYSTEMROOT || 'C:\\Windows', |
| 'System32', |
| 'WindowsPowerShell', |
| 'v1.0', |
| 'powershell.exe' |
| ), |
| ], |
| platform: 'win32', |
| }, |
| { |
| id: 'cmd', |
| name: 'Command Prompt', |
| cliCommand: 'cmd', |
| windowsPaths: [join(process.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'cmd.exe')], |
| platform: 'win32', |
| }, |
| { |
| id: 'git-bash', |
| name: 'Git Bash', |
| windowsPaths: [ |
| join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Git', 'git-bash.exe'), |
| join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Git', 'git-bash.exe'), |
| ], |
| platform: 'win32', |
| }, |
|
|
| |
| { |
| id: 'gnome-terminal', |
| name: 'GNOME Terminal', |
| cliCommand: 'gnome-terminal', |
| platform: 'linux', |
| }, |
| { |
| id: 'konsole', |
| name: 'Konsole', |
| cliCommand: 'konsole', |
| platform: 'linux', |
| }, |
| { |
| id: 'xfce4-terminal', |
| name: 'XFCE4 Terminal', |
| cliCommand: 'xfce4-terminal', |
| platform: 'linux', |
| }, |
| { |
| id: 'tilix', |
| name: 'Tilix', |
| cliCommand: 'tilix', |
| platform: 'linux', |
| }, |
| { |
| id: 'terminator', |
| name: 'Terminator', |
| cliCommand: 'terminator', |
| platform: 'linux', |
| }, |
| { |
| id: 'foot', |
| name: 'Foot', |
| cliCommand: 'foot', |
| platform: 'linux', |
| }, |
| { |
| id: 'xterm', |
| name: 'XTerm', |
| cliCommand: 'xterm', |
| platform: 'linux', |
| }, |
| ]; |
|
|
| |
| |
| |
| |
| async function findTerminal(definition: TerminalDefinition): Promise<TerminalInfo | null> { |
| |
| if (definition.platform) { |
| if (definition.platform === 'darwin' && !isMac) return null; |
| if (definition.platform === 'win32' && !isWindows) return null; |
| if (definition.platform === 'linux' && !isLinux) return null; |
| } |
|
|
| |
| const cliCandidates = [definition.cliCommand, ...(definition.cliAliases ?? [])].filter( |
| Boolean |
| ) as string[]; |
| for (const cliCommand of cliCandidates) { |
| if (await commandExists(cliCommand)) { |
| return { |
| id: definition.id, |
| name: definition.name, |
| command: cliCommand, |
| }; |
| } |
| } |
|
|
| |
| if (isMac && definition.macAppName) { |
| const appPath = await findMacApp(definition.macAppName); |
| if (appPath) { |
| return { |
| id: definition.id, |
| name: definition.name, |
| command: `open -a "${appPath}"`, |
| }; |
| } |
| } |
|
|
| |
| if (isWindows && definition.windowsPaths) { |
| for (const windowsPath of definition.windowsPaths) { |
| if (await windowsPathExists(windowsPath)) { |
| return { |
| id: definition.id, |
| name: definition.name, |
| command: windowsPath, |
| }; |
| } |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| export async function detectAllTerminals(): Promise<TerminalInfo[]> { |
| |
| if (isCacheValid() && cachedTerminals) { |
| return cachedTerminals; |
| } |
|
|
| |
| const terminalChecks = SUPPORTED_TERMINALS.map((def) => findTerminal(def)); |
| const results = await Promise.all(terminalChecks); |
|
|
| |
| const terminals = results.filter((t): t is TerminalInfo => t !== null); |
|
|
| |
| cachedTerminals = terminals; |
| cacheTimestamp = Date.now(); |
|
|
| return terminals; |
| } |
|
|
| |
| |
| |
| |
| export async function detectDefaultTerminal(): Promise<TerminalInfo | null> { |
| const terminals = await detectAllTerminals(); |
| return terminals[0] ?? null; |
| } |
|
|
| |
| |
| |
| |
| export async function findTerminalById(id: string): Promise<TerminalInfo | null> { |
| const terminals = await detectAllTerminals(); |
| return terminals.find((t) => t.id === id) ?? null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function openInExternalTerminal( |
| targetPath: string, |
| terminalId?: string |
| ): Promise<{ terminalName: string }> { |
| |
| let terminal: TerminalInfo | null; |
|
|
| if (terminalId) { |
| terminal = await findTerminalById(terminalId); |
| if (!terminal) { |
| |
| terminal = await detectDefaultTerminal(); |
| } |
| } else { |
| terminal = await detectDefaultTerminal(); |
| } |
|
|
| if (!terminal) { |
| throw new Error('No external terminal available'); |
| } |
|
|
| |
| await executeTerminalCommand(terminal, targetPath); |
|
|
| return { terminalName: terminal.name }; |
| } |
|
|
| |
| |
| |
| |
| async function executeTerminalCommand(terminal: TerminalInfo, targetPath: string): Promise<void> { |
| const { id, command } = terminal; |
|
|
| |
| if (command.startsWith('open -a ')) { |
| const appPath = command.replace('open -a ', '').replace(/"/g, ''); |
|
|
| |
| if (id === 'iterm2') { |
| |
| await execFileAsync('osascript', [ |
| '-e', |
| `tell application "iTerm" |
| create window with default profile |
| tell current session of current window |
| write text "cd ${escapeShellArg(targetPath)}" |
| end tell |
| end tell`, |
| ]); |
| } else if (id === 'terminal-macos') { |
| |
| await execFileAsync('osascript', [ |
| '-e', |
| `tell application "Terminal" |
| do script "cd ${escapeShellArg(targetPath)}" |
| activate |
| end tell`, |
| ]); |
| } else if (id === 'warp') { |
| |
| await execFileAsync('open', ['-a', appPath, targetPath]); |
| } else { |
| |
| await execFileAsync('open', ['-a', appPath, targetPath]); |
| } |
| return; |
| } |
|
|
| |
| switch (id) { |
| case 'iterm2': |
| |
| await execFileAsync('osascript', [ |
| '-e', |
| `tell application "iTerm" |
| create window with default profile |
| tell current session of current window |
| write text "cd ${escapeShellArg(targetPath)}" |
| end tell |
| end tell`, |
| ]); |
| break; |
|
|
| case 'ghostty': |
| |
| await spawnDetached(command, [`--working-directory=${targetPath}`]); |
| break; |
|
|
| case 'warp': |
| |
| await spawnDetached(command, ['--cwd', targetPath]); |
| break; |
|
|
| case 'alacritty': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'wezterm': |
| |
| await spawnDetached(command, ['start', '--cwd', targetPath]); |
| break; |
|
|
| case 'kitty': |
| |
| await spawnDetached(command, ['--directory', targetPath]); |
| break; |
|
|
| case 'hyper': |
| |
| await spawnDetached(command, [targetPath]); |
| break; |
|
|
| case 'tabby': |
| |
| await spawnDetached(command, ['open', targetPath]); |
| break; |
|
|
| case 'rio': |
| |
| await spawnDetached(command, ['--working-dir', targetPath]); |
| break; |
|
|
| case 'windows-terminal': |
| |
| await spawnDetached(command, ['-d', targetPath], { shell: true }); |
| break; |
|
|
| case 'powershell': |
| case 'cmd': |
| |
| await spawnDetached('start', [command, '/K', `cd /d "${targetPath}"`], { |
| shell: true, |
| }); |
| break; |
|
|
| case 'git-bash': |
| |
| await spawnDetached(command, ['--cd', targetPath], { shell: true }); |
| break; |
|
|
| case 'gnome-terminal': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'konsole': |
| |
| await spawnDetached(command, ['--workdir', targetPath]); |
| break; |
|
|
| case 'xfce4-terminal': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'tilix': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'terminator': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'foot': |
| |
| await spawnDetached(command, ['--working-directory', targetPath]); |
| break; |
|
|
| case 'xterm': |
| |
| await spawnDetached(command, [ |
| '-e', |
| 'sh', |
| '-c', |
| `cd ${escapeShellArg(targetPath)} && $SHELL`, |
| ]); |
| break; |
|
|
| default: |
| |
| await spawnDetached(command, [targetPath]); |
| } |
| } |
|
|
| |
| |
| |
| function spawnDetached( |
| command: string, |
| args: string[], |
| options: { shell?: boolean } = {} |
| ): Promise<void> { |
| return new Promise((resolve, reject) => { |
| const child: ChildProcess = spawn(command, args, { |
| shell: options.shell ?? false, |
| stdio: 'ignore', |
| detached: true, |
| }); |
|
|
| |
| child.unref(); |
|
|
| child.on('error', (err) => { |
| reject(err); |
| }); |
|
|
| |
| |
| setTimeout(() => resolve(), 100); |
| }); |
| } |
|
|
| |
| |
| |
| function escapeShellArg(arg: string): string { |
| |
| return `'${arg.replace(/'/g, "'\\''")}'`; |
| } |
| |