diff --git a/apps/studio/electron/main/bun/index.ts b/apps/studio/electron/main/bun/index.ts index 2bc36257c5..37541fc408 100644 --- a/apps/studio/electron/main/bun/index.ts +++ b/apps/studio/electron/main/bun/index.ts @@ -10,6 +10,7 @@ import path from 'path'; import { promisify } from 'util'; import { __dirname } from '../index'; import { PersistentStorage } from '../storage'; +import { getShellCommand, isWSL, detectUserShell } from '../utils/platform'; import { replaceCommand } from './parse'; const execAsync = promisify(exec); @@ -32,9 +33,20 @@ export async function runBunCommand( ): Promise { try { const commandToExecute = getBunCommand(command); - const shell = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'; - console.log('Executing command: ', commandToExecute, options.cwd); + // Get user's shell preference from settings + const userSettings = PersistentStorage.USER_SETTINGS.read(); + const userShellPreference = userSettings?.editor?.shellType; + + // Use the shared shell detection function with user preference + const shell = getShellCommand(userShellPreference); + const detectedShell = detectUserShell(); + + console.log(`Executing command: ${commandToExecute}`); + console.log( + ` Shell: ${shell}, WSL: ${isWSL()}, User Preference: ${userShellPreference || 'auto-detect'}, Detected: ${detectedShell}`, + options.cwd, + ); const { stdout, stderr } = await execAsync(commandToExecute, { cwd: options.cwd, maxBuffer: 1024 * 1024 * 10, diff --git a/apps/studio/electron/main/run/terminal.ts b/apps/studio/electron/main/run/terminal.ts index a5cf8805b2..586f4c91ac 100644 --- a/apps/studio/electron/main/run/terminal.ts +++ b/apps/studio/electron/main/run/terminal.ts @@ -4,6 +4,8 @@ import * as pty from 'node-pty'; import os from 'os'; import { mainWindow } from '..'; import { getBunCommand } from '../bun'; +import { getShellCommand, isWSL, getLineEnding, detectUserShell } from '../utils/platform'; +import { PersistentStorage } from '../storage'; class TerminalManager { private static instance: TerminalManager; @@ -24,7 +26,20 @@ class TerminalManager { create(id: string, options?: { cwd?: string }): boolean { try { - const shell = os.platform() === 'win32' ? 'powershell.exe' : '/bin/sh'; + // Get user's shell preference from settings + const userSettings = PersistentStorage.USER_SETTINGS.read(); + const userShellPreference = userSettings?.editor?.shellType; + + // Use the shell detection function with user preference + const shell = getShellCommand(userShellPreference); + const detectedShell = detectUserShell(); + console.log(`Creating terminal with shell: ${shell}`); + console.log(` Platform: ${os.platform()}`); + console.log(` WSL: ${isWSL()}`); + console.log(` User Preference: ${userShellPreference || 'auto-detect'}`); + console.log(` Detected User Shell: ${detectedShell}`); + console.log(` Final Shell: ${shell}`); + const ptyProcess = pty.spawn(shell, [], { name: 'xterm-color', cwd: options?.cwd, @@ -176,7 +191,8 @@ class TerminalManager { executeCommand(id: string, command: string): boolean { try { const commandToExecute = getBunCommand(command); - const newline = os.platform() === 'win32' ? '\r\n' : '\n'; + // Use platform-aware line ending that considers WSL + const newline = getLineEnding(); return this.write(id, commandToExecute + newline); } catch (error) { console.error('Failed to execute command.', error); diff --git a/apps/studio/electron/main/utils/platform.ts b/apps/studio/electron/main/utils/platform.ts new file mode 100644 index 0000000000..f37c75b699 --- /dev/null +++ b/apps/studio/electron/main/utils/platform.ts @@ -0,0 +1,148 @@ +import fs from 'fs'; +import os from 'os'; +import { execSync } from 'child_process'; +import { ShellType } from '@onlook/models/constants'; + +/** + * Detects if the current environment is Windows Subsystem for Linux (WSL) + * @returns true if running in WSL, false otherwise + */ +export function isWSL(): boolean { + try { + // Check for WSL-specific environment variables + if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) { + return true; + } + + // Check for WSL in /proc/version (available in WSL) + if (os.platform() === 'linux') { + try { + const procVersion = fs.readFileSync('/proc/version', 'utf8'); + return ( + procVersion.toLowerCase().includes('microsoft') || + procVersion.toLowerCase().includes('wsl') + ); + } catch { + // If we can't read /proc/version, fall back to other checks + } + } + + // Check for WSL in /proc/sys/kernel/osrelease (WSL 2) + if (os.platform() === 'linux') { + try { + const osRelease = fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8'); + return ( + osRelease.toLowerCase().includes('microsoft') || + osRelease.toLowerCase().includes('wsl') + ); + } catch { + // If we can't read the file, it's likely not WSL + } + } + + return false; + } catch (error) { + console.warn('Error detecting WSL environment:', error); + return false; + } +} + +/** + * Detects the user's default shell from environment variables and system configuration + * @returns the path to the user's default shell + */ +export function detectUserShell(): string { + try { + // First, try the SHELL environment variable (most reliable) + if (process.env.SHELL) { + return process.env.SHELL; + } + + // On Unix-like systems, try to get the shell from /etc/passwd + if (os.platform() !== 'win32') { + try { + const username = os.userInfo().username; + const passwdEntry = execSync(`getent passwd ${username}`, { + encoding: 'utf8', + }).trim(); + const fields = passwdEntry.split(':'); + if (fields.length >= 7 && fields[6]) { + return fields[6]; + } + } catch (error) { + console.warn( + 'Could not read user shell from passwd:', + error instanceof Error ? error.message : String(error), + ); + } + } + + // Fallback to common shells based on platform + if (os.platform() === 'win32') { + return 'powershell.exe'; + } else { + return '/bin/bash'; // Most common fallback + } + } catch (error) { + console.warn('Error detecting user shell:', error); + // Ultimate fallback + return os.platform() === 'win32' ? 'powershell.exe' : '/bin/bash'; + } +} + +/** + * Determines the appropriate shell to use based on the platform and environment + * @param userPreference Optional user preference for shell type + * @returns the shell command to use + */ +export function getShellCommand(userPreference?: ShellType): string { + // If user has specified a preference, use it (except for auto-detect) + if (userPreference && userPreference !== ShellType.AUTO_DETECT) { + return getShellCommandForType(userPreference); + } + + // Auto-detect logic: use the user's actual default shell + return detectUserShell(); +} + +/** + * Gets the shell command for a specific shell type + * @param shellType The shell type to get the command for + * @returns the shell command + */ +export function getShellCommandForType(shellType: ShellType): string { + switch (shellType) { + case ShellType.POWERSHELL: + return 'powershell.exe'; + case ShellType.BASH: + return '/bin/bash'; + case ShellType.FISH: + return '/usr/bin/fish'; + case ShellType.ZSH: + return '/bin/zsh'; + case ShellType.SYSTEM_SHELL: + return '/bin/sh'; + case ShellType.AUTO_DETECT: + default: + // Fall back to auto-detection + return getShellCommand(); + } +} + +/** + * Determines if we're running on a Windows-like environment (including WSL) + * This is useful for path handling and other Windows-specific logic + * @returns true if running on Windows or WSL, false otherwise + */ +export function isWindowsLike(): boolean { + return os.platform() === 'win32' || isWSL(); +} + +/** + * Gets platform-specific line ending + * @returns '\r\n' for Windows, '\n' for Unix-like systems + */ +export function getLineEnding(): string { + // Use Windows line endings only for native Windows, not WSL + return os.platform() === 'win32' && !isWSL() ? '\r\n' : '\n'; +} diff --git a/apps/studio/src/components/Modals/Settings/Preferences/index.tsx b/apps/studio/src/components/Modals/Settings/Preferences/index.tsx index b5985e4be3..d86d20373f 100644 --- a/apps/studio/src/components/Modals/Settings/Preferences/index.tsx +++ b/apps/studio/src/components/Modals/Settings/Preferences/index.tsx @@ -1,7 +1,14 @@ import { useUserManager } from '@/components/Context'; import { useTheme } from '@/components/ThemeProvider'; import { invokeMainChannel } from '@/lib/utils'; -import { Language, LANGUAGE_DISPLAY_NAMES, MainChannels, Theme } from '@onlook/models/constants'; +import { + Language, + LANGUAGE_DISPLAY_NAMES, + MainChannels, + Theme, + ShellType, + SHELL_TYPE_DISPLAY_NAMES, +} from '@onlook/models/constants'; import { DEFAULT_IDE } from '@onlook/models/ide'; import { Button } from '@onlook/ui/button'; import { @@ -23,6 +30,7 @@ const PreferencesTab = observer(() => { const ide = IDE.fromType(userManager.settings.settings?.editor?.ideType || DEFAULT_IDE); const isAnalyticsEnabled = userManager.settings.settings?.enableAnalytics || false; const shouldWarnDelete = userManager.settings.settings?.editor?.shouldWarnDelete ?? true; + const shellType = userManager.settings.settings?.editor?.shellType || ShellType.AUTO_DETECT; const IDEIcon = Icons[ide.icon]; function updateIde(ide: IDE) { @@ -38,6 +46,10 @@ const PreferencesTab = observer(() => { userManager.settings.updateEditor({ shouldWarnDelete: enabled }); } + function updateShellType(newShellType: ShellType) { + userManager.settings.updateEditor({ shellType: newShellType }); + } + return (
@@ -142,6 +154,33 @@ const PreferencesTab = observer(() => {
+
+
+

Terminal Shell

+

+ Choose the shell to use for terminal operations +

+
+ + + + + + {Object.entries(SHELL_TYPE_DISPLAY_NAMES).map(([type, displayName]) => ( + updateShellType(type as ShellType)} + > + {displayName} + {shellType === type && } + + ))} + + +

{'Warn before delete'}

diff --git a/packages/models/src/constants/editor.ts b/packages/models/src/constants/editor.ts index 78610b170b..0636244c6f 100644 --- a/packages/models/src/constants/editor.ts +++ b/packages/models/src/constants/editor.ts @@ -1,4 +1,5 @@ import { DEFAULT_IDE } from '../ide/index.ts'; +import { ShellType } from './terminal'; export const APP_NAME = 'Onlook'; export const APP_SCHEMA = 'onlook'; @@ -86,6 +87,7 @@ export const DefaultSettings = { ideType: DEFAULT_IDE, enableBunReplace: true, buildFlags: '--no-lint', + shellType: ShellType.AUTO_DETECT, }, }; diff --git a/packages/models/src/constants/terminal.ts b/packages/models/src/constants/terminal.ts index dd17b51482..ae8ff5b086 100644 --- a/packages/models/src/constants/terminal.ts +++ b/packages/models/src/constants/terminal.ts @@ -1,3 +1,27 @@ export const TerminalCommands = { CTRL_C: String.fromCharCode(3), }; + +/** + * Available shell types for terminal usage + */ +export enum ShellType { + AUTO_DETECT = 'auto-detect', + POWERSHELL = 'powershell', + BASH = 'bash', + FISH = 'fish', + ZSH = 'zsh', + SYSTEM_SHELL = 'system-shell', +} + +/** + * Display names for shell types in the UI + */ +export const SHELL_TYPE_DISPLAY_NAMES: Record = { + [ShellType.AUTO_DETECT]: 'Auto-detect', + [ShellType.POWERSHELL]: 'PowerShell', + [ShellType.BASH]: 'Bash', + [ShellType.FISH]: 'Fish', + [ShellType.ZSH]: 'Zsh', + [ShellType.SYSTEM_SHELL]: 'System Shell (/bin/sh)', +}; diff --git a/packages/models/src/settings/index.ts b/packages/models/src/settings/index.ts index 46b5265f14..a6ef96b2f7 100644 --- a/packages/models/src/settings/index.ts +++ b/packages/models/src/settings/index.ts @@ -1,5 +1,6 @@ import { IdeType } from '../ide'; import { type Project } from '../projects'; +import { ShellType } from '../constants/terminal'; export interface UserSettings { id?: string; @@ -15,6 +16,7 @@ export interface EditorSettings { enableBunReplace?: boolean; buildFlags?: string; newProjectPath?: string; + shellType?: ShellType; } export interface ChatSettings {