diff --git a/README.md b/README.md index 01f1eb72..052a07de 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ This is an early-stage product. **Requirements:** -* ✅ **OS:** macOS (Fully supported). ⚠️ Linux/Windows are untested. +* ✅ **OS:** macOS (fully supported), Linux (GUI terminals + tmux for headless), WSL (Windows Terminal via [setup guide](docs/windows-wsl-guide.md)). ⚠️ Native Windows is unsupported. * ✅ **Runtime:** Node.js 16+, Git 2.5+. @@ -726,6 +726,8 @@ Acknowledgments ---------------- - [@NoahCardoza](https://github.com/NoahCardoza) — Jira Cloud integration (PR [#588](https://github.com/iloom-ai/iloom-cli/pull/588)): JiraApiClient, JiraIssueTracker, ADF/Markdown conversion, MCP provider, sprint/mine filtering, and `il issues` Jira support. +- [@TickTockBent](https://github.com/TickTockBent) — Linux, WSL, and tmux terminal support (PR [#796](https://github.com/iloom-ai/iloom-cli/pull/796)): strategy-pattern terminal backends, GUI-to-tmux fallback for headless environments, WSL detection, and cross-platform terminal launching. +- [@rexsilex](https://github.com/rexsilex) — Original Linux/WSL terminal support design (PR [#649](https://github.com/iloom-ai/iloom-cli/pull/649)): pioneered the strategy pattern and backend interface that inspired the final implementation. License & Name -------------- diff --git a/docs/windows-wsl-guide.md b/docs/windows-wsl-guide.md new file mode 100644 index 00000000..e993ed20 --- /dev/null +++ b/docs/windows-wsl-guide.md @@ -0,0 +1,169 @@ +# iloom on Windows (WSL) + +iloom runs on Windows through **Windows Subsystem for Linux (WSL)**. It does **not** run natively in PowerShell or Command Prompt. All iloom commands must be run from inside a WSL distribution. + +## Installing iloom + +Before installing iloom, you'll need these prerequisites set up inside WSL: + +- [WSL 2](https://learn.microsoft.com/en-us/windows/wsl/install) with a Linux distribution (Ubuntu recommended) +- [Windows Terminal](https://aka.ms/terminal) (pre-installed on Windows 11) +- [Node.js 22+](https://github.com/nvm-sh/nvm#installing-and-updating) installed inside WSL (not the Windows version) +- [Git 2.5+](https://git-scm.com/) installed inside WSL +- [GitHub CLI (`gh`)](https://cli.github.com/) installed and authenticated inside WSL +- [Claude CLI](https://docs.anthropic.com/en/docs/claude-code/overview) installed inside WSL + +Then install iloom from inside your WSL terminal: + +```bash +npm install -g @iloom/cli +``` + +If you need help setting up the prerequisites, see [Setting up WSL](#setting-up-wsl) below. + +## Using VS Code with WSL + +You must open VS Code **from WSL**, not from Windows. This ensures VS Code's integrated terminal runs inside WSL where iloom is installed. + +```bash +# Navigate to your project inside WSL +cd ~/projects/my-app + +# Open VS Code from WSL — this launches VS Code with the WSL remote extension +code . +``` + +When you do this, VS Code will: +- Install the **WSL extension** automatically (first time only) +- Show "WSL: Ubuntu" (or your distro name) in the bottom-left corner +- Run its integrated terminal inside WSL +- Have access to all your WSL-installed tools (Node.js, Git, iloom, Claude CLI) + +### Do NOT open from Windows Explorer + +If you open VS Code from the Windows Start menu or by double-clicking a folder in Windows Explorer, it runs in Windows mode. The integrated terminal will be PowerShell, and iloom won't work. Always use `code .` from your WSL terminal. + +### iloom VS Code extension + +Once VS Code is open in WSL mode (via `code .` from your WSL terminal), install the iloom extension from the Extensions panel. Because VS Code is running in WSL, the extension runs inside WSL too — it has full access to iloom, Claude CLI, and your development tools. + +### Verifying your setup + +In VS Code's integrated terminal, run: + +```bash +# Should show a Linux path like /home/username/projects/my-app +pwd + +# Should show "Linux" +uname -s + +# Should work without errors +il --version +``` + +If `pwd` shows a Windows path (like `/mnt/c/Users/...`), you're accessing Windows files through WSL. While this works, it's significantly slower than using files stored natively in WSL (like `~/projects/`). For best performance, keep your projects in your WSL home directory. + +## How iloom uses Windows Terminal + +When you run `il start `, iloom detects that you're in WSL and: + +1. Launches **Windows Terminal** (`wt.exe`) to open new tabs +2. Each tab runs inside your WSL distribution automatically +3. Terminal tabs get titled with the task context (e.g., "Dev Server", "Claude") + +Your development terminals appear as native Windows Terminal tabs alongside your other terminal sessions. + +## Setting up WSL + +If you don't have the prerequisites yet, follow these steps. + +### 1. Install WSL + +Open PowerShell as Administrator and run: + +```powershell +wsl --install +``` + +This installs WSL 2 with Ubuntu by default. Restart your computer when prompted. + +### 2. Install Windows Terminal + +Install from the [Microsoft Store](https://aka.ms/terminal) if you don't have it already. On Windows 11, it's pre-installed. + +### 3. Set up your WSL environment + +Open your WSL terminal (Ubuntu) and install the tools: + +```bash +# Update packages +sudo apt update && sudo apt upgrade -y + +# Install Node.js (using nvm) +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash +source ~/.bashrc +nvm install 22 + +# Install GitHub CLI +(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \ + && sudo mkdir -p -m 755 /etc/apt/keyrings \ + && out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && sudo apt update \ + && sudo apt install gh -y + +# Authenticate with GitHub +gh auth login + +# Install Claude CLI +npm install -g @anthropic-ai/claude-code + +# Verify everything +node --version # Should be 22+ +git --version # Should be 2.5+ +gh --version # Should show gh version +claude --version # Should show Claude CLI version +``` + +## Troubleshooting + +### "Windows Terminal (wt.exe) is not available" + +Install Windows Terminal from the [Microsoft Store](https://aka.ms/terminal). It's required for iloom to open terminal tabs from WSL. + +### iloom commands not found + +Make sure you installed iloom inside WSL, not in Windows PowerShell: + +```bash +# Run this inside WSL +which il +# Should show something like /home/username/.nvm/versions/node/v22.x.x/bin/il +``` + +### VS Code not detecting WSL + +Install the [WSL extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) for VS Code. Then always open projects with `code .` from your WSL terminal. + +### Slow file access + +If you're working with files on the Windows filesystem (paths starting with `/mnt/c/`), file operations will be slow due to the WSL-Windows filesystem bridge. Move your projects to your WSL home directory for much better performance: + +```bash +# Move project to WSL native filesystem +cp -r /mnt/c/Users/you/projects/my-app ~/projects/my-app +cd ~/projects/my-app +``` + +### tmux fallback + +If Windows Terminal is unavailable for some reason, iloom can fall back to **tmux** (a terminal multiplexer). Install it with: + +```bash +sudo apt install tmux +``` + +iloom will automatically use tmux when no GUI terminal is available (e.g., in SSH sessions or Docker containers). diff --git a/src/utils/platform-detect.test.ts b/src/utils/platform-detect.test.ts new file mode 100644 index 00000000..86027715 --- /dev/null +++ b/src/utils/platform-detect.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { isWSL, detectTerminalEnvironment, detectWSLDistro, _resetWSLCache } from './platform-detect.js' + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + +import { readFileSync } from 'node:fs' + +describe('platform-detect', () => { + const originalPlatform = process.platform + const originalEnv = { ...process.env } + + beforeEach(() => { + vi.clearAllMocks() + _resetWSLCache() + process.env = { ...originalEnv } + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }) + process.env = originalEnv + }) + + describe('isWSL', () => { + it('should return false on non-linux platforms', () => { + Object.defineProperty(process, 'platform', { value: 'darwin', writable: true }) + expect(isWSL()).toBe(false) + }) + + it('should return true when WSL_DISTRO_NAME is set', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + process.env.WSL_DISTRO_NAME = 'Ubuntu' + expect(isWSL()).toBe(true) + }) + + it('should fall back to /proc/version check', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue('Linux version 5.15.0 (microsoft-standard-WSL2)') + expect(isWSL()).toBe(true) + }) + + it('should return false when not WSL', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue('Linux version 6.8.0-90-generic') + expect(isWSL()).toBe(false) + }) + + it('should return false when /proc/version is not found (ENOENT)', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException + err.code = 'ENOENT' + vi.mocked(readFileSync).mockImplementation(() => { throw err }) + expect(isWSL()).toBe(false) + }) + + it('should return false when /proc/version throws an unexpected error', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockImplementation(() => { throw new Error('unexpected failure') }) + expect(isWSL()).toBe(false) + }) + + it('should cache the result', () => { + Object.defineProperty(process, 'platform', { value: 'linux', writable: true }) + delete process.env.WSL_DISTRO_NAME + vi.mocked(readFileSync).mockReturnValue('Linux version 6.8.0-90-generic') + + isWSL() + isWSL() + + expect(readFileSync).toHaveBeenCalledTimes(1) + }) + }) + + 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 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.8.0-90-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 the distro name from env', () => { + process.env.WSL_DISTRO_NAME = 'Ubuntu-22.04' + expect(detectWSLDistro()).toBe('Ubuntu-22.04') + }) + + it('should return undefined when not set', () => { + delete process.env.WSL_DISTRO_NAME + expect(detectWSLDistro()).toBeUndefined() + }) + + it('should return undefined for empty string', () => { + process.env.WSL_DISTRO_NAME = '' + expect(detectWSLDistro()).toBeUndefined() + }) + }) +}) diff --git a/src/utils/platform-detect.ts b/src/utils/platform-detect.ts new file mode 100644 index 00000000..35b9e9cc --- /dev/null +++ b/src/utils/platform-detect.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs' + +/** + * 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 + } + + 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 (error: unknown) { + // /proc/version not found — not WSL + if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT') { + cachedIsWSL = false + return false + } + // Unexpected error — assume not WSL + 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 { + const distro = process.env.WSL_DISTRO_NAME + // Empty string means unset; nullish coalescing won't catch it + if (!distro) return undefined + return distro +} + +/** + * 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..d4d68d99 --- /dev/null +++ b/src/utils/terminal-backends/command-builder.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest' +import { buildCommandSequence, escapeSingleQuotes, rgbToHex } from './command-builder.js' + +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})) + +describe('command-builder', () => { + describe('escapeSingleQuotes', () => { + it('should escape single quotes', () => { + expect(escapeSingleQuotes("it's")).toBe("it'\\''s") + }) + + it('should handle strings without single quotes', () => { + expect(escapeSingleQuotes('hello')).toBe('hello') + }) + + it('should handle multiple single quotes', () => { + expect(escapeSingleQuotes("it's a 'test'")).toBe("it'\\''s a '\\''test'\\''") + }) + }) + + describe('rgbToHex', () => { + it('should convert RGB to hex', () => { + expect(rgbToHex({ r: 255, g: 0, b: 128 })).toBe('#ff0080') + }) + + it('should pad single-digit hex values', () => { + expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000') + }) + + it('should clamp values to 0-255', () => { + expect(rgbToHex({ r: 300, g: -10, b: 128 })).toBe('#ff0080') + }) + }) + + describe('buildCommandSequence', () => { + it('should prefix with space for history suppression', async () => { + const result = await buildCommandSequence({ command: 'echo hello' }) + expect(result).toBe(' echo hello') + }) + + it('should build cd command for workspace path', async () => { + const result = await buildCommandSequence({ workspacePath: '/test/path' }) + expect(result).toContain("cd '/test/path'") + }) + + it('should escape single quotes in workspace path', async () => { + const result = await buildCommandSequence({ workspacePath: "/test/it's" }) + expect(result).toContain("cd '/test/it'\\''s'") + }) + + it('should include port export', async () => { + const result = await buildCommandSequence({ port: 3000, includePortExport: true }) + expect(result).toContain('export PORT=3000') + }) + + it('should not include port when includePortExport is false', async () => { + const result = await buildCommandSequence({ port: 3000, includePortExport: false }) + expect(result).not.toContain('export PORT') + }) + + it('should chain commands with &&', async () => { + const result = await buildCommandSequence({ + workspacePath: '/test', + command: 'pnpm dev', + port: 3000, + includePortExport: true, + }) + expect(result).toContain(' && ') + expect(result).toContain("cd '/test'") + expect(result).toContain('export PORT=3000') + expect(result).toContain('pnpm dev') + }) + + it('should return space-only for empty options', async () => { + const result = await buildCommandSequence({}) + expect(result).toBe(' ') + }) + }) +}) 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.ts b/src/utils/terminal-backends/darwin.ts new file mode 100644 index 00000000..5b01b9af --- /dev/null +++ b/src/utils/terminal-backends/darwin.ts @@ -0,0 +1,186 @@ +import { execa } from 'execa' +import { existsSync } from 'node:fs' +import type { TerminalWindowOptions } from '../terminal.js' +import type { TerminalBackend } from './types.js' +import { buildCommandSequence } from './command-builder.js' + +/** + * Detect if iTerm2 is installed on macOS. + */ +export function detectITerm2(): boolean { + return existsSync('/Applications/iTerm.app') +} + +/** + * Escape command string for embedding inside an AppleScript `do script "..."`. + */ +function escapeForAppleScript(command: string): string { + return command + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') +} + +/** + * Build AppleScript for macOS Terminal.app (single tab). + * + * Delegates to the shared buildCommandSequence for command construction, + * then wraps the result with AppleScript escaping for `do script "..."`. + */ +async function buildTerminalAppScript(options: TerminalWindowOptions): Promise { + const command = await buildCommandSequence(options) + + let script = `tell application "Terminal"\n` + script += ` set newTab to do script "${escapeForAppleScript(command)}"\n` + + if (options.backgroundColor) { + const { r, g, b } = options.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' + + 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` + } + + 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]) + + 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 { + 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) + + 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..7309d19d --- /dev/null +++ b/src/utils/terminal-backends/index.ts @@ -0,0 +1,61 @@ +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) with TmuxBackend fallback + * + * On Linux, if no GUI terminal emulator is detected (headless SSH, Docker, + * Code Server, etc.), falls back to tmux automatically. + * + * Throws a descriptive error on unsupported platforms or when no backend is available. + */ +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': { + // Only try GUI terminals if a display server is available. + // A terminal emulator like konsole may be installed but unusable + // without X11/Wayland (e.g., SSH, Docker, Code Server). + const hasDisplay = !!(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY) + + if (hasDisplay) { + const { detectLinuxTerminal } = await import('./linux.js') + if (await detectLinuxTerminal()) { + const { LinuxBackend } = await import('./linux.js') + return new LinuxBackend() + } + } + + // Fall back to tmux for headless environments + const { isTmuxAvailable, TmuxBackend } = await import('./tmux.js') + if (await isTmuxAvailable()) { + return new TmuxBackend() + } + + throw new Error( + 'No supported terminal found on Linux. ' + + 'Install tmux for headless environments, or set DISPLAY and install a GUI terminal (gnome-terminal, konsole, xterm).' + ) + } + default: + throw new Error( + `Terminal window launching is not supported on ${env}. ` + + 'Supported platforms: macOS, WSL (Windows Terminal), Linux (GUI terminals or tmux).' + ) + } +} diff --git a/src/utils/terminal-backends/linux.ts b/src/utils/terminal-backends/linux.ts new file mode 100644 index 00000000..9f494982 --- /dev/null +++ b/src/utils/terminal-backends/linux.ts @@ -0,0 +1,134 @@ +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 GUI terminal emulators in preference order. + */ +const TERMINAL_EMULATORS = ['gnome-terminal', 'konsole', 'xterm'] as const +type LinuxTerminal = (typeof TERMINAL_EMULATORS)[number] + +/** + * Detect which GUI terminal emulator is available on the system. + * Checks in preference order: gnome-terminal, konsole, xterm. + * Returns null if none are found (headless environment). + */ +export async function detectLinuxTerminal(): Promise { + for (const terminal of TERMINAL_EMULATORS) { + try { + await execa('which', [terminal]) + return terminal + } catch (error) { + // `which` exits with code 1 when the command is not found + if (error instanceof Error && 'exitCode' in error) { + continue + } + throw error + } + } + return null +} + +/** + * Native Linux GUI terminal backend. + * Supports gnome-terminal (with tabs), konsole, and xterm (fallback). + * + * 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 this.resolveTerminal() + await this.openSingleWithTerminal(options, terminal) + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): Promise { + const terminal = await this.resolveTerminal() + + // gnome-terminal --tab adds a tab to the most recently focused window. + // Opening sequentially achieves multi-tab behavior reliably without the + // `--` option parsing issue that breaks multi-tab in a single invocation + // (gnome-terminal's `--` terminates ALL option parsing, so subsequent + // --tab flags would be passed to bash as arguments). + 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.openSingleWithTerminal(options, terminal) + } + } + + private async resolveTerminal(): Promise { + const terminal = await detectLinuxTerminal() + if (!terminal) { + throw new Error( + 'No supported GUI terminal emulator found. ' + + 'Install gnome-terminal, konsole, or xterm — or use tmux for headless environments.' + ) + } + return terminal + } + + private async openSingleWithTerminal( + options: TerminalWindowOptions, + terminal: LinuxTerminal + ): Promise { + if (options.backgroundColor) { + logger.debug( + 'Terminal background colors are not supported via CLI on Linux terminal emulators.' + ) + } + + const shellCommand = (await buildCommandSequence(options)).trim() + const keepAliveCommand = shellCommand ? `${shellCommand}; exec bash` : 'exec bash' + + await this.execTerminal(terminal, keepAliveCommand, options.title) + } + + 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'}` + ) + } + } +} diff --git a/src/utils/terminal-backends/tmux.test.ts b/src/utils/terminal-backends/tmux.test.ts new file mode 100644 index 00000000..a77992c7 --- /dev/null +++ b/src/utils/terminal-backends/tmux.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { execa } from 'execa' +import { TmuxBackend, isTmuxAvailable } from './tmux.js' + +vi.mock('execa') +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false), +})) +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + success: vi.fn(), + }, +})) + +describe('tmux backend', () => { + describe('isTmuxAvailable', () => { + it('should return true when tmux is found', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'which' && args?.[0] === 'tmux') { + return {} as never + } + throw new Error('not found') + }) + + expect(await isTmuxAvailable()).toBe(true) + }) + + it('should return false when tmux is not found', async () => { + vi.mocked(execa).mockRejectedValue( + Object.assign(new Error('not found'), { exitCode: 1 }) + ) + + expect(await isTmuxAvailable()).toBe(false) + }) + }) + + describe('TmuxBackend', () => { + let backend: TmuxBackend + + beforeEach(() => { + vi.clearAllMocks() + backend = new TmuxBackend() + }) + + describe('openSingle', () => { + it('should create a new tmux session when no iloom session exists', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'list-sessions') { + throw Object.assign(new Error('no server running'), { exitCode: 1 }) + } + if (cmd === 'tmux' && args?.[0] === 'new-session') { + return {} as never + } + return {} as never + }) + + await backend.openSingle({ + command: 'pnpm dev', + title: 'Dev Server', + }) + + const newSessionCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'tmux' && (c[1] as string[])?.[0] === 'new-session' + ) + expect(newSessionCall).toBeDefined() + const args = newSessionCall![1] as string[] + expect(args).toContain('-d') + expect(args).toContain('-s') + expect(args).toContain('-n') + expect(args).toContain('iloom-Dev-Server') + }) + + it('should add a window to existing iloom session', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'list-sessions') { + return { stdout: 'iloom-test\nother-session' } as never + } + if (cmd === 'tmux' && args?.[0] === 'new-window') { + return {} as never + } + return {} as never + }) + + await backend.openSingle({ + command: 'pnpm dev', + title: 'Dev Server', + }) + + const newWindowCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'tmux' && (c[1] as string[])?.[0] === 'new-window' + ) + expect(newWindowCall).toBeDefined() + const args = newWindowCall![1] as string[] + expect(args).toContain('-t') + expect(args).toContain('iloom-test') + }) + + it('should use bash as default when no command provided', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'list-sessions') { + throw Object.assign(new Error('no server running'), { exitCode: 1 }) + } + return {} as never + }) + + await backend.openSingle({}) + + const newSessionCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'tmux' && (c[1] as string[])?.[0] === 'new-session' + ) + expect(newSessionCall).toBeDefined() + const args = newSessionCall![1] as string[] + expect(args[args.length - 1]).toBe('bash') + }) + + it('should throw when tmux command fails', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'list-sessions') { + throw Object.assign(new Error('no server running'), { exitCode: 1 }) + } + if (cmd === 'tmux' && args?.[0] === 'new-session') { + throw new Error('tmux error') + } + return {} as never + }) + + await expect(backend.openSingle({ command: 'test' })).rejects.toThrow( + 'Failed to create tmux session' + ) + }) + }) + + describe('openMultiple', () => { + it('should create session with first window then add remaining', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'has-session') { + throw Object.assign(new Error('no such session'), { exitCode: 1 }) + } + return {} as never + }) + + await backend.openMultiple([ + { command: 'cmd1', title: 'Window 1' }, + { command: 'cmd2', title: 'Window 2' }, + { command: 'cmd3', title: 'Window 3' }, + ]) + + const tmuxCalls = vi.mocked(execa).mock.calls.filter(c => c[0] === 'tmux') + + // has-session check + new-session + 2 new-window + const newSessionCalls = tmuxCalls.filter(c => (c[1] as string[])?.[0] === 'new-session') + const newWindowCalls = tmuxCalls.filter(c => (c[1] as string[])?.[0] === 'new-window') + + expect(newSessionCalls).toHaveLength(1) + expect(newWindowCalls).toHaveLength(2) + }) + + it('should handle empty options array', async () => { + await backend.openMultiple([]) + // Should not throw, just return + expect(execa).not.toHaveBeenCalledWith('tmux', expect.arrayContaining(['new-session'])) + }) + + it('should avoid session name collisions', async () => { + vi.mocked(execa).mockImplementation(async (cmd: string, args?: readonly string[]) => { + if (cmd === 'tmux' && args?.[0] === 'has-session') { + return {} as never // session exists + } + return {} as never + }) + + await backend.openMultiple([ + { command: 'cmd1', title: 'Test' }, + { command: 'cmd2', title: 'Test 2' }, + ]) + + const newSessionCall = vi.mocked(execa).mock.calls.find( + c => c[0] === 'tmux' && (c[1] as string[])?.[0] === 'new-session' + ) + expect(newSessionCall).toBeDefined() + const args = newSessionCall![1] as string[] + const sessionNameIndex = args.indexOf('-s') + 1 + // Should have timestamp suffix to avoid collision + expect(args[sessionNameIndex]).toMatch(/iloom-Test-\d+/) + }) + }) + }) +}) diff --git a/src/utils/terminal-backends/tmux.ts b/src/utils/terminal-backends/tmux.ts new file mode 100644 index 00000000..41bd963a --- /dev/null +++ b/src/utils/terminal-backends/tmux.ts @@ -0,0 +1,211 @@ +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' + +/** + * Check if tmux is available on the system. + */ +export async function isTmuxAvailable(): Promise { + try { + await execa('which', ['tmux']) + return true + } catch (error) { + // `which` exits with code 1 when the command is not found + if (error instanceof Error && 'exitCode' in error) { + return false + } + throw error + } +} + +/** + * Generate a tmux session name from an iloom window title. + * Strips characters tmux doesn't allow in session names (dots and colons). + */ +function sanitizeSessionName(title: string): string { + return title + .replace(/[.:]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .substring(0, 64) +} + +/** + * Generate a tmux window name from an iloom terminal title. + */ +function sanitizeWindowName(title: string): string { + return title + .replace(/[.:]/g, '-') + .substring(0, 32) +} + +/** + * Check if a tmux session already exists. + */ +async function sessionExists(sessionName: string): Promise { + try { + await execa('tmux', ['has-session', '-t', sessionName]) + return true + } catch (error) { + // `tmux has-session` exits with code 1 when the session doesn't exist + if (error instanceof Error && 'exitCode' in error) { + return false + } + throw error + } +} + +/** + * tmux backend for headless Linux environments. + * + * Used when no GUI terminal emulator is available (SSH sessions, Docker + * containers, Code Server, CI environments). Creates detached tmux sessions + * with named windows for each terminal. + * + * Background colors are not supported in tmux via this backend. + */ +export class TmuxBackend implements TerminalBackend { + readonly name = 'tmux' + + async openSingle(options: TerminalWindowOptions): Promise { + const shellCommand = (await buildCommandSequence(options)).trim() + // Keep the shell alive after the command exits so users can see output + // and interact. Mirrors the `; exec bash` pattern in the Linux GUI backend. + const command = shellCommand ? `${shellCommand}; exec bash` : 'bash' + + if (options.backgroundColor) { + logger.debug('Terminal background colors are not supported in tmux sessions.') + } + + const sessionName = options.title + ? sanitizeSessionName(`iloom-${options.title}`) + : `iloom-${Date.now()}` + + const windowName = options.title + ? sanitizeWindowName(options.title) + : 'main' + + // Check for an existing iloom session to add a window to + const iloomSession = await this.findIloomSession() + + if (iloomSession) { + // Add a new window to the existing iloom session + const args = ['new-window', '-t', iloomSession, '-n', windowName, 'bash', '-lic', command] + try { + await execa('tmux', args) + logger.info(`Added tmux window "${windowName}" to session "${iloomSession}"`) + } catch (error) { + throw new Error( + `Failed to add tmux window: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } else { + // Create a new detached session + const args = ['new-session', '-d', '-s', sessionName, '-n', windowName, 'bash', '-lic', command] + try { + await execa('tmux', args) + logger.info(`Created tmux session "${sessionName}" — attach with: tmux attach -t ${sessionName}`) + } catch (error) { + throw new Error( + `Failed to create tmux session: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): Promise { + if (optionsArray.length === 0) return + + const firstOptions = optionsArray[0] + if (!firstOptions) { + throw new Error('First terminal option is undefined') + } + + // Derive session name from the first window's title or generate one + const sessionName = firstOptions.title + ? sanitizeSessionName(`iloom-${firstOptions.title}`) + : `iloom-${Date.now()}` + + // Avoid collision with existing session + const finalSessionName = await sessionExists(sessionName) + ? `${sessionName}-${Date.now()}` + : sessionName + + if (firstOptions.backgroundColor) { + logger.debug('Terminal background colors are not supported in tmux sessions.') + } + + // Create the session with the first window + const firstShellCommand = (await buildCommandSequence(firstOptions)).trim() + const firstCommand = firstShellCommand ? `${firstShellCommand}; exec bash` : 'bash' + const firstName = firstOptions.title + ? sanitizeWindowName(firstOptions.title) + : 'window-1' + + try { + await execa('tmux', [ + 'new-session', '-d', '-s', finalSessionName, '-n', firstName, + 'bash', '-lic', firstCommand, + ]) + } catch (error) { + throw new Error( + `Failed to create tmux session: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + + // Add remaining windows + for (let i = 1; i < optionsArray.length; i++) { + const options = optionsArray[i] + if (!options) { + throw new Error(`Terminal option at index ${i} is undefined`) + } + + if (options.backgroundColor) { + logger.debug('Terminal background colors are not supported in tmux sessions.') + } + + const shellCommand = (await buildCommandSequence(options)).trim() + const command = shellCommand ? `${shellCommand}; exec bash` : 'bash' + const windowName = options.title + ? sanitizeWindowName(options.title) + : `window-${i + 1}` + + try { + await execa('tmux', [ + 'new-window', '-t', finalSessionName, '-n', windowName, + 'bash', '-lic', command, + ]) + } catch (error) { + throw new Error( + `Failed to add tmux window "${windowName}": ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + logger.info( + `Created tmux session "${finalSessionName}" with ${optionsArray.length} windows — ` + + `attach with: tmux attach -t ${finalSessionName}` + ) + } + + /** + * Look for an existing iloom tmux session to add windows to. + * Returns the session name if found, null otherwise. + */ + private async findIloomSession(): Promise { + try { + const result = await execa('tmux', ['list-sessions', '-F', '#{session_name}']) + const sessions = result.stdout.split('\n').filter(Boolean) + return sessions.find(s => s.startsWith('iloom-')) ?? null + } catch (error) { + // `tmux list-sessions` exits with code 1 when no server is running + if (error instanceof Error && 'exitCode' in error) { + return null + } + throw error + } + } +} diff --git a/src/utils/terminal-backends/types.ts b/src/utils/terminal-backends/types.ts new file mode 100644 index 00000000..7fe3a044 --- /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 (or tmux for headless). + */ +export interface TerminalBackend { + readonly name: string + openSingle(options: TerminalWindowOptions): Promise + openMultiple(optionsArray: TerminalWindowOptions[]): Promise +} diff --git a/src/utils/terminal-backends/wsl.ts b/src/utils/terminal-backends/wsl.ts new file mode 100644 index 00000000..79eb3363 --- /dev/null +++ b/src/utils/terminal-backends/wsl.ts @@ -0,0 +1,103 @@ +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' + +/** + * Build wt.exe arguments for a single tab. + * + * Uses `wsl.exe -d -e bash -lic ""` so the tab runs + * inside the correct WSL distribution with the user's profile 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)) + } + + 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 rawCommand = (await buildCommandSequence(options)).trim() + const shellCommand = rawCommand ? `${rawCommand}; exec bash` : 'exec bash' + 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 rawCommand = (await buildCommandSequence(options)).trim() + const shellCommand = rawCommand ? `${rawCommand}; exec bash` : 'exec bash' + const tabArgs = buildTabArgs(shellCommand, options, distro) + + if (i > 0) { + 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..f7d2af19 100644 --- a/src/utils/terminal.test.ts +++ b/src/utils/terminal.test.ts @@ -145,14 +145,19 @@ describe('openTerminalWindow', () => { }) }) - it('should throw error on non-macOS platforms', async () => { + it('should delegate to backend on non-macOS platforms', async () => { Object.defineProperty(process, 'platform', { value: 'linux', writable: true, }) + // On Linux with no GUI terminal or tmux, it should throw a descriptive error. + // Simulate execa's behavior: `which` exits with code 1 when command not found. + const notFoundError = Object.assign(new Error('not found'), { exitCode: 1 }) + vi.mocked(execa).mockRejectedValue(notFoundError) + await expect(openTerminalWindow({})).rejects.toThrow( - 'Terminal window launching not yet supported on linux' + 'No supported terminal found on Linux' ) }) @@ -459,18 +464,23 @@ describe('openDualTerminalWindow', () => { }) }) - it('should throw error on non-macOS platforms', async () => { + it('should delegate to backend on non-macOS platforms', async () => { Object.defineProperty(process, 'platform', { value: 'linux', writable: true, }) + // On Linux with no GUI terminal or tmux, it should throw a descriptive error. + // Simulate execa's behavior: `which` exits with code 1 when command not found. + const notFoundError = Object.assign(new Error('not found'), { exitCode: 1 }) + vi.mocked(execa).mockRejectedValue(notFoundError) + await expect( openDualTerminalWindow( { workspacePath: '/test/path1' }, { workspacePath: '/test/path2' } ) - ).rejects.toThrow('Terminal window launching not yet supported on linux') + ).rejects.toThrow('No supported terminal found on Linux') }) it('should use iTerm2 when available and create single window with two tabs', async () => { diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 2f70d6f6..bc13dda6 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -1,7 +1,8 @@ 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' +import { detectITerm2 as darwinDetectITerm2 } from './terminal-backends/darwin.js' +import { detectTerminalEnvironment } from './platform-detect.js' export interface TerminalWindowOptions { workspacePath?: string @@ -14,14 +15,15 @@ export interface TerminalWindowOptions { } /** - * Detect current platform + * Detect current platform. + * + * Delegates to detectTerminalEnvironment() from platform-detect.ts, + * mapping 'wsl' back to 'linux' to preserve the Platform return type. */ export function detectPlatform(): Platform { - const platform = process.platform - if (platform === 'darwin') return 'darwin' - if (platform === 'linux') return 'linux' - if (platform === 'win32') return 'win32' - return 'unsupported' + const env = detectTerminalEnvironment() + if (env === 'wsl') return 'linux' + return env } /** @@ -53,294 +55,33 @@ export async function detectDarkMode(): Promise { } /** - * Detect if iTerm2 is installed on macOS - * Returns false on non-macOS platforms + * Detect if iTerm2 is installed on macOS. + * Returns false on non-macOS platforms. + * + * Delegates to the canonical implementation in darwin.ts. */ export async function detectITerm2(): Promise { - const platform = detectPlatform() - if (platform !== 'darwin') return false - - // Check if iTerm.app exists at standard location - return existsSync('/Applications/iTerm.app') + if (detectPlatform() !== 'darwin') return false + return darwinDetectITerm2() } /** - * 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), + * Linux GUI terminals (gnome-terminal/konsole/xterm), and tmux for headless. */ 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 -} - -/** - * 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}` + const backend = await getTerminalBackend() + await backend.openSingle(options) } /** - * 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 or tmux. */ export async function openMultipleTerminalWindows( optionsArray: TerminalWindowOptions[] @@ -349,45 +90,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) } /** @@ -399,6 +103,5 @@ export async function openDualTerminalWindow( options1: TerminalWindowOptions, options2: TerminalWindowOptions ): Promise { - // Delegate to openMultipleTerminalWindows for consistency await openMultipleTerminalWindows([options1, options2]) }