diff --git a/apps/linux/src-tauri/src/cli.rs b/apps/linux/src-tauri/src/cli.rs index d7d53ba438f1..70b68a4138b8 100644 --- a/apps/linux/src-tauri/src/cli.rs +++ b/apps/linux/src-tauri/src/cli.rs @@ -16,6 +16,7 @@ pub enum CliError { Missing, Environment(String), Spawn(String), + CommandFailed(String), InvalidJson(String), } @@ -23,9 +24,10 @@ impl fmt::Display for CliError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Missing => write!(formatter, "OpenClaw CLI not found"), - Self::Environment(message) | Self::Spawn(message) | Self::InvalidJson(message) => { - formatter.write_str(message) - } + Self::Environment(message) + | Self::Spawn(message) + | Self::CommandFailed(message) + | Self::InvalidJson(message) => formatter.write_str(message), } } } @@ -102,6 +104,14 @@ impl OpenClawCli { S: AsRef, { let output = self.output(args)?; + // Failed commands own their stderr; parsing first would mislabel real + // failures as missing CLI dashboard support. + if !output.status.success() { + let message = output_tail(&output.stderr) + .or_else(|| output_tail(&output.stdout)) + .unwrap_or_else(|| format!("OpenClaw CLI exited with {}", output.status)); + return Err(CliError::CommandFailed(message)); + } let value = serde_json::from_slice(&output.stdout).map_err(|error| { CliError::InvalidJson(format!("OpenClaw CLI returned invalid JSON: {error}")) })?; @@ -121,6 +131,21 @@ impl OpenClawCli { } } +pub(crate) fn output_tail(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + let mut lines: Vec<&str> = Vec::new(); + for line in text.lines().filter(|line| !line.trim().is_empty()) { + // The CLI repeats identical progress lines while waiting; one occurrence + // carries the same information in a user-facing failure message. + if lines.last() != Some(&line) { + lines.push(line); + } + } + let start = lines.len().saturating_sub(12); + let tail = &lines[start..]; + (!tail.is_empty()).then(|| tail.join("\n")) +} + pub fn openclaw_home() -> Result { #[cfg(target_os = "windows")] let home = env::var_os("HOME") @@ -131,3 +156,23 @@ pub fn openclaw_home() -> Result { let home = home.ok_or_else(|| CliError::Environment("HOME is not set".to_string()))?; Ok(PathBuf::from(home).join(".openclaw")) } + +#[cfg(test)] +mod tests { + use super::output_tail; + + #[test] + fn output_tail_keeps_the_last_twelve_nonempty_lines() { + let output = (1..=15) + .map(|line| format!("message {line}")) + .collect::>() + .join("\n\n"); + let expected = (4..=15) + .map(|line| format!("message {line}")) + .collect::>() + .join("\n"); + + assert_eq!(output_tail(output.as_bytes()), Some(expected)); + assert_eq!(output_tail(b"\n \n"), None); + } +} diff --git a/apps/linux/src-tauri/src/gateway.rs b/apps/linux/src-tauri/src/gateway.rs index 58064fbdb062..d90560178133 100644 --- a/apps/linux/src-tauri/src/gateway.rs +++ b/apps/linux/src-tauri/src/gateway.rs @@ -220,13 +220,14 @@ pub fn dashboard(cli: &OpenClawCli, snapshot: GatewaySnapshot) -> Result(["dashboard", "--json", "--no-open"]) { Ok(result) => result, + // Older CLIs reject the app's own --json flag (prose on stdout, or a nonzero + // exit naming the flag); both mean the same missing integration, not a failure + // the user can repair in place. Err(crate::cli::CliError::InvalidJson(_)) => { - return Err( - "The installed OpenClaw CLI does not support the desktop dashboard \ - integration. Update OpenClaw (for example: npm install -g openclaw@latest), \ - then retry." - .to_string(), - ); + return Err(unsupported_dashboard_integration()); + } + Err(crate::cli::CliError::CommandFailed(message)) if message.contains("\"--json\"") => { + return Err(unsupported_dashboard_integration()); } Err(error) => return Err(error.to_string()), }; @@ -254,6 +255,13 @@ pub fn dashboard(cli: &OpenClawCli, snapshot: GatewaySnapshot) -> Result String { + "The installed OpenClaw CLI does not support the desktop dashboard integration. \ + Choose the Beta or Development release channel and install again, or wait for \ + the next stable release." + .to_string() +} + fn dashboard_token(dashboard_url: &str) -> Result, String> { let parsed = tauri::Url::parse(dashboard_url) .map_err(|_| "Dashboard returned an invalid URL.".to_string())?; diff --git a/apps/linux/src-tauri/src/installer.rs b/apps/linux/src-tauri/src/installer.rs index d510bbad2393..b07d68876894 100644 --- a/apps/linux/src-tauri/src/installer.rs +++ b/apps/linux/src-tauri/src/installer.rs @@ -105,6 +105,13 @@ pub fn install(app: &AppHandle, channel: InstallChannel) -> Result<(), String> { line: &line, }, ); + // Structured step events belong to the log pane; the failure tail is + // shown as prose and must keep only human-readable diagnostics. + if serde_json::from_str::(&line) + .is_ok_and(|value| value.get("event").is_some()) + { + continue; + } if tail.len() == ERROR_TAIL_LINES { tail.pop_front(); } diff --git a/apps/linux/src-tauri/src/main.rs b/apps/linux/src-tauri/src/main.rs index 03382132c5f6..50ef2416f13a 100644 --- a/apps/linux/src-tauri/src/main.rs +++ b/apps/linux/src-tauri/src/main.rs @@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; -use tauri::{AppHandle, Manager, State, Url, WebviewWindow}; +use tauri::{AppHandle, Emitter, Manager, State, Url, WebviewWindow}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_global_shortcut::{Code, Modifiers}; @@ -237,17 +237,50 @@ impl DesktopState { .lock() .map_err(|_| "Installer lock is unavailable.".to_string())?; installer::install(app, channel)?; + let cli = OpenClawCli::discover().map_err(|error| { + format!("OpenClaw is installed, but the CLI could not be found: {error}") + })?; + *self.inner.cli.lock().expect("CLI mutex poisoned") = Some(cli.clone()); + + // The installed CLI owns config/state migrations; repair before any + // Gateway readiness checks consume an outdated home. + let repair_error = match cli.output(["doctor", "--fix", "--non-interactive"]) { + Ok(output) if !output.status.success() => Some( + cli::output_tail(&output.stderr) + .unwrap_or_else(|| format!("OpenClaw repair exited with {}", output.status)), + ), + Err(error) => Some(format!("OpenClaw repair could not start: {error}")), + _ => None, + }; + if let Some(error) = repair_error { + for line in error.lines() { + let _ = app.emit_to( + "main", + "install-progress", + serde_json::json!({ "stream": "stderr", "line": line }), + ); + } + } + self.inner .navigation .lock() - .map_err(|_| "Dashboard navigation lock is unavailable.".to_string())? + .map_err(|_| { + "OpenClaw is installed, but preparing the Gateway dashboard failed: \ + Dashboard navigation lock is unavailable." + .to_string() + })? .mark_onboarding_pending(); - let cli = OpenClawCli::discover().map_err(|error| error.to_string())?; - *self.inner.cli.lock().expect("CLI mutex poisoned") = Some(cli.clone()); - let ready = gateway::ensure_ready(&cli)?; + let ready = gateway::ensure_ready(&cli).map_err(|error| { + format!("OpenClaw is installed, but connecting to the Gateway failed: {error}") + })?; app.state::() .configure(app, ready.gateway_ws.clone()); - let navigated = self.navigate_local(app, &ready.dashboard_url, false, None, true, true)?; + let navigated = self + .navigate_local(app, &ready.dashboard_url, false, None, true, true) + .map_err(|error| { + format!("OpenClaw is installed, but opening the Gateway dashboard failed: {error}") + })?; self.update_tray(&ready.snapshot); if navigated { self.start_watchdog(app.clone()); diff --git a/apps/linux/ui/main.js b/apps/linux/ui/main.js index 74f48c68f4c2..c247582eb14e 100644 --- a/apps/linux/ui/main.js +++ b/apps/linux/ui/main.js @@ -63,8 +63,48 @@ function renderAction(options, action) { show(elements.actionControls, true); } +function formatInstallLine(line) { + let event; + try { + event = JSON.parse(line); + } catch { + return line; + } + if (!event || typeof event !== "object" || !event.event) { + return line; + } + if (event.event === "done" && event.ok === true) { + return `✓ Installed${event.version ? ` ${event.version}` : ""}`; + } + if (event.event !== "step" || !event.name) { + return line; + } + + const name = + { + node: "Node runtime", + git: "Git checkout", + openclaw: "OpenClaw CLI", + "gateway-service": "Gateway service", + "control-ui": "Control UI build", + "cli-build": "CLI build", + }[event.name] || event.name; + switch (event.status) { + case "start": + return `→ ${name}${event.version ? ` ${event.version}` : ""}…`; + case "ok": + return `✓ ${name}`; + case "skip": + return `– ${name} skipped${event.reason ? ` (${event.reason})` : ""}`; + case "warn": + return `! ${name}${event.reason ? `: ${event.reason}` : ""}`; + default: + return line; + } +} + function appendLog(line) { - elements.installLog.textContent += `${line}\n`; + elements.installLog.textContent += `${formatInstallLine(line)}\n`; elements.installLog.scrollTop = elements.installLog.scrollHeight; } @@ -235,13 +275,13 @@ async function install() { await invoke("install_cli", { channel: elements.channel.value }); elements.logStatus.textContent = "COMPLETE"; } catch (error) { + const message = friendlyError(error); elements.logStatus.textContent = "FAILED"; - appendLog(friendlyError(error)); + appendLog(message); render({ - description: - "Installation did not finish. Review the final log lines, choose a release channel, then retry.", + description: message, dot: "error", - eyebrow: "INSTALLATION ISSUE", + eyebrow: "SETUP ISSUE", showInstall: true, title: "OpenClaw needs attention", }); @@ -273,6 +313,9 @@ function renderRetry(message) { description: message, dot: "error", eyebrow: "CONNECTION ISSUE", + // A broken managed CLI can only be replaced by reinstalling; retry alone + // must never be the sole exit from a connection failure. + showInstall: true, title: "OpenClaw needs attention", }, connect, diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 31ca5b093062..936af10f8666 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -1152,10 +1152,16 @@ ensure_pnpm() { emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"start\",\"method\":\"corepack\"}" log "Installing pnpm via Corepack..." "$(node_dir)/bin/corepack" enable >/dev/null 2>&1 || true - "$(node_dir)/bin/corepack" prepare pnpm@11 --activate - if detect_pnpm_cmd && pnpm_cmd_is_ready && [[ "$("${PNPM_CMD[@]}" --version 2>/dev/null || true)" =~ ^11\. ]]; then - emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}" - return 0 + # Corepack downloads fail hard on npm registry key rotation (its bundled + # signature set goes stale); the npm fallback below must stay reachable. + if "$(node_dir)/bin/corepack" prepare pnpm@11 --activate; then + if detect_pnpm_cmd && pnpm_cmd_is_ready && [[ "$("${PNPM_CMD[@]}" --version 2>/dev/null || true)" =~ ^11\. ]]; then + emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"ok\"}" + return 0 + fi + else + emit_json "{\"event\":\"step\",\"name\":\"pnpm\",\"status\":\"warn\",\"reason\":\"corepack-failed\"}" + log "Corepack could not provision pnpm; falling back to npm." fi fi @@ -1450,7 +1456,9 @@ clone_git_checkout_transactionally() { fi TMPFILES+=("$staging_dir") - git clone "$repo_url" "$staging_dir" || clone_status=$? + # Blobless partial clone: the dev checkout only needs current files plus pullable + # history refs; full multi-gigabyte blob history would dominate install time. + git clone --filter=blob:none "$repo_url" "$staging_dir" || clone_status=$? if [[ "$clone_status" -ne 0 ]]; then return "$clone_status" fi @@ -1624,12 +1632,25 @@ is_gateway_daemon_loaded() { fi local status_json="" - status_json="$("$claw" daemon status --json 2>/dev/null || true)" + # Unlike daemon status, gateway status reports service.loaded during pending migrations. + status_json="$("$claw" gateway status --json 2>/dev/null || true)" if [[ -z "$status_json" ]]; then return 1 fi - printf '%s' "$status_json" | node -e ' + # Managed installs must parse with their provisioned Node even when the system has none. + local node_bin="${PREFIX}/tools/node/bin/node" + if [[ ! -x "$node_bin" ]]; then + if command -v node >/dev/null 2>&1; then + node_bin="$(command -v node)" + else + # Approximate POSIX-safe fallback when neither managed nor system Node is available. + printf '%s\n' "$status_json" | grep -Eq '"loaded"[[:space:]]*:[[:space:]]*true' + return + fi + fi + + printf '%s' "$status_json" | "$node_bin" -e ' const fs = require("fs"); const raw = fs.readFileSync(0, "utf8").trim(); if (!raw) process.exit(1);