fix(linux): truthful install failures, post-install repair, reachable reinstall (#128614)

The Linux desktop companion could complete a CLI install and still claim
'Installation did not finish' with circular update advice, discarding the
real failure. Verified end-to-end in a clean Ubuntu VM across all three
release channels:

- cli.rs: failed CLI commands now surface their stderr tail (deduped, last
  12 lines) instead of being mislabeled as JSON parse failures.
- gateway.rs: missing dashboard --json support maps to an honest curated
  message pointing at Beta/Development channels, not a circular npm-update
  hint.
- main.rs: run 'doctor --fix --non-interactive' right after install so the
  CLI repairs config/state before Gateway readiness checks; wrap
  post-install failures as 'installed, but connecting failed: <reason>'.
- installer.rs: keep structured step events out of the prose failure tail.
- ui/main.js: humanize streamed install steps, render real errors on the
  failure screen, and always offer Reinstall from connection failures.
- scripts/install-cli.sh: service refresh uses 'gateway status --json' with
  the bundled node runtime, corepack failure falls back to npm, dev channel
  clones with --filter=blob:none.
This commit is contained in:
Peter Steinberger
2026-08-24 02:01:15 -07:00
committed by GitHub
parent 12d0fd2ef8
commit f6aa7c24f1
6 changed files with 184 additions and 27 deletions
+48 -3
View File
@@ -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<std::ffi::OsStr>,
{
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<String> {
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<PathBuf, CliError> {
#[cfg(target_os = "windows")]
let home = env::var_os("HOME")
@@ -131,3 +156,23 @@ pub fn openclaw_home() -> Result<PathBuf, CliError> {
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::<Vec<_>>()
.join("\n\n");
let expected = (4..=15)
.map(|line| format!("message {line}"))
.collect::<Vec<_>>()
.join("\n");
assert_eq!(output_tail(output.as_bytes()), Some(expected));
assert_eq!(output_tail(b"\n \n"), None);
}
}
+14 -6
View File
@@ -220,13 +220,14 @@ pub fn dashboard(cli: &OpenClawCli, snapshot: GatewaySnapshot) -> Result<ReadyGa
let (response, output) =
match cli.json::<DashboardResponse, _, _>(["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<ReadyGa
.unwrap_or_else(|| "Dashboard is not ready.".to_string()))
}
fn unsupported_dashboard_integration() -> 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<Option<String>, String> {
let parsed = tauri::Url::parse(dashboard_url)
.map_err(|_| "Dashboard returned an invalid URL.".to_string())?;
+7
View File
@@ -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::<serde_json::Value>(&line)
.is_ok_and(|value| value.get("event").is_some())
{
continue;
}
if tail.len() == ERROR_TAIL_LINES {
tail.pop_front();
}
+39 -6
View File
@@ -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::<gateway_ws::GatewayClient>()
.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());
+48 -5
View File
@@ -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,
+28 -7
View File
@@ -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);