feat: add update command to CLI, relay, and client
This commit is contained in:
parent
835d20f734
commit
6345209538
9 changed files with 302 additions and 0 deletions
|
|
@ -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::<Value>()
|
||||
.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<String> = 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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ tokio-tungstenite = "0.21"
|
|||
futures-util = "0.3"
|
||||
dashmap = "5"
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
State(state): State<AppState>,
|
||||
) -> 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<String>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue