From ede33eee7db83dc8762f779166382dfc91d76c0a Mon Sep 17 00:00:00 2001 From: TickTockBent Date: Thu, 26 Feb 2026 11:12:07 -0500 Subject: [PATCH 1/6] feat: add Linux, WSL, and tmux terminal backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor terminal launching into pluggable backends using a strategy pattern, enabling cross-platform support. The public API (openTerminalWindow, openMultipleTerminalWindows) is unchanged — backends are selected automatically based on the detected platform. Backends: - darwin: Refactored existing macOS code (Terminal.app + iTerm2) - linux: GUI terminals (gnome-terminal, konsole, xterm) - wsl: Windows Terminal via wt.exe - tmux: Headless fallback for SSH, Docker, Code Server, CI On Linux, the factory tries GUI terminals first, then falls back to tmux automatically when no GUI is available. This unblocks `il start` on headless Linux environments where the previous "not yet supported" error made iloom unusable. Fixes gnome-terminal multi-tab by using sequential openSingle calls (gnome-terminal --tab adds to the focused window) instead of a single invocation where `--` terminates all option parsing. Based on the architecture from #649 by @rexsilex. Resolves #795. Co-Authored-By: Claude Opus 4.6 --- src/utils/platform-detect.test.ts | 116 +++++++ src/utils/platform-detect.ts | 77 +++++ .../terminal-backends/command-builder.test.ts | 81 +++++ .../terminal-backends/command-builder.ts | 65 ++++ src/utils/terminal-backends/darwin.ts | 220 ++++++++++++ src/utils/terminal-backends/index.ts | 55 +++ src/utils/terminal-backends/linux.ts | 125 +++++++ src/utils/terminal-backends/tmux.test.ts | 190 +++++++++++ src/utils/terminal-backends/tmux.ts | 196 +++++++++++ src/utils/terminal-backends/types.ts | 13 + src/utils/terminal-backends/wsl.ts | 101 ++++++ src/utils/terminal.test.ts | 14 +- src/utils/terminal.ts | 323 +----------------- 13 files changed, 1261 insertions(+), 315 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.ts create mode 100644 src/utils/terminal-backends/index.ts create mode 100644 src/utils/terminal-backends/linux.ts create mode 100644 src/utils/terminal-backends/tmux.test.ts create mode 100644 src/utils/terminal-backends/tmux.ts create mode 100644 src/utils/terminal-backends/types.ts create mode 100644 src/utils/terminal-backends/wsl.ts diff --git a/src/utils/platform-detect.test.ts b/src/utils/platform-detect.test.ts new file mode 100644 index 00000000..e24576dc --- /dev/null +++ b/src/utils/platform-detect.test.ts @@ -0,0 +1,116 @@ +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 unreadable', () => { + 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 }) + 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..c5211615 --- /dev/null +++ b/src/utils/platform-detect.ts @@ -0,0 +1,77 @@ +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 { + 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..8227e528 --- /dev/null +++ b/src/utils/terminal-backends/darwin.ts @@ -0,0 +1,220 @@ +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. + */ +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). + * + * Note: Terminal.app builds its own command sequence instead of using the shared + * buildCommandSequence because AppleScript requires different escaping for paths + * inside `do script` vs shell strings. + */ +async function buildTerminalAppScript(options: TerminalWindowOptions): Promise { + const { + workspacePath, + command, + backgroundColor, + 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(' && ') + 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' + + 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..88b22f27 --- /dev/null +++ b/src/utils/terminal-backends/index.ts @@ -0,0 +1,55 @@ +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': { + // Try GUI terminals first + 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 a GUI terminal (gnome-terminal, konsole, xterm) or tmux for headless environments.' + ) + } + 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..40a88af1 --- /dev/null +++ b/src/utils/terminal-backends/linux.ts @@ -0,0 +1,125 @@ +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 { + // not found, try next + } + } + 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 detectLinuxTerminal() + if (!terminal) { + throw new Error( + 'No supported GUI terminal emulator found. ' + + 'Install gnome-terminal, konsole, or xterm — or use tmux for headless environments.' + ) + } + + 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) + } + + async openMultiple(optionsArray: TerminalWindowOptions[]): 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.' + ) + } + + // gnome-terminal --tab adds a tab to the most recently focused window. + // Calling openSingle 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.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'}` + ) + } + } +} diff --git a/src/utils/terminal-backends/tmux.test.ts b/src/utils/terminal-backends/tmux.test.ts new file mode 100644 index 00000000..b1315339 --- /dev/null +++ b/src/utils/terminal-backends/tmux.test.ts @@ -0,0 +1,190 @@ +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(new Error('not found')) + + 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 new Error('no server running') + } + 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('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 new Error('no server running') + } + 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 new Error('no server running') + } + 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 new Error('no such session') + } + 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..17be0ca2 --- /dev/null +++ b/src/utils/terminal-backends/tmux.ts @@ -0,0 +1,196 @@ +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 { + return false + } +} + +/** + * 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 { + return false + } +} + +/** + * 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() + const command = shellCommand || 'bash' + + if (options.backgroundColor) { + logger.debug('Terminal background colors are not supported in tmux sessions.') + } + + const sessionName = options.title + ? sanitizeSessionName(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 firstCommand = (await buildCommandSequence(firstOptions)).trim() || '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 command = (await buildCommandSequence(options)).trim() || '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 { + // No tmux server running or no sessions + return null + } + } +} 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..dfd4437b --- /dev/null +++ b/src/utils/terminal-backends/wsl.ts @@ -0,0 +1,101 @@ +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 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) { + 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..642f0b54 100644 --- a/src/utils/terminal.test.ts +++ b/src/utils/terminal.test.ts @@ -145,14 +145,17 @@ 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 + vi.mocked(execa).mockRejectedValue(new Error('not found')) + await expect(openTerminalWindow({})).rejects.toThrow( - 'Terminal window launching not yet supported on linux' + 'No supported terminal found on Linux' ) }) @@ -459,18 +462,21 @@ 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 + vi.mocked(execa).mockRejectedValue(new Error('not found')) + 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..0dc1901c 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 @@ -60,287 +60,26 @@ 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') } /** - * 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 + 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 or tmux. */ 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) } /** @@ -399,6 +101,5 @@ export async function openDualTerminalWindow( options1: TerminalWindowOptions, options2: TerminalWindowOptions ): Promise { - // Delegate to openMultipleTerminalWindows for consistency await openMultipleTerminalWindows([options1, options2]) } From 439a3b05bc3022b04c77dfefe8d5a527b72688fa Mon Sep 17 00:00:00 2001 From: TickTockBent Date: Thu, 26 Feb 2026 11:32:47 -0500 Subject: [PATCH 2/6] fix: check DISPLAY before GUI terminals and keep tmux sessions alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip GUI terminal detection on Linux when no display server (X11/Wayland) is available — prevents crashes like konsole SIGABRT in headless environments (SSH, Docker, Code Server). Add `; exec bash` keep-alive to tmux backend so sessions persist after the command exits, matching the Linux GUI backend pattern. Co-Authored-By: Claude Opus 4.6 --- src/utils/terminal-backends/index.ts | 18 ++++++++++++------ src/utils/terminal-backends/tmux.ts | 10 +++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/utils/terminal-backends/index.ts b/src/utils/terminal-backends/index.ts index 88b22f27..7309d19d 100644 --- a/src/utils/terminal-backends/index.ts +++ b/src/utils/terminal-backends/index.ts @@ -28,11 +28,17 @@ export async function getTerminalBackend(): Promise { return new WSLBackend() } case 'linux': { - // Try GUI terminals first - const { detectLinuxTerminal } = await import('./linux.js') - if (await detectLinuxTerminal()) { - const { LinuxBackend } = await import('./linux.js') - return new LinuxBackend() + // 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 @@ -43,7 +49,7 @@ export async function getTerminalBackend(): Promise { throw new Error( 'No supported terminal found on Linux. ' + - 'Install a GUI terminal (gnome-terminal, konsole, xterm) or tmux for headless environments.' + 'Install tmux for headless environments, or set DISPLAY and install a GUI terminal (gnome-terminal, konsole, xterm).' ) } default: diff --git a/src/utils/terminal-backends/tmux.ts b/src/utils/terminal-backends/tmux.ts index 17be0ca2..26ae3ba8 100644 --- a/src/utils/terminal-backends/tmux.ts +++ b/src/utils/terminal-backends/tmux.ts @@ -64,7 +64,9 @@ export class TmuxBackend implements TerminalBackend { async openSingle(options: TerminalWindowOptions): Promise { const shellCommand = (await buildCommandSequence(options)).trim() - const command = shellCommand || 'bash' + // 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.') @@ -129,7 +131,8 @@ export class TmuxBackend implements TerminalBackend { } // Create the session with the first window - const firstCommand = (await buildCommandSequence(firstOptions)).trim() || 'bash' + const firstShellCommand = (await buildCommandSequence(firstOptions)).trim() + const firstCommand = firstShellCommand ? `${firstShellCommand}; exec bash` : 'bash' const firstName = firstOptions.title ? sanitizeWindowName(firstOptions.title) : 'window-1' @@ -156,7 +159,8 @@ export class TmuxBackend implements TerminalBackend { logger.debug('Terminal background colors are not supported in tmux sessions.') } - const command = (await buildCommandSequence(options)).trim() || 'bash' + const shellCommand = (await buildCommandSequence(options)).trim() + const command = shellCommand ? `${shellCommand}; exec bash` : 'bash' const windowName = options.title ? sanitizeWindowName(options.title) : `window-${i + 1}` From 9be4231f8458235e2419fa0f0b5ec71601886132 Mon Sep 17 00:00:00 2001 From: TickTockBent Date: Sat, 28 Feb 2026 07:18:06 -0500 Subject: [PATCH 3/6] refactor: deduplicate darwin command-building and platform detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code review feedback (PR #796): 1. darwin.ts buildTerminalAppScript now delegates to the shared buildCommandSequence instead of reimplementing the same logic. Removes unused escapeSingleQuotes and buildEnvSourceCommands imports. 2. terminal.ts detectITerm2 now delegates to darwin.ts's canonical implementation instead of duplicating the existsSync check. 3. terminal.ts detectPlatform now delegates to detectTerminalEnvironment from platform-detect.ts, eliminating the duplicate platform detection logic. Maps 'wsl' → 'linux' to preserve the Platform return type. Co-Authored-By: Claude Opus 4.6 --- src/utils/terminal-backends/darwin.ts | 48 ++++----------------------- src/utils/terminal.ts | 28 ++++++++-------- 2 files changed, 22 insertions(+), 54 deletions(-) diff --git a/src/utils/terminal-backends/darwin.ts b/src/utils/terminal-backends/darwin.ts index 8227e528..5b01b9af 100644 --- a/src/utils/terminal-backends/darwin.ts +++ b/src/utils/terminal-backends/darwin.ts @@ -2,8 +2,7 @@ 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' +import { buildCommandSequence } from './command-builder.js' /** * Detect if iTerm2 is installed on macOS. @@ -24,50 +23,17 @@ function escapeForAppleScript(command: string): string { /** * Build AppleScript for macOS Terminal.app (single tab). * - * Note: Terminal.app builds its own command sequence instead of using the shared - * buildCommandSequence because AppleScript requires different escaping for paths - * inside `do script` vs shell strings. + * 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 { - workspacePath, - command, - backgroundColor, - 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(' && ') - const historyFreeCommand = ` ${fullCommand}` + const command = await buildCommandSequence(options) let script = `tell application "Terminal"\n` - script += ` set newTab to do script "${escapeForAppleScript(historyFreeCommand)}"\n` + script += ` set newTab to do script "${escapeForAppleScript(command)}"\n` - if (backgroundColor) { - const { r, g, b } = backgroundColor + 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` } diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 0dc1901c..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 { 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,14 +55,14 @@ 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 - - return existsSync('/Applications/iTerm.app') + if (detectPlatform() !== 'darwin') return false + return darwinDetectITerm2() } /** From b42202930006c40e355fa255dfc8be2e3e0007d8 Mon Sep 17 00:00:00 2001 From: TickTockBent Date: Sat, 28 Feb 2026 20:07:43 -0500 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20session=20naming,=20WSL=20keep-alive,=20catch=20blo?= =?UTF-8?q?cks,=20N+1=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. [Critical] Tmux openSingle() sessions now use iloom- prefix so findIloomSession() can discover them, matching openMultiple() behavior. 2. [Critical] WSL backend appends '; exec bash' keep-alive to prevent terminal tabs from closing when the command exits. 3. [Medium] Narrowed bare catch blocks in isTmuxAvailable, sessionExists, findIloomSession, and detectLinuxTerminal to check for exitCode before swallowing — unexpected errors now propagate instead of being silently ignored. Test mocks updated to include exitCode matching real execa behavior. 4. [Medium] LinuxBackend.openMultiple() now calls execTerminal() directly with the pre-detected terminal instead of delegating to openSingle(), eliminating redundant detectLinuxTerminal() calls per window. Co-Authored-By: Claude Opus 4.6 --- src/utils/terminal-backends/linux.ts | 28 ++++++++++++++++++------ src/utils/terminal-backends/tmux.test.ts | 14 +++++++----- src/utils/terminal-backends/tmux.ts | 27 ++++++++++++++++------- src/utils/terminal-backends/wsl.ts | 6 +++-- src/utils/terminal.test.ts | 12 ++++++---- 5 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/utils/terminal-backends/linux.ts b/src/utils/terminal-backends/linux.ts index 40a88af1..7835a3c3 100644 --- a/src/utils/terminal-backends/linux.ts +++ b/src/utils/terminal-backends/linux.ts @@ -20,8 +20,12 @@ export async function detectLinuxTerminal(): Promise { try { await execa('which', [terminal]) return terminal - } catch { - // not found, try next + } 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 @@ -68,16 +72,26 @@ export class LinuxBackend implements TerminalBackend { } // gnome-terminal --tab adds a tab to the most recently focused window. - // Calling openSingle 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). + // 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.openSingle(options) + + 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) } } diff --git a/src/utils/terminal-backends/tmux.test.ts b/src/utils/terminal-backends/tmux.test.ts index b1315339..a77992c7 100644 --- a/src/utils/terminal-backends/tmux.test.ts +++ b/src/utils/terminal-backends/tmux.test.ts @@ -30,7 +30,9 @@ describe('tmux backend', () => { }) it('should return false when tmux is not found', async () => { - vi.mocked(execa).mockRejectedValue(new Error('not found')) + vi.mocked(execa).mockRejectedValue( + Object.assign(new Error('not found'), { exitCode: 1 }) + ) expect(await isTmuxAvailable()).toBe(false) }) @@ -48,7 +50,7 @@ describe('tmux backend', () => { 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 new Error('no server running') + throw Object.assign(new Error('no server running'), { exitCode: 1 }) } if (cmd === 'tmux' && args?.[0] === 'new-session') { return {} as never @@ -69,7 +71,7 @@ describe('tmux backend', () => { expect(args).toContain('-d') expect(args).toContain('-s') expect(args).toContain('-n') - expect(args).toContain('Dev-Server') + expect(args).toContain('iloom-Dev-Server') }) it('should add a window to existing iloom session', async () => { @@ -100,7 +102,7 @@ describe('tmux backend', () => { 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 new Error('no server running') + throw Object.assign(new Error('no server running'), { exitCode: 1 }) } return {} as never }) @@ -118,7 +120,7 @@ describe('tmux backend', () => { 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 new Error('no server running') + throw Object.assign(new Error('no server running'), { exitCode: 1 }) } if (cmd === 'tmux' && args?.[0] === 'new-session') { throw new Error('tmux error') @@ -136,7 +138,7 @@ describe('tmux backend', () => { 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 new Error('no such session') + throw Object.assign(new Error('no such session'), { exitCode: 1 }) } return {} as never }) diff --git a/src/utils/terminal-backends/tmux.ts b/src/utils/terminal-backends/tmux.ts index 26ae3ba8..41bd963a 100644 --- a/src/utils/terminal-backends/tmux.ts +++ b/src/utils/terminal-backends/tmux.ts @@ -11,8 +11,12 @@ export async function isTmuxAvailable(): Promise { try { await execa('which', ['tmux']) return true - } catch { - return false + } catch (error) { + // `which` exits with code 1 when the command is not found + if (error instanceof Error && 'exitCode' in error) { + return false + } + throw error } } @@ -45,8 +49,12 @@ async function sessionExists(sessionName: string): Promise { try { await execa('tmux', ['has-session', '-t', sessionName]) return true - } catch { - return false + } 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 } } @@ -73,7 +81,7 @@ export class TmuxBackend implements TerminalBackend { } const sessionName = options.title - ? sanitizeSessionName(options.title) + ? sanitizeSessionName(`iloom-${options.title}`) : `iloom-${Date.now()}` const windowName = options.title @@ -192,9 +200,12 @@ export class TmuxBackend implements TerminalBackend { 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 { - // No tmux server running or no sessions - return 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/wsl.ts b/src/utils/terminal-backends/wsl.ts index dfd4437b..79eb3363 100644 --- a/src/utils/terminal-backends/wsl.ts +++ b/src/utils/terminal-backends/wsl.ts @@ -44,7 +44,8 @@ export class WSLBackend implements TerminalBackend { readonly name = 'wsl' async openSingle(options: TerminalWindowOptions): Promise { - const shellCommand = await buildCommandSequence(options) + const rawCommand = (await buildCommandSequence(options)).trim() + const shellCommand = rawCommand ? `${rawCommand}; exec bash` : 'exec bash' const distro = detectWSLDistro() const args = buildTabArgs(shellCommand, options, distro) @@ -75,7 +76,8 @@ export class WSLBackend implements TerminalBackend { throw new Error(`Terminal option at index ${i} is undefined`) } - const shellCommand = await buildCommandSequence(options) + const rawCommand = (await buildCommandSequence(options)).trim() + const shellCommand = rawCommand ? `${rawCommand}; exec bash` : 'exec bash' const tabArgs = buildTabArgs(shellCommand, options, distro) if (i > 0) { diff --git a/src/utils/terminal.test.ts b/src/utils/terminal.test.ts index 642f0b54..f7d2af19 100644 --- a/src/utils/terminal.test.ts +++ b/src/utils/terminal.test.ts @@ -151,8 +151,10 @@ describe('openTerminalWindow', () => { writable: true, }) - // On Linux with no GUI terminal or tmux, it should throw a descriptive error - vi.mocked(execa).mockRejectedValue(new Error('not found')) + // 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( 'No supported terminal found on Linux' @@ -468,8 +470,10 @@ describe('openDualTerminalWindow', () => { writable: true, }) - // On Linux with no GUI terminal or tmux, it should throw a descriptive error - vi.mocked(execa).mockRejectedValue(new Error('not found')) + // 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( From b7ba7d9470dbe38e4096c92edb8d3d958ff6ff5b Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Sat, 28 Feb 2026 20:23:20 -0500 Subject: [PATCH 5/6] fix: ENOENT-specific catch in WSL detection, DRY linux backend, update README platform support - platform-detect: check for ENOENT specifically instead of bare catch - linux backend: extract resolveTerminal() and openSingleWithTerminal() to eliminate duplicated detection/command-building between openSingle/openMultiple - README: update OS support line to reflect Linux/WSL/tmux support, credit @TickTockBent and @rexsilex in Acknowledgments --- README.md | 4 +- src/utils/platform-detect.test.ts | 13 +++++- src/utils/platform-detect.ts | 8 +++- src/utils/terminal-backends/linux.ts | 61 +++++++++++++--------------- 4 files changed, 49 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 01f1eb72..dc27f650 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). ⚠️ 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/src/utils/platform-detect.test.ts b/src/utils/platform-detect.test.ts index e24576dc..86027715 100644 --- a/src/utils/platform-detect.test.ts +++ b/src/utils/platform-detect.test.ts @@ -48,10 +48,19 @@ describe('platform-detect', () => { expect(isWSL()).toBe(false) }) - it('should return false when /proc/version is unreadable', () => { + 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 - vi.mocked(readFileSync).mockImplementation(() => { throw new Error('ENOENT') }) + 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) }) diff --git a/src/utils/platform-detect.ts b/src/utils/platform-detect.ts index c5211615..35b9e9cc 100644 --- a/src/utils/platform-detect.ts +++ b/src/utils/platform-detect.ts @@ -38,7 +38,13 @@ export function isWSL(): boolean { const procVersion = readFileSync('/proc/version', 'utf-8') cachedIsWSL = /microsoft|wsl/i.test(procVersion) return cachedIsWSL - } catch { + } 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 } diff --git a/src/utils/terminal-backends/linux.ts b/src/utils/terminal-backends/linux.ts index 7835a3c3..9f494982 100644 --- a/src/utils/terminal-backends/linux.ts +++ b/src/utils/terminal-backends/linux.ts @@ -42,6 +42,28 @@ 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( @@ -49,7 +71,13 @@ export class LinuxBackend implements TerminalBackend { '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.' @@ -62,39 +90,6 @@ export class LinuxBackend implements TerminalBackend { await this.execTerminal(terminal, keepAliveCommand, options.title) } - async openMultiple(optionsArray: TerminalWindowOptions[]): 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.' - ) - } - - // 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`) - } - - 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, From 4fdab87b0280dfe9c4dd0f641ebbca18b0023f01 Mon Sep 17 00:00:00 2001 From: Adam Creeger Date: Sat, 28 Feb 2026 20:30:31 -0500 Subject: [PATCH 6/6] docs: add Windows/WSL setup guide and update README platform support - New docs/windows-wsl-guide.md with full WSL setup, VS Code integration, Windows Terminal usage, and troubleshooting - Link from README system requirements to the WSL guide --- README.md | 2 +- docs/windows-wsl-guide.md | 169 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 docs/windows-wsl-guide.md diff --git a/README.md b/README.md index dc27f650..052a07de 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ This is an early-stage product. **Requirements:** -* ✅ **OS:** macOS (fully supported), Linux (GUI terminals + tmux for headless), WSL (Windows Terminal). ⚠️ Native Windows is unsupported. +* ✅ **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+. 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).