mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
12d0fd2ef8
commit
f6aa7c24f1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user