/** * OpenClaw CLI utilities — cross-platform auto-install */ import { app } from 'electron'; import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, } from 'node:fs'; import { spawn } from 'node:child_process'; import { homedir } from 'node:os'; import { join, dirname } from 'node:path'; import { getOpenClawDir, getOpenClawEntryPath } from './paths'; import { logger } from './logger'; // ── Quoting helpers ────────────────────────────────────────────────────────── function escapeForDoubleQuotes(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } function quoteForPosix(value: string): string { return `"${escapeForDoubleQuotes(value)}"`; } function quoteForPowerShell(value: string): string { return `'${value.replace(/'/g, "''")}'`; } // ── CLI command string (for display / copy) ────────────────────────────────── export function getOpenClawCliCommand(): string { const entryPath = getOpenClawEntryPath(); const platform = process.platform; if (platform === 'darwin' || platform === 'linux') { const localBinPath = join(homedir(), '.local', 'bin', 'openclaw'); if (existsSync(localBinPath)) { return quoteForPosix(localBinPath); } } if (platform === 'linux') { if (existsSync('/usr/local/bin/openclaw')) { return '/usr/local/bin/openclaw'; } } if (!app.isPackaged) { const openclawDir = getOpenClawDir(); const nodeModulesDir = dirname(openclawDir); const binName = platform === 'win32' ? 'openclaw.cmd' : 'openclaw'; const binPath = join(nodeModulesDir, '.bin', binName); if (existsSync(binPath)) { if (platform === 'win32') { return `& ${quoteForPowerShell(binPath)}`; } return quoteForPosix(binPath); } } if (app.isPackaged) { if (platform === 'win32') { const cliDir = join(process.resourcesPath, 'cli'); const cmdPath = join(cliDir, 'openclaw.cmd'); if (existsSync(cmdPath)) { return quoteForPowerShell(cmdPath); } } const execPath = process.execPath; if (platform === 'win32') { return `$env:ELECTRON_RUN_AS_NODE=1; & ${quoteForPowerShell(execPath)} ${quoteForPowerShell(entryPath)}`; } return `ELECTRON_RUN_AS_NODE=1 ${quoteForPosix(execPath)} ${quoteForPosix(entryPath)}`; } if (platform === 'win32') { return `node ${quoteForPowerShell(entryPath)}`; } return `node ${quoteForPosix(entryPath)}`; } // ── Packaged CLI wrapper path ──────────────────────────────────────────────── function getPackagedCliWrapperPath(): string | null { if (!app.isPackaged) return null; const platform = process.platform; if (platform === 'darwin' || platform === 'linux') { const wrapper = join(process.resourcesPath, 'cli', 'openclaw'); return existsSync(wrapper) ? wrapper : null; } if (platform === 'win32') { const wrapper = join(process.resourcesPath, 'cli', 'openclaw.cmd'); return existsSync(wrapper) ? wrapper : null; } return null; } function getWindowsPowerShellPath(): string { const systemRoot = process.env.SystemRoot || 'C:\\Windows'; return join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); } // ── macOS / Linux install ──────────────────────────────────────────────────── function getCliTargetPath(): string { return join(homedir(), '.local', 'bin', 'openclaw'); } export async function installOpenClawCli(): Promise<{ success: boolean; path?: string; error?: string; }> { const platform = process.platform; if (platform === 'win32') { return { success: false, error: 'Windows CLI is configured by the installer.' }; } if (!app.isPackaged) { return { success: false, error: 'CLI install is only available in packaged builds.' }; } const wrapperSrc = getPackagedCliWrapperPath(); if (!wrapperSrc) { return { success: false, error: 'CLI wrapper not found in app resources.' }; } const targetDir = join(homedir(), '.local', 'bin'); const target = getCliTargetPath(); try { mkdirSync(targetDir, { recursive: true }); // Remove existing file/symlink to avoid EEXIST if (existsSync(target)) { unlinkSync(target); } symlinkSync(wrapperSrc, target); chmodSync(wrapperSrc, 0o755); logger.info(`OpenClaw CLI symlink created: ${target} -> ${wrapperSrc}`); return { success: true, path: target }; } catch (error) { logger.error('Failed to install OpenClaw CLI:', error); return { success: false, error: String(error) }; } } // ── Auto-install on first launch ───────────────────────────────────────────── function isCliInstalled(): boolean { const platform = process.platform; if (platform === 'win32') return true; // handled by NSIS installer const target = getCliTargetPath(); if (!existsSync(target)) return false; // Also check /usr/local/bin/openclaw for deb installs if (platform === 'linux' && existsSync('/usr/local/bin/openclaw')) return true; return true; } function ensureWindowsCliOnPath(): Promise<'updated' | 'already-present'> { return new Promise((resolve, reject) => { const cliWrapper = getPackagedCliWrapperPath(); if (!cliWrapper) { reject(new Error('CLI wrapper not found in app resources.')); return; } const cliDir = dirname(cliWrapper); const helperPath = join(cliDir, 'update-user-path.ps1'); if (!existsSync(helperPath)) { reject(new Error(`PATH helper not found at ${helperPath}`)); return; } const child = spawn( getWindowsPowerShellPath(), [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helperPath, '-Action', 'add', '-CliDir', cliDir, ], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }, ); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code !== 0) { reject(new Error(stderr.trim() || `PowerShell exited with code ${code}`)); return; } const status = stdout.trim(); if (status === 'updated' || status === 'already-present') { resolve(status); return; } reject(new Error(`Unexpected PowerShell output: ${status || '(empty)'}`)); }); }); } function ensureLocalBinInPath(): void { if (process.platform === 'win32') return; const localBin = join(homedir(), '.local', 'bin'); const pathEnv = process.env.PATH || ''; if (pathEnv.split(':').includes(localBin)) return; const shell = process.env.SHELL || '/bin/zsh'; const profileFile = shell.includes('zsh') ? join(homedir(), '.zshrc') : shell.includes('fish') ? join(homedir(), '.config', 'fish', 'config.fish') : join(homedir(), '.bashrc'); try { const marker = '.local/bin'; let content = ''; try { content = readFileSync(profileFile, 'utf-8'); } catch { // file doesn't exist yet } if (content.includes(marker)) return; const line = shell.includes('fish') ? '\n# Added by ClawX\nfish_add_path "$HOME/.local/bin"\n' : '\n# Added by ClawX\nexport PATH="$HOME/.local/bin:$PATH"\n'; appendFileSync(profileFile, line); logger.info(`Added ~/.local/bin to PATH in ${profileFile}`); } catch (error) { logger.warn('Failed to add ~/.local/bin to PATH:', error); } } export async function autoInstallCliIfNeeded( notify?: (path: string) => void, ): Promise { if (!app.isPackaged) return; if (process.platform === 'win32') { try { const result = await ensureWindowsCliOnPath(); if (result === 'updated') { logger.info('Added Windows CLI directory to user PATH.'); } } catch (error) { logger.warn('Failed to ensure Windows CLI is on PATH:', error); } return; } const target = getCliTargetPath(); const wrapperSrc = getPackagedCliWrapperPath(); if (isCliInstalled()) { if (target && wrapperSrc && existsSync(target)) { try { unlinkSync(target); symlinkSync(wrapperSrc, target); logger.debug(`Refreshed CLI symlink: ${target} -> ${wrapperSrc}`); } catch { // non-critical } } return; } logger.info('Auto-installing openclaw CLI...'); const result = await installOpenClawCli(); if (result.success) { logger.info(`CLI auto-installed at ${result.path}`); ensureLocalBinInPath(); if (result.path) notify?.(result.path); } else { logger.warn(`CLI auto-install failed: ${result.error}`); } } // ── Completion helpers ─────────────────────────────────────────────────────── function getNodeExecForCli(): string { if (process.platform === 'darwin' && app.isPackaged) { const appName = app.getName(); const helperName = `${appName} Helper`; const helperPath = join( dirname(process.execPath), '../Frameworks', `${helperName}.app`, 'Contents/MacOS', helperName, ); if (existsSync(helperPath)) return helperPath; } return process.execPath; } export function generateCompletionCache(): void { if (!app.isPackaged) return; const entryPath = getOpenClawEntryPath(); if (!existsSync(entryPath)) return; const execPath = getNodeExecForCli(); const child = spawn(execPath, [entryPath, 'completion', '--write-state'], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', OPENCLAW_NO_RESPAWN: '1', OPENCLAW_EMBEDDED_IN: 'ClawX', }, stdio: 'ignore', detached: false, windowsHide: true, }); child.on('close', (code) => { if (code === 0) { logger.info('OpenClaw completion cache generated'); } else { logger.warn(`OpenClaw completion cache generation exited with code ${code}`); } }); child.on('error', (err) => { logger.warn('Failed to generate completion cache:', err); }); } export function installCompletionToProfile(): void { if (!app.isPackaged) return; if (process.platform === 'win32') return; const entryPath = getOpenClawEntryPath(); if (!existsSync(entryPath)) return; const execPath = getNodeExecForCli(); const child = spawn( execPath, [entryPath, 'completion', '--install', '-y'], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', OPENCLAW_NO_RESPAWN: '1', OPENCLAW_EMBEDDED_IN: 'ClawX', }, stdio: 'ignore', detached: false, windowsHide: true, } ); child.on('close', (code) => { if (code === 0) { logger.info('OpenClaw completion installed to shell profile'); } else { logger.warn(`OpenClaw completion install exited with code ${code}`); } }); child.on('error', (err) => { logger.warn('Failed to install completion to shell profile:', err); }); }