From fbb8cf034029ee931c96d381d7e5f09667b27775 Mon Sep 17 00:00:00 2001 From: "Dominic Damoah (Agent)" Date: Wed, 18 Mar 2026 08:43:58 +0000 Subject: [PATCH 001/319] fix: proactive sudo, prerequisite checks, PATH detection, and post-install verification - macOS: check Xcode CLI Tools before install, trigger xcode-select --install if missing - macOS/Linux: detect if npm prefix is writable BEFORE install, request sudo upfront - Windows: fail fast with nodejs.org link if Node.js/npm not found - Add nvm, fnm, and Homebrew Node paths to _buildExecEnv() for post-install detection - Gate auto-configure on CLI verification (verifyCliBeforeSetup) to prevent ENOENT - Reuse cached sudo across Node.js install + openclaw install steps - Add INSTALL.md documenting the full install flow and prerequisite matrix Fixes: fresh Mac install failure (no Homebrew, no Node.js, no npm) --- INSTALL.md | 179 +++++++++++++++ .../extensions/openclaw/src/panels/home.ts | 208 +++++++++++++++--- 2 files changed, 357 insertions(+), 30 deletions(-) create mode 100644 INSTALL.md diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..b007d473 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,179 @@ +# OCCode Installation — Technical Reference + +This document covers how OCCode's built-in OpenClaw installer works, what +prerequisites it checks, and the design decisions behind the install flow. + +--- + +## Overview + +When a user opens OCCode and OpenClaw is not installed, the Home panel offers a +one-click install. The installer runs **silently** (no terminal window) and +streams output to the Home panel in real-time. + +The install flow follows this sequence: + +``` +1. Prerequisite checks (platform-specific) +2. Permission detection (proactive sudo) +3. Install via npm or fallback script +4. Post-install CLI verification +5. Auto-configure (if signed in) +``` + +--- + +## Prerequisites by Platform + +### macOS + +| Prerequisite | Check | If Missing | +|---|---|---| +| Xcode CLI Tools | `xcode-select -p` | Triggers `xcode-select --install` (system dialog), asks user to restart | +| Node.js >= 20 | `node --version` + nvm check | Auto-installs via nvm or official .pkg | +| npm | `npm --version` | Comes with Node.js install | +| Write access | `fs.accessSync(npmPrefix, W_OK)` | Asks for sudo password **upfront** | + +### Windows + +| Prerequisite | Check | If Missing | +|---|---|---| +| Node.js | `node --version` | Fails with link to nodejs.org | +| npm | `npm --version` | Fails with link to nodejs.org | + +### Linux + +| Prerequisite | Check | If Missing | +|---|---|---| +| Node.js >= 20 | `node --version` + nvm check | Auto-installs via nvm, apt-get, dnf, or yum | +| npm | `npm --version` | Comes with Node.js install | +| Write access | `fs.accessSync(/usr/local/*, W_OK)` | Asks for sudo password **upfront** | + +--- + +## Proactive Sudo (macOS / Linux) + +**Problem solved:** Previously, the installer would attempt `npm install -g`, +fail with `EACCES`, then ask for a password and retry. This caused confusing +error output and a poor UX. + +**New approach:** Before any install command runs: + +1. If npm is available: check write access to `$(npm config get prefix)/bin` + and `$(npm config get prefix)/lib` +2. If npm is not available: check write access to `/usr/local/bin` and + `/usr/local/lib` +3. If either check fails -> prompt for password via the in-webview modal +4. Cache credentials with `sudo -S -v` +5. All subsequent install commands use `sudo -E`/`sudo -n` from the start + +The cached sudo session is reused across Node.js install + openclaw install, +so the user only enters their password once. + +After install, `~/.openclaw` ownership is fixed with +`sudo chown -R $USER:$USER ~/.openclaw && chmod 700 ~/.openclaw`. + +--- + +## PATH Detection (`_buildExecEnv`) + +After install, the extension needs to find the `openclaw` binary. The following +directories are added to PATH for all spawned processes: + +### macOS / Linux + +| Path | Source | +|---|---| +| `/usr/local/bin` | Standard system | +| `/opt/homebrew/bin` | Homebrew (Apple Silicon) | +| `~/.local/bin` | User-local installs | +| `~/.npm-global/bin` | Custom npm prefix | +| `~/.openclaw/bin` | OpenClaw self-install | +| `~/.nvm/versions/node/v*/bin` | nvm (top 3 versions, newest first) | +| `~/.local/share/fnm/node-versions/v*/bin` | fnm | +| `/opt/homebrew/opt/node/bin` | Homebrew Node (macOS) | +| `/usr/local/opt/node/bin` | Homebrew Node Intel (macOS) | + +### Windows + +| Path | Source | +|---|---| +| `%APPDATA%\npm` | npm global | +| `%ProgramFiles%\nodejs` | Official installer | +| `%LOCALAPPDATA%\Programs\nodejs` | User-scoped installer | +| `%SystemRoot%\System32` | System | + +--- + +## Post-Install CLI Verification + +After install reports success, the extension does **not** immediately run +`openclaw onboard`. Instead: + +1. Waits 2 seconds for PATH changes to settle +2. Sends `verifyCliBeforeSetup` to extension host +3. Extension host runs `_testOpenClawCli()` (nvm-aware) +4. If found -> proceeds to auto-configure +5. If not found -> shows "Please restart OCCode" message + +This prevents the `ENOENT` crash that occurred when the binary was installed +but not yet visible to the extension process. + +--- + +## Install Methods (in order of preference) + +### Unix with npm available + +```bash +# Without sudo (nvm, user-writable prefix) +npm install -g openclaw + +# With sudo (system prefix like /usr/local) +sudo -E npm install -g openclaw +``` + +### Unix without npm (auto Node.js install) + +1. Try nvm if `~/.nvm/nvm.sh` exists +2. macOS: download official Node.js .pkg, install via `sudo installer` +3. Linux: use nodesource setup script + apt-get/dnf/yum +4. Then `npm install -g openclaw` + +### Windows + +```bash +npm install -g openclaw +``` + +Fallback: +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -Command ` + "Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression" +``` + +--- + +## Failure Handling + +If all install methods fail: + +1. Full output log is collected +2. AI assistant (MoltPilot) is invoked with the complete log + system info +3. User can ask MoltPilot for platform-specific troubleshooting + +--- + +## Files Modified + +All install logic lives in: + +``` +apps/editor/extensions/openclaw/src/panels/home.ts +``` + +Key methods: +- `runInstall()` — main install flow with prerequisite checks + proactive sudo +- `_buildExecEnv()` — PATH construction for spawned processes (nvm/fnm/homebrew aware) +- `_testOpenClawCli()` — post-install binary detection (nvm-aware) +- `_findOpenClawPath()` — binary path resolution diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index bcad8849..d86a3565 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -121,6 +121,24 @@ export class HomePanel { 'agent'); }, 1000); }, 500); + } else if (msg.command === 'verifyCliBeforeSetup') { + // After install, verify the CLI is actually findable before auto-configuring. + void this._testOpenClawCli().then(result => { + if (result.ok) { + try { + this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); + } catch {} + } else { + try { + this._panel.webview.postMessage({ + type: 'installLog', + text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + + ' Please restart OCCode to pick up the new PATH.\n' + }); + } catch {} + void this._update(); + } + }); } else if (msg.command === 'sudoPassword') { // Password modal submitted or cancelled from the webview. HomePanel._pendingPasswordResolve?.(msg.password as string | undefined); @@ -267,6 +285,12 @@ export class HomePanel { const env = panel._buildExecEnv(); const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); + // Quick command check helper (no output, just exit code) + const cmdExists = (cmd: string): Promise => + new Promise(resolve => + cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) + ); + // Spawn a command silently and stream output to the panel. const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => new Promise(resolve => { @@ -336,25 +360,109 @@ export class HomePanel { // Webview drives the post-install navigation via autoSetupSkipped or wizardLog done }; - // ── Step 1: try npm install -g openclaw ─────────────────────────────────── - tee('Checking for npm...\n'); - const npmOk = await new Promise(resolve => - cp.exec('npm --version', { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) - ); + // ══════════════════════════════════════════════════════════════════════════ + // PREREQUISITE CHECKS + PROACTIVE SUDO — run BEFORE any install attempt + // ══════════════════════════════════════════════════════════════════════════ + tee('Checking prerequisites...\n'); + + if (platform === 'darwin') { + // macOS: check Xcode Command Line Tools + const xcodeOk = await new Promise(resolve => + cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { + resolve(!err && !!stdout?.toString().trim()); + }) + ); + if (!xcodeOk) { + tee(' ⚠ Xcode Command Line Tools not installed.\n'); + tee(' Installing Xcode Command Line Tools (this may show a system dialog)...\n'); + await runCaptured('xcode-select', ['--install']); + tee(' ⏳ Please complete the Xcode tools installation in the system dialog,\n'); + tee(' then restart OCCode and try again.\n'); + await fail(); return; + } + tee(' ✓ Xcode Command Line Tools\n'); + } + + if (platform === 'win32') { + // Windows: check Node.js exists + const nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + tee(' ❌ Node.js not found.\n'); + tee(' Please install Node.js 20+ from https://nodejs.org\n'); + tee(' After installing Node.js, restart OCCode and try again.\n'); + await fail(); return; + } + tee(' ✓ Node.js found\n'); + } + + const npmOk = await cmdExists('npm --version'); + if (npmOk) { + tee(' ✓ npm found\n'); + } else if (platform === 'win32') { + tee(' ❌ npm not found (should come with Node.js).\n'); + tee(' Please reinstall Node.js from https://nodejs.org\n'); + await fail(); return; + } else { + tee(' ⚠ npm not found — will attempt to install Node.js\n'); + } + + // Proactive sudo detection (macOS / Linux only) + let sudoCached = false; + if (platform !== 'win32') { + let needsSudo = false; + if (npmOk) { + const prefixResult = await new Promise(resolve => + cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => + resolve(err ? '' : stdout?.toString().trim() || '')) + ); + if (prefixResult) { + try { + const gBin = path.join(prefixResult, 'bin'); + const gLib = path.join(prefixResult, 'lib'); + if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); + if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); + } catch { + needsSudo = true; + } + } + } else { + // No npm — will need to install Node.js, likely needs sudo + for (const dir of ['/usr/local/bin', '/usr/local/lib']) { + try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } + } + } + + if (needsSudo) { + tee('\n Administrator password required for installation.\n'); + const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); + if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } + tee(' ✓ Credentials verified\n'); + sudoCached = true; + } else { + tee(' ✓ Write access OK\n'); + } + } + + tee('\n'); + // ── Step 1: try npm install -g openclaw ─────────────────────────────────── if (npmOk) { - tee('npm found — installing openclaw...\n'); + tee('Installing openclaw via npm...\n'); const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; - const r1 = await runCaptured('npm', ['install', '-g', 'openclaw'], spawnOpts); + const npmArgs = ['install', '-g', 'openclaw']; + const r1 = sudoCached + ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) + : await runCaptured('npm', npmArgs, spawnOpts); if (r1.code === 0) { - await fixOpenclawPermissions(); + if (sudoCached) await fixOpenclawPermissions(); await succeed(); return; } - // Permission error on Unix → ask for sudo, then retry - if (platform !== 'win32' && isPermError(fullLog)) { + // If sudo wasn't cached and we hit permission error, ask now (fallback) + if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { tee('\nPermission error — elevated access required.\n'); const ok = await cacheSudo('Enter your system password to install OpenClaw'); if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; tee('Retrying with elevated permissions...\n'); const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); if (r2.code === 0) { @@ -377,10 +485,13 @@ export class HomePanel { tee('nvm install failed — falling back to system install...\n'); } - // Step B: collect password once, cache sudo for all subsequent steps - tee('\nNode.js is required. Your password is needed once to install it.\n'); - const sudoOk = await cacheSudo('Enter your password to install Node.js'); - if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + // Step B: ensure sudo is cached (may already be from proactive check above) + if (!sudoCached) { + tee('\nNode.js is required. Your password is needed once to install it.\n'); + const sudoOk = await cacheSudo('Enter your password to install Node.js'); + if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } + sudoCached = true; + } if (platform === 'darwin') { // macOS: download official Node.js universal pkg, install silently with cached sudo @@ -1864,29 +1975,33 @@ The binary is already downloaded — do NOT re-download or compile anything.`; // Advance timeline: mark install done, configure active document.getElementById('step-install').className = 'step-item done'; document.getElementById('step-configure').className = 'step-item active'; - // Auto-configure after short delay — skip interactive panel + // Verify CLI is findable before auto-configuring (2s for PATH to settle) setTimeout(function() { - document.getElementById('log-wrap').classList.remove('visible'); - document.getElementById('log-box').innerHTML = ''; - fullLog = ''; - setLogStatus('', ''); - var occKey = _occUser && _occUser.api_keys && _occUser.api_keys.occKey; - if (occKey) { - showLog('Setting up with OCC Legacy inference...\\n'); - setLogStatus('Configuring', 'dots'); - vscode.postMessage({ command: 'runSetup', provider: 'free', apiKey: occKey, port: '18789' }); - } else { - showLog('Skipping AI configuration — not logged in.\\n'); - setLogStatus('Skipping', 'done'); - setTimeout(function() { vscode.postMessage({ command: 'autoSetupSkipped' }); }, 1500); - } - }, 1200); + vscode.postMessage({ command: 'verifyCliBeforeSetup' }); + }, 2000); } else if (d.state === 'failed') { setLogStatus('Installation failed', 'failed'); showMoltHelp(); } } + if (d.type === 'proceedAutoSetup') { + document.getElementById('log-wrap').classList.remove('visible'); + document.getElementById('log-box').innerHTML = ''; + fullLog = ''; + setLogStatus('', ''); + var occKey = _occUser && _occUser.api_keys && _occUser.api_keys.occKey; + if (occKey) { + showLog('Setting up with OCC Legacy inference...\n'); + setLogStatus('Configuring', 'dots'); + vscode.postMessage({ command: 'runSetup', provider: 'free', apiKey: occKey, port: '18789' }); + } else { + showLog('Skipping AI configuration — not logged in.\n'); + setLogStatus('Skipping', 'done'); + setTimeout(function() { vscode.postMessage({ command: 'autoSetupSkipped' }); }, 1500); + } + } + // Configure (wizard) log stream if (d.type === 'wizardLog') { if (!d.done) { @@ -4090,6 +4205,39 @@ The binary is already downloaded — do NOT re-download or compile anything.`; extra.push(path.join(os.homedir(), '.local', 'bin')); extra.push(path.join(os.homedir(), '.npm-global', 'bin')); extra.push(path.join(os.homedir(), '.openclaw', 'bin')); + + // Add nvm-managed Node.js paths (newest version first) + const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); + const nvmVersionsDir = path.join(nvmDir, 'versions', 'node'); + if (fs.existsSync(nvmVersionsDir)) { + try { + const versions = fs.readdirSync(nvmVersionsDir) + .filter(e => /^v?\d+/.test(e)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions.slice(0, 3)) { + extra.push(path.join(nvmVersionsDir, v, 'bin')); + } + } catch { /* non-fatal */ } + } + + // Add fnm paths (popular Node version manager on macOS) + const fnmDir = path.join(os.homedir(), '.local', 'share', 'fnm', 'node-versions'); + if (fs.existsSync(fnmDir)) { + try { + const versions = fs.readdirSync(fnmDir) + .filter(e => /^v?\d+/.test(e)) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + for (const v of versions.slice(0, 3)) { + extra.push(path.join(fnmDir, v, 'installation', 'bin')); + } + } catch { /* non-fatal */ } + } + + // Homebrew Node.js (macOS) + if (process.platform === 'darwin') { + extra.push('/opt/homebrew/opt/node/bin'); + extra.push('/usr/local/opt/node/bin'); + } } const sep = process.platform === 'win32' ? ';' : ':'; env.PATH = [...extra, basePath].filter(Boolean).join(sep); From 18e947e0735c9171f89153dbb9229c39937767fa Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Wed, 18 Mar 2026 04:58:42 -0400 Subject: [PATCH 002/319] feat: auto-install Node.js on Windows when missing (no UAC required) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads official Node.js zip from nodejs.org/dist, extracts to %LOCALAPPDATA%\Programs\nodejs via PowerShell Expand-Archive, updates env PATH, and continues the install flow — no manual step or restart needed. Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/panels/home.ts | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index d86a3565..92fb07af 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -384,23 +384,66 @@ export class HomePanel { } if (platform === 'win32') { - // Windows: check Node.js exists - const nodeOk = await cmdExists('node --version'); + // Windows: check Node.js exists; auto-install if missing (no UAC required) + let nodeOk = await cmdExists('node --version'); if (!nodeOk) { - tee(' ❌ Node.js not found.\n'); - tee(' Please install Node.js 20+ from https://nodejs.org\n'); - tee(' After installing Node.js, restart OCCode and try again.\n'); - await fail(); return; + const nodeVersion = '20.18.2'; + const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; + const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; + const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + const installDir = path.join(localAppData, 'Programs', 'nodejs'); + const tmpZip = path.join(os.tmpdir(), zipName); + const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); + + tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); + const dlR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + if (dlR.code !== 0) { + tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); + await fail(); return; + } + + tee(` Extracting...\n`); + const exR = await runCaptured('powershell', [ + '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', + `$ProgressPreference='SilentlyContinue'; ` + + `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + + `$inner = Get-ChildItem '${tmpExtract}' | Select-Object -First 1; ` + + `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + + `Move-Item $inner.FullName '${installDir}'`, + ], { windowsHide: true, shell: true } as cp.SpawnOptions); + + try { fs.unlinkSync(tmpZip); } catch {} + + if (exR.code !== 0) { + tee(' ❌ Failed to extract Node.js.\n'); + await fail(); return; + } + + // Make new Node.js dir visible to all subsequent commands in this install run + env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); + (env as any).Path = env.PATH; + + nodeOk = await cmdExists('node --version'); + if (!nodeOk) { + tee(' ❌ Node.js install did not complete properly.\n'); + await fail(); return; + } + tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); + } else { + tee(' ✓ Node.js found\n'); } - tee(' ✓ Node.js found\n'); } const npmOk = await cmdExists('npm --version'); if (npmOk) { tee(' ✓ npm found\n'); } else if (platform === 'win32') { - tee(' ❌ npm not found (should come with Node.js).\n'); - tee(' Please reinstall Node.js from https://nodejs.org\n'); + tee(' ❌ npm not found after Node.js install — unexpected.\n'); await fail(); return; } else { tee(' ⚠ npm not found — will attempt to install Node.js\n'); From 5be13ee52a81959c3759c202b775a69ebc12ed79 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Wed, 18 Mar 2026 05:02:55 -0400 Subject: [PATCH 003/319] fix: Windows Node.js install robustness and cleaner install log UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-compute Node.js zip inner dir to avoid | pipe being intercepted by cmd.exe when shell:true wraps PowerShell commands - Expand log box from 160px to 200px to comfortably show download output - Improve log line coloring: ✓ → green, ❌ → red, ⚠ → amber Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/panels/home.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 92fb07af..e14a7e51 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -408,13 +408,14 @@ export class HomePanel { } tee(` Extracting...\n`); + // Pre-compute the inner folder name (avoids | pipe which cmd.exe would intercept) + const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); const exR = await runCaptured('powershell', [ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', `$ProgressPreference='SilentlyContinue'; ` + `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + - `$inner = Get-ChildItem '${tmpExtract}' | Select-Object -First 1; ` + `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + - `Move-Item $inner.FullName '${installDir}'`, + `Move-Item '${innerDir}' '${installDir}'`, ], { windowsHide: true, shell: true } as cp.SpawnOptions); try { fs.unlinkSync(tmpZip); } catch {} @@ -1659,14 +1660,15 @@ The binary is already downloaded — do NOT re-download or compile anything.`; .log-wrap.visible { display: block; } .log-box { background: #0d0d0d; border: 1px solid #222; border-radius: 8px; - padding: 12px 14px; height: 160px; overflow-y: auto; + padding: 12px 14px; height: 200px; overflow-y: auto; font-family: 'SF Mono', 'Fira Mono', 'Consolas', monospace; font-size: 11px; line-height: 1.6; text-align: left; color: #888; scroll-behavior: smooth; } .log-line { white-space: pre-wrap; word-break: break-all; } - .log-line.ok { color: #4ade80; } - .log-line.err { color: #f87171; } + .log-line.ok { color: #4ade80; } + .log-line.err { color: #f87171; } + .log-line.warn { color: #fbbf24; } .log-status { font-size: 12px; color: #555; margin-top: 8px; text-align: center; } @@ -1937,7 +1939,10 @@ The binary is already downloaded — do NOT re-download or compile anything.`; lines.forEach(function(line) { if (!line.trim()) return; var el = document.createElement('div'); - el.className = 'log-line' + (line.includes('✅') || line.includes('successfully') ? ' ok' : line.includes('Error') || line.includes('failed') || line.includes('FAIL') ? ' err' : ''); + var isOk = line.includes('✅') || line.includes('✓') || /successfully|installed to/i.test(line); + var isErr = line.includes('❌') || /\\bError\\b|\\bfailed\\b|\\bFAIL\\b/.test(line); + var isWarn = line.includes('⚠'); + el.className = 'log-line' + (isOk ? ' ok' : isErr ? ' err' : isWarn ? ' warn' : ''); el.textContent = line; box.appendChild(el); }); From 55e27bc3fa754d328ee51200881b10af982dc988 Mon Sep 17 00:00:00 2001 From: Dominic Damoah Date: Wed, 18 Mar 2026 05:15:05 -0400 Subject: [PATCH 004/319] feat: persistent diagnostics log + Open Logs in Search Actions - writeLog() appends timestamped lines to ~/.openclaw/occ-home.log (auto-creates dir, rotates at 500KB by dropping oldest half) - All install output (tee) and setup output (wizardPost) written to log - Session markers: === runInstall START/END === and _runSetup START/END - CLI check result logged on every panel load: ok, cmd path, version - openLogs message handler opens the file in VS Code editor (or shows info message if file doesn't exist yet) - New "Diagnostics > Open Logs" item in Search Actions dropdown (searchable via "log", "diag", "debug") Log location: ~/.openclaw/occ-home.log Co-Authored-By: Claude Sonnet 4.6 --- .../extensions/openclaw/src/panels/home.ts | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index e14a7e51..75ea173a 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -8,6 +8,34 @@ import * as path from 'path'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; +// ── Persistent diagnostics log ──────────────────────────────────────────────── +const LOG_PATH = path.join(os.homedir(), '.openclaw', 'occ-home.log'); +const LOG_MAX_BYTES = 512 * 1024; // 500 KB — rotate when exceeded + +/** + * Append timestamped text to ~/.openclaw/occ-home.log. + * Creates the file (and directory) on first use. Rotates by dropping the + * oldest half of lines when the file exceeds LOG_MAX_BYTES. Never throws. + */ +function writeLog(text: string): void { + try { + const dir = path.dirname(LOG_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + // Rotate if oversized + if (fs.existsSync(LOG_PATH) && fs.statSync(LOG_PATH).size > LOG_MAX_BYTES) { + const lines = fs.readFileSync(LOG_PATH, 'utf-8').split('\n'); + fs.writeFileSync(LOG_PATH, lines.slice(Math.floor(lines.length / 2)).join('\n'), 'utf-8'); + } + const ts = new Date().toISOString(); + // Stamp every non-empty line; leave blank lines unstemmed + const stamped = text + .split('\n') + .map(l => (l.trim() ? `[${ts}] ${l}` : l)) + .join('\n'); + fs.appendFileSync(LOG_PATH, stamped, 'utf-8'); + } catch { /* non-fatal */ } +} + // ── OCC Legacy model constants ──────────────────────────────────────────────── const OCC_LEGACY_MODEL_ID = 'occ-legacy'; const OCC_LEGACY_MODEL_NAME = 'occ-legacy'; @@ -165,6 +193,12 @@ export class HomePanel { } else if (msg.command === 'openConfigFile') { const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); vscode.commands.executeCommand('vscode.open', vscode.Uri.file(configPath)); + } else if (msg.command === 'openLogs') { + if (!fs.existsSync(LOG_PATH)) { + vscode.window.showInformationMessage('No log file yet — logs are created when install or setup runs.'); + } else { + vscode.commands.executeCommand('vscode.open', vscode.Uri.file(LOG_PATH)); + } } else if (msg.command === 'openWorkspaceFile') { const allowed = new Set(['AGENTS.md', 'IDENTITY.md', 'USER.md', 'MEMORY.md', 'SOUL.md', 'HEARTBEAT.md']); const file = msg.file as string; @@ -278,8 +312,9 @@ export class HomePanel { const post = (msg: object) => { try { panel._panel.webview.postMessage(msg); } catch {} }; let fullLog = ''; - const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); }; + const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; + writeLog(`\n=== runInstall START platform=${platform} arch=${arch} ===\n`); post({ type: 'installState', state: 'running' }); const env = panel._buildExecEnv(); @@ -340,6 +375,7 @@ export class HomePanel { }; const fail = async () => { + writeLog('=== runInstall END failed ===\n'); post({ type: 'installState', state: 'failed' }); const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; await vscode.commands.executeCommand('void.openChatWithMessage', [ @@ -356,6 +392,7 @@ export class HomePanel { const succeed = async () => { tee('\n✅ Installed successfully!\n'); + writeLog('=== runInstall END ok ===\n'); post({ type: 'installState', state: 'done' }); // Webview drives the post-install navigation via autoSetupSkipped or wizardLog done }; @@ -657,6 +694,7 @@ export class HomePanel { } const cliCheck = await this._testOpenClawCli(); + writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); const configFile = path.join(os.homedir(), '.openclaw', 'openclaw.json'); const isConfigured = fs.existsSync(configFile); const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" @@ -1103,6 +1141,10 @@ export class HomePanel { private async _runSetup(data: { provider: string; apiKey: string; port: string }): Promise { const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; + const wizardPost = (text: string, done: boolean, ok: boolean) => { + writeLog(text); + post({ type: 'wizardLog', text, done, ok }); + }; const env = this._buildExecEnv(); const cliPath = await this._findOpenClawPath() ?? 'openclaw'; const port = data.port && /^\d+$/.test(data.port) ? data.port : '18789'; @@ -1131,7 +1173,7 @@ export class HomePanel { }; const flags = providerFlags[data.provider]; if (!flags) { - post({ type: 'wizardLog', text: 'Unknown provider selected.\n', done: true, ok: false }); + wizardPost('Unknown provider selected.\n', true, false); return; } @@ -1145,7 +1187,8 @@ export class HomePanel { ...flags, ]; - post({ type: 'wizardLog', text: isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', done: false, ok: false }); + writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); + wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); await new Promise(resolve => { const child = cp.spawn(cliPath, args, { @@ -1153,11 +1196,12 @@ export class HomePanel { stdio: ['ignore', 'pipe', 'pipe'], ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), }); - child.stdout?.on('data', (d: Buffer) => post({ type: 'wizardLog', text: d.toString(), done: false, ok: false })); - child.stderr?.on('data', (d: Buffer) => post({ type: 'wizardLog', text: d.toString(), done: false, ok: false })); + child.stdout?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); + child.stderr?.on('data', (d: Buffer) => wizardPost(d.toString(), false, false)); child.on('close', code => { const ok = code === 0; - post({ type: 'wizardLog', text: ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${code}.\n`, done: true, ok }); + writeLog(`=== _runSetup END code=${code} ===\n`); + wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${code}.\n`, true, ok); if (ok) { if (isFree) { // Write local free-tier marker (no remote enforcement). @@ -1217,7 +1261,7 @@ export class HomePanel { resolve(); }); child.on('error', err => { - post({ type: 'wizardLog', text: `Error: ${err.message}\n`, done: true, ok: false }); + wizardPost(`Error: ${err.message}\n`, true, false); resolve(); }); }); @@ -3268,6 +3312,13 @@ The binary is already downloaded — do NOT re-download or compile anything.`; +
+ + +
+ `; + } else { + const initial = (occUser.email || '?')[0].toUpperCase(); + const safeEmail = occUser.email.replace(/"/g, '"').replace(/` + : initial; + userAreaHtml = ` +
+ +
+
+
${avatarImg}
+
${safeEmail}
+
+ +
+ +
+
`; + } + + const providers = [ + { id: 'anthropic', label: 'Anthropic Claude', hint: 'console.anthropic.com/settings/keys', placeholder: 'sk-ant-...' }, + { id: 'openai', label: 'OpenAI', hint: 'platform.openai.com/api-keys', placeholder: 'sk-...' }, + { id: 'openrouter', label: 'OpenRouter', hint: 'openrouter.ai/settings/keys', placeholder: 'sk-or-...' }, + { id: 'gemini', label: 'Google Gemini', hint: 'aistudio.google.com/apikey', placeholder: 'AIza...' }, + ]; + + const providerCards = providers.map(p => + `` + ).join('\n '); + + return ` + + + + + + + + +
${userAreaHtml}
+ + + +
Set up OpenClaw
+
Follow the steps below to get started
+ + +
+
+
${isInstalled ? '✓' : '1'}
+
Install
OpenClaw
+
+
+
2
+
Configure
AI Model
+
+
+
3
+
Ready
+
+
+ + +
+ + +
+ + + + + + + + + + + + + + +
+
+
Working
+ + + +
+ + + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/package.json b/apps/editor/extensions/openclaw-ssh/package.json index 323749bd..47df9f67 100644 --- a/apps/editor/extensions/openclaw-ssh/package.json +++ b/apps/editor/extensions/openclaw-ssh/package.json @@ -9,8 +9,10 @@ "extensionDependencies": ["openclaw.home"], "categories": ["Other"], "activationEvents": ["onStartupFinished"], - "main": "./out/extension", - "contributes": {}, + "main": "./out/openclaw-ssh/src/extension", + "contributes": { + "commands": [{ "command": "openclaw.host.setup.ssh", "title": "OpenClaw: Set up SSH" }] + }, "scripts": { "compile": "tsc -p ./", "watch": "tsc -watch -p ./" diff --git a/apps/editor/extensions/openclaw-ssh/src/adapter.ts b/apps/editor/extensions/openclaw-ssh/src/adapter.ts new file mode 100644 index 00000000..2d862a87 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/adapter.ts @@ -0,0 +1,71 @@ +import * as vscode from 'vscode'; +import type { + HostAdapter, + HostType, + HostConnection, + HostConnectionConfig, + SSHConnection, + TestResult, + DiscoveredHost, + ConfigField, + ConfigValidationResult, +} from '../../openclaw/src/hosts/types'; +import { SSHHostConnection } from './connection'; + +export class SSHHostAdapter implements HostAdapter { + readonly type: HostType = 'ssh'; + readonly displayName = 'SSH Server'; + readonly icon = new vscode.ThemeIcon('remote'); + + async discover(): Promise { + // Future: parse ~/.ssh/config for known hosts + return []; + } + + async connect(config: HostConnectionConfig): Promise { + return new SSHHostConnection(config as SSHConnection); + } + + async testConnection(config: HostConnectionConfig): Promise { + const sshCfg = config as SSHConnection; + const conn = new SSHHostConnection(sshCfg); + try { + const r = await conn.exec('echo', ['ok'], { timeout: 15000 }); + if (r.code !== 0 || !r.stdout.includes('ok')) { + return { success: false, message: `SSH connection failed: ${r.stderr.trim()}` }; + } + const cliCheck = await conn.testOpenClawCli(); + return { + success: true, + message: cliCheck.ok + ? `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw ${cliCheck.output ?? ''}` + : `Connected to ${sshCfg.user}@${sshCfg.host}. OpenClaw not installed.`, + details: { + openclawInstalled: cliCheck.ok, + openclawVersion: cliCheck.output, + os: 'remote', + hostname: sshCfg.host, + }, + }; + } catch (err) { + return { success: false, message: `SSH error: ${String(err)}` }; + } + } + + getConfigFields(): ConfigField[] { + return [ + { id: 'host', label: 'Hostname or IP', type: 'text', placeholder: 'example.com or 192.168.1.10', required: true }, + { id: 'user', label: 'SSH User', type: 'text', placeholder: 'ubuntu, ec2-user, root...', required: true }, + { id: 'port', label: 'Port', type: 'number', placeholder: '22', defaultValue: 22 }, + { id: 'keyPath', label: 'SSH Key Path', type: 'text', placeholder: '~/.ssh/id_rsa (blank = SSH agent)', required: false }, + ]; + } + + validateConfig(config: HostConnectionConfig): ConfigValidationResult { + const sshCfg = config as SSHConnection; + const errors: { fieldId: string; message: string }[] = []; + if (!sshCfg.host) { errors.push({ fieldId: 'host', message: 'Hostname is required' }); } + if (!sshCfg.user) { errors.push({ fieldId: 'user', message: 'SSH user is required' }); } + return errors.length ? { valid: false, errors } : { valid: true }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/connection.ts b/apps/editor/extensions/openclaw-ssh/src/connection.ts new file mode 100644 index 00000000..79dcfd99 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/connection.ts @@ -0,0 +1,268 @@ +import * as cp from 'child_process'; +import * as path from 'path'; +import type { + HostConnection, + HostType, + ExecOpts, + ExecResult, + LogFn, + CliCheckResult, + GatewayStatus, + GatewayRunState, + OpenClawConfig, + SetupParams, + SSHConnection, +} from '../../openclaw/src/hosts/types'; + +export class SSHHostConnection implements HostConnection { + readonly type: HostType = 'ssh'; + + get id(): string { return `ssh:${this._cfg.user}@${this._cfg.host}`; } + get label(): string { return `${this._cfg.user}@${this._cfg.host}`; } + + constructor(private readonly _cfg: SSHConnection) {} + + dispose(): void {} + + // ── SSH helpers ─────────────────────────── + + private _sshBaseArgs(): string[] { + const args = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (this._cfg.port && this._cfg.port !== 22) { args.push('-p', String(this._cfg.port)); } + if (this._cfg.keyPath) { args.push('-i', this._cfg.keyPath); } + if (this._cfg.jumpHost) { args.push('-J', this._cfg.jumpHost); } + return args; + } + + private _remote(): string { + return `${this._cfg.user}@${this._cfg.host}`; + } + + private _remoteHome(): string { + return this._cfg.user === 'root' ? '/root' : `/home/${this._cfg.user}`; + } + + // ── Process execution ───────────────────── + + exec(cmd: string, args: string[], opts: ExecOpts = {}): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); }); + proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); }); + proc.on('error', reject); + proc.on('close', code => resolve({ stdout, stderr, code: code ?? -1 })); + }); + } + + execStream(cmd: string, args: string[], opts: ExecOpts, onData: LogFn, onError: LogFn): Promise { + const remoteCmd = [cmd, ...args] + .map(a => `'${a.replace(/'/g, "'\\''")}'`) + .join(' '); + return new Promise((resolve, reject) => { + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), remoteCmd], { + env: { ...process.env, ...opts.env }, + timeout: opts.timeout, + windowsHide: opts.windowsHide ?? true, + }); + if (opts.stdinData !== undefined) { proc.stdin.write(opts.stdinData); proc.stdin.end(); } + proc.stdout.on('data', (d: Buffer) => { onData(d.toString()); }); + proc.stderr.on('data', (d: Buffer) => { onError(d.toString()); }); + proc.on('error', reject); + proc.on('close', code => resolve(code ?? -1)); + }); + } + + // ── Filesystem ──────────────────────────── + + async readFile(filePath: string): Promise { + const r = await this.exec('cat', [filePath]); + if (r.code !== 0) { throw new Error(`cat ${filePath} failed: ${r.stderr}`); } + return r.stdout; + } + + async writeFile(filePath: string, content: string): Promise { + const dir = path.posix.dirname(filePath); + await this.exec('mkdir', ['-p', dir]); + await new Promise((resolve, reject) => { + const quotedPath = filePath.replace(/'/g, "'\\''"); + const proc = cp.spawn('ssh', [...this._sshBaseArgs(), this._remote(), `cat > '${quotedPath}'`], { windowsHide: true }); + proc.stdin.write(content); + proc.stdin.end(); + proc.on('error', reject); + proc.on('close', code => code === 0 ? resolve() : reject(new Error(`writeFile failed: code ${code}`))); + }); + } + + async exists(filePath: string): Promise { + const r = await this.exec('test', ['-e', filePath]); + return r.code === 0; + } + + async mkdir(dirPath: string): Promise { + await this.exec('mkdir', ['-p', dirPath]); + } + + async stat(filePath: string): Promise<{ size: number; isDirectory: boolean } | null> { + const r = await this.exec('stat', ['-c', '%s %F', filePath]); + if (r.code !== 0) { return null; } + const parts = r.stdout.trim().split(' '); + return { + size: parseInt(parts[0] ?? '0', 10), + isDirectory: (parts[1] ?? '').includes('directory'), + }; + } + + // ── CLI ─────────────────────────────────── + + async findOpenClawPath(): Promise { + const r = await this.exec('which', ['openclaw']); + if (r.code === 0) { + const p = r.stdout.trim().split('\n')[0]; + if (p) { return p; } + } + for (const p of [ + '/usr/local/bin/openclaw', + '/usr/bin/openclaw', + `${this._remoteHome()}/.local/bin/openclaw`, + ]) { + if (await this.exists(p)) { return p; } + } + return undefined; + } + + async isCliInstalled(): Promise { + return (await this.findOpenClawPath()) !== undefined; + } + + async getCliVersion(): Promise { + const p = await this.findOpenClawPath(); + if (!p) { return null; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + const m = out.match(/[\d]+\.[\d]+\.[\d]+/); + return m ? m[0] : out || null; + } catch { return null; } + } + + async testOpenClawCli(): Promise { + const p = await this.findOpenClawPath(); + const command = p ? `ssh ${this._remote()} ${p} --version` : `ssh ${this._remote()} openclaw --version`; + if (!p) { return { ok: false, command, error: 'OpenClaw CLI not found on remote host' }; } + try { + const r = await this.exec(p, ['--version'], { timeout: 8000 }); + const out = (r.stdout + r.stderr).trim(); + return r.code === 0 + ? { ok: true, command, output: out } + : { ok: false, command, error: `Exit ${r.code}: ${out}` }; + } catch (err) { return { ok: false, command, error: String(err) }; } + } + + async installCli(onLog: LogFn): Promise { + onLog('Installing OpenClaw on remote host...\n'); + const code = await this.execStream( + 'bash', ['-c', 'curl -fsSL https://get.openclaw.sh | bash'], + { timeout: 120_000 }, onLog, onLog, + ); + if (code !== 0) { throw new Error(`Installer exited with code ${code}`); } + onLog('OpenClaw installed.\n'); + } + + // ── OpenClaw config ─────────────────────── + + async getConfigPath(): Promise { + return `${this._remoteHome()}/.openclaw/openclaw.json`; + } + + async readConfig(): Promise { + const p = await this.getConfigPath(); + if (!(await this.exists(p))) { return {}; } + try { return JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { return {}; } + } + + async writeConfig(patch: Partial): Promise { + const p = await this.getConfigPath(); + let existing: OpenClawConfig = {}; + if (await this.exists(p)) { + try { existing = JSON.parse(await this.readFile(p)) as OpenClawConfig; } catch { /* ok */ } + } + await this.writeFile(p, JSON.stringify({ ...existing, ...patch }, null, 2)); + } + + // ── Gateway ─────────────────────────────── + + async gatewayHealthCheck(): Promise { + const cliPath = await this.findOpenClawPath(); + if (!cliPath) { return { state: 'unknown' as GatewayRunState, error: 'CLI not installed' }; } + try { + const r = await this.exec(cliPath, ['gateway', 'status', '--json'], { timeout: 8000 }); + if (r.code === 0) { + try { + const parsed = JSON.parse(r.stdout) as Partial; + return { + state: parsed.state ?? 'unknown', + port: parsed.port ?? this._cfg.gatewayPort, + version: parsed.version, + uptime: parsed.uptime, + }; + } catch { + const out = (r.stdout + r.stderr).toLowerCase(); + return { state: out.includes('running') ? 'running' : 'stopped', port: this._cfg.gatewayPort }; + } + } + return { state: 'stopped' as GatewayRunState }; + } catch { return { state: 'error' as GatewayRunState, error: 'Health check failed' }; } + } + + async gatewayStart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'start'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway start exited with code ${code}`); } + } + + async gatewayStop(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'stop'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway stop exited with code ${code}`); } + } + + async gatewayRestart(onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed'); } + const code = await this.execStream(p, ['gateway', 'restart'], {}, onLog, onLog); + if (code !== 0) { throw new Error(`gateway restart exited with code ${code}`); } + } + + async runSetup(params: SetupParams, onLog: LogFn): Promise { + const p = await this.findOpenClawPath(); + if (!p) { throw new Error('OpenClaw CLI not installed — call installCli first'); } + const code = await this.execStream( + p, + ['onboard', '--provider', params.provider, '--api-key', params.apiKey, '--port', params.port], + {}, onLog, onLog, + ); + if (code !== 0) { throw new Error(`onboard exited with code ${code}`); } + } + + // ── Environment ─────────────────────────── + + buildExecEnv(): Record { + return { ...process.env }; + } +} diff --git a/apps/editor/extensions/openclaw-ssh/src/extension.ts b/apps/editor/extensions/openclaw-ssh/src/extension.ts index 55a38ee4..47ac0855 100644 --- a/apps/editor/extensions/openclaw-ssh/src/extension.ts +++ b/apps/editor/extensions/openclaw-ssh/src/extension.ts @@ -1,13 +1,28 @@ import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; +import { SSHHostAdapter } from './adapter'; +import { SSHSetupPanel } from './setup-panel'; -/** - * openclaw-ssh — Preview stub. - * - * SSH host support is coming soon. This extension activates silently - * and will register an SSHHostAdapter once the implementation lands. - */ -export function activate(_context: vscode.ExtensionContext): void { - console.log('[openclaw-ssh] SSH adapter — coming soon'); +export async function activate(context: vscode.ExtensionContext): Promise { + const coreExt = vscode.extensions.getExtension('openclaw.home'); + if (!coreExt) { + console.warn('[openclaw-ssh] Core extension not found'); + return; + } + const coreAPI = coreExt.isActive ? coreExt.exports : await coreExt.activate(); + if (!coreAPI) { + console.warn('[openclaw-ssh] Core extension did not export API'); + return; + } + const disposable = coreAPI.registerHostAdapter(new SSHHostAdapter()); + context.subscriptions.push(disposable); + + const setupCmd = vscode.commands.registerCommand('openclaw.host.setup.ssh', () => { + SSHSetupPanel.createOrShow(context.extensionUri, coreAPI); + }); + context.subscriptions.push(setupCmd); + + console.log('[openclaw-ssh] SSHHostAdapter registered'); } export function deactivate(): void {} diff --git a/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts new file mode 100644 index 00000000..dc5e7212 --- /dev/null +++ b/apps/editor/extensions/openclaw-ssh/src/setup-panel.ts @@ -0,0 +1,341 @@ +import * as cp from 'child_process'; +import * as vscode from 'vscode'; +import type { OpenClawCoreAPI } from '../../openclaw/src/hosts/types'; + +export class SSHSetupPanel { + public static currentPanel: SSHSetupPanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private _disposables: vscode.Disposable[] = []; + + public static createOrShow(extensionUri: vscode.Uri, coreAPI: OpenClawCoreAPI): void { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (SSHSetupPanel.currentPanel) { + SSHSetupPanel.currentPanel._panel.reveal(column); + return; + } + + const homeExt = vscode.extensions.getExtension('openclaw.home'); + const homeUri = homeExt?.extensionUri ?? extensionUri; + const iconUri = vscode.Uri.joinPath(homeUri, 'media', 'icon.png'); + + const panel = vscode.window.createWebviewPanel( + 'openclawSSHSetup', + 'OCC Home [SSH]', + column ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(homeUri, 'media'), + ], + retainContextWhenHidden: true, + }, + ); + + SSHSetupPanel.currentPanel = new SSHSetupPanel(panel, extensionUri, coreAPI, iconUri); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _coreAPI: OpenClawCoreAPI, + iconUri: vscode.Uri, + ) { + this._panel = panel; + this._extensionUri = extensionUri; + + const webviewIconUri = panel.webview.asWebviewUri(iconUri).toString(); + this._panel.webview.html = this._getHtml(webviewIconUri); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + async (msg: { command: string; host?: string; user?: string; port?: string; keyPath?: string }) => { + switch (msg.command) { + case 'sshInstallBegin': { + const host = msg.host ?? ''; + const user = msg.user ?? ''; + const port = parseInt(msg.port ?? '22', 10) || 22; + const keyPath = msg.keyPath || undefined; + await this._handleSSHInstall(host, user, port, keyPath); + break; + } + case 'closePanel': + this.dispose(); + break; + default: + if (msg.command) { + void vscode.commands.executeCommand(msg.command); + } + break; + } + }, + null, + this._disposables, + ); + } + + public dispose(): void { + SSHSetupPanel.currentPanel = undefined; + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) { d.dispose(); } + } + } + + // ── Handler ──────────────────────────────────────────────────────────────── + + private async _handleSSHInstall(host: string, user: string, port: number, keyPath?: string): Promise { + const log = (text: string, isErr = false) => { + try { this._panel.webview.postMessage({ type: 'sshLog', text, isErr }); } catch { /* ignore */ } + }; + const fail = (text: string) => { + try { this._panel.webview.postMessage({ type: 'sshError', text }); } catch { /* ignore */ } + }; + + try { + const sshBase = [ + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=15', + '-o', 'StrictHostKeyChecking=accept-new', + ]; + if (port !== 22) { sshBase.push('-p', String(port)); } + if (keyPath) { sshBase.push('-i', keyPath); } + const remote = `${user}@${host}`; + + // Test connection + log(`Testing SSH connection to ${remote}...\n`); + const test = cp.spawnSync('ssh', [...sshBase, remote, 'echo ok'], { timeout: 15000, windowsHide: true }); + if (test.status !== 0 || !test.stdout.toString().includes('ok')) { + fail(`Cannot connect to ${remote}.\n${test.stderr?.toString().trim() || 'Check hostname, user, and SSH key.'}`); + return; + } + log(`\u2713 Connected to ${remote}\n`); + + // Check if openclaw already installed + log('Checking for OpenClaw CLI...\n'); + const check = cp.spawnSync('ssh', [...sshBase, remote, 'which openclaw'], { timeout: 8000, windowsHide: true }); + const alreadyInstalled = check.status === 0 && check.stdout.toString().trim().length > 0; + + if (!alreadyInstalled) { + log('Installing OpenClaw...\n'); + const code = await new Promise((resolve) => { + const proc = cp.spawn('ssh', [...sshBase, remote, 'curl -fsSL https://get.openclaw.sh | bash'], { windowsHide: true }); + proc.stdout.on('data', (d: Buffer) => log(d.toString())); + proc.stderr.on('data', (d: Buffer) => log(d.toString(), true)); + proc.on('close', (c) => resolve(c ?? -1)); + proc.on('error', () => resolve(-1)); + }); + if (code !== 0) { fail('OpenClaw installation failed. See the log above for details.'); return; } + log('\n\u2713 OpenClaw installed\n'); + } else { + log('\u2713 OpenClaw already installed \u2014 skipping\n'); + } + + // Register SSH host + log('\nRegistering SSH host...\n'); + const entry = await this._coreAPI.addHost({ + type: 'ssh', + label: `SSH (${user}@${host})`, + connection: { + type: 'ssh', + host, + port: port !== 22 ? port : undefined, + user, + authMethod: keyPath ? 'key' : 'agent', + keyPath: keyPath || undefined, + }, + lastStatus: 'online', + }); + await this._coreAPI.setActiveHost(entry.id); + + log('\u2713 Done \u2014 loading setup...\n'); + try { this._panel.webview.postMessage({ type: 'sshInstallDone' }); } catch { /* ignore */ } + } catch (err) { + fail(String(err)); + } + } + + // ── HTML ─────────────────────────────────────────────────────────────────── + + private _getHtml(iconUri: string): string { + return ` + + + + + + + + +
SSH Installation
+
Install OpenClaw on a remote server via SSH
+ + +
+
+
+
+
Hostname or IP
+ +
+
+
Port
+ +
+
+
+
SSH User
+ +
+
+
SSH Key Path (leave blank to use SSH agent)
+ +
+
+ +
+
+ + +
+
+
+ Connecting... +
+
+ + +
+ + + + + +`; + } +} diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index d39f3e48..7ae0d746 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -628,12 +628,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { - void HomePanel.runInstall( - context.extensionUri, - process.platform, - process.arch, - process.env.SHELL ?? '', - ); + // Delegate to LocalSetupPanel via the host setup command + void vscode.commands.executeCommand('openclaw.host.setup.local'); }), vscode.commands.registerCommand('openclaw.openWorkspace', () => { void openOpenClawFolder(); diff --git a/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts new file mode 100644 index 00000000..77f82d4b --- /dev/null +++ b/apps/editor/extensions/openclaw/src/hosts/hostsFile.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export interface HostFileEntry { + id: string; + type: 'local' | 'docker' | 'ssh'; + label: string; + configuredAt: string; + containerName?: string; + lastStatus?: string; +} + +export interface HostsFileData { + hosts: HostFileEntry[]; +} + +const HOSTS_PATH = path.join(os.homedir(), '.occ', 'hosts.json'); + +export function readHostsFile(): HostsFileData { + try { + const raw = fs.readFileSync(HOSTS_PATH, 'utf-8'); + return JSON.parse(raw) as HostsFileData; + } catch { + return { hosts: [] }; + } +} + +export function writeHostEntry(entry: HostFileEntry): void { + const occDir = path.join(os.homedir(), '.occ'); + if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } + const file = readHostsFile(); + // Remove existing entry with same id + file.hosts = file.hosts.filter(h => h.id !== entry.id); + file.hosts.push(entry); + fs.writeFileSync(HOSTS_PATH, JSON.stringify(file, null, 2), 'utf-8'); +} diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts index 275a3d45..868755d9 100644 --- a/apps/editor/extensions/openclaw/src/hosts/manager.ts +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -130,6 +130,15 @@ export class HostManager implements OpenClawCoreAPI, vscode.Disposable { return undefined; } + async addHost(entry: Omit): Promise { + const id = `${entry.type}-${Date.now()}`; + const full: HostEntry = { ...entry, id, createdAt: new Date().toISOString() }; + this.registry.addHost(full); + this._onDidAddHost.fire(full); + await this._ensureConnected(id).catch(() => { /* ignore — caller handles */ }); + return full; + } + async refreshHost(id: string): Promise { const conn = this._connections.get(id); if (!conn) { diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index 86a628b0..932d4634 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -300,5 +300,6 @@ export interface OpenClawCoreAPI { showHostPicker(): Promise; showAddHostWizard(type?: HostType): Promise; + addHost(entry: Omit): Promise; refreshHost(id: string): Promise; } diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 20af41df..f794e2e6 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -7,6 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import type { HostConnection, OpenClawCoreAPI } from '../hosts/types'; import { DefaultLocalHostConnection } from '../hosts/localDefault'; +import { renderStatusHtml } from './statusHtml'; type GatewayStatus = 'checking' | 'running' | 'stopped' | 'starting' | 'stopping' | 'restarting' | 'errored' | 'ai-fixing'; @@ -85,10 +86,6 @@ function getOpenClawWorkspaceDir(): string { export class HomePanel { public static currentPanel: HomePanel | undefined; private static _installTerminal: vscode.Terminal | undefined; - /** Resolves with the password (or undefined on cancel) when the webview modal submits. */ - private static _pendingPasswordResolve: ((pwd: string | undefined) => void) | undefined; - /** Absolute path to the openclaw binary found immediately after a successful install. */ - private static _installedCliPath: string | undefined; private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; @@ -108,6 +105,8 @@ export class HomePanel { private _host: HostConnection = new DefaultLocalHostConnection(); /** Cached gateway port — populated on every _update() so _getConfiguredPort() stays sync. */ private _cachedGatewayPort = 18789; + /** Core extension API — used to register docker hosts after wizard completes. */ + private _coreAPI: OpenClawCoreAPI | undefined; private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) { this._panel = panel; @@ -117,6 +116,7 @@ export class HomePanel { const coreExt = vscode.extensions.getExtension('openclaw.home'); if (coreExt?.isActive && coreExt.exports != null) { const coreAPI = coreExt.exports; + this._coreAPI = coreAPI; const hostSub = coreAPI.onDidChangeActiveHost(conn => { this._host = conn ?? new DefaultLocalHostConnection(); void this._update(); @@ -162,47 +162,6 @@ export class HomePanel { 'Please run `openclaw update` to upgrade OpenClaw to the latest version.', 'agent', ); - } else if (msg.command === 'runSetup') { - void this._runSetup(msg as { command: string; provider: string; apiKey: string; port: string }); - } else if (msg.command === 'autoSetupSkipped') { - setTimeout(() => { - HomePanel.refresh(); - void vscode.commands.executeCommand('openclaw.openWorkspace'); - setTimeout(() => { - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 500); - } else if (msg.command === 'verifyCliBeforeSetup') { - // After install, verify the CLI is actually findable before auto-configuring. - // First: use the path we captured immediately after npm install (most reliable). - const storedPath = HomePanel._installedCliPath; - if (storedPath && fs.existsSync(storedPath)) { - try { this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); } catch {} - return; - } - // Fallback: full PATH-based search. - void this._testOpenClawCli().then(result => { - if (result.ok) { - try { - this._panel.webview.postMessage({ type: 'proceedAutoSetup' }); - } catch {} - } else { - try { - this._panel.webview.postMessage({ - type: 'installLog', - text: '\n⚠️ OpenClaw was installed but could not be found in PATH.\n' + - ' Please restart OCCode to pick up the new PATH.\n' - }); - } catch {} - void this._update(); - } - }); - } else if (msg.command === 'sudoPassword') { - // Password modal submitted or cancelled from the webview. - HomePanel._pendingPasswordResolve?.(msg.password as string | undefined); - HomePanel._pendingPasswordResolve = undefined; } else if (msg.command === 'toggleChat') { const cmd = this._sidebarOpen ? 'void.sidebar.close' : 'void.sidebar.open'; void vscode.commands.executeCommand(cmd).then(async () => { @@ -293,6 +252,15 @@ export class HomePanel { if (args && args.length > 0) { void vscode.commands.executeCommand('void.openChatWithMessage', args[0], 'agent'); } + } else if (msg.command === 'chooseHostType') { + const t = msg.hostType as string; + if (t === 'local') { + void vscode.commands.executeCommand('openclaw.host.setup.local'); + } else if (t === 'docker') { + void vscode.commands.executeCommand('openclaw.host.setup.docker'); + } else if (t === 'ssh') { + void vscode.commands.executeCommand('openclaw.host.setup.ssh'); + } } else if (msg.command) { vscode.commands.executeCommand(msg.command); } @@ -325,405 +293,6 @@ export class HomePanel { } } - /** - * Fully silent install — no terminal is ever opened. - * Output is streamed line-by-line to the home panel webview. - * If sudo is needed, a VS Code password dialog is shown. - * On any failure the AI is invoked immediately with full context. - */ - public static async runInstall( - extensionUri: vscode.Uri, - platform: string, - arch: string, - shell: string, - ): Promise { - HomePanel.createOrShow(extensionUri); - const panel = HomePanel.currentPanel; - if (!panel) return; - - const post = (msg: object) => { try { panel._panel.webview.postMessage(msg); } catch {} }; - let fullLog = ''; - const tee = (text: string) => { fullLog += text; post({ type: 'installLog', text }); writeLog(text); }; - - writeLog(`\n=== runInstall START platform=${platform} arch=${arch} ===\n`); - post({ type: 'installState', state: 'running' }); - - const env = panel._buildExecEnv(); - const isPermError = (s: string) => /EACCES|permission denied|EPERM|not permitted|Need sudo access|needs to be an Administrator/i.test(s); - - // Quick command check helper (no output, just exit code) - const cmdExists = (cmd: string): Promise => - new Promise(resolve => - cp.exec(cmd, { env, timeout: 5000, windowsHide: true }, err => resolve(!err)) - ); - - // Spawn a command silently and stream output to the panel. - const runCaptured = (cmd: string, args: string[], opts: cp.SpawnOptions = {}): Promise<{ code: number }> => - new Promise(resolve => { - const child = cp.spawn(cmd, args, { env, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); - child.stdout?.on('data', (d: Buffer) => tee(d.toString())); - child.stderr?.on('data', (d: Buffer) => tee(d.toString())); - child.on('close', code => resolve({ code: code ?? 1 })); - child.on('error', err => { tee(`\nError: ${err.message}\n`); resolve({ code: 1 }); }); - }); - - // Ask for sudo password via in-webview modal, cache with `sudo -S -v`, return success. - const cacheSudo = async (_prompt: string): Promise => { - const password = await new Promise(resolve => { - HomePanel._pendingPasswordResolve = resolve; - post({ type: 'requestPassword' }); - }); - if (!password) return false; - tee('Verifying credentials...\n'); - return new Promise(resolve => { - const child = cp.spawn('sudo', ['-S', '-v'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); - child.stdin?.write(password + '\n'); - child.stdin?.end(); - child.on('close', code => resolve(code === 0)); - child.on('error', () => resolve(false)); - }); - }; - - // Fix ~/.openclaw ownership after any sudo-based install. - // Uses `sudo -n` (non-interactive) which succeeds as long as the cached sudo session - // from cacheSudo() is still valid (~15 min default). Safe to call even when sudo - // wasn't used — chown is a no-op when the directory is already user-owned. - const fixOpenclawPermissions = async () => { - if (platform === 'win32') return; - const openclawDir = path.join(os.homedir(), '.openclaw'); - if (!fs.existsSync(openclawDir)) return; - try { - const username = os.userInfo().username; - tee('Fixing .openclaw folder ownership...\n'); - await runCaptured('sudo', ['-n', 'chown', '-R', username, openclawDir]); - await runCaptured('chmod', ['700', openclawDir]); - tee('✅ Permissions set (700, owned by you)\n'); - } catch { /* non-fatal */ } - }; - - const failCancelled = () => { - post({ type: 'installState', state: 'cancelled' }); - }; - - const fail = async () => { - writeLog('=== runInstall END failed ===\n'); - post({ type: 'installState', state: 'failed' }); - const platformDesc = platform === 'darwin' ? 'macOS' : platform === 'win32' ? 'Windows' : `Linux (${arch})`; - await vscode.commands.executeCommand('void.openChatWithMessage', [ - `OpenClaw installation failed on **${platformDesc}**.`, - ``, `**System info:**`, - `- Node.js: \`${process.version}\``, - `- Shell: \`${shell || 'unknown'}\``, - ``, `**Full output:**`, `\`\`\``, fullLog.trim(), `\`\`\``, ``, - `Please diagnose what went wrong and provide exact steps to fix it on this platform.`, - `If Node.js or npm is missing, explain how to install them first.`, - ].join('\n')); - void vscode.commands.executeCommand('openclaw.balance.spend'); - }; - - // After a successful npm install, find the binary's absolute path immediately - // (before PATH settles) so verifyCliBeforeSetup can use it directly. - const captureInstalledPath = async (): Promise => { - if (platform === 'win32') return; // Windows uses cmd shims; handled by _testOpenClawCli - const candidates: string[] = []; - // 1. Ask npm where its global prefix is (same env as the install used) - const prefixResult = await new Promise(resolve => { - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : (stdout || '').trim()) - ); - }); - if (prefixResult) candidates.push(path.join(prefixResult, 'bin', 'openclaw')); - // 2. Hard-coded common macOS/Linux paths - const home = os.homedir(); - candidates.push( - '/usr/local/bin/openclaw', - '/opt/homebrew/bin/openclaw', - path.join(home, '.local', 'bin', 'openclaw'), - path.join(home, '.npm-global', 'bin', 'openclaw'), - path.join(home, '.openclaw', 'bin', 'openclaw'), - ); - // 3. nvm versions (newest first) - const nvmVersionsDir = path.join(home, '.nvm', 'versions', 'node'); - if (fs.existsSync(nvmVersionsDir)) { - fs.readdirSync(nvmVersionsDir) - .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })) - .forEach(ver => candidates.push(path.join(nvmVersionsDir, ver, 'bin', 'openclaw'))); - } - const found = candidates.find(c => c && fs.existsSync(c)); - if (found) { - HomePanel._installedCliPath = found; - tee(` ✓ Binary found at ${found}\n`); - } - }; - - const succeed = async () => { - await captureInstalledPath(); - tee('\n✅ Installed successfully!\n'); - writeLog('=== runInstall END ok ===\n'); - post({ type: 'installState', state: 'done' }); - // Webview drives the post-install navigation via autoSetupSkipped or wizardLog done - }; - - // ══════════════════════════════════════════════════════════════════════════ - // PREREQUISITE CHECKS + PROACTIVE SUDO — run BEFORE any install attempt - // ══════════════════════════════════════════════════════════════════════════ - tee('Checking prerequisites...\n'); - - if (platform === 'darwin') { - // macOS: check Xcode Command Line Tools - const xcodeOk = await new Promise(resolve => - cp.exec('xcode-select -p', { env, timeout: 5000 }, (err, stdout) => { - resolve(!err && !!stdout?.toString().trim()); - }) - ); - if (!xcodeOk) { - post({ type: 'xcodeRequired' }); - return; - } - tee(' ✓ Xcode Command Line Tools\n'); - } - - if (platform === 'win32') { - // Windows: check Node.js exists; auto-install if missing (no UAC required) - let nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - const nodeVersion = '20.18.2'; - const nodeArch = arch === 'arm64' ? 'arm64' : 'x64'; - const zipName = `node-v${nodeVersion}-win-${nodeArch}.zip`; - const zipUrl = `https://nodejs.org/dist/v${nodeVersion}/${zipName}`; - const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); - const installDir = path.join(localAppData, 'Programs', 'nodejs'); - const tmpZip = path.join(os.tmpdir(), zipName); - const tmpExtract = path.join(os.tmpdir(), `occ-node-${Date.now()}`); - - tee(` ⚠ Node.js not found — downloading v${nodeVersion} (${nodeArch})...\n`); - const dlR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing '${zipUrl}' -OutFile '${tmpZip}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - if (dlR.code !== 0) { - tee(' ❌ Failed to download Node.js. Check your internet connection.\n'); - await fail(); return; - } - - tee(` Extracting...\n`); - // Pre-compute the inner folder name (avoids | pipe which cmd.exe would intercept) - const innerDir = path.join(tmpExtract, `node-v${nodeVersion}-win-${nodeArch}`); - const exR = await runCaptured('powershell', [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ProgressPreference='SilentlyContinue'; ` + - `Expand-Archive -Path '${tmpZip}' -DestinationPath '${tmpExtract}' -Force; ` + - `if (Test-Path '${installDir}') { Remove-Item '${installDir}' -Recurse -Force }; ` + - `Move-Item '${innerDir}' '${installDir}'`, - ], { windowsHide: true, shell: true } as cp.SpawnOptions); - - try { fs.unlinkSync(tmpZip); } catch {} - - if (exR.code !== 0) { - tee(' ❌ Failed to extract Node.js.\n'); - await fail(); return; - } - - // Make new Node.js dir visible to all subsequent commands in this install run - env.PATH = [installDir, env.PATH || ''].filter(Boolean).join(';'); - (env as any).Path = env.PATH; - - nodeOk = await cmdExists('node --version'); - if (!nodeOk) { - tee(' ❌ Node.js install did not complete properly.\n'); - await fail(); return; - } - tee(` ✓ Node.js v${nodeVersion} installed to ${installDir}\n`); - } else { - tee(' ✓ Node.js found\n'); - } - } - - const npmOk = await cmdExists('npm --version'); - if (npmOk) { - tee(' ✓ npm found\n'); - } else if (platform === 'win32') { - tee(' ❌ npm not found after Node.js install — unexpected.\n'); - await fail(); return; - } else { - tee(' ⚠ npm not found — will attempt to install Node.js\n'); - } - - // Proactive sudo detection (macOS / Linux only) - let sudoCached = false; - if (platform !== 'win32') { - let needsSudo = false; - if (npmOk) { - const prefixResult = await new Promise(resolve => - cp.exec('npm config get prefix', { env, timeout: 5000 }, (err, stdout) => - resolve(err ? '' : stdout?.toString().trim() || '')) - ); - if (prefixResult) { - try { - const gBin = path.join(prefixResult, 'bin'); - const gLib = path.join(prefixResult, 'lib'); - if (fs.existsSync(gBin)) fs.accessSync(gBin, fs.constants.W_OK); - if (fs.existsSync(gLib)) fs.accessSync(gLib, fs.constants.W_OK); - } catch { - needsSudo = true; - } - } - } else { - // No npm — will need to install Node.js, likely needs sudo - for (const dir of ['/usr/local/bin', '/usr/local/lib']) { - try { if (fs.existsSync(dir)) fs.accessSync(dir, fs.constants.W_OK); } catch { needsSudo = true; break; } - } - } - - if (needsSudo) { - tee('\n Administrator password required for installation.\n'); - const sudoOk = await cacheSudo('Enter your system password to install OpenClaw'); - if (!sudoOk) { tee(' Incorrect password or cancelled.\n'); failCancelled(); return; } - tee(' ✓ Credentials verified\n'); - sudoCached = true; - } else { - tee(' ✓ Write access OK\n'); - } - } - - tee('\n'); - - // ── Step 1: try npm install -g openclaw ─────────────────────────────────── - if (npmOk) { - tee('Installing openclaw via npm...\n'); - const spawnOpts: cp.SpawnOptions = platform === 'win32' ? { shell: true, windowsHide: true } : {}; - const npmArgs = ['install', '-g', 'openclaw']; - const r1 = sudoCached - ? await runCaptured('sudo', ['-E', 'npm', ...npmArgs]) - : await runCaptured('npm', npmArgs, spawnOpts); - if (r1.code === 0) { - if (sudoCached) await fixOpenclawPermissions(); - await succeed(); return; - } - // If sudo wasn't cached and we hit permission error, ask now (fallback) - if (!sudoCached && platform !== 'win32' && isPermError(fullLog)) { - tee('\nPermission error — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to install OpenClaw'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'npm', 'install', '-g', 'openclaw']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - tee('\nnpm install did not succeed — trying full installer script...\n'); - } else if (platform !== 'win32') { - - // ── Unix: npm not found — silent Node.js install (no terminal) ────────── - // Step A: try nvm (no sudo, no password needed) - const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); - if (fs.existsSync(nvmSh)) { - tee('nvm detected — installing Node.js LTS...\n'); - const nvmR = await runCaptured('bash', ['-c', - `. "${nvmSh}" && nvm install --lts && nvm use --lts && npm install -g openclaw` - ]); - if (nvmR.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - tee('nvm install failed — falling back to system install...\n'); - } - - // Step B: ensure sudo is cached (may already be from proactive check above) - if (!sudoCached) { - tee('\nNode.js is required. Your password is needed once to install it.\n'); - const sudoOk = await cacheSudo('Enter your password to install Node.js'); - if (!sudoOk) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - sudoCached = true; - } - - if (platform === 'darwin') { - // macOS: download official Node.js universal pkg, install silently with cached sudo - const nodeVersion = '20.18.2'; - const pkgUrl = `https://nodejs.org/dist/v${nodeVersion}/node-v${nodeVersion}.pkg`; - const pkgPath = `/tmp/.occ-node-${nodeVersion}.pkg`; - tee(`Downloading Node.js v${nodeVersion}...\n`); - const dlR = await runCaptured('curl', ['-fsSL', pkgUrl, '-o', pkgPath]); - if (dlR.code !== 0) { try { fs.unlinkSync(pkgPath); } catch {} await fail(); return; } - tee('Installing Node.js (this may take a moment)...\n'); - const instR = await runCaptured('sudo', ['-n', 'installer', '-pkg', pkgPath, '-target', '/']); - try { fs.unlinkSync(pkgPath); } catch { /* non-fatal */ } - if (instR.code !== 0) { await fail(); return; } - } else { - // Linux: detect package manager and install Node.js LTS via nodesource - const hasCmdSync = (cmd: string): boolean => { - try { cp.execSync(`which ${cmd}`, { env, stdio: 'ignore' }); return true; } catch { return false; } - }; - tee('Installing Node.js via package manager...\n'); - let pkgResult: { code: number } | undefined; - if (hasCmdSync('apt-get')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs' - ]); - } else if (hasCmdSync('dnf')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && dnf install -y nodejs' - ]); - } else if (hasCmdSync('yum')) { - pkgResult = await runCaptured('sudo', ['-n', 'bash', '-c', - 'curl -fsSL https://rpm.nodesource.com/setup_lts.x | bash - && yum install -y nodejs' - ]); - } - if (!pkgResult) { tee('No supported package manager found (tried apt-get, dnf, yum).\n'); await fail(); return; } - if (pkgResult.code !== 0) { await fail(); return; } - } - - // Step C: npm is now installed — find it and install openclaw - tee('Installing OpenClaw...\n'); - const npmCandidates = ['/usr/local/bin/npm', '/usr/bin/npm']; - const npmBin = npmCandidates.find(p => fs.existsSync(p)) ?? 'npm'; - const npmR1 = await runCaptured(npmBin, ['install', '-g', 'openclaw']); - if (npmR1.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - // Global prefix dir may be root-owned — retry with cached sudo - if (isPermError(fullLog)) { - const npmR2 = await runCaptured('sudo', ['-n', npmBin, 'install', '-g', 'openclaw']); - if (npmR2.code === 0) { await fixOpenclawPermissions(); await succeed(); return; } - } - await fail(); return; - - } else { - tee('npm not found — running full installer script...\n'); - } - - // ── Step 2: full install script ── (npm found but failed, or Windows no npm) - if (platform === 'win32') { - tee('Running PowerShell installer...\n'); - const psArgs = [ - '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', - `$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; ` + - `Invoke-WebRequest -UseBasicParsing https://openclaw.ai/install.ps1 | Invoke-Expression`, - ]; - const r = await runCaptured('powershell', psArgs, { windowsHide: true } as cp.SpawnOptions); - if (r.code === 0) { await succeed(); return; } - } else { - // npm was found but install failed — try the openclaw installer script - tee('Running install script...\n'); - const r1 = await runCaptured('bash', ['-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r1.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - if (isPermError(fullLog)) { - tee('\nPermission error in installer — elevated access required.\n'); - const ok = await cacheSudo('Enter your system password to complete installation'); - if (!ok) { tee('Incorrect password or cancelled.\n'); failCancelled(); return; } - tee('Retrying with elevated permissions...\n'); - const r2 = await runCaptured('sudo', ['-E', 'bash', '-c', 'curl -fsSL https://openclaw.ai/install.sh | bash']); - if (r2.code === 0) { - await fixOpenclawPermissions(); - await succeed(); return; - } - } - } - - await fail(); - } - public dispose() { HomePanel.currentPanel = undefined; this._stopPolling(); @@ -773,7 +342,9 @@ export class HomePanel { const cliCheck = await this._testOpenClawCli(); writeLog(`[cli-check] ok=${cliCheck.ok} cmd="${cliCheck.command}" output="${(cliCheck.output ?? '').trim()}"\n`); - const isInstalled = isConfigured; // config file is the sole source of truth — a leftover binary without config is not "installed" + // For remote hosts (docker/ssh): CLI installed but not yet onboarded — go straight to configure step. + // For local: config file is the sole source of truth (leftover binary without config = not installed). + const isInstalled = isConfigured || (this._host.type !== 'local' && cliCheck.ok); this._lastInstalledState = isInstalled; this._lastInstalledVersion = cliCheck.ok ? (cliCheck.output ?? '').trim() : null; const iconUri = this._panel.webview.asWebviewUri( @@ -795,7 +366,7 @@ export class HomePanel { // Show unified setup view when OpenClaw is not fully configured yet. if (!isConfigured) { - this._panel.webview.html = this._getSetupHtml(isInstalled, iconUri.toString(), occUser); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard } else { const emojiBaseUri = this._panel.webview.asWebviewUri( @@ -1113,6 +684,83 @@ export class HomePanel { } catch { /* best-effort */ } } + private _getHostTypeSelectionHtml(iconUri: string): string { + return ` + + + + + + + + +

Welcome to OpenClaw

+

Where do you want to run OpenClaw?

+
+ + + +
+ + +`; + } + private _getLoadingHtml(iconUri: string): string { return ` @@ -1210,172 +858,6 @@ export class HomePanel { `; } - // ── Setup wizard ─────────────────────────────────────────────────────────── - - private async _runSetup(data: { provider: string; apiKey: string; port: string }): Promise { - const post = (msg: object) => { try { this._panel.webview.postMessage(msg); } catch {} }; - const wizardPost = (text: string, done: boolean, ok: boolean) => { - writeLog(text); - post({ type: 'wizardLog', text, done, ok }); - }; - const env = this._buildExecEnv(); - - // openclaw requires Node.js >= 22. Check the active version and auto-install - // via nvm if needed — the editor pins v20, so users may only have v20 active. - if (process.platform !== 'win32') { - const nodeResult = await this._host.exec('node', ['--version'], { env: env as Record, timeout: 5000 }); - const nodeVerRaw = nodeResult.stdout.trim(); - const nodeMinor = parseInt((nodeVerRaw.match(/^v?(\d+)/) || [])[1] || '0', 10); - if (nodeMinor < 22) { - const nvmSh = path.join(os.homedir(), '.nvm', 'nvm.sh'); - if (fs.existsSync(nvmSh)) { - wizardPost(`Node.js ${nodeVerRaw || 'unknown'} detected — openclaw requires v22+. Installing Node.js 22 via nvm...\n`, false, false); - const nvmCode = await this._host.execStream( - 'bash', - ['-c', `. "${nvmSh}" && nvm install 22 && nvm use 22 && nvm alias default 22`], - { env: env as Record }, - (d) => wizardPost(d, false, false), - (d) => wizardPost(d, false, false), - ); - if (nvmCode === 0) { - // Rebuild env so the new Node 22 bin is on PATH - const nvmVersionsDir = path.join(os.homedir(), '.nvm', 'versions', 'node'); - const v22dirs = fs.existsSync(nvmVersionsDir) - ? fs.readdirSync(nvmVersionsDir).filter(v => /^v?22/.test(v)) - : []; - if (v22dirs.length > 0) { - const v22bin = path.join(nvmVersionsDir, v22dirs[0], 'bin'); - env.PATH = [v22bin, env.PATH || ''].filter(Boolean).join(':'); - (env as any).Path = env.PATH; - } - wizardPost(' ✓ Node.js 22 ready.\n', false, false); - } else { - wizardPost(' ⚠️ Node.js 22 install via nvm failed. Setup may fail.\n', false, false); - } - } else { - wizardPost(`⚠️ Node.js ${nodeVerRaw || 'v20'} is active but openclaw requires v22+.\n Please install Node.js 22 (e.g. via https://nodejs.org) and restart OCCode.\n`, true, false); - return; - } - } - } - - const cliPath = await this._findOpenClawPath() ?? 'openclaw'; - const port = data.port && /^\d+$/.test(data.port) ? data.port : '18789'; - const isFree = data.provider === 'free'; - - // Map provider choice to openclaw flags. - const providerFlags: Record = { - free: [ - '--auth-choice', 'custom-api-key', - '--custom-base-url', 'https://occ.mba.sh/v1', - '--custom-api-key', data.apiKey, - '--custom-model-id', 'occ-legacy', - '--custom-compatibility', 'openai', - ], - anthropic: ['--auth-choice', 'apiKey', '--anthropic-api-key', data.apiKey], - openai: ['--auth-choice', 'openai-api-key', '--openai-api-key', data.apiKey], - openrouter: ['--auth-choice', 'openrouter-api-key', '--openrouter-api-key', data.apiKey], - gemini: ['--auth-choice', 'gemini-api-key', '--gemini-api-key', data.apiKey], - ollama: [ - '--auth-choice', 'custom-api-key', - '--custom-base-url', data.apiKey || 'http://localhost:11434', - '--custom-api-key', 'ollama', - '--custom-model-id', 'llama3', - '--custom-compatibility', 'openai', - ], - }; - const flags = providerFlags[data.provider]; - if (!flags) { - wizardPost('Unknown provider selected.\n', true, false); - return; - } - - const args = [ - 'onboard', - '--non-interactive', '--accept-risk', - '--flow', 'quickstart', - '--gateway-auth', 'token', - '--gateway-port', port, - '--skip-channels', '--skip-skills', '--skip-health', - ...flags, - ]; - - writeLog(`\n=== _runSetup START provider=${data.provider} port=${port} ===\n`); - wizardPost(isFree ? 'Installing Inference for MoltPilot...\nInstalling Inference for your new OpenClaw...\n' : 'Installing Inference for your new OpenClaw...\n', false, false); - - let exitCode: number; - try { - exitCode = await this._host.execStream( - cliPath, args, - { - env: env as Record, - timeout: 120_000, - ...(process.platform === 'win32' ? { shell: true, windowsHide: true } : {}), - }, - (d) => wizardPost(d, false, false), - (d) => wizardPost(d, false, false), - ); - } catch (err) { - wizardPost(`Error: ${String(err)}\n`, true, false); - return; - } - - const ok = exitCode === 0; - writeLog(`=== _runSetup END code=${exitCode} ===\n`); - wizardPost(ok ? '\n✅ Setup complete!\n' : `\nSetup exited with code ${exitCode}.\n`, true, ok); - - if (ok) { - if (isFree) { - // Write local free-tier marker (extension-host local state — always local fs). - try { - const occDir = path.join(os.homedir(), '.occ'); - if (!fs.existsSync(occDir)) { fs.mkdirSync(occDir, { recursive: true }); } - fs.writeFileSync( - path.join(occDir, 'moltpilot-tier.json'), - JSON.stringify({ tier: 'free', grantedAt: new Date().toISOString(), limitUsd: 1.00 }), - ); - } catch { /* non-fatal */ } - // Patch openclaw.json on the active host to inject correct cost/context metadata. - try { - const cfg = await this._host.readConfig() as Record; - const patchModel = (obj: unknown): boolean => { - if (!obj || typeof obj !== 'object') return false; - if (Array.isArray(obj)) { - for (const item of obj) { if (patchModel(item)) return true; } - return false; - } - const o = obj as Record; - if (o['id'] === OCC_LEGACY_MODEL_ID) { - o['name'] = OCC_LEGACY_MODEL_NAME; - o['reasoning'] = false; - o['input'] = ['text']; - o['cost'] = { ...OCC_LEGACY_COST }; - o['contextWindow'] = OCC_LEGACY_CONTEXT_WINDOW; - o['maxTokens'] = OCC_LEGACY_MAX_TOKENS; - return true; - } - for (const v of Object.values(o)) { patchModel(v); } - return false; - }; - patchModel(cfg); - await this._host.writeConfig(cfg); - } catch { /* non-fatal — openclaw.json may not exist yet */ } - } - setTimeout(() => { - HomePanel.refresh(); - if (isFree) { - vscode.commands.executeCommand('openclaw.openWorkspace'); - } - setTimeout(() => { - this._closeSidebarOnGatewayStart = true; - vscode.commands.executeCommand('void.openChatWithMessage', - 'Run `openclaw gateway start` to start the OpenClaw gateway.', - 'agent'); - }, 1000); - }, 1500); - } - } - // ── Uninstall ────────────────────────────────────────────────────────────── private _schedulePostUninstallClose(): void { if (this._uninstallCloseWatcher !== undefined) return; @@ -1587,11 +1069,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } - private _getSetupHtml( - isInstalled: boolean, - iconUri: string, - occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null - ): string { + private _getWizardHtml(iconUri: string, occUser: { email: string; picture: string | null; balance_usd: number; api_keys?: { moltpilotKey?: string; occKey?: string } | null } | null = null): string { // Render user area statically (avoids JS innerHTML escaping issues) let userAreaHtml: string; if (!occUser) { @@ -1650,10 +1128,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; font-family: var(--vscode-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); background: #1a1a1a; color: #e0e0e0; display: flex; flex-direction: column; align-items: center; justify-content: center; - min-height: 100vh; padding: 32px 20px 40px; text-align: center; + min-height: 100vh; padding: 20px; text-align: center; } - - /* ── Header ── */ + /* ── Header bar ──────────────────────────────────────────────── */ .header-bar { position: fixed; top: 12px; right: 12px; z-index: 200; display: flex; align-items: center; gap: 8px; @@ -1674,6 +1151,7 @@ The binary is already downloaded — do NOT re-download or compile anything.`; padding: 4px 10px; border-radius: 6px; cursor: pointer; transition: background 0.15s; } .sign-in-btn:hover { background: rgba(220,40,40,0.16); } + /* User popover */ .user-popover-wrap { position: relative; } .user-popover { display: none; position: absolute; top: calc(100% + 8px); right: 0; @@ -1710,47 +1188,9 @@ The binary is already downloaded — do NOT re-download or compile anything.`; text-align: left; cursor: pointer; transition: background 0.12s, color 0.12s; } .user-popover-signout:hover { background: rgba(255,255,255,0.06); color: #fff; } - - /* ── Logo + title ── */ - .logo { width: 56px; height: 56px; filter: drop-shadow(0 4px 12px rgba(220,40,40,0.3)); margin-bottom: 8px; } - .setup-title { font-size: 20px; font-weight: 700; color: #fff; margin-bottom: 4px; } - .setup-sub { font-size: 12px; color: #555; margin-bottom: 28px; } - - /* ── Step timeline ── */ - .steps { - display: flex; align-items: flex-start; gap: 0; - margin-bottom: 28px; width: min(420px, 96vw); - } - .step-item { - display: flex; flex-direction: column; align-items: center; flex: 1; - position: relative; - } - .step-item:not(:last-child)::after { - content: ''; - position: absolute; top: 13px; left: calc(50% + 16px); - width: calc(100% - 32px); height: 1px; - background: #2b2b2b; - } - .step-item.done:not(:last-child)::after { background: #dc2828; } - .step-dot { - width: 26px; height: 26px; border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: 11px; font-weight: 700; margin-bottom: 6px; - flex-shrink: 0; position: relative; z-index: 1; - } - .step-item.done .step-dot { background: #dc2828; color: #fff; border: 2px solid #dc2828; } - .step-item.active .step-dot { background: transparent; border: 2px solid #dc2828; color: #dc2828; } - .step-item.pending .step-dot { background: transparent; border: 2px solid #2b2b2b; color: #444; } - .step-label-text { font-size: 10px; color: #555; text-align: center; line-height: 1.3; display: flex; flex-direction: column; align-items: center; } - .step-item.done .step-label-text { color: #dc2828; } - .step-item.active .step-label-text { color: #e0e0e0; } - - /* ── Action panels ── */ - .panel { width: min(440px, 96vw); } - .panel-title { font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 6px; } - .panel-desc { font-size: 12px; color: #888; margin-bottom: 20px; line-height: 1.5; } - - /* ── Buttons ── */ + /* ── Logo ──────────────────────────────────────────────────────── */ + .logo { width: 64px; height: 64px; filter: drop-shadow(0 4px 12px rgba(220,40,40,0.3)); } + /* ── Buttons ───────────────────────────────────────────────────── */ .btn-primary { background: #dc2828; border: none; color: #fff; font-size: 14px; font-weight: 600; padding: 10px 28px; border-radius: 8px; @@ -1765,19 +1205,17 @@ The binary is already downloaded — do NOT re-download or compile anything.`; transition: color 0.15s; text-decoration: underline; text-underline-offset: 2px; } .btn-link:hover { color: #aaa; } - .btn-back { - background: transparent; border: 1px solid #333; color: #888; - font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; font-family: inherit; - } - .btn-back:hover { background: rgba(255,255,255,0.05); } @keyframes spin { to { transform: rotate(360deg); } } .btn-spin { display: inline-block; width: 13px; height: 13px; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fff; border-radius: 50%; animation: spin 0.65s linear infinite; flex-shrink: 0; } - - /* ── Provider cards ── */ + /* ── Provider cards (BYOK) ─────────────────────────────────────── */ + .step { width: min(480px, 96vw); text-align: left; } + .step-label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; text-align: center; } + h2 { font-size: 15px; font-weight: 600; color: #fff; margin-bottom: 6px; text-align: center; } + .step-desc { font-size: 12px; color: #888; margin-bottom: 20px; line-height: 1.5; text-align: center; } .prov-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 20px; } .prov-card { background: rgba(255,255,255,0.03); border: 1px solid #2b2b2b; @@ -1789,14 +1227,14 @@ The binary is already downloaded — do NOT re-download or compile anything.`; .prov-card.selected { border-color: #dc2828; background: rgba(220,40,40,0.08); } .prov-label { font-size: 13px; font-weight: 600; color: #e0e0e0; } .prov-hint { font-size: 11px; color: #666; } - .field-label { font-size: 11px; color: #888; margin-bottom: 5px; text-align: left; } + .field-label { font-size: 11px; color: #888; margin-bottom: 5px; } .key-input { width: 100%; background: #111; border: 1px solid #2b2b2b; border-radius: 6px; color: #e0e0e0; font-size: 13px; padding: 9px 12px; outline: none; margin-bottom: 6px; box-sizing: border-box; font-family: monospace; } .key-input:focus { outline: none; border-color: #dc2828; } - .key-hint { font-size: 11px; color: #555; margin-bottom: 16px; text-align: left; } + .key-hint { font-size: 11px; color: #555; margin-bottom: 20px; } .port-row { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; } .port-label { font-size: 12px; color: #888; white-space: nowrap; } .port-input { @@ -1805,272 +1243,118 @@ The binary is already downloaded — do NOT re-download or compile anything.`; } .port-input:focus { outline: none; border-color: #dc2828; } .btn-row { display: flex; gap: 10px; justify-content: flex-end; } - - /* ── Log panel ── */ - .log-wrap { - display: none; width: min(480px, 96vw); margin-top: 4px; - } - .log-wrap.visible { display: block; } - .log-box { - background: #0d0d0d; border: 1px solid #222; border-radius: 8px; - padding: 12px 14px; height: 200px; overflow-y: auto; - font-family: 'SF Mono', 'Fira Mono', 'Consolas', monospace; - font-size: 11px; line-height: 1.6; text-align: left; color: #888; - scroll-behavior: smooth; - } - .log-line { white-space: pre-wrap; word-break: break-all; } - .log-line.ok { color: #4ade80; } - .log-line.err { color: #f87171; } - .log-line.warn { color: #fbbf24; } - .log-status { - font-size: 12px; color: #555; margin-top: 8px; text-align: center; - } - .log-status.done { color: #4ade80; } - .log-status.failed { color: #f87171; } - @keyframes dots { 0%,100%{content:''} 33%{content:'.'} 66%{content:'..'} } - .dots::after { content: ''; animation: dots 1.2s steps(1) infinite; } - - /* ── MoltPilot help button ── */ - .molt-help { - display: none; margin-top: 16px; - background: rgba(167,139,250,0.1); border: 1px solid rgba(167,139,250,0.3); - color: #a78bfa; font-size: 13px; font-weight: 600; - padding: 10px 20px; border-radius: 8px; cursor: pointer; font-family: inherit; - transition: background 0.15s; - } - .molt-help.visible { display: inline-flex; align-items: center; gap: 8px; } - .molt-help:hover { background: rgba(167,139,250,0.2); } - - /* ── Password modal ── */ - .modal-overlay { - display: none; position: fixed; inset: 0; - background: rgba(0,0,0,0.7); z-index: 500; - align-items: center; justify-content: center; - } - .modal-overlay.open { display: flex; } - .modal-box { - background: #1e1e1e; border: 1px solid rgba(255,255,255,0.12); - border-radius: 16px; padding: 28px 28px 24px; width: min(360px, 92vw); - box-shadow: 0 24px 60px rgba(0,0,0,0.7); text-align: left; - } - .modal-title { font-size: 15px; font-weight: 700; color: #fff; margin-bottom: 6px; } - .modal-desc { font-size: 12px; color: #888; margin-bottom: 18px; line-height: 1.5; } - .modal-input { - width: 100%; background: #111; border: 1px solid #333; border-radius: 8px; - color: #e0e0e0; font-size: 14px; padding: 10px 14px; outline: none; - box-sizing: border-box; margin-bottom: 16px; letter-spacing: 0.1em; - } - .modal-input:focus { outline: none; border-color: #dc2828; } - .modal-btns { display: flex; gap: 10px; justify-content: flex-end; } - .modal-cancel { + .btn-back { background: transparent; border: 1px solid #333; color: #888; - font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; font-family: inherit; + font-size: 13px; padding: 8px 18px; border-radius: 6px; cursor: pointer; } - .modal-cancel:hover { background: rgba(255,255,255,0.05); } - .modal-confirm { - background: #dc2828; border: none; color: #fff; - font-size: 13px; font-weight: 600; padding: 8px 20px; border-radius: 6px; - cursor: pointer; font-family: inherit; transition: background 0.15s; + .btn-back:hover { background: rgba(255,255,255,0.05); } + /* Running step */ + .run-status { + font-size: 12px; color: #555; margin-top: 12px; + max-width: 280px; text-align: center; line-height: 1.5; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .modal-confirm:hover { background: #b91c1c; } + .run-status.done { color: #4ade80; white-space: normal; } + .run-status.failed { color: #f87171; white-space: normal; } + @keyframes dots { 0%,100%{content:''} 33%{content:'.'} 66%{content:'..'} 100%{content:'...'} } + .dots::after { content: ''; animation: dots 1.2s steps(1) infinite; } - -
${userAreaHtml}
- - - -
Set up OpenClaw
-
Follow the steps below to get started
- - -
-
-
${isInstalled ? '✓' : '1'}
-
Install
OpenClaw
-
-
-
2
-
Configure
AI Model
-
-
-
3
-
Ready
-
-
- - -
- + +
+ ${userAreaHtml}
- -