From 6b96344268f79a1e50459622f5ea43d486951955 Mon Sep 17 00:00:00 2001 From: Flint Date: Thu, 19 Feb 2026 16:00:33 -0500 Subject: [PATCH] feat: add WSL/Linux terminal window launching support Add platform-specific terminal backends for WSL (Windows Terminal) and native Linux (gnome-terminal, konsole, xterm) alongside the existing macOS backend. Extract terminal logic into backend modules while keeping the public API unchanged. Key changes: - Add platform-detect.ts for WSL/Linux environment detection - Add terminal-backends/ with darwin, wsl, linux backends and factory - Refactor terminal.ts to delegate to platform-specific backends - Fix E2BIG on Linux by externalising large CLI args to temp files - Fix race condition: write loom metadata before opening terminal tabs - Fix empty error messages in Claude CLI error handling - Update TerminalColorManager with WSL-aware messaging WIP: Terminal tabs open and Claude launches, but behavior still being refined for full end-to-end WSL support. Co-Authored-By: Claude Opus 4.6 --- docs/iloom-commands.md | 26 + src/lib/LoomManager.ts | 79 +-- src/lib/TerminalColorManager.test.ts | 20 +- src/lib/TerminalColorManager.ts | 22 +- src/utils/claude.ts | 156 +++++- src/utils/platform-detect.test.ts | 168 ++++++ src/utils/platform-detect.ts | 89 ++++ .../terminal-backends/command-builder.test.ts | 105 ++++ .../terminal-backends/command-builder.ts | 65 +++ src/utils/terminal-backends/darwin.test.ts | 169 ++++++ src/utils/terminal-backends/darwin.ts | 234 +++++++++ src/utils/terminal-backends/index.ts | 37 ++ src/utils/terminal-backends/linux.test.ts | 215 ++++++++ src/utils/terminal-backends/linux.ts | 159 ++++++ src/utils/terminal-backends/types.ts | 13 + src/utils/terminal-backends/wsl.test.ts | 195 +++++++ src/utils/terminal-backends/wsl.ts | 117 +++++ src/utils/terminal.test.ts | 495 +++--------------- src/utils/terminal.ts | 320 +---------- 19 files changed, 1881 insertions(+), 803 deletions(-) create mode 100644 src/utils/platform-detect.test.ts create mode 100644 src/utils/platform-detect.ts create mode 100644 src/utils/terminal-backends/command-builder.test.ts create mode 100644 src/utils/terminal-backends/command-builder.ts create mode 100644 src/utils/terminal-backends/darwin.test.ts create mode 100644 src/utils/terminal-backends/darwin.ts create mode 100644 src/utils/terminal-backends/index.ts create mode 100644 src/utils/terminal-backends/linux.test.ts create mode 100644 src/utils/terminal-backends/linux.ts create mode 100644 src/utils/terminal-backends/types.ts create mode 100644 src/utils/terminal-backends/wsl.test.ts create mode 100644 src/utils/terminal-backends/wsl.ts diff --git a/docs/iloom-commands.md b/docs/iloom-commands.md index 256f09a7..2c18d7dc 100644 --- a/docs/iloom-commands.md +++ b/docs/iloom-commands.md @@ -1672,6 +1672,32 @@ iloom respects these environment variables: --- +## Platform Support + +Terminal window launching (`il start` opening Claude, dev-server, and shell tabs) is supported on the following platforms: + +| Platform | Terminal Emulator | Tab Colors | Tab Titles | +|----------|-------------------|------------|------------| +| macOS | Terminal.app | Yes (background) | No | +| macOS | iTerm2 | Yes (background) | Yes | +| WSL | Windows Terminal (`wt.exe`) | Yes (`--tabColor`) | Yes | +| Linux | gnome-terminal | No | Yes | +| Linux | konsole | No | Yes | +| Linux | xterm (fallback) | No | Yes (window title) | + +**WSL Notes:** +- Windows Terminal must be installed (available from the [Microsoft Store](https://aka.ms/terminal)) +- `wt.exe` is automatically available on `PATH` inside WSL when Windows Terminal is installed +- The WSL distribution is detected from the `WSL_DISTRO_NAME` environment variable +- Tab colors are applied via the `--tabColor` flag at launch time + +**Linux Notes:** +- Terminal emulators are detected in preference order: gnome-terminal, konsole, xterm +- gnome-terminal supports opening multiple tabs in a single invocation +- Background colors are not controllable via CLI on most Linux terminals + +--- + ## Additional Resources - [Main README](../README.md) - Overview and quick start diff --git a/src/lib/LoomManager.ts b/src/lib/LoomManager.ts index 70657b62..0c09998d 100644 --- a/src/lib/LoomManager.ts +++ b/src/lib/LoomManager.ts @@ -345,46 +345,8 @@ export class LoomManager { } } - // 11.5. Launch workspace components based on individual flags - const enableClaude = input.options?.enableClaude !== false - const enableCode = input.options?.enableCode !== false - const enableDevServer = input.options?.enableDevServer !== false - const enableTerminal = input.options?.enableTerminal ?? false - const oneShot = input.options?.oneShot ?? 'default' - const setArguments = input.options?.setArguments - const executablePath = input.options?.executablePath - - // Only launch if at least one component is enabled - if (enableClaude || enableCode || enableDevServer || enableTerminal) { - const { LoomLauncher } = await import('./LoomLauncher.js') - const { ClaudeContextManager } = await import('./ClaudeContextManager.js') - - // Create ClaudeContextManager with shared SettingsManager to ensure CLI overrides work - const claudeContext = new ClaudeContextManager(undefined, undefined, this.settings) - const launcher = new LoomLauncher(claudeContext, this.settings) - - await launcher.launchLoom({ - enableClaude, - enableCode, - enableDevServer, - enableTerminal, - worktreePath, - branchName, - port, - capabilities, - workflowType: input.type === 'branch' ? 'regular' : input.type, - identifier: input.identifier, - ...(issueData?.title && { title: issueData.title }), - oneShot, - ...(setArguments && { setArguments }), - ...(executablePath && { executablePath }), - sourceEnvOnStart: settingsData.sourceEnvOnStart ?? false, - colorTerminal: input.options?.colorTerminal ?? settingsData.colors?.terminal ?? true, - colorHex: colorData.hex, - }) - } - // 12. Write loom metadata (spec section 3.1) + // Must happen BEFORE launching terminals so that `il spin` can read the session ID // Derive description from issue/PR title or branch name const description = issueData?.title ?? branchName @@ -449,6 +411,45 @@ export class LoomManager { } await this.metadataManager.writeMetadata(worktreePath, metadataInput) + // 11.5. Launch workspace components based on individual flags + const enableClaude = input.options?.enableClaude !== false + const enableCode = input.options?.enableCode !== false + const enableDevServer = input.options?.enableDevServer !== false + const enableTerminal = input.options?.enableTerminal ?? false + const oneShot = input.options?.oneShot ?? 'default' + const setArguments = input.options?.setArguments + const executablePath = input.options?.executablePath + + // Only launch if at least one component is enabled + if (enableClaude || enableCode || enableDevServer || enableTerminal) { + const { LoomLauncher } = await import('./LoomLauncher.js') + const { ClaudeContextManager } = await import('./ClaudeContextManager.js') + + // Create ClaudeContextManager with shared SettingsManager to ensure CLI overrides work + const claudeContext = new ClaudeContextManager(undefined, undefined, this.settings) + const launcher = new LoomLauncher(claudeContext, this.settings) + + await launcher.launchLoom({ + enableClaude, + enableCode, + enableDevServer, + enableTerminal, + worktreePath, + branchName, + port, + capabilities, + workflowType: input.type === 'branch' ? 'regular' : input.type, + identifier: input.identifier, + ...(issueData?.title && { title: issueData.title }), + oneShot, + ...(setArguments && { setArguments }), + ...(executablePath && { executablePath }), + sourceEnvOnStart: settingsData.sourceEnvOnStart ?? false, + colorTerminal: input.options?.colorTerminal ?? settingsData.colors?.terminal ?? true, + colorHex: colorData.hex, + }) + } + // 13. Create and return loom metadata const loom: Loom = { id: this.generateLoomId(input), diff --git a/src/lib/TerminalColorManager.test.ts b/src/lib/TerminalColorManager.test.ts index 9d4f1045..562fc037 100644 --- a/src/lib/TerminalColorManager.test.ts +++ b/src/lib/TerminalColorManager.test.ts @@ -3,10 +3,16 @@ import { TerminalColorManager } from './TerminalColorManager.js' import { execa } from 'execa' import { generateColorFromBranchName } from '../utils/color.js' import { logger } from '../utils/logger.js' +import { isWSL } from '../utils/platform-detect.js' // Mock execa vi.mock('execa') +// Mock platform-detect +vi.mock('../utils/platform-detect.js', () => ({ + isWSL: vi.fn().mockReturnValue(false), +})) + // Mock logger vi.mock('../utils/logger.js', () => ({ logger: { @@ -147,7 +153,8 @@ describe('TerminalColorManager', () => { await expect(manager.applyTerminalColor('feature/test-branch')).resolves.not.toThrow() }) - it('should log warning about limited Linux support', async () => { + it('should log warning about limited Linux support on native Linux', async () => { + vi.mocked(isWSL).mockReturnValue(false) await manager.applyTerminalColor('feature/test-branch') @@ -155,6 +162,17 @@ describe('TerminalColorManager', () => { expect.stringContaining('limited support on Linux') ) }) + + it('should log debug message about tab colors on WSL', async () => { + vi.mocked(isWSL).mockReturnValue(true) + + await manager.applyTerminalColor('feature/test-branch') + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('Windows Terminal tab colors are applied at launch time') + ) + expect(logger.warn).not.toHaveBeenCalled() + }) }) describe('applyTerminalColor - Windows', () => { diff --git a/src/lib/TerminalColorManager.ts b/src/lib/TerminalColorManager.ts index 1de644e2..94d68c4b 100644 --- a/src/lib/TerminalColorManager.ts +++ b/src/lib/TerminalColorManager.ts @@ -1,6 +1,7 @@ import { execa } from 'execa' import { generateColorFromBranchName } from '../utils/color.js' import { logger } from '../utils/logger.js' +import { isWSL } from '../utils/platform-detect.js' /** * Platform type for color application @@ -76,15 +77,22 @@ export class TerminalColorManager { /** * Apply terminal color on Linux - * Limited support - graceful degradation with warning + * On WSL, tab colors are applied at launch time via Windows Terminal's --tabColor flag. + * On native Linux, terminal background colors have limited CLI support. */ private async applyLinuxColor(_rgb: { r: number; g: number; b: number }): Promise { - logger.warn( - 'Terminal background colors have limited support on Linux. ' + - 'VSCode title bar colors will still be applied. ' + - 'Future versions may add support for specific terminal emulators.' - ) - // Future: Detect terminal emulator (gnome-terminal, konsole, etc.) and apply accordingly + if (isWSL()) { + logger.debug( + 'Windows Terminal tab colors are applied at launch time via --tabColor. ' + + 'VSCode title bar colors will still be applied.' + ) + } else { + logger.warn( + 'Terminal background colors have limited support on Linux. ' + + 'VSCode title bar colors will still be applied. ' + + 'Future versions may add support for specific terminal emulators.' + ) + } } /** diff --git a/src/utils/claude.ts b/src/utils/claude.ts index 467ab614..873dbd10 100644 --- a/src/utils/claude.ts +++ b/src/utils/claude.ts @@ -1,11 +1,132 @@ import { execa } from 'execa' -import { existsSync } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'node:fs' import { join } from 'node:path' +import { tmpdir } from 'node:os' import { createHash, randomUUID } from 'node:crypto' import { logger } from './logger.js' import { getLogger } from './logger-context.js' import { openTerminalWindow } from './terminal.js' +/** + * Linux has a per-argument size limit (MAX_ARG_STRLEN = 128KB) and a total + * argv+envp limit (ARG_MAX ≈ 2MB). Large CLI arguments like --append-system-prompt, + * --mcp-config, --agents, and the prompt can exceed these limits, causing E2BIG. + * + * This helper externalises large arguments to temp files: + * - appendSystemPrompt → temp dir with CLAUDE.md, added via --add-dir + * - mcpConfig entries → temp JSON files (--mcp-config accepts file paths) + * - prompt → temp file in workspace, short proxy prompt references it + * - agents JSON → temp file, read back as arg value (typically small) + * + * Returns a cleanup function to remove all temp artefacts. + */ +interface ExternalizedArgs { + args: string[] + prompt: string + cleanupFiles: string[] + cleanupDirs: string[] +} + +function externalizeIfNeeded( + args: string[], + prompt: string, + appendSystemPrompt: string | undefined, + mcpConfig: Record[] | undefined, + _agents: Record | undefined, + _workspacePath: string | undefined, +): ExternalizedArgs { + // On non-Linux platforms, skip externalisation (macOS has higher limits) + if (process.platform !== 'linux') { + return { args, prompt, cleanupFiles: [], cleanupDirs: [] } + } + + // Linux has a 128KB per-argument limit (MAX_ARG_STRLEN) and 2MB total argv+envp + // (ARG_MAX ≈ 2MB). Check if any single arg or the total exceeds safe thresholds. + const MAX_SINGLE_ARG = 120_000 // 128KB limit minus safety margin + const MAX_TOTAL_ARGS = 1_500_000 // 2MB minus env vars headroom + + const totalArgSize = args.reduce((sum, a) => sum + Buffer.byteLength(a), 0) + + Buffer.byteLength(prompt) + const maxSingleArg = Math.max( + ...args.map(a => Buffer.byteLength(a)), + Buffer.byteLength(prompt), + ) + + if (totalArgSize < MAX_TOTAL_ARGS && maxSingleArg < MAX_SINGLE_ARG) { + return { args, prompt, cleanupFiles: [], cleanupDirs: [] } + } + + getLogger().debug('CLI args exceed Linux limits, externalising', { + totalArgSize, maxSingleArg, MAX_TOTAL_ARGS, MAX_SINGLE_ARG, + }) + + const cleanupFiles: string[] = [] + const cleanupDirs: string[] = [] + const newArgs = [...args] + + // 1. Externalise --append-system-prompt → CLAUDE.md in temp --add-dir + if (appendSystemPrompt) { + const tempDir = join(tmpdir(), `iloom-sp-${randomUUID()}`) + mkdirSync(tempDir, { recursive: true }) + writeFileSync(join(tempDir, 'CLAUDE.md'), appendSystemPrompt, 'utf-8') + cleanupDirs.push(tempDir) + + // Remove the --append-system-prompt arg pair and add --add-dir instead + const spIdx = newArgs.indexOf('--append-system-prompt') + if (spIdx !== -1) { + newArgs.splice(spIdx, 2) // remove flag + value + } + newArgs.push('--add-dir', tempDir) + getLogger().debug('Externalised --append-system-prompt to temp CLAUDE.md', { tempDir }) + } + + // 2. Externalise --mcp-config entries → temp JSON files (--mcp-config accepts file paths) + if (mcpConfig && mcpConfig.length > 0) { + const mcpIndices: number[] = [] + for (let i = 0; i < newArgs.length; i++) { + if (newArgs[i] === '--mcp-config') mcpIndices.push(i) + } + // Process in reverse to preserve indices during splice + for (let j = mcpIndices.length - 1; j >= 0; j--) { + const idx = mcpIndices[j] + if (idx === undefined) continue + const value = newArgs[idx + 1] + if (value) { + const tmpFile = join(tmpdir(), `iloom-mcp-${randomUUID()}.json`) + writeFileSync(tmpFile, value, 'utf-8') + cleanupFiles.push(tmpFile) + newArgs[idx + 1] = tmpFile + getLogger().debug('Externalised --mcp-config to temp file', { tmpFile }) + } + } + } + + // 3. Externalise --agents JSON → temp file (--agents accepts file paths) + const agentsIdx = newArgs.indexOf('--agents') + if (agentsIdx !== -1 && newArgs[agentsIdx + 1]) { + const tmpFile = join(tmpdir(), `iloom-agents-${randomUUID()}.json`) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + writeFileSync(tmpFile, newArgs[agentsIdx + 1]!, 'utf-8') + cleanupFiles.push(tmpFile) + newArgs[agentsIdx + 1] = tmpFile + getLogger().debug('Externalised --agents to temp file', { tmpFile }) + } + + // 4. Prompt: kept as CLI arg. With agents/system-prompt/mcp-config externalised, + // the prompt alone should be well within Linux's 128KB per-arg limit. + + return { args: newArgs, prompt, cleanupFiles, cleanupDirs } +} + +function cleanupExternalizedFiles(files: string[], dirs: string[]): void { + for (const f of files) { + try { unlinkSync(f) } catch { /* ignore */ } + } + for (const d of dirs) { + try { rmSync(d, { recursive: true }) } catch { /* ignore */ } + } +} + /** * Generate a deterministic UUID v5 from a worktree path * Uses SHA1 hash with URL namespace to create a consistent session ID @@ -216,6 +337,11 @@ export async function launchClaude( args.push('--no-session-persistence') } + // Externalise large arguments to temp files on Linux to avoid E2BIG + const ext = externalizeIfNeeded(args, prompt, appendSystemPrompt, mcpConfig, agents, addDir) + const effectiveArgs = ext.args + const effectivePrompt = ext.prompt + try { if (headless) { // Headless mode: capture and return output @@ -223,17 +349,17 @@ export async function launchClaude( // Set up execa options based on debug mode const execaOptions = { - input: prompt, + input: effectivePrompt, timeout: 0, // Disable timeout for long responses ...(addDir && { cwd: addDir }), // Run Claude in the worktree directory verbose: isDebugMode, ...(isDebugMode && { stdio: ['pipe', 'pipe', 'pipe'] as const }), // Enable streaming in debug mode } - const subprocess = execa('claude', args, execaOptions) + const subprocess = execa('claude', effectiveArgs, execaOptions) // Check if JSON streaming format is enabled (always true in headless mode) - const isJsonStreamFormat = args.includes('--output-format') && args.includes('stream-json') + const isJsonStreamFormat = effectiveArgs.includes('--output-format') && effectiveArgs.includes('stream-json') // Handle real-time streaming (enabled for progress tracking) let outputBuffer = '' @@ -301,7 +427,7 @@ export async function launchClaude( // First attempt: capture stderr to detect session ID conflicts // stdin/stdout inherit for interactivity, stderr captured for error detection try { - await execa('claude', [...args, '--', prompt], { + await execa('claude', [...effectiveArgs, '--', effectivePrompt], { ...(addDir && { cwd: addDir }), stdio: ['inherit', 'inherit', 'pipe'], // Capture stderr to detect session conflicts timeout: 0, // Disable timeout @@ -319,9 +445,9 @@ export async function launchClaude( log.debug(`Session ID ${conflictSessionId} already in use, retrying with --resume`) // Rebuild args with --resume instead of --session-id - const resumeArgs = args.filter((arg, idx) => { + const resumeArgs = effectiveArgs.filter((arg, idx) => { if (arg === '--session-id') return false - if (idx > 0 && args[idx - 1] === '--session-id') return false + if (idx > 0 && effectiveArgs[idx - 1] === '--session-id') return false return true }) resumeArgs.push('--resume', conflictSessionId) @@ -349,7 +475,9 @@ export async function launchClaude( exitCode?: number } - const errorMessage = execaError.stderr ?? execaError.message ?? 'Unknown Claude CLI error' + // Use || (not ??) intentionally: stderr can be empty string "" which ?? treats as truthy + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const errorMessage = execaError.stderr || execaError.message || 'Unknown Claude CLI error' // Check for "Session ID ... is already in use" error and retry with --resume const sessionInUseMatch = errorMessage.match(/Session ID ([0-9a-f-]+) is already in use/i) @@ -358,10 +486,10 @@ export async function launchClaude( log.debug(`Session ID ${extractedSessionId} already in use, retrying with --resume`) // Rebuild args with --resume instead of --session-id - const resumeArgs = args.filter((arg, idx) => { + const resumeArgs = effectiveArgs.filter((arg, idx) => { // Filter out --session-id and its value if (arg === '--session-id') return false - if (idx > 0 && args[idx - 1] === '--session-id') return false + if (idx > 0 && effectiveArgs[idx - 1] === '--session-id') return false return true }) resumeArgs.push('--resume', extractedSessionId) @@ -372,7 +500,7 @@ export async function launchClaude( // Note: In headless mode, we still need to pass the prompt even with --resume // because there's no interactive input mechanism const execaOptions = { - input: prompt, + input: effectivePrompt, timeout: 0, ...(addDir && { cwd: addDir }), verbose: isDebugMode, @@ -440,13 +568,17 @@ export async function launchClaude( } } catch (retryError) { const retryExecaError = retryError as { stderr?: string; message?: string } - const retryErrorMessage = retryExecaError.stderr ?? retryExecaError.message ?? 'Unknown Claude CLI error' + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const retryErrorMessage = retryExecaError.stderr || retryExecaError.message || 'Unknown Claude CLI error' throw new Error(`Claude CLI error: ${retryErrorMessage}`) } } // Re-throw with more context throw new Error(`Claude CLI error: ${errorMessage}`) + } finally { + // Clean up any temp files created for large argument externalisation + cleanupExternalizedFiles(ext.cleanupFiles, ext.cleanupDirs) } } diff --git a/src/utils/platform-detect.test.ts b/src/utils/platform-detect.test.ts new file mode 100644 index 00000000..3c022fef --- /dev/null +++ b/src/utils/platform-detect.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { readFileSync } from 'node:fs' +import { execa } from 'execa' +import { + isWSL, + detectTerminalEnvironment, + detectWSLDistro, + isWindowsTerminalAvailable, + _resetWSLCache, +} from './platform-detect.js' + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + +vi.mock('execa') + +describe('platform-detect', () => { + const originalPlatform = process.platform + const originalEnv = { ...process.env } + + beforeEach(() => { + _resetWSLCache() + process.env = { ...originalEnv } + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + }) + process.env = originalEnv + }) + + describe('isWSL', () => { + it('should return false on macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin', writable: true }) + delete process.env.WSL_DISTRO_NAME + + expect(isWSL()).toBe(false) + }) + + it('should return false on native Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + delete process.env.WSL_DISTRO_NAME + + expect(isWSL()).toBe(false) + }) + + it('should return true when WSL_DISTRO_NAME is set on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + process.env.WSL_DISTRO_NAME = 'Ubuntu' + + expect(isWSL()).toBe(true) + }) + + it('should return true when /proc/version contains "microsoft"', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue( + 'Linux version 5.15.167.4-microsoft-standard-WSL2 (gcc)' + ) + + expect(isWSL()).toBe(true) + }) + + it('should return true when /proc/version contains "WSL"', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue( + 'Linux version 5.15.0 WSL2 (gcc version 12)' + ) + + expect(isWSL()).toBe(true) + }) + + it('should return false on native Linux without WSL signatures', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue( + 'Linux version 6.1.0-generic (gcc version 12)' + ) + + expect(isWSL()).toBe(false) + }) + + it('should return false when /proc/version cannot be read', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockImplementation(() => { + throw new Error('ENOENT') + }) + + expect(isWSL()).toBe(false) + }) + + it('should cache the result', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + process.env.WSL_DISTRO_NAME = 'Ubuntu' + + expect(isWSL()).toBe(true) + + // Even if we change the env, cached result should be returned + delete process.env.WSL_DISTRO_NAME + expect(isWSL()).toBe(true) + }) + }) + + describe('detectTerminalEnvironment', () => { + it('should return "darwin" on macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin', writable: true }) + expect(detectTerminalEnvironment()).toBe('darwin') + }) + + it('should return "win32" on native Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32', writable: true }) + expect(detectTerminalEnvironment()).toBe('win32') + }) + + it('should return "wsl" on WSL', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + process.env.WSL_DISTRO_NAME = 'Ubuntu' + expect(detectTerminalEnvironment()).toBe('wsl') + }) + + it('should return "linux" on native Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue('Linux version 6.1.0-generic') + expect(detectTerminalEnvironment()).toBe('linux') + }) + + it('should return "unsupported" for unknown platforms', () => { + Object.defineProperty(process, 'platform', { value: 'freebsd', writable: true }) + expect(detectTerminalEnvironment()).toBe('unsupported') + }) + }) + + describe('detectWSLDistro', () => { + it('should return WSL_DISTRO_NAME when set', () => { + process.env.WSL_DISTRO_NAME = 'Ubuntu-22.04' + expect(detectWSLDistro()).toBe('Ubuntu-22.04') + }) + + it('should return undefined when WSL_DISTRO_NAME is not set', () => { + delete process.env.WSL_DISTRO_NAME + expect(detectWSLDistro()).toBeUndefined() + }) + + it('should return undefined when WSL_DISTRO_NAME is empty string', () => { + process.env.WSL_DISTRO_NAME = '' + expect(detectWSLDistro()).toBeUndefined() + }) + }) + + describe('isWindowsTerminalAvailable', () => { + it('should return true when wt.exe is found', async () => { + vi.mocked(execa).mockResolvedValue({} as never) + expect(await isWindowsTerminalAvailable()).toBe(true) + expect(execa).toHaveBeenCalledWith('which', ['wt.exe']) + }) + + it('should return false when wt.exe is not found', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not found')) + expect(await isWindowsTerminalAvailable()).toBe(false) + }) + }) +}) diff --git a/src/utils/platform-detect.ts b/src/utils/platform-detect.ts new file mode 100644 index 00000000..a61143d2 --- /dev/null +++ b/src/utils/platform-detect.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { execa } from 'execa' + +/** + * Terminal environment types. + * 'darwin' = macOS, 'wsl' = Windows Subsystem for Linux, 'linux' = native Linux, 'win32' = native Windows + */ +export type TerminalEnvironment = 'darwin' | 'wsl' | 'linux' | 'win32' | 'unsupported' + +let cachedIsWSL: boolean | undefined + +/** + * Detect if running inside Windows Subsystem for Linux. + * + * Detection strategy (in order): + * 1. Check WSL_DISTRO_NAME env var (always set in WSL2, most reliable) + * 2. Fallback: read /proc/version for "microsoft" or "WSL" signature + * + * Result is cached to avoid repeated /proc reads. + */ +export function isWSL(): boolean { + if (cachedIsWSL !== undefined) { + return cachedIsWSL + } + + // Must be linux for WSL + if (process.platform !== 'linux') { + cachedIsWSL = false + return false + } + + // Most reliable: WSL_DISTRO_NAME is always set in WSL2 + if (process.env.WSL_DISTRO_NAME) { + cachedIsWSL = true + return true + } + + // Fallback: check /proc/version for WSL signature + try { + const procVersion = readFileSync('/proc/version', 'utf-8') + cachedIsWSL = /microsoft|wsl/i.test(procVersion) + return cachedIsWSL + } catch { + cachedIsWSL = false + return false + } +} + +/** + * Detect the terminal environment, distinguishing WSL from plain Linux. + */ +export function detectTerminalEnvironment(): TerminalEnvironment { + const platform = process.platform + if (platform === 'darwin') return 'darwin' + if (platform === 'win32') return 'win32' + if (platform === 'linux') { + return isWSL() ? 'wsl' : 'linux' + } + return 'unsupported' +} + +/** + * Get the WSL distribution name from the environment. + * Returns undefined when not running in WSL or when the variable is not set. + */ +export function detectWSLDistro(): string | undefined { + return process.env.WSL_DISTRO_NAME || undefined // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- empty string should be treated as unset +} + +/** + * Check if Windows Terminal (wt.exe) is accessible from the current environment. + * In WSL, wt.exe is available on PATH when Windows Terminal is installed. + */ +export async function isWindowsTerminalAvailable(): Promise { + try { + await execa('which', ['wt.exe']) + return true + } catch { + return false + } +} + +/** + * Reset the cached WSL detection result. + * Exposed for testing only. + */ +export function _resetWSLCache(): void { + cachedIsWSL = undefined +} diff --git a/src/utils/terminal-backends/command-builder.test.ts b/src/utils/terminal-backends/command-builder.test.ts new file mode 100644 index 00000000..172ca949 --- /dev/null +++ b/src/utils/terminal-backends/command-builder.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest' +import { existsSync } from 'node:fs' +import { buildCommandSequence, escapeSingleQuotes, rgbToHex } from './command-builder.js' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + +describe('command-builder', () => { + describe('buildCommandSequence', () => { + it('should build cd command for workspace path', async () => { + const result = await buildCommandSequence({ + workspacePath: '/home/user/project', + }) + expect(result).toBe(" cd '/home/user/project'") + }) + + it('should chain multiple commands with &&', async () => { + const result = await buildCommandSequence({ + workspacePath: '/home/user/project', + command: 'pnpm dev', + port: 3042, + includePortExport: true, + }) + expect(result).toBe(" cd '/home/user/project' && export PORT=3042 && pnpm dev") + }) + + it('should include env source commands when requested', async () => { + vi.mocked(existsSync).mockImplementation((path) => { + const p = String(path) + return p.endsWith('.env') || p.endsWith('.env.local') + }) + + const result = await buildCommandSequence({ + workspacePath: '/home/user/project', + includeEnvSetup: true, + }) + expect(result).toContain('source .env') + expect(result).toContain('source .env.local') + }) + + it('should prefix with space for history suppression', async () => { + const result = await buildCommandSequence({ + command: 'echo hello', + }) + expect(result).toMatch(/^ /) + }) + + it('should handle empty options', async () => { + const result = await buildCommandSequence({}) + expect(result).toBe(' ') + }) + + it('should not export PORT when includePortExport is false', async () => { + const result = await buildCommandSequence({ + port: 3042, + includePortExport: false, + }) + expect(result).not.toContain('PORT') + }) + + it('should escape single quotes in workspace path', async () => { + const result = await buildCommandSequence({ + workspacePath: "/home/user/project's", + }) + expect(result).toContain("cd '/home/user/project'\\''s'") + }) + }) + + describe('escapeSingleQuotes', () => { + it('should escape single quotes', () => { + expect(escapeSingleQuotes("it's")).toBe("it'\\''s") + }) + + it('should handle no quotes', () => { + expect(escapeSingleQuotes('hello')).toBe('hello') + }) + + it('should handle multiple quotes', () => { + expect(escapeSingleQuotes("it's a 'test'")).toBe("it'\\''s a '\\''test'\\''") + }) + }) + + describe('rgbToHex', () => { + it('should convert RGB to hex', () => { + expect(rgbToHex({ r: 128, g: 77, b: 179 })).toBe('#804db3') + }) + + it('should handle black', () => { + expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000') + }) + + it('should handle white', () => { + expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe('#ffffff') + }) + + it('should clamp values above 255', () => { + expect(rgbToHex({ r: 300, g: 256, b: 999 })).toBe('#ffffff') + }) + + it('should clamp values below 0', () => { + expect(rgbToHex({ r: -1, g: -50, b: -100 })).toBe('#000000') + }) + }) +}) diff --git a/src/utils/terminal-backends/command-builder.ts b/src/utils/terminal-backends/command-builder.ts new file mode 100644 index 00000000..d9a20f36 --- /dev/null +++ b/src/utils/terminal-backends/command-builder.ts @@ -0,0 +1,65 @@ +import { existsSync } from 'node:fs' +import type { TerminalWindowOptions } from '../terminal.js' +import { buildEnvSourceCommands } from '../env.js' + +/** + * Build the shell command sequence from TerminalWindowOptions. + * + * The returned string is a chain of commands joined by ` && `, prefixed with + * a space to prevent shell history pollution (HISTCONTROL=ignorespace). + * + * This logic is shared across all backends — each backend applies its own + * escaping on top of the raw command string. + */ +export async function buildCommandSequence(options: TerminalWindowOptions): Promise { + const { + workspacePath, + command, + port, + includeEnvSetup, + includePortExport, + } = options + + const commands: string[] = [] + + if (workspacePath) { + commands.push(`cd '${escapeSingleQuotes(workspacePath)}'`) + } + + if (includeEnvSetup && workspacePath) { + const sourceCommands = await buildEnvSourceCommands( + workspacePath, + async (p) => existsSync(p) + ) + commands.push(...sourceCommands) + } + + if (includePortExport && port !== undefined) { + commands.push(`export PORT=${port}`) + } + + if (command) { + commands.push(command) + } + + const fullCommand = commands.join(' && ') + + // Prefix with space to prevent shell history pollution + return ` ${fullCommand}` +} + +/** + * Escape single quotes for use inside a single-quoted shell string. + * 'it'\''s' → ends quote, adds escaped quote, resumes quote + */ +export function escapeSingleQuotes(s: string): string { + return s.replace(/'/g, "'\\''") +} + +/** + * Convert {r, g, b} (0–255) to a hex color string "#RRGGBB". + */ +export function rgbToHex(rgb: { r: number; g: number; b: number }): string { + const toHex = (n: number): string => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0') + return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}` +} diff --git a/src/utils/terminal-backends/darwin.test.ts b/src/utils/terminal-backends/darwin.test.ts new file mode 100644 index 00000000..200d92bd --- /dev/null +++ b/src/utils/terminal-backends/darwin.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { execa } from 'execa' +import { existsSync } from 'node:fs' +import { DarwinBackend, detectITerm2, escapeForAppleScript, escapePathForAppleScript } from './darwin.js' + +vi.mock('execa') +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) + +describe('darwin backend', () => { + describe('detectITerm2', () => { + it('should return true when iTerm.app exists', () => { + vi.mocked(existsSync).mockReturnValue(true) + expect(detectITerm2()).toBe(true) + }) + + it('should return false when iTerm.app does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false) + expect(detectITerm2()).toBe(false) + }) + }) + + describe('escapeForAppleScript', () => { + it('should escape backslashes', () => { + expect(escapeForAppleScript('\\path')).toBe('\\\\path') + }) + + it('should escape double quotes', () => { + expect(escapeForAppleScript('"hello"')).toBe('\\"hello\\"') + }) + + it('should escape both backslashes and quotes', () => { + expect(escapeForAppleScript('echo "\\$PATH"')).toBe('echo \\"\\\\$PATH\\"') + }) + }) + + describe('escapePathForAppleScript', () => { + it('should escape single quotes', () => { + expect(escapePathForAppleScript("/user's/path")).toBe("/user'\\''s/path") + }) + + it('should handle no quotes', () => { + expect(escapePathForAppleScript('/home/user/project')).toBe('/home/user/project') + }) + }) + + describe('DarwinBackend', () => { + const originalPlatform = process.platform + let backend: DarwinBackend + + beforeEach(() => { + backend = new DarwinBackend() + Object.defineProperty(process, 'platform', { value: 'darwin', writable: true }) + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }) + }) + + describe('openSingle', () => { + it('should use Terminal.app when iTerm2 is not available', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + }) + + expect(execa).toHaveBeenCalledTimes(2) + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('tell application "Terminal"') + expect(applescript).toContain('pnpm dev') + }) + + it('should use iTerm2 when available', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + workspacePath: '/Users/test/workspace', + command: 'pnpm dev', + }) + + expect(execa).toHaveBeenCalledTimes(1) + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('tell application id "com.googlecode.iterm2"') + }) + + it('should apply background color in Terminal.app', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + backgroundColor: { r: 128, g: 77, b: 179 }, + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('set background color of newTab to {32896, 19789, 46003}') + }) + + it('should apply background color in iTerm2', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + backgroundColor: { r: 128, g: 77, b: 179 }, + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('set background color of s1 to {32896, 19789, 46003}') + }) + + it('should set tab title in iTerm2', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + title: 'Dev Server - Issue #42', + }) + + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('set name of s1 to "Dev Server - Issue #42"') + }) + + it('should throw on osascript failure', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(execa).mockRejectedValue(new Error('AppleScript failed')) + + await expect(backend.openSingle({})).rejects.toThrow( + 'Failed to open terminal window: AppleScript failed' + ) + }) + }) + + describe('openMultiple', () => { + it('should use iTerm2 multi-tab script when iTerm2 is available', async () => { + vi.mocked(existsSync).mockReturnValue(true) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openMultiple([ + { workspacePath: '/test/1', command: 'cmd1' }, + { workspacePath: '/test/2', command: 'cmd2' }, + ]) + + expect(execa).toHaveBeenCalledTimes(1) + const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string + expect(applescript).toContain('create window with default profile') + expect(applescript).toContain('create tab with default profile') + expect(applescript).toContain('cmd1') + expect(applescript).toContain('cmd2') + }) + + it('should fall back to multiple Terminal.app windows', async () => { + vi.mocked(existsSync).mockReturnValue(false) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openMultiple([ + { workspacePath: '/test/1', command: 'cmd1' }, + { workspacePath: '/test/2', command: 'cmd2' }, + ]) + + // Each Terminal.app window = 2 execa calls (script + activate) + expect(execa).toHaveBeenCalledTimes(4) + }) + }) + }) +}) diff --git a/src/utils/terminal-backends/darwin.ts b/src/utils/terminal-backends/darwin.ts new file mode 100644 index 00000000..c851c09d --- /dev/null +++ b/src/utils/terminal-backends/darwin.ts @@ -0,0 +1,234 @@ +import { execa } from 'execa' +import { existsSync } from 'node:fs' +import type { TerminalWindowOptions } from '../terminal.js' +import type { TerminalBackend } from './types.js' +import { buildCommandSequence, escapeSingleQuotes } from './command-builder.js' +import { buildEnvSourceCommands } from '../env.js' + +/** + * Detect if iTerm2 is installed on macOS. + * Checks for iTerm.app at the standard /Applications location. + */ +export function detectITerm2(): boolean { + return existsSync('/Applications/iTerm.app') +} + +/** + * Escape command string for embedding inside an AppleScript `do script "..."`. + * Must handle double quotes and backslashes. + */ +export function escapeForAppleScript(command: string): string { + return command + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') +} + +/** + * Escape path for use inside a single-quoted shell string within AppleScript. + * (Delegates to the shared single-quote escaper.) + */ +export function escapePathForAppleScript(path: string): string { + return escapeSingleQuotes(path) +} + +/** + * Build AppleScript for macOS Terminal.app (single tab). + */ +async function buildTerminalAppScript(options: TerminalWindowOptions): Promise { + const { + workspacePath, + command, + backgroundColor, + port, + includeEnvSetup, + includePortExport, + } = options + + const commands: string[] = [] + + if (workspacePath) { + commands.push(`cd '${escapePathForAppleScript(workspacePath)}'`) + } + + if (includeEnvSetup && workspacePath) { + const sourceCommands = await buildEnvSourceCommands( + workspacePath, + async (p) => existsSync(p) + ) + commands.push(...sourceCommands) + } + + if (includePortExport && port !== undefined) { + commands.push(`export PORT=${port}`) + } + + if (command) { + commands.push(command) + } + + const fullCommand = commands.join(' && ') + // Prefix with space to prevent shell history pollution + const historyFreeCommand = ` ${fullCommand}` + + let script = `tell application "Terminal"\n` + script += ` set newTab to do script "${escapeForAppleScript(historyFreeCommand)}"\n` + + if (backgroundColor) { + const { r, g, b } = backgroundColor + script += ` set background color of newTab to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` + } + + script += `end tell` + return script +} + +/** + * Build iTerm2 AppleScript for a single tab in a new window. + */ +async function buildITerm2SingleTabScript(options: TerminalWindowOptions): Promise { + const command = await buildCommandSequence(options) + + let script = 'tell application id "com.googlecode.iterm2"\n' + script += ' create window with default profile\n' + script += ' set s1 to current session of current window\n\n' + + if (options.backgroundColor) { + const { r, g, b } = options.backgroundColor + script += ` set background color of s1 to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` + } + + script += ` tell s1 to write text "${escapeForAppleScript(command)}"\n\n` + + if (options.title) { + script += ` set name of s1 to "${escapeForAppleScript(options.title)}"\n\n` + } + + script += ' activate\n' + script += 'end tell' + + return script +} + +/** + * Build iTerm2 AppleScript for multiple tabs (2+) in a single window. + */ +async function buildITerm2MultiTabScript( + optionsArray: TerminalWindowOptions[] +): Promise { + if (optionsArray.length < 2) { + throw new Error('buildITerm2MultiTabScript requires at least 2 terminal options') + } + + let script = 'tell application id "com.googlecode.iterm2"\n' + script += ' create window with default profile\n' + script += ' set newWindow to current window\n' + + // First tab + const options1 = optionsArray[0] + if (!options1) { + throw new Error('First terminal option is undefined') + } + const command1 = await buildCommandSequence(options1) + + script += ' set s1 to current session of newWindow\n\n' + + if (options1.backgroundColor) { + const { r, g, b } = options1.backgroundColor + script += ` set background color of s1 to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` + } + + script += ` tell s1 to write text "${escapeForAppleScript(command1)}"\n\n` + + if (options1.title) { + script += ` set name of s1 to "${escapeForAppleScript(options1.title)}"\n\n` + } + + // Subsequent tabs + for (let i = 1; i < optionsArray.length; i++) { + const options = optionsArray[i] + if (!options) { + throw new Error(`Terminal option at index ${i} is undefined`) + } + const command = await buildCommandSequence(options) + const sessionVar = `s${i + 1}` + + script += ' tell newWindow\n' + script += ` set newTab${i} to (create tab with default profile)\n` + script += ' end tell\n' + script += ` set ${sessionVar} to current session of newTab${i}\n\n` + + if (options.backgroundColor) { + const { r, g, b } = options.backgroundColor + script += ` set background color of ${sessionVar} to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` + } + + script += ` tell ${sessionVar} to write text "${escapeForAppleScript(command)}"\n\n` + + if (options.title) { + script += ` set name of ${sessionVar} to "${escapeForAppleScript(options.title)}"\n\n` + } + } + + script += ' activate\n' + script += 'end tell' + + return script +} + +/** + * macOS terminal backend — supports Terminal.app and iTerm2. + */ +export class DarwinBackend implements TerminalBackend { + readonly name = 'darwin' + + async openSingle(options: TerminalWindowOptions): Promise { + const hasITerm2 = detectITerm2() + + const applescript = hasITerm2 + ? await buildITerm2SingleTabScript(options) + : await buildTerminalAppScript(options) + + try { + await execa('osascript', ['-e', applescript]) + + // Terminal.app needs a separate activation command; iTerm2 script includes its own + if (!hasITerm2) { + await execa('osascript', ['-e', 'tell application "Terminal" to activate']) + } + } catch (error) { + throw new Error( + `Failed to open terminal window: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): Promise { + const hasITerm2 = detectITerm2() + + if (hasITerm2) { + const applescript = await buildITerm2MultiTabScript(optionsArray) + + try { + await execa('osascript', ['-e', applescript]) + } catch (error) { + throw new Error( + `Failed to open iTerm2 window: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } else { + // Fall back to multiple Terminal.app windows + for (let i = 0; i < optionsArray.length; i++) { + const options = optionsArray[i] + if (!options) { + throw new Error(`Terminal option at index ${i} is undefined`) + } + await this.openSingle(options) + + // Brief pause between terminals (except after last one) + if (i < optionsArray.length - 1) { + await new Promise((resolve) => globalThis.setTimeout(resolve, 1000)) + } + } + } + } +} diff --git a/src/utils/terminal-backends/index.ts b/src/utils/terminal-backends/index.ts new file mode 100644 index 00000000..5f3e023a --- /dev/null +++ b/src/utils/terminal-backends/index.ts @@ -0,0 +1,37 @@ +import { detectTerminalEnvironment } from '../platform-detect.js' +import type { TerminalBackend } from './types.js' + +export type { TerminalBackend } from './types.js' + +/** + * Get the appropriate terminal backend for the current platform. + * + * - macOS → DarwinBackend (Terminal.app / iTerm2) + * - WSL → WSLBackend (Windows Terminal via wt.exe) + * - Linux → LinuxBackend (gnome-terminal / konsole / xterm) + * + * Throws a descriptive error on unsupported platforms. + */ +export async function getTerminalBackend(): Promise { + const env = detectTerminalEnvironment() + + switch (env) { + case 'darwin': { + const { DarwinBackend } = await import('./darwin.js') + return new DarwinBackend() + } + case 'wsl': { + const { WSLBackend } = await import('./wsl.js') + return new WSLBackend() + } + case 'linux': { + const { LinuxBackend } = await import('./linux.js') + return new LinuxBackend() + } + default: + throw new Error( + `Terminal window launching is not supported on ${env}. ` + + `Supported platforms: macOS, WSL (Windows Terminal), and Linux (gnome-terminal/konsole/xterm).` + ) + } +} diff --git a/src/utils/terminal-backends/linux.test.ts b/src/utils/terminal-backends/linux.test.ts new file mode 100644 index 00000000..9fd111d2 --- /dev/null +++ b/src/utils/terminal-backends/linux.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { execa } from 'execa' +import { LinuxBackend, detectLinuxTerminal } from './linux.js' + +vi.mock('execa') +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + }, +})) + +describe('linux backend', () => { + describe('detectLinuxTerminal', () => { + it('should detect gnome-terminal', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'gnome-terminal') { + return {} as never + } + throw new Error('not found') + }) + + expect(await detectLinuxTerminal()).toBe('gnome-terminal') + }) + + it('should detect konsole when gnome-terminal is not available', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'konsole') { + return {} as never + } + throw new Error('not found') + }) + + expect(await detectLinuxTerminal()).toBe('konsole') + }) + + it('should detect xterm as fallback', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'xterm') { + return {} as never + } + throw new Error('not found') + }) + + expect(await detectLinuxTerminal()).toBe('xterm') + }) + + it('should return null when no terminal is available', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not found')) + + expect(await detectLinuxTerminal()).toBeNull() + }) + }) + + describe('LinuxBackend', () => { + let backend: LinuxBackend + + beforeEach(() => { + backend = new LinuxBackend() + }) + + describe('openSingle', () => { + it('should open gnome-terminal with --tab', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'gnome-terminal') { + return {} as never + } + if (cmd === 'gnome-terminal') { + return {} as never + } + throw new Error('not found') + }) + + await backend.openSingle({ + command: 'pnpm dev', + title: 'Dev Server', + }) + + const gnomeCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'gnome-terminal' + ) + expect(gnomeCall).toBeDefined() + const args = gnomeCall![1] as string[] + expect(args).toContain('--tab') + expect(args).toContain('--title') + expect(args).toContain('Dev Server') + expect(args).toContain('bash') + }) + + it('should open konsole with --new-tab', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'konsole') { + return {} as never + } + if (cmd === 'konsole') { + return {} as never + } + throw new Error('not found') + }) + + await backend.openSingle({ + command: 'pnpm dev', + title: 'Dev Server', + }) + + const konsoleCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'konsole' + ) + expect(konsoleCall).toBeDefined() + const args = konsoleCall![1] as string[] + expect(args).toContain('--new-tab') + expect(args).toContain('-p') + expect(args).toContain('tabtitle=Dev Server') + }) + + it('should open xterm as fallback', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'xterm') { + return {} as never + } + if (cmd === 'xterm') { + return {} as never + } + throw new Error('not found') + }) + + await backend.openSingle({ + command: 'pnpm dev', + title: 'Dev Server', + }) + + const xtermCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'xterm' + ) + expect(xtermCall).toBeDefined() + const args = xtermCall![1] as string[] + expect(args).toContain('-title') + expect(args).toContain('Dev Server') + }) + + it('should throw when no terminal emulator is found', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not found')) + + await expect(backend.openSingle({ command: 'test' })).rejects.toThrow( + 'No supported terminal emulator found' + ) + }) + + it('should append exec bash to keep terminal open', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'gnome-terminal') { + return {} as never + } + if (cmd === 'gnome-terminal') { + return {} as never + } + throw new Error('not found') + }) + + await backend.openSingle({ command: 'echo hello' }) + + const gnomeCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'gnome-terminal' + ) + const args = gnomeCall![1] as string[] + const bashCArg = args[args.length - 1] + expect(bashCArg).toContain('; exec bash') + }) + }) + + describe('openMultiple', () => { + it('should throw when no terminal emulator is found', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not found')) + + await expect( + backend.openMultiple([ + { command: 'cmd1' }, + { command: 'cmd2' }, + ]) + ).rejects.toThrow('No supported terminal emulator found') + }) + + it('should open gnome-terminal with multiple --tab flags', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'gnome-terminal') { + return {} as never + } + if (cmd === 'gnome-terminal') { + return {} as never + } + throw new Error('not found') + }) + + await backend.openMultiple([ + { command: 'cmd1', title: 'Tab 1' }, + { command: 'cmd2', title: 'Tab 2' }, + ]) + + const gnomeCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'gnome-terminal' + ) + expect(gnomeCall).toBeDefined() + const args = gnomeCall![1] as string[] + const tabCount = args.filter(a => a === '--tab').length + expect(tabCount).toBe(2) + }) + }) + }) +}) diff --git a/src/utils/terminal-backends/linux.ts b/src/utils/terminal-backends/linux.ts new file mode 100644 index 00000000..193189bb --- /dev/null +++ b/src/utils/terminal-backends/linux.ts @@ -0,0 +1,159 @@ +import { execa } from 'execa' +import type { TerminalWindowOptions } from '../terminal.js' +import type { TerminalBackend } from './types.js' +import { buildCommandSequence } from './command-builder.js' +import { logger } from '../logger.js' + +/** + * Supported Linux terminal emulators in preference order. + */ +const TERMINAL_EMULATORS = ['gnome-terminal', 'konsole', 'xterm'] as const +type LinuxTerminal = (typeof TERMINAL_EMULATORS)[number] + +/** + * Detect which terminal emulator is available on the system. + * Checks in preference order: gnome-terminal, konsole, xterm. + */ +export async function detectLinuxTerminal(): Promise { + for (const terminal of TERMINAL_EMULATORS) { + try { + await execa('which', [terminal]) + return terminal + } catch { + // not found, try next + } + } + return null +} + +/** + * Native Linux terminal backend. + * Supports gnome-terminal (tabs), konsole (tabs), and xterm (fallback, no tabs). + * + * Background colors are not controllable via CLI on most Linux terminal + * emulators — a debug message is logged and the color is skipped. + */ +export class LinuxBackend implements TerminalBackend { + readonly name = 'linux' + + async openSingle(options: TerminalWindowOptions): Promise { + const terminal = await detectLinuxTerminal() + if (!terminal) { + throw new Error( + 'No supported terminal emulator found. ' + + 'Install gnome-terminal, konsole, or xterm.' + ) + } + + if (options.backgroundColor) { + logger.debug( + 'Terminal background colors are not supported via CLI on Linux terminal emulators. ' + + 'Color will be applied at the window-manager level if possible.' + ) + } + + const shellCommand = await buildCommandSequence(options) + // Append `; exec bash` so the tab stays open after the command completes + const keepAliveCommand = `${shellCommand}; exec bash` + + await this.execTerminal(terminal, keepAliveCommand, options.title) + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): Promise { + const terminal = await detectLinuxTerminal() + if (!terminal) { + throw new Error( + 'No supported terminal emulator found. ' + + 'Install gnome-terminal, konsole, or xterm.' + ) + } + + if (terminal === 'gnome-terminal') { + // gnome-terminal supports multi-tab in a single invocation + await this.openGnomeTerminalMultiTab(optionsArray) + } else { + // konsole and xterm: open separate windows/tabs sequentially + for (let i = 0; i < optionsArray.length; i++) { + const options = optionsArray[i] + if (!options) { + throw new Error(`Terminal option at index ${i} is undefined`) + } + await this.openSingle(options) + } + } + } + + private async execTerminal( + terminal: LinuxTerminal, + command: string, + title?: string + ): Promise { + try { + switch (terminal) { + case 'gnome-terminal': { + const args = ['--tab'] + if (title) { + args.push('--title', title) + } + args.push('--', 'bash', '-lic', command) + await execa('gnome-terminal', args) + break + } + case 'konsole': { + const args = ['--new-tab'] + if (title) { + args.push('-p', `tabtitle=${title}`) + } + args.push('-e', 'bash', '-lic', command) + await execa('konsole', args) + break + } + case 'xterm': { + const args: string[] = [] + if (title) { + args.push('-title', title) + } + args.push('-e', 'bash', '-lic', command) + await execa('xterm', args) + break + } + } + } catch (error) { + throw new Error( + `Failed to open ${terminal}: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + private async openGnomeTerminalMultiTab( + optionsArray: TerminalWindowOptions[] + ): Promise { + // gnome-terminal supports multiple --tab flags in a single command + const args: string[] = [] + + for (const options of optionsArray) { + if (options.backgroundColor) { + logger.debug( + 'Terminal background colors are not supported via CLI on Linux terminal emulators.' + ) + } + + const shellCommand = await buildCommandSequence(options) + const keepAliveCommand = `${shellCommand}; exec bash` + + args.push('--tab') + if (options.title) { + args.push('--title', options.title) + } + args.push('--', 'bash', '-c', keepAliveCommand) + } + + try { + await execa('gnome-terminal', args) + } catch (error) { + throw new Error( + `Failed to open gnome-terminal: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } +} diff --git a/src/utils/terminal-backends/types.ts b/src/utils/terminal-backends/types.ts new file mode 100644 index 00000000..a89ea20a --- /dev/null +++ b/src/utils/terminal-backends/types.ts @@ -0,0 +1,13 @@ +import type { TerminalWindowOptions } from '../terminal.js' + +/** + * Backend interface for platform-specific terminal window launching. + * + * Each backend implements opening single and multiple terminal tabs/windows + * using the platform's native terminal emulator. + */ +export interface TerminalBackend { + readonly name: string + openSingle(options: TerminalWindowOptions): Promise + openMultiple(optionsArray: TerminalWindowOptions[]): Promise +} diff --git a/src/utils/terminal-backends/wsl.test.ts b/src/utils/terminal-backends/wsl.test.ts new file mode 100644 index 00000000..88b2bcf3 --- /dev/null +++ b/src/utils/terminal-backends/wsl.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { execa } from 'execa' +import { WSLBackend, escapeForBashC } from './wsl.js' + +vi.mock('execa') +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})) +vi.mock('../platform-detect.js', () => ({ + detectWSLDistro: vi.fn(), +})) + +import { detectWSLDistro } from '../platform-detect.js' + +describe('wsl backend', () => { + describe('escapeForBashC', () => { + it('should escape backslashes', () => { + expect(escapeForBashC('\\path')).toBe('\\\\path') + }) + + it('should escape double quotes', () => { + expect(escapeForBashC('"hello"')).toBe('\\"hello\\"') + }) + + it('should escape dollar signs', () => { + expect(escapeForBashC('$PATH')).toBe('\\$PATH') + }) + + it('should escape backticks', () => { + expect(escapeForBashC('`cmd`')).toBe('\\`cmd\\`') + }) + + it('should escape all special characters together', () => { + // Parentheses don't need escaping inside double quotes + expect(escapeForBashC('echo "\\$(`cmd`)\\n"')).toBe( + 'echo \\"\\\\\\$(\\`cmd\\`)\\\\n\\"' + ) + }) + }) + + describe('WSLBackend', () => { + let backend: WSLBackend + const originalEnv = { ...process.env } + + beforeEach(() => { + backend = new WSLBackend() + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe('openSingle', () => { + it('should call wt.exe with new-tab and wsl.exe', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + workspacePath: '/home/user/project', + command: 'pnpm dev', + }) + + expect(execa).toHaveBeenCalledWith('wt.exe', expect.arrayContaining([ + 'new-tab', + 'wsl.exe', + '-d', 'Ubuntu', + '-e', 'bash', '-lic', + expect.stringContaining('pnpm dev'), + ])) + }) + + it('should include --title when title is provided', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + title: 'Dev Server', + command: 'pnpm dev', + }) + + const args = vi.mocked(execa).mock.calls[0][1] as string[] + expect(args).toContain('--title') + expect(args).toContain('Dev Server') + }) + + it('should include --tabColor with hex color', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + backgroundColor: { r: 128, g: 77, b: 179 }, + command: 'echo test', + }) + + const args = vi.mocked(execa).mock.calls[0][1] as string[] + expect(args).toContain('--tabColor') + expect(args).toContain('#804db3') + }) + + it('should omit -d flag when distro is not available', async () => { + vi.mocked(detectWSLDistro).mockReturnValue(undefined) + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openSingle({ + command: 'echo test', + }) + + const args = vi.mocked(execa).mock.calls[0][1] as string[] + expect(args).not.toContain('-d') + }) + + it('should throw helpful error when wt.exe is not found', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockRejectedValue(new Error('ENOENT: wt.exe')) + + await expect(backend.openSingle({ command: 'test' })).rejects.toThrow( + 'Windows Terminal (wt.exe) is not available' + ) + }) + + it('should throw generic error for other failures', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockRejectedValue(new Error('Permission denied')) + + await expect(backend.openSingle({ command: 'test' })).rejects.toThrow( + 'Failed to open Windows Terminal tab: Permission denied' + ) + }) + }) + + describe('openMultiple', () => { + it('should combine multiple tabs with ; separator', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openMultiple([ + { command: 'cmd1', title: 'Tab 1' }, + { command: 'cmd2', title: 'Tab 2' }, + ]) + + expect(execa).toHaveBeenCalledTimes(1) + const args = vi.mocked(execa).mock.calls[0][1] as string[] + + // Should contain separator between tabs + expect(args).toContain(';') + + // Should have both tab commands + const argsStr = args.join(' ') + expect(argsStr).toContain('Tab 1') + expect(argsStr).toContain('Tab 2') + }) + + it('should handle three tabs', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockResolvedValue({} as never) + + await backend.openMultiple([ + { command: 'cmd1', title: 'Tab 1' }, + { command: 'cmd2', title: 'Tab 2' }, + { command: 'cmd3', title: 'Tab 3' }, + ]) + + const args = vi.mocked(execa).mock.calls[0][1] as string[] + // Should have 2 separators for 3 tabs + const separators = args.filter(a => a === ';') + expect(separators).toHaveLength(2) + }) + + it('should throw error for undefined options', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + + const arr = [{ command: 'cmd1' }] + // @ts-expect-error intentionally testing undefined + arr.push(undefined) + + await expect(backend.openMultiple(arr)).rejects.toThrow( + 'Terminal option at index 1 is undefined' + ) + }) + + it('should throw helpful error when wt.exe is not found', async () => { + vi.mocked(detectWSLDistro).mockReturnValue('Ubuntu') + vi.mocked(execa).mockRejectedValue(new Error('ENOENT')) + + await expect( + backend.openMultiple([ + { command: 'cmd1' }, + { command: 'cmd2' }, + ]) + ).rejects.toThrow('Windows Terminal (wt.exe) is not available') + }) + }) + }) +}) diff --git a/src/utils/terminal-backends/wsl.ts b/src/utils/terminal-backends/wsl.ts new file mode 100644 index 00000000..8bd04377 --- /dev/null +++ b/src/utils/terminal-backends/wsl.ts @@ -0,0 +1,117 @@ +import { execa } from 'execa' +import type { TerminalWindowOptions } from '../terminal.js' +import type { TerminalBackend } from './types.js' +import { buildCommandSequence, rgbToHex } from './command-builder.js' +import { detectWSLDistro } from '../platform-detect.js' + +/** + * Escape a string for use inside `bash -c "..."`. + * Handles double quotes, backslashes, dollar signs, and backticks. + */ +export function escapeForBashC(s: string): string { + return s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`') +} + +/** + * Build wt.exe arguments for a single tab. + * + * We use `wsl.exe -d -e bash -lic ""` as the tab's + * commandline. The `-e` flag tells wsl.exe to run the given command + * (instead of the default login shell), and `bash -lic` ensures the + * user's profile (asdf, nvm, etc.) is loaded. + */ +function buildTabArgs( + shellCommand: string, + options: TerminalWindowOptions, + distro: string | undefined +): string[] { + const args: string[] = ['new-tab'] + + if (options.title) { + args.push('--title', options.title) + } + + if (options.backgroundColor) { + args.push('--tabColor', rgbToHex(options.backgroundColor)) + } + + // The commandline for the tab: wsl.exe runs bash inside the correct distro + args.push('wsl.exe') + + if (distro) { + args.push('-d', distro) + } + + args.push('-e', 'bash', '-lic', shellCommand) + + return args +} + +/** + * WSL terminal backend — uses Windows Terminal (wt.exe) to open tabs + * running inside the current WSL distribution. + */ +export class WSLBackend implements TerminalBackend { + readonly name = 'wsl' + + async openSingle(options: TerminalWindowOptions): Promise { + const shellCommand = await buildCommandSequence(options) + const distro = detectWSLDistro() + const args = buildTabArgs(shellCommand, options, distro) + + try { + await execa('wt.exe', args) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + if (message.includes('ENOENT') || message.includes('not found')) { + throw new Error( + 'Windows Terminal (wt.exe) is not available. ' + + 'Install Windows Terminal from the Microsoft Store: https://aka.ms/terminal' + ) + } + throw new Error(`Failed to open Windows Terminal tab: ${message}`) + } + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): Promise { + const distro = detectWSLDistro() + + // Build combined wt.exe command with multiple new-tab subcommands + // separated by `;` (wt.exe subcommand separator) + const allArgs: string[] = [] + + for (let i = 0; i < optionsArray.length; i++) { + const options = optionsArray[i] + if (!options) { + throw new Error(`Terminal option at index ${i} is undefined`) + } + + const shellCommand = await buildCommandSequence(options) + const tabArgs = buildTabArgs(shellCommand, options, distro) + + if (i > 0) { + // Separate subcommands with `;` + allArgs.push(';') + } + + allArgs.push(...tabArgs) + } + + try { + await execa('wt.exe', allArgs) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + if (message.includes('ENOENT') || message.includes('not found')) { + throw new Error( + 'Windows Terminal (wt.exe) is not available. ' + + 'Install Windows Terminal from the Microsoft Store: https://aka.ms/terminal' + ) + } + throw new Error(`Failed to open Windows Terminal tabs: ${message}`) + } + } +} diff --git a/src/utils/terminal.test.ts b/src/utils/terminal.test.ts index d1c095b0..4968f542 100644 --- a/src/utils/terminal.test.ts +++ b/src/utils/terminal.test.ts @@ -1,13 +1,24 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { detectPlatform, detectDarkMode, openTerminalWindow, openDualTerminalWindow } from './terminal.js' +import { detectPlatform, detectDarkMode, openTerminalWindow, openDualTerminalWindow, openMultipleTerminalWindows } from './terminal.js' import { execa } from 'execa' -import { existsSync } from 'node:fs' // Mock execa vi.mock('execa') // Mock fs vi.mock('node:fs', () => ({ existsSync: vi.fn(), + readFileSync: vi.fn(), +})) + +// Mock the terminal backend factory +const mockOpenSingle = vi.fn() +const mockOpenMultiple = vi.fn() +vi.mock('./terminal-backends/index.js', () => ({ + getTerminalBackend: vi.fn(() => Promise.resolve({ + name: 'mock', + openSingle: (...args: unknown[]) => mockOpenSingle(...args), + openMultiple: (...args: unknown[]) => mockOpenMultiple(...args), + })), })) describe('detectPlatform', () => { @@ -57,10 +68,6 @@ describe('detectPlatform', () => { describe('detectDarkMode', () => { const originalPlatform = process.platform - beforeEach(() => { - vi.clearAllMocks() - }) - afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, @@ -127,471 +134,89 @@ describe('detectDarkMode', () => { }) describe('openTerminalWindow', () => { - const originalPlatform = process.platform - beforeEach(() => { - vi.clearAllMocks() - // Set to macOS by default - Object.defineProperty(process, 'platform', { - value: 'darwin', - writable: true, - }) + mockOpenSingle.mockResolvedValue(undefined) }) - afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - writable: true, - }) - }) - - it('should throw error on non-macOS platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - writable: true, - }) - - await expect(openTerminalWindow({})).rejects.toThrow( - 'Terminal window launching not yet supported on linux' - ) - }) - - it('should create AppleScript for macOS', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - }) - - expect(execa).toHaveBeenCalledWith('osascript', ['-e', expect.any(String)]) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - expect(applescript).toContain('tell application "Terminal"') - expect(applescript).toContain(" cd '/Users/test/workspace'") // Commands now start with space - }) - - it('should escape single quotes in paths', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: "/Users/test/workspace's/path", - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Single quotes should be escaped as '\'' within the do script string - // The full pattern is: do script " cd '/Users/test/workspace'\''s/path'" (note leading space) - expect(applescript).toContain(" cd '/Users/test/workspace'\\\\''s/path'") - }) - - it('should include environment setup when requested', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - // Mock existsSync to return true for .env files - vi.mocked(existsSync).mockImplementation((path) => { - const pathStr = String(path) - return pathStr.endsWith('.env') || pathStr.endsWith('.env.local') || - pathStr.endsWith('.env.development') || pathStr.endsWith('.env.development.local') - }) - - await openTerminalWindow({ + it('should delegate to backend.openSingle', async () => { + const options = { workspacePath: '/Users/test/workspace', - includeEnvSetup: true, - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Should contain source commands for all env files - expect(applescript).toContain('source .env') - expect(applescript).toContain('source .env.local') - expect(applescript).toContain('source .env.development') - expect(applescript).toContain('source .env.development.local') - }) - - it('should export PORT variable when provided', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - port: 3042, - includePortExport: true, - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - expect(applescript).toContain('export PORT=3042') - }) - - it('should not export PORT when includePortExport is false', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - port: 3042, - includePortExport: false, - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - expect(applescript).not.toContain('export PORT') - }) - - it('should apply background color when provided', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) + command: 'pnpm dev', + } - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - backgroundColor: { r: 128, g: 77, b: 179 }, - }) + await openTerminalWindow(options) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // 8-bit RGB (0-255) converted to 16-bit RGB (0-65535): multiply by 257 - // 128 * 257 = 32896, 77 * 257 = 19789, 179 * 257 = 46003 - expect(applescript).toContain('set background color of newTab to {32896, 19789, 46003}') + expect(mockOpenSingle).toHaveBeenCalledWith(options) }) - it('should execute command in terminal when provided', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ + it('should pass all options to backend', async () => { + const options = { workspacePath: '/Users/test/workspace', command: 'pnpm dev', - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - expect(applescript).toContain('pnpm dev') - }) - - it('should handle multi-command sequences with &&', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - // Mock existsSync to return true for .env files - vi.mocked(existsSync).mockImplementation((path) => { - const pathStr = String(path) - return pathStr.endsWith('.env') || pathStr.endsWith('.env.local') || - pathStr.endsWith('.env.development') || pathStr.endsWith('.env.development.local') - }) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - includeEnvSetup: true, + backgroundColor: { r: 128, g: 77, b: 179 }, port: 3042, + includeEnvSetup: true, includePortExport: true, - command: 'code . && pnpm dev', - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Should have all commands joined with && - expect(applescript).toContain('&&') - expect(applescript).toContain('source .env') - expect(applescript).toContain('export PORT=3042') - expect(applescript).toContain('code . && pnpm dev') - }) - - it('should activate Terminal.app after opening', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - }) - - // Should call execa twice: once for terminal creation, once for activation - expect(execa).toHaveBeenCalledTimes(2) - expect(execa).toHaveBeenNthCalledWith(2, 'osascript', [ - '-e', - 'tell application "Terminal" to activate', - ]) - }) - - it('should throw error when AppleScript fails', async () => { - vi.mocked(execa).mockRejectedValue(new Error('AppleScript execution failed')) - - await expect( - openTerminalWindow({ - workspacePath: '/Users/test/workspace', - }) - ).rejects.toThrow('Failed to open terminal window: AppleScript execution failed') - }) - - it('should escape double quotes in commands', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'echo "Hello World"', - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Double quotes should be escaped as \" - expect(applescript).toContain('echo \\"Hello World\\"') - }) + title: 'Dev Server', + } - it('should escape backslashes in commands', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) + await openTerminalWindow(options) - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'echo \\$PATH', - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Backslashes should be escaped as \\ - expect(applescript).toContain('echo \\\\$PATH') + expect(mockOpenSingle).toHaveBeenCalledWith(options) }) - it('should prefix commands with space to prevent shell history pollution', async () => { - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - }) + it('should propagate backend errors', async () => { + mockOpenSingle.mockRejectedValue(new Error('Backend failed')) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // The entire command sequence should start with a space - // This prevents commands from appearing in shell history when HISTCONTROL=ignorespace - expect(applescript).toMatch(/do script " [^"]+/) + await expect(openTerminalWindow({})).rejects.toThrow('Backend failed') }) +}) - it('should use iTerm2 when available for single terminal', async () => { - vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - }) - - // Should call osascript once (iTerm2 script includes activation) - expect(execa).toHaveBeenCalledTimes(1) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - - // Verify iTerm2 AppleScript structure - expect(applescript).toContain('tell application id "com.googlecode.iterm2"') - expect(applescript).toContain('create window with default profile') - expect(applescript).toContain('activate') - expect(applescript).not.toContain('tell application "Terminal"') +describe('openMultipleTerminalWindows', () => { + beforeEach(() => { + mockOpenMultiple.mockResolvedValue(undefined) }) - it('should set session name when title provided in iTerm2 mode', async () => { - vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - title: 'Dev Server - Issue #42', - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Verify session name is set with escaped title - expect(applescript).toContain('set name of s1 to "Dev Server - Issue #42"') + it('should require at least 2 options', async () => { + await expect( + openMultipleTerminalWindows([{ workspacePath: '/test' }]) + ).rejects.toThrow('openMultipleTerminalWindows requires at least 2 terminal options') }) - it('should apply background color in iTerm2 mode', async () => { - vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists - vi.mocked(execa).mockResolvedValue({} as unknown) + it('should delegate to backend.openMultiple', async () => { + const optionsArray = [ + { workspacePath: '/test/1', command: 'cmd1' }, + { workspacePath: '/test/2', command: 'cmd2' }, + ] - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - backgroundColor: { r: 128, g: 77, b: 179 }, - }) + await openMultipleTerminalWindows(optionsArray) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // 8-bit RGB (0-255) converted to 16-bit RGB (0-65535): multiply by 257 - // 128 * 257 = 32896, 77 * 257 = 19789, 179 * 257 = 46003 - expect(applescript).toContain('set background color of s1 to {32896, 19789, 46003}') + expect(mockOpenMultiple).toHaveBeenCalledWith(optionsArray) }) - it('should fall back to Terminal.app when iTerm2 not available', async () => { - vi.mocked(existsSync).mockReturnValue(false) // iTerm2 not available - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - }) + it('should propagate backend errors', async () => { + mockOpenMultiple.mockRejectedValue(new Error('Backend failed')) - // Should call execa twice: once for terminal creation, once for activation - expect(execa).toHaveBeenCalledTimes(2) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - - // Verify Terminal.app AppleScript is used, not iTerm2 - expect(applescript).toContain('tell application "Terminal"') - expect(applescript).not.toContain('tell application id "com.googlecode.iterm2"') - }) - - it('should handle all options in iTerm2 single-tab mode', async () => { - vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openTerminalWindow({ - workspacePath: '/Users/test/workspace', - command: 'pnpm dev', - title: 'Dev Server', - backgroundColor: { r: 128, g: 77, b: 179 }, - port: 3042, - includeEnvSetup: true, - includePortExport: true, - }) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - - // Verify all options are present in the iTerm2 script - expect(applescript).toContain('/Users/test/workspace') - expect(applescript).toContain('source .env') - expect(applescript).toContain('export PORT=3042') - expect(applescript).toContain('pnpm dev') - expect(applescript).toContain('set name of s1 to "Dev Server"') - expect(applescript).toContain('set background color of s1 to {32896, 19789, 46003}') + await expect( + openMultipleTerminalWindows([ + { command: 'cmd1' }, + { command: 'cmd2' }, + ]) + ).rejects.toThrow('Backend failed') }) }) describe('openDualTerminalWindow', () => { - const originalPlatform = process.platform - beforeEach(() => { - vi.clearAllMocks() - Object.defineProperty(process, 'platform', { - value: 'darwin', - writable: true, - }) - }) - - afterEach(() => { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - writable: true, - }) - }) - - it('should throw error on non-macOS platforms', async () => { - Object.defineProperty(process, 'platform', { - value: 'linux', - writable: true, - }) - - await expect( - openDualTerminalWindow( - { workspacePath: '/test/path1' }, - { workspacePath: '/test/path2' } - ) - ).rejects.toThrow('Terminal window launching not yet supported on linux') + mockOpenMultiple.mockResolvedValue(undefined) }) - it('should use iTerm2 when available and create single window with two tabs', async () => { - vi.mocked(existsSync).mockReturnValue(true) // iTerm2 exists - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openDualTerminalWindow( - { - workspacePath: '/Users/test/workspace1', - command: 'il spin', - title: 'Claude - Issue #42', - backgroundColor: { r: 128, g: 77, b: 179 }, - }, - { - workspacePath: '/Users/test/workspace2', - command: 'pnpm dev', - title: 'Dev Server - Issue #42', - backgroundColor: { r: 128, g: 77, b: 179 }, - port: 3042, - includePortExport: true, - } - ) - - // Should call osascript once for iTerm2 dual tab creation - expect(execa).toHaveBeenCalledWith('osascript', ['-e', expect.any(String)]) - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - - // Verify iTerm2 AppleScript structure (uses application id, not name) - expect(applescript).toContain('tell application id "com.googlecode.iterm2"') - expect(applescript).toContain('create window with default profile') - expect(applescript).toContain('create tab with default profile') - - // Verify both commands are present - expect(applescript).toContain('il spin') - expect(applescript).toContain('pnpm dev') - - // Verify both paths - expect(applescript).toContain('/Users/test/workspace1') - expect(applescript).toContain('/Users/test/workspace2') - - // Verify background colors applied to both tabs (16-bit RGB) - // 8-bit RGB (0-255) converted to 16-bit RGB (0-65535): multiply by 257 - // 128 * 257 = 32896, 77 * 257 = 19789, 179 * 257 = 46003 - expect(applescript).toContain('{32896, 19789, 46003}') - - // Verify tab titles - expect(applescript).toContain('Claude - Issue #42') - expect(applescript).toContain('Dev Server - Issue #42') - - // Verify port export in second tab - expect(applescript).toContain('export PORT=3042') - - // Verify iTerm2 is activated - expect(applescript).toContain('activate') - }) + it('should delegate to openMultipleTerminalWindows with two options', async () => { + const opts1 = { workspacePath: '/test/1', command: 'cmd1' } + const opts2 = { workspacePath: '/test/2', command: 'cmd2' } - it('should fall back to Terminal.app when iTerm2 not available', async () => { - vi.mocked(existsSync).mockReturnValue(false) // iTerm2 not available - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openDualTerminalWindow( - { - workspacePath: '/Users/test/workspace1', - command: 'il spin', - }, - { - workspacePath: '/Users/test/workspace2', - command: 'pnpm dev', - } - ) - - // Should call osascript multiple times for Terminal.app (2 windows + 2 activations) - expect(execa).toHaveBeenCalled() - const calls = vi.mocked(execa).mock.calls - - // Check that Terminal.app is used, not iTerm2 - const firstScript = calls[0][1]?.[1] as string - expect(firstScript).toContain('tell application "Terminal"') - expect(firstScript).not.toContain('tell application "iTerm2"') - }) - - it('should handle paths with single quotes in iTerm2 mode', async () => { - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openDualTerminalWindow( - { - workspacePath: "/Users/test/workspace's/path1", - command: 'echo test', - }, - { - workspacePath: "/Users/test/workspace's/path2", - command: 'echo test2', - } - ) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Single quotes should be properly escaped in both paths - expect(applescript).toContain("workspace'\\\\''s") - }) + await openDualTerminalWindow(opts1, opts2) - it('should handle environment setup in both tabs', async () => { - vi.mocked(existsSync).mockReturnValue(true) - vi.mocked(execa).mockResolvedValue({} as unknown) - - await openDualTerminalWindow( - { - workspacePath: '/Users/test/workspace1', - command: 'il spin', - includeEnvSetup: true, - }, - { - workspacePath: '/Users/test/workspace2', - command: 'pnpm dev', - includeEnvSetup: true, - } - ) - - const applescript = vi.mocked(execa).mock.calls[0][1]?.[1] as string - // Should include source commands for all dotenv-flow files in both tabs (4 files × 2 tabs = 8 occurrences) - const envCount = (applescript.match(/source \.env/g) || []).length - expect(envCount).toBe(8) + expect(mockOpenMultiple).toHaveBeenCalledWith([opts1, opts2]) }) }) diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 2f70d6f6..44ae302b 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -1,7 +1,7 @@ import { execa } from 'execa' import { existsSync } from 'node:fs' import type { Platform } from '../types/index.js' -import { buildEnvSourceCommands } from './env.js' +import { getTerminalBackend } from './terminal-backends/index.js' export interface TerminalWindowOptions { workspacePath?: string @@ -65,282 +65,21 @@ export async function detectITerm2(): Promise { } /** - * Open new terminal window with specified options - * Currently supports macOS only + * Open new terminal window with specified options. + * Supports macOS (Terminal.app/iTerm2), WSL (Windows Terminal), and Linux (gnome-terminal/konsole/xterm). */ export async function openTerminalWindow( options: TerminalWindowOptions ): Promise { - const platform = detectPlatform() - - if (platform !== 'darwin') { - throw new Error( - `Terminal window launching not yet supported on ${platform}. ` + - `Currently only macOS is supported.` - ) - } - - // Detect if iTerm2 is available - const hasITerm2 = await detectITerm2() - - // Build appropriate AppleScript based on terminal availability - const applescript = hasITerm2 - ? await buildITerm2SingleTabScript(options) - : await buildAppleScript(options) - - try { - await execa('osascript', ['-e', applescript]) - - // Activate the appropriate terminal application (only needed for Terminal.app) - // iTerm2 script includes its own activation - if (!hasITerm2) { - await execa('osascript', ['-e', 'tell application "Terminal" to activate']) - } - } catch (error) { - throw new Error( - `Failed to open terminal window: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } -} - -/** - * Build AppleScript for macOS Terminal.app - */ -async function buildAppleScript(options: TerminalWindowOptions): Promise { - const { - workspacePath, - command, - backgroundColor, - port, - includeEnvSetup, - includePortExport, - } = options - - // Build command sequence - const commands: string[] = [] - - // Navigate to workspace - if (workspacePath) { - commands.push(`cd '${escapePathForAppleScript(workspacePath)}'`) - } - - // Source all dotenv-flow files - if (includeEnvSetup && workspacePath) { - const sourceCommands = await buildEnvSourceCommands( - workspacePath, - async (p) => existsSync(p) - ) - commands.push(...sourceCommands) - } - - // Export PORT variable - if (includePortExport && port !== undefined) { - commands.push(`export PORT=${port}`) - } - - // Add custom command - if (command) { - commands.push(command) - } - - // Join with && - const fullCommand = commands.join(' && ') - - // Prefix with space to prevent shell history pollution - // Most shells (bash/zsh) ignore commands starting with space when HISTCONTROL=ignorespace - const historyFreeCommand = ` ${fullCommand}` - - // Build AppleScript - let script = `tell application "Terminal"\n` - script += ` set newTab to do script "${escapeForAppleScript(historyFreeCommand)}"\n` - - // Apply background color if provided - if (backgroundColor) { - const { r, g, b } = backgroundColor - // Convert 8-bit RGB (0-255) to 16-bit RGB (0-65535) - script += ` set background color of newTab to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` - } - - script += `end tell` - - return script -} - -/** - * Escape path for AppleScript string - * Single quotes in path need special escaping - */ -function escapePathForAppleScript(path: string): string { - // Replace single quote with '\'' - return path.replace(/'/g, "'\\''") -} - -/** - * Escape command for AppleScript do script - * Must handle double quotes and backslashes - */ -function escapeForAppleScript(command: string): string { - return ( - command - .replace(/\\/g, '\\\\') // Escape backslashes - .replace(/"/g, '\\"') // Escape double quotes - ) -} - -/** - * Build iTerm2 AppleScript for single tab - */ -async function buildITerm2SingleTabScript(options: TerminalWindowOptions): Promise { - const command = await buildCommandSequence(options) - - let script = 'tell application id "com.googlecode.iterm2"\n' - script += ' create window with default profile\n' - script += ' set s1 to current session of current window\n\n' - - // Set background color - if (options.backgroundColor) { - const { r, g, b } = options.backgroundColor - script += ` set background color of s1 to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` - } - - // Execute command - script += ` tell s1 to write text "${escapeForAppleScript(command)}"\n\n` - - // Set session name (tab title) - if (options.title) { - script += ` set name of s1 to "${escapeForAppleScript(options.title)}"\n\n` - } - - // Activate iTerm2 - script += ' activate\n' - script += 'end tell' - - return script + const backend = await getTerminalBackend() + await backend.openSingle(options) } /** - * Build command sequence for terminal - */ -async function buildCommandSequence(options: TerminalWindowOptions): Promise { - const { - workspacePath, - command, - port, - includeEnvSetup, - includePortExport, - } = options - - const commands: string[] = [] - - // Navigate to workspace - if (workspacePath) { - commands.push(`cd '${escapePathForAppleScript(workspacePath)}'`) - } - - // Source all dotenv-flow files - if (includeEnvSetup && workspacePath) { - const sourceCommands = await buildEnvSourceCommands( - workspacePath, - async (p) => existsSync(p) - ) - commands.push(...sourceCommands) - } - - // Export PORT variable - if (includePortExport && port !== undefined) { - commands.push(`export PORT=${port}`) - } - - // Add custom command - if (command) { - commands.push(command) - } - - // Join with && - const fullCommand = commands.join(' && ') - - // Prefix with space to prevent shell history pollution - return ` ${fullCommand}` -} - -/** - * Build iTerm2 AppleScript for multiple tabs (2+) in single window - */ -async function buildITerm2MultiTabScript( - optionsArray: TerminalWindowOptions[] -): Promise { - if (optionsArray.length < 2) { - throw new Error('buildITerm2MultiTabScript requires at least 2 terminal options') - } - - let script = 'tell application id "com.googlecode.iterm2"\n' - script += ' create window with default profile\n' - script += ' set newWindow to current window\n' - - // First tab - const options1 = optionsArray[0] - if (!options1) { - throw new Error('First terminal option is undefined') - } - const command1 = await buildCommandSequence(options1) - - script += ' set s1 to current session of newWindow\n\n' - - // Set background color for first tab - if (options1.backgroundColor) { - const { r, g, b } = options1.backgroundColor - script += ` set background color of s1 to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` - } - - // Execute command in first tab - script += ` tell s1 to write text "${escapeForAppleScript(command1)}"\n\n` - - // Set tab title for first tab - if (options1.title) { - script += ` set name of s1 to "${escapeForAppleScript(options1.title)}"\n\n` - } - - // Subsequent tabs (2, 3, ...) - for (let i = 1; i < optionsArray.length; i++) { - const options = optionsArray[i] - if (!options) { - throw new Error(`Terminal option at index ${i} is undefined`) - } - const command = await buildCommandSequence(options) - const sessionVar = `s${i + 1}` - - // Create tab - script += ' tell newWindow\n' - script += ` set newTab${i} to (create tab with default profile)\n` - script += ' end tell\n' - script += ` set ${sessionVar} to current session of newTab${i}\n\n` - - // Set background color - if (options.backgroundColor) { - const { r, g, b } = options.backgroundColor - script += ` set background color of ${sessionVar} to {${Math.round(r * 257)}, ${Math.round(g * 257)}, ${Math.round(b * 257)}}\n` - } - - // Execute command - script += ` tell ${sessionVar} to write text "${escapeForAppleScript(command)}"\n\n` - - // Set tab title - if (options.title) { - script += ` set name of ${sessionVar} to "${escapeForAppleScript(options.title)}"\n\n` - } - } - - // Activate iTerm2 - script += ' activate\n' - script += 'end tell' - - return script -} - -/** - * Open multiple terminal windows/tabs (2+) with specified options - * If iTerm2 is available on macOS, creates single window with multiple tabs - * Otherwise falls back to multiple separate Terminal.app windows + * Open multiple terminal windows/tabs (2+) with specified options. + * On macOS with iTerm2, creates a single window with multiple tabs. + * On WSL, creates multiple Windows Terminal tabs. + * On Linux, uses the detected terminal emulator's multi-tab support where available. */ export async function openMultipleTerminalWindows( optionsArray: TerminalWindowOptions[] @@ -349,45 +88,8 @@ export async function openMultipleTerminalWindows( throw new Error('openMultipleTerminalWindows requires at least 2 terminal options. Use openTerminalWindow for single terminal.') } - const platform = detectPlatform() - - if (platform !== 'darwin') { - throw new Error( - `Terminal window launching not yet supported on ${platform}. ` + - `Currently only macOS is supported.` - ) - } - - // Detect if iTerm2 is available - const hasITerm2 = await detectITerm2() - - if (hasITerm2) { - // Use iTerm2 with multiple tabs in single window - const applescript = await buildITerm2MultiTabScript(optionsArray) - - try { - await execa('osascript', ['-e', applescript]) - } catch (error) { - throw new Error( - `Failed to open iTerm2 window: ${error instanceof Error ? error.message : 'Unknown error'}` - ) - } - } else { - // Fall back to multiple Terminal.app windows - for (let i = 0; i < optionsArray.length; i++) { - const options = optionsArray[i] - if (!options) { - throw new Error(`Terminal option at index ${i} is undefined`) - } - await openTerminalWindow(options) - - // Brief pause between terminals (except after last one) - if (i < optionsArray.length - 1) { - // eslint-disable-next-line no-undef - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - } + const backend = await getTerminalBackend() + await backend.openMultiple(optionsArray) } /**