From 634520953838936f7e322b6c38e44a2419e65e6b Mon Sep 17 00:00:00 2001 From: Helios Agent Date: Fri, 6 Mar 2026 12:16:10 +0100 Subject: [PATCH] feat: add update command to CLI, relay, and client --- .github/workflows/ci.yml | 4 + crates/cli/src/main.rs | 139 ++++++++++++++++++++++++++++++++ crates/client/Cargo.toml | 1 + crates/client/src/main.rs | 61 ++++++++++++++ crates/common/src/protocol.rs | 8 ++ crates/server/Cargo.toml | 1 + crates/server/src/api.rs | 85 +++++++++++++++++++ crates/server/src/main.rs | 2 + crates/server/src/ws_handler.rs | 1 + 9 files changed, 302 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9dd35d6..38eee93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,6 +123,10 @@ jobs: scp -i ~/.ssh/deploy_key \ target/x86_64-unknown-linux-gnu/release/helios-remote-relay \ root@46.225.185.232:/opt/helios-remote/target/release/helios-remote-relay-new + # Also publish relay binary to download URL for self-update + scp -i ~/.ssh/deploy_key \ + target/x86_64-unknown-linux-gnu/release/helios-remote-relay \ + root@46.225.185.232:/var/www/helios-remote/helios-remote-relay-linux ssh -i ~/.ssh/deploy_key root@46.225.185.232 \ "mv /opt/helios-remote/target/release/helios-remote-relay-new \ /opt/helios-remote/target/release/helios-remote-relay && \ diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9dacc38..d29fb9b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -337,6 +337,12 @@ enum Commands { device: String, }, + /// Update all components (relay, client, CLI) if behind + Update { + /// Device label + device: String, + }, + /// Fetch last N lines of client log file Logs { /// Device label @@ -713,6 +719,139 @@ fn main() { ); } + Commands::Update { device } => { + validate_label(&device); + + // Fetch all three commits + let relay_commit = match reqwest::blocking::get(&format!("{}/version", cfg.base_url)) { + Ok(r) => r + .json::() + .ok() + .and_then(|v| v["commit"].as_str().map(String::from)) + .unwrap_or_else(|| "?".into()), + Err(e) => format!("error: {}", e), + }; + + let client_commit = { + let data = req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10); + data["commit"].as_str().unwrap_or("?").to_string() + }; + + let cli_commit = GIT_COMMIT; + + println!(" relay {}", relay_commit); + println!(" cli {}", cli_commit); + println!(" client {}", client_commit); + + let all_same = relay_commit == cli_commit && cli_commit == client_commit; + if all_same { + println!(" ✅ Already up to date (commit: {})", cli_commit); + return; + } + + println!(); + let mut updated_any = false; + + // Update relay if needed + if relay_commit != cli_commit { + println!(" → Updating relay..."); + let data = req(&cfg, "POST", "/relay/update", None, 15); + println!(" {}", data["message"].as_str().unwrap_or("triggered")); + updated_any = true; + } + + // Update client if needed + if client_commit != cli_commit { + println!(" → Updating client on {}...", device); + let data = req( + &cfg, + "POST", + &format!("/devices/{}/update", device), + None, + 65, + ); + println!( + " {}", + data["message"].as_str().unwrap_or( + if data["success"].as_bool() == Some(true) { "triggered" } else { "failed" } + ) + ); + updated_any = true; + } + + // Self-update CLI if needed + // (relay_commit is the "canonical" latest — if we differ from it, we're outdated) + if cli_commit != relay_commit { + println!(" → Updating CLI..."); + #[cfg(target_os = "windows")] + let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe"; + #[cfg(not(target_os = "windows"))] + let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux"; + + let bytes = match reqwest::blocking::get(url) { + Ok(r) => match r.bytes() { + Ok(b) => b, + Err(e) => { + eprintln!("[helios-remote] CLI update: read failed: {}", e); + process::exit(1); + } + }, + Err(e) => { + eprintln!("[helios-remote] CLI update: download failed: {}", e); + process::exit(1); + } + }; + + let exe = std::env::current_exe().unwrap_or_else(|e| { + eprintln!("[helios-remote] CLI update: current_exe failed: {}", e); + process::exit(1); + }); + + #[cfg(not(target_os = "windows"))] + { + let tmp = exe.with_extension("new"); + std::fs::write(&tmp, &bytes).unwrap_or_else(|e| { + eprintln!("[helios-remote] CLI update: write failed: {}", e); + process::exit(1); + }); + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)); + std::fs::rename(&tmp, &exe).unwrap_or_else(|e| { + eprintln!("[helios-remote] CLI update: rename failed: {}", e); + process::exit(1); + }); + println!(" CLI updated. Re-executing..."); + use std::os::unix::process::CommandExt; + let args: Vec = std::env::args().collect(); + let err = std::process::Command::new(&exe).args(&args[1..]).exec(); + eprintln!("[helios-remote] CLI re-exec failed: {}", err); + process::exit(1); + } + #[cfg(target_os = "windows")] + { + let tmp = exe.with_extension("update.exe"); + std::fs::write(&tmp, &bytes).unwrap_or_else(|e| { + eprintln!("[helios-remote] CLI update: write failed: {}", e); + process::exit(1); + }); + let old = exe.with_extension("old.exe"); + let _ = std::fs::remove_file(&old); + let _ = std::fs::rename(&exe, &old); + std::fs::rename(&tmp, &exe).unwrap_or_else(|e| { + eprintln!("[helios-remote] CLI update: rename failed: {}", e); + process::exit(1); + }); + println!(" CLI updated. Please restart the command."); + } + updated_any = true; + } + + if updated_any { + println!(); + println!(" ✅ Update(s) triggered."); + } + } + Commands::Logs { device, lines } => { validate_label(&device); let data = req( diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 8f1df46..02d3fff 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -25,6 +25,7 @@ png = "0.17" futures-util = "0.3" colored = "2" scopeguard = "1" +reqwest = { version = "0.12", features = ["json"] } terminal_size = "0.3" unicode-width = "0.1" diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 4ea9b6f..6641f4a 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -11,6 +11,8 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con use base64::Engine; use helios_common::{ClientMessage, ServerMessage}; +#[allow(unused_imports)] +use reqwest; use helios_common::protocol::{is_valid_label, sanitize_label}; mod display; @@ -661,6 +663,65 @@ async fn handle_message( } } + ServerMessage::UpdateRequest { request_id } => { + display::cmd_start("🔄", "update", "downloading..."); + let exe = std::env::current_exe().ok(); + tokio::spawn(async move { + // Give the response time to be sent before we restart + tokio::time::sleep(tokio::time::Duration::from_millis(800)).await; + let exe = match exe { + Some(p) => p, + None => { + display::err("❌", "update: could not determine current exe path"); + return; + } + }; + let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe"; + let bytes = match reqwest::get(url).await { + Ok(r) => match r.bytes().await { + Ok(b) => b, + Err(e) => { + display::err("❌", &format!("update: read body failed: {e}")); + return; + } + }, + Err(e) => { + display::err("❌", &format!("update: download failed: {e}")); + return; + } + }; + // Write new binary to a temp path, then swap + let tmp = exe.with_extension("update.exe"); + if let Err(e) = std::fs::write(&tmp, &bytes) { + display::err("❌", &format!("update: write failed: {e}")); + return; + } + // Rename current → .old, then tmp → current + let old = exe.with_extension("old.exe"); + let _ = std::fs::remove_file(&old); + if let Err(e) = std::fs::rename(&exe, &old) { + display::err("❌", &format!("update: rename old failed: {e}")); + return; + } + if let Err(e) = std::fs::rename(&tmp, &exe) { + // Attempt rollback + let _ = std::fs::rename(&old, &exe); + display::err("❌", &format!("update: rename new failed: {e}")); + return; + } + display::cmd_done("🔄", "update", "", true, "updated — restarting"); + // Restart + let _ = std::process::Command::new(&exe).spawn(); + std::process::exit(0); + }); + display::cmd_done("🔄", "update", "", true, "triggered"); + ClientMessage::UpdateResponse { + request_id, + success: true, + message: "update triggered, client restarting...".into(), + } + } + ServerMessage::Ack { request_id } => { ClientMessage::Ack { request_id } } diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index 94740ad..25b4ceb 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -108,6 +108,8 @@ pub enum ServerMessage { ClipboardGetRequest { request_id: Uuid }, /// Set the contents of the client's clipboard ClipboardSetRequest { request_id: Uuid, text: String }, + /// Request client to self-update and restart + UpdateRequest { request_id: Uuid }, } /// Messages sent from the client to the relay server @@ -163,6 +165,12 @@ pub enum ClientMessage { ClipboardGetResponse { request_id: Uuid, text: String }, /// Response to a prompt request PromptResponse { request_id: Uuid, answer: String }, + /// Response to an update request + UpdateResponse { + request_id: Uuid, + success: bool, + message: String, + }, } #[cfg(test)] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d85c9c7..9fcf539 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -22,3 +22,4 @@ tokio-tungstenite = "0.21" futures-util = "0.3" dashmap = "5" anyhow = "1" +reqwest = { version = "0.12", features = ["json"] } diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index b89715c..3395c7d 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -401,6 +401,91 @@ pub async fn clipboard_set( } } +/// POST /relay/update — self-update the relay binary and restart the service +pub async fn relay_update() -> impl IntoResponse { + tokio::spawn(async { + // Give the HTTP response time to be sent before we restart + tokio::time::sleep(Duration::from_millis(800)).await; + + let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-relay-linux"; + let bytes = match reqwest::get(url).await { + Ok(r) => match r.bytes().await { + Ok(b) => b, + Err(e) => { + error!("relay update: failed to read response body: {e}"); + return; + } + }, + Err(e) => { + error!("relay update: download failed: {e}"); + return; + } + }; + + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(e) => { + error!("relay update: current_exe: {e}"); + return; + } + }; + + let tmp = exe.with_extension("new"); + if let Err(e) = std::fs::write(&tmp, &bytes) { + error!("relay update: write tmp failed: {e}"); + return; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)); + } + + if let Err(e) = std::fs::rename(&tmp, &exe) { + error!("relay update: rename failed: {e}"); + return; + } + + let _ = std::process::Command::new("systemctl") + .args(["restart", "helios-remote"]) + .spawn(); + }); + + ( + axum::http::StatusCode::OK, + Json(serde_json::json!({ "ok": true, "message": "update triggered, relay restarting..." })), + ) +} + +/// POST /devices/:label/update — trigger client self-update +pub async fn client_update( + Path(label): Path, + State(state): State, +) -> impl IntoResponse { + match dispatch_with_timeout(&state, &label, "update", |rid| { + ServerMessage::UpdateRequest { request_id: rid } + }, Duration::from_secs(60)).await { + Ok(ClientMessage::UpdateResponse { success, message, .. }) => ( + StatusCode::OK, + Json(serde_json::json!({ "success": success, "message": message })), + ).into_response(), + Ok(ClientMessage::Ack { .. }) => ( + StatusCode::OK, + Json(serde_json::json!({ "success": true, "message": "update acknowledged" })), + ).into_response(), + Ok(ClientMessage::Error { message, .. }) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "success": false, "message": message })), + ).into_response(), + Ok(_) => ( + StatusCode::OK, + Json(serde_json::json!({ "success": true, "message": "acknowledged" })), + ).into_response(), + Err(e) => e.into_response(), + } +} + /// POST /devices/:label/inform pub async fn inform_user( Path(label): Path, diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 118e64c..d60a143 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -62,6 +62,8 @@ async fn main() -> anyhow::Result<()> { .route("/devices/:label/run", post(api::run_program)) .route("/devices/:label/clipboard", get(api::clipboard_get)) .route("/devices/:label/clipboard", post(api::clipboard_set)) + .route("/relay/update", post(api::relay_update)) + .route("/devices/:label/update", post(api::client_update)) .layer(middleware::from_fn_with_state(state.clone(), require_api_key)); let app = Router::new() diff --git a/crates/server/src/ws_handler.rs b/crates/server/src/ws_handler.rs index 4f8d419..ce661c2 100644 --- a/crates/server/src/ws_handler.rs +++ b/crates/server/src/ws_handler.rs @@ -115,6 +115,7 @@ async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState | ClientMessage::DownloadResponse { request_id, .. } | ClientMessage::ClipboardGetResponse { request_id, .. } | ClientMessage::PromptResponse { request_id, .. } + | ClientMessage::UpdateResponse { request_id, .. } | ClientMessage::Ack { request_id } | ClientMessage::Error { request_id, .. } => { let rid = *request_id;