mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 20:23:36 +00:00
feat: Add WSL support for Cursor CLI on Windows
- Add reusable WSL utilities in @automaker/platform (wsl.ts): - isWslAvailable() - Check if WSL is available on Windows - findCliInWsl() - Find CLI tools in WSL, tries multiple distributions - execInWsl() - Execute commands in WSL - createWslCommand() - Create spawn-compatible command/args for WSL - windowsToWslPath/wslToWindowsPath - Path conversion utilities - getWslDistributions() - List available WSL distributions - Update CursorProvider to use WSL on Windows: - Detect cursor-agent in WSL distributions (prioritizes Ubuntu) - Use full path to wsl.exe for spawn() compatibility - Pass --cd flag for working directory inside WSL - Store and use WSL distribution for all commands - Show "(WSL:Ubuntu) /path" in installation status - Add 'wsl' to InstallationStatus.method type - Fix bugs: - Fix ternary in settings-view.tsx that always returned 'claude' - Fix findIndex -1 handling in WSL command construction - Remove 'gpt-5.2' from unknown models test (now valid Cursor model) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,18 @@ export {
|
||||
type NodeFinderResult,
|
||||
type NodeFinderOptions,
|
||||
} from './node-finder.js';
|
||||
|
||||
// WSL (Windows Subsystem for Linux) utilities
|
||||
export {
|
||||
isWslAvailable,
|
||||
clearWslCache,
|
||||
getDefaultWslDistribution,
|
||||
getWslDistributions,
|
||||
findCliInWsl,
|
||||
execInWsl,
|
||||
createWslCommand,
|
||||
windowsToWslPath,
|
||||
wslToWindowsPath,
|
||||
type WslCliResult,
|
||||
type WslOptions,
|
||||
} from './wsl.js';
|
||||
|
||||
389
libs/platform/src/wsl.ts
Normal file
389
libs/platform/src/wsl.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* WSL (Windows Subsystem for Linux) utilities
|
||||
*
|
||||
* Provides cross-platform support for CLI tools that are only available
|
||||
* on Linux/macOS. On Windows, these tools can be accessed via WSL.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { isWslAvailable, findCliInWsl, createWslCommand } from '@automaker/platform';
|
||||
*
|
||||
* // Check if WSL is available
|
||||
* if (process.platform === 'win32' && isWslAvailable()) {
|
||||
* // Find a CLI tool installed in WSL
|
||||
* const cliPath = findCliInWsl('cursor-agent');
|
||||
* if (cliPath) {
|
||||
* // Create command/args for spawning via WSL
|
||||
* const { command, args } = createWslCommand(cliPath, ['--version']);
|
||||
* // command = 'wsl.exe', args = ['cursor-agent', '--version']
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Get the full path to wsl.exe
|
||||
* This is needed because spawn() may not find wsl.exe in PATH
|
||||
*/
|
||||
function getWslExePath(): string {
|
||||
// wsl.exe is in System32 on Windows
|
||||
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
|
||||
return path.join(systemRoot, 'System32', 'wsl.exe');
|
||||
}
|
||||
|
||||
/** Result of finding a CLI in WSL */
|
||||
export interface WslCliResult {
|
||||
/** Path to the CLI inside WSL (Linux path) */
|
||||
wslPath: string;
|
||||
/** The WSL distribution where it was found (if detected) */
|
||||
distribution?: string;
|
||||
}
|
||||
|
||||
/** Options for WSL operations */
|
||||
export interface WslOptions {
|
||||
/** Specific WSL distribution to use (default: use default distro) */
|
||||
distribution?: string;
|
||||
/** Timeout for WSL commands in milliseconds (default: 10000) */
|
||||
timeout?: number;
|
||||
/** Custom logger function */
|
||||
logger?: (message: string) => void;
|
||||
}
|
||||
|
||||
// Cache WSL availability to avoid repeated checks
|
||||
let wslAvailableCache: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check if WSL is available on the current system
|
||||
*
|
||||
* Returns false immediately on non-Windows platforms.
|
||||
* On Windows, checks if wsl.exe exists and can execute commands.
|
||||
*
|
||||
* Results are cached after first check.
|
||||
*/
|
||||
export function isWslAvailable(options: WslOptions = {}): boolean {
|
||||
const { timeout = 5000, logger = () => {} } = options;
|
||||
|
||||
// Only relevant on Windows
|
||||
if (process.platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return cached result if available
|
||||
if (wslAvailableCache !== null) {
|
||||
return wslAvailableCache;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to run a simple command via WSL
|
||||
execSync('wsl.exe echo ok', {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
wslAvailableCache = true;
|
||||
logger('WSL is available');
|
||||
return true;
|
||||
} catch {
|
||||
// Try wsl --status as fallback
|
||||
try {
|
||||
execSync('wsl.exe --status', {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
wslAvailableCache = true;
|
||||
logger('WSL is available (via --status)');
|
||||
return true;
|
||||
} catch {
|
||||
wslAvailableCache = false;
|
||||
logger('WSL is not available');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the WSL availability cache
|
||||
* Useful for testing or when WSL state may have changed
|
||||
*/
|
||||
export function clearWslCache(): void {
|
||||
wslAvailableCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default WSL distribution name
|
||||
*/
|
||||
export function getDefaultWslDistribution(options: WslOptions = {}): string | null {
|
||||
const { timeout = 5000 } = options;
|
||||
|
||||
if (!isWslAvailable(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// wsl -l -q returns distributions, first one marked with (Default)
|
||||
const result = execSync('wsl.exe -l -q', {
|
||||
encoding: 'utf16le', // WSL list output uses UTF-16LE on Windows
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
// First non-empty line is the default
|
||||
const lines = result.split(/\r?\n/).filter((l) => l.trim());
|
||||
return lines[0]?.replace(/\0/g, '').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available WSL distributions
|
||||
*/
|
||||
export function getWslDistributions(options: WslOptions = {}): string[] {
|
||||
const { timeout = 5000, logger = () => {} } = options;
|
||||
|
||||
if (!isWslAvailable(options)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync('wsl.exe -l -q', {
|
||||
encoding: 'utf16le', // WSL list output uses UTF-16LE on Windows
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
const distributions = result
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.replace(/\0/g, '').trim())
|
||||
.filter((l) => l && !l.includes('docker-desktop')); // Exclude docker-desktop as it's minimal
|
||||
|
||||
logger(`Found WSL distributions: ${distributions.join(', ')}`);
|
||||
return distributions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a CLI tool installed in WSL
|
||||
*
|
||||
* Searches for the CLI using 'which' inside WSL, then checks common paths.
|
||||
* If no distribution is specified, tries all available distributions (excluding docker-desktop).
|
||||
*
|
||||
* @param cliName - Name of the CLI to find (e.g., 'cursor-agent')
|
||||
* @param options - WSL options
|
||||
* @returns The Linux path to the CLI and the distribution where found, or null if not found
|
||||
*/
|
||||
export function findCliInWsl(cliName: string, options: WslOptions = {}): WslCliResult | null {
|
||||
const { distribution, timeout = 10000, logger = () => {} } = options;
|
||||
|
||||
if (!isWslAvailable(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper to search in a specific distribution
|
||||
const searchInDistribution = (distro: string | undefined): WslCliResult | null => {
|
||||
const wslPrefix = distro ? `wsl.exe -d ${distro}` : 'wsl.exe';
|
||||
const distroLabel = distro || 'default';
|
||||
|
||||
// Try 'which' first (works if PATH is set up correctly)
|
||||
try {
|
||||
const result = execSync(`${wslPrefix} which ${cliName}`, {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
if (result && !result.includes('not found') && result.startsWith('/')) {
|
||||
logger(`Found ${cliName} in WSL (${distroLabel}) via 'which': ${result}`);
|
||||
return { wslPath: result, distribution: distro };
|
||||
}
|
||||
} catch {
|
||||
// Not found via which, continue to path checks
|
||||
}
|
||||
|
||||
// Check common installation paths using sh -c for better compatibility
|
||||
// Use $HOME instead of ~ for reliable expansion
|
||||
const commonPaths = ['$HOME/.local/bin', '/usr/local/bin', '/usr/bin'];
|
||||
|
||||
for (const basePath of commonPaths) {
|
||||
try {
|
||||
// Use sh -c to properly expand $HOME and test if executable
|
||||
const checkCmd = `${wslPrefix} sh -c "test -x ${basePath}/${cliName} && echo ${basePath}/${cliName}"`;
|
||||
const result = execSync(checkCmd, {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
if (result && result.startsWith('/')) {
|
||||
logger(`Found ${cliName} in WSL (${distroLabel}) at: ${result}`);
|
||||
return { wslPath: result, distribution: distro };
|
||||
}
|
||||
} catch {
|
||||
// Path doesn't exist or not executable, continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// If a specific distribution is requested, only search there
|
||||
if (distribution) {
|
||||
return searchInDistribution(distribution);
|
||||
}
|
||||
|
||||
// Try available distributions (excluding docker-desktop and similar minimal distros)
|
||||
const distributions = getWslDistributions(options);
|
||||
|
||||
// Prioritize common user distributions
|
||||
const priorityDistros = ['Ubuntu', 'Debian', 'openSUSE', 'Fedora', 'Arch'];
|
||||
const sortedDistros = distributions.sort((a, b) => {
|
||||
const aIndex = priorityDistros.findIndex((p) => a.toLowerCase().includes(p.toLowerCase()));
|
||||
const bIndex = priorityDistros.findIndex((p) => b.toLowerCase().includes(p.toLowerCase()));
|
||||
if (aIndex === -1 && bIndex === -1) return 0;
|
||||
if (aIndex === -1) return 1;
|
||||
if (bIndex === -1) return -1;
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
logger(`Searching for ${cliName} in WSL distributions: ${sortedDistros.join(', ')}`);
|
||||
|
||||
for (const distro of sortedDistros) {
|
||||
const result = searchInDistribution(distro);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try default distribution as last resort
|
||||
const defaultResult = searchInDistribution(undefined);
|
||||
if (defaultResult) {
|
||||
return defaultResult;
|
||||
}
|
||||
|
||||
logger(`${cliName} not found in any WSL distribution`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in WSL and return the output
|
||||
*
|
||||
* @param command - Command to execute (can include arguments)
|
||||
* @param options - WSL options
|
||||
* @returns Command output, or null if failed
|
||||
*/
|
||||
export function execInWsl(command: string, options: WslOptions = {}): string | null {
|
||||
const { distribution, timeout = 30000 } = options;
|
||||
|
||||
if (!isWslAvailable(options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wslPrefix = distribution ? `wsl.exe -d ${distribution}` : 'wsl.exe';
|
||||
|
||||
try {
|
||||
return execSync(`${wslPrefix} ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create command and arguments for spawning a process via WSL
|
||||
*
|
||||
* This is useful for constructing spawn() calls that work through WSL.
|
||||
* Uses the full path to wsl.exe to ensure spawn() can find it.
|
||||
*
|
||||
* @param wslCliPath - The Linux path to the CLI inside WSL
|
||||
* @param args - Arguments to pass to the CLI
|
||||
* @param options - WSL options
|
||||
* @returns Object with command (full path to wsl.exe) and modified args
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { command, args } = createWslCommand('/home/user/.local/bin/cursor-agent', ['-p', 'hello']);
|
||||
* // command = 'C:\\Windows\\System32\\wsl.exe'
|
||||
* // args = ['/home/user/.local/bin/cursor-agent', '-p', 'hello']
|
||||
*
|
||||
* spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
* ```
|
||||
*/
|
||||
export function createWslCommand(
|
||||
wslCliPath: string,
|
||||
args: string[],
|
||||
options: WslOptions = {}
|
||||
): { command: string; args: string[] } {
|
||||
const { distribution } = options;
|
||||
// Use full path to wsl.exe to ensure spawn() can find it
|
||||
const wslExe = getWslExePath();
|
||||
|
||||
if (distribution) {
|
||||
return {
|
||||
command: wslExe,
|
||||
args: ['-d', distribution, wslCliPath, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: wslExe,
|
||||
args: [wslCliPath, ...args],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Windows path to a WSL path
|
||||
*
|
||||
* @param windowsPath - Windows path (e.g., 'C:\\Users\\foo\\project')
|
||||
* @returns WSL path (e.g., '/mnt/c/Users/foo/project')
|
||||
*/
|
||||
export function windowsToWslPath(windowsPath: string): string {
|
||||
// Handle UNC paths
|
||||
if (windowsPath.startsWith('\\\\')) {
|
||||
// UNC paths are not directly supported, return as-is
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
// Extract drive letter and convert
|
||||
const match = windowsPath.match(/^([A-Za-z]):\\(.*)$/);
|
||||
if (match) {
|
||||
const [, drive, rest] = match;
|
||||
const wslPath = `/mnt/${drive.toLowerCase()}/${rest.replace(/\\/g, '/')}`;
|
||||
return wslPath;
|
||||
}
|
||||
|
||||
// Already a Unix-style path or relative path
|
||||
return windowsPath.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a WSL path to a Windows path
|
||||
*
|
||||
* @param wslPath - WSL path (e.g., '/mnt/c/Users/foo/project')
|
||||
* @returns Windows path (e.g., 'C:\\Users\\foo\\project'), or original if not a /mnt/ path
|
||||
*/
|
||||
export function wslToWindowsPath(wslPath: string): string {
|
||||
const match = wslPath.match(/^\/mnt\/([a-z])\/(.*)$/);
|
||||
if (match) {
|
||||
const [, drive, rest] = match;
|
||||
return `${drive.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
// Not a /mnt/ path, return as-is
|
||||
return wslPath;
|
||||
}
|
||||
Reference in New Issue
Block a user