feat: rewrite remote.py as Rust CLI binary (crates/cli)
This commit is contained in:
parent
ba3b365f4e
commit
98b6fabef6
6 changed files with 815 additions and 45 deletions
16
crates/cli/Cargo.toml
Normal file
16
crates/cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[package]
|
||||
name = "helios-cli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "helios"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
||||
base64 = "0.22"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
urlencoding = "2"
|
||||
11
crates/cli/build.rs
Normal file
11
crates/cli/build.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
fn main() {
|
||||
// Embed git commit hash at build time
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["log", "-1", "--format=%h"])
|
||||
.output();
|
||||
let commit = match output {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
println!("cargo:rustc-env=GIT_COMMIT={commit}");
|
||||
}
|
||||
742
crates/cli/src/main.rs
Normal file
742
crates/cli/src/main.rs
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
use base64::Engine;
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process;
|
||||
|
||||
const GIT_COMMIT: &str = env!("GIT_COMMIT");
|
||||
|
||||
// ── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
struct Config {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
fn load_config() -> Config {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let dir = exe.parent().unwrap_or(Path::new("."));
|
||||
let path = dir.join("config.env");
|
||||
|
||||
let mut map = HashMap::new();
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
map.insert(k.trim().to_string(), v.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let base_url = map
|
||||
.get("HELIOS_REMOTE_URL")
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let api_key = map
|
||||
.get("HELIOS_REMOTE_API_KEY")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if base_url.is_empty() || api_key.is_empty() {
|
||||
eprintln!(
|
||||
"[helios-remote] ERROR: config.env missing or incomplete at {}",
|
||||
path.display()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
Config { base_url, api_key }
|
||||
}
|
||||
|
||||
// ── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn client() -> reqwest::blocking::Client {
|
||||
reqwest::blocking::Client::new()
|
||||
}
|
||||
|
||||
fn req(
|
||||
cfg: &Config,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Option<Value>,
|
||||
timeout_secs: u64,
|
||||
) -> Value {
|
||||
let url = format!("{}{}", cfg.base_url, path);
|
||||
let c = client();
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs);
|
||||
|
||||
let builder = match method {
|
||||
"GET" => c.get(&url),
|
||||
"POST" => c.post(&url),
|
||||
_ => c.get(&url),
|
||||
};
|
||||
|
||||
let builder = builder
|
||||
.header("X-Api-Key", &cfg.api_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(timeout);
|
||||
|
||||
let builder = if let Some(b) = body {
|
||||
builder.body(b.to_string())
|
||||
} else {
|
||||
builder
|
||||
};
|
||||
|
||||
let resp = match builder.send() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
if e.is_timeout() {
|
||||
eprintln!(
|
||||
"[helios-remote] TIMEOUT: {} did not respond within {} s",
|
||||
url, timeout_secs
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"[helios-remote] CONNECTION ERROR: Cannot reach {}\n → {}",
|
||||
url, e
|
||||
);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body_text = resp.text().unwrap_or_default();
|
||||
let body_preview = if body_text.len() > 1000 {
|
||||
&body_text[..1000]
|
||||
} else {
|
||||
&body_text
|
||||
};
|
||||
eprintln!(
|
||||
"[helios-remote] HTTP {} {}\n URL : {}\n Method : {}\n Body : {}",
|
||||
status.as_u16(),
|
||||
status.canonical_reason().unwrap_or(""),
|
||||
url,
|
||||
method,
|
||||
body_preview,
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
resp.json::<Value>().unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
// ── Label validation ────────────────────────────────────────────────────────
|
||||
|
||||
fn validate_label(label: &str) {
|
||||
let valid = !label.is_empty()
|
||||
&& label
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
&& label.chars().next().unwrap().is_ascii_alphanumeric();
|
||||
if !valid {
|
||||
eprintln!(
|
||||
"[helios-remote] Invalid label '{}'. Must be lowercase, no whitespace, only a-z 0-9 - _",
|
||||
label
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window resolution ───────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_window(cfg: &Config, device: &str, window_label: &str) -> i64 {
|
||||
let data = req(cfg, "GET", &format!("/devices/{}/windows", device), None, 30);
|
||||
let windows = data["windows"].as_array().cloned().unwrap_or_default();
|
||||
let query = window_label.to_lowercase();
|
||||
|
||||
// Exact label match
|
||||
for w in &windows {
|
||||
if w["visible"].as_bool() == Some(true) && w["label"].as_str() == Some(&query) {
|
||||
return w["id"].as_i64().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// Substring match on label
|
||||
let mut matches: Vec<&Value> = windows
|
||||
.iter()
|
||||
.filter(|w| {
|
||||
w["visible"].as_bool() == Some(true)
|
||||
&& w["label"]
|
||||
.as_str()
|
||||
.map(|l| l.contains(&query))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Fallback: substring on title
|
||||
if matches.is_empty() {
|
||||
matches = windows
|
||||
.iter()
|
||||
.filter(|w| {
|
||||
w["visible"].as_bool() == Some(true)
|
||||
&& w["title"]
|
||||
.as_str()
|
||||
.map(|t| t.to_lowercase().contains(&query))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
if matches.is_empty() {
|
||||
eprintln!(
|
||||
"[helios-remote] No visible window matching '{}'",
|
||||
window_label
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
if matches.len() > 1 {
|
||||
println!(
|
||||
"[helios-remote] Multiple matches for '{}', using first:",
|
||||
window_label
|
||||
);
|
||||
for w in &matches {
|
||||
println!(
|
||||
" {:<30} {}",
|
||||
w["label"].as_str().unwrap_or("?"),
|
||||
w["title"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
matches[0]["id"].as_i64().unwrap()
|
||||
}
|
||||
|
||||
// ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "helios",
|
||||
about = "Control devices connected to the Helios Remote Relay Server."
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// List all connected devices
|
||||
Devices,
|
||||
|
||||
/// Capture screenshot (screen or window label)
|
||||
Screenshot {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// 'screen' for full screen, or a window label
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Run a shell command on the remote device
|
||||
Exec {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Timeout in seconds
|
||||
#[arg(long)]
|
||||
timeout: Option<u64>,
|
||||
/// Command (and arguments) to execute
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
parts: Vec<String>,
|
||||
},
|
||||
|
||||
/// List all visible windows on the remote device
|
||||
Windows {
|
||||
/// Device label
|
||||
device: String,
|
||||
},
|
||||
|
||||
/// Bring a window to the foreground
|
||||
Focus {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Window label
|
||||
window: String,
|
||||
},
|
||||
|
||||
/// Maximize and focus a window
|
||||
Maximize {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Window label
|
||||
window: String,
|
||||
},
|
||||
|
||||
/// Minimize all windows
|
||||
MinimizeAll {
|
||||
/// Device label
|
||||
device: String,
|
||||
},
|
||||
|
||||
/// Show a MessageBox asking the user to do something
|
||||
Prompt {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Message to display
|
||||
message: String,
|
||||
/// Custom dialog title
|
||||
#[arg(long)]
|
||||
title: Option<String>,
|
||||
},
|
||||
|
||||
/// Launch a program (fire-and-forget)
|
||||
Run {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Program to launch
|
||||
program: String,
|
||||
/// Program arguments
|
||||
#[arg(trailing_var_arg = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
|
||||
/// Get clipboard contents
|
||||
ClipboardGet {
|
||||
/// Device label
|
||||
device: String,
|
||||
},
|
||||
|
||||
/// Set clipboard contents
|
||||
ClipboardSet {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Text to set
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Upload a local file to the remote device
|
||||
Upload {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Local file path
|
||||
local_path: PathBuf,
|
||||
/// Remote file path
|
||||
remote_path: String,
|
||||
},
|
||||
|
||||
/// Download a file from the remote device
|
||||
Download {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Remote file path
|
||||
remote_path: String,
|
||||
/// Local file path
|
||||
local_path: PathBuf,
|
||||
},
|
||||
|
||||
/// Compare relay, CLI, and client commits
|
||||
Version {
|
||||
/// Device label
|
||||
device: String,
|
||||
},
|
||||
|
||||
/// Fetch last N lines of client log file
|
||||
Logs {
|
||||
/// Device label
|
||||
device: String,
|
||||
/// Number of lines
|
||||
#[arg(long, default_value = "100")]
|
||||
lines: u32,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let cfg = load_config();
|
||||
|
||||
match cli.command {
|
||||
Commands::Devices => {
|
||||
let data = req(&cfg, "GET", "/devices", None, 30);
|
||||
let devices = data["devices"].as_array();
|
||||
match devices {
|
||||
Some(devs) if !devs.is_empty() => {
|
||||
println!("{:<30}", "Device");
|
||||
println!("{}", "-".repeat(30));
|
||||
for d in devs {
|
||||
println!("{}", d["label"].as_str().unwrap_or("?"));
|
||||
}
|
||||
}
|
||||
_ => println!("No devices connected."),
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Screenshot { device, target } => {
|
||||
validate_label(&device);
|
||||
let out_path = Path::new("/tmp/helios-remote-screenshot.png");
|
||||
|
||||
let data = if target == "screen" {
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/screenshot", device),
|
||||
None,
|
||||
30,
|
||||
)
|
||||
} else {
|
||||
let wid = resolve_window(&cfg, &device, &target);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/windows/{}/screenshot", device, wid),
|
||||
None,
|
||||
30,
|
||||
)
|
||||
};
|
||||
|
||||
let b64 = data["image_base64"]
|
||||
.as_str()
|
||||
.or(data["screenshot"].as_str())
|
||||
.or(data["image"].as_str())
|
||||
.or(data["data"].as_str())
|
||||
.or(data["png"].as_str());
|
||||
|
||||
let b64 = match b64 {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
eprintln!("[helios-remote] ERROR: No image in response.");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let b64 = if let Some((_, after)) = b64.split_once(',') {
|
||||
after
|
||||
} else {
|
||||
b64
|
||||
};
|
||||
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
std::fs::write(out_path, &bytes).unwrap_or_else(|e| {
|
||||
eprintln!("[helios-remote] ERROR: Failed to write screenshot: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
println!("{}", out_path.display());
|
||||
}
|
||||
|
||||
Commands::Exec {
|
||||
device,
|
||||
timeout,
|
||||
parts,
|
||||
} => {
|
||||
validate_label(&device);
|
||||
let command = parts.join(" ");
|
||||
let mut body = json!({"command": command});
|
||||
if let Some(t) = timeout {
|
||||
body["timeout_ms"] = json!(t * 1000);
|
||||
}
|
||||
let http_timeout = timeout.unwrap_or(30) + 5;
|
||||
let data = req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/exec", device),
|
||||
Some(body),
|
||||
http_timeout.max(35),
|
||||
);
|
||||
|
||||
let stdout = data["stdout"]
|
||||
.as_str()
|
||||
.or(data["output"].as_str())
|
||||
.unwrap_or("");
|
||||
let stderr = data["stderr"].as_str().unwrap_or("");
|
||||
let exit_code = data["exit_code"].as_i64();
|
||||
|
||||
if !stdout.is_empty() {
|
||||
if stdout.ends_with('\n') {
|
||||
print!("{}", stdout);
|
||||
} else {
|
||||
println!("{}", stdout);
|
||||
}
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
eprintln!("[stderr] {}", stderr);
|
||||
}
|
||||
if let Some(code) = exit_code {
|
||||
if code != 0 {
|
||||
eprintln!("[helios-remote] Command exited with code {}", code);
|
||||
process::exit(code as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Windows { device } => {
|
||||
validate_label(&device);
|
||||
let data = req(
|
||||
&cfg,
|
||||
"GET",
|
||||
&format!("/devices/{}/windows", device),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
let windows = data["windows"].as_array().cloned().unwrap_or_default();
|
||||
let visible: Vec<&Value> = windows
|
||||
.iter()
|
||||
.filter(|w| w["visible"].as_bool() == Some(true))
|
||||
.collect();
|
||||
if visible.is_empty() {
|
||||
println!("No windows returned.");
|
||||
return;
|
||||
}
|
||||
println!("{:<30} Title", "Label");
|
||||
println!("{}", "-".repeat(70));
|
||||
for w in visible {
|
||||
println!(
|
||||
"{:<30} {}",
|
||||
w["label"].as_str().unwrap_or("?"),
|
||||
w["title"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Focus { device, window } => {
|
||||
validate_label(&device);
|
||||
let wid = resolve_window(&cfg, &device, &window);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/windows/{}/focus", device, wid),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
println!("Window '{}' focused on {}.", window, device);
|
||||
}
|
||||
|
||||
Commands::Maximize { device, window } => {
|
||||
validate_label(&device);
|
||||
let wid = resolve_window(&cfg, &device, &window);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/windows/{}/maximize", device, wid),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
println!("Window '{}' maximized on {}.", window, device);
|
||||
}
|
||||
|
||||
Commands::MinimizeAll { device } => {
|
||||
validate_label(&device);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/windows/minimize-all", device),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
println!("All windows minimized on {}.", device);
|
||||
}
|
||||
|
||||
Commands::Prompt {
|
||||
device,
|
||||
message,
|
||||
title,
|
||||
} => {
|
||||
validate_label(&device);
|
||||
let mut body = json!({"message": message});
|
||||
if let Some(t) = title {
|
||||
body["title"] = json!(t);
|
||||
}
|
||||
let data = req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/prompt", device),
|
||||
Some(body),
|
||||
30,
|
||||
);
|
||||
let answer = data["answer"].as_str().unwrap_or("");
|
||||
if !answer.is_empty() {
|
||||
println!("{}", answer);
|
||||
} else {
|
||||
println!("Prompt confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
Commands::Run {
|
||||
device,
|
||||
program,
|
||||
args,
|
||||
} => {
|
||||
validate_label(&device);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/run", device),
|
||||
Some(json!({"program": program, "args": args})),
|
||||
30,
|
||||
);
|
||||
println!("Started {:?} on {}.", program, device);
|
||||
}
|
||||
|
||||
Commands::ClipboardGet { device } => {
|
||||
validate_label(&device);
|
||||
let data = req(
|
||||
&cfg,
|
||||
"GET",
|
||||
&format!("/devices/{}/clipboard", device),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
println!("{}", data["text"].as_str().unwrap_or(""));
|
||||
}
|
||||
|
||||
Commands::ClipboardSet { device, text } => {
|
||||
validate_label(&device);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/clipboard", device),
|
||||
Some(json!({"text": text})),
|
||||
30,
|
||||
);
|
||||
println!("Clipboard set ({} chars) on {}.", text.len(), device);
|
||||
}
|
||||
|
||||
Commands::Upload {
|
||||
device,
|
||||
local_path,
|
||||
remote_path,
|
||||
} => {
|
||||
validate_label(&device);
|
||||
if !local_path.exists() {
|
||||
eprintln!(
|
||||
"[helios-remote] ERROR: Local file not found: {}",
|
||||
local_path.display()
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
let bytes = std::fs::read(&local_path).unwrap_or_else(|e| {
|
||||
eprintln!("[helios-remote] ERROR: Failed to read file: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
req(
|
||||
&cfg,
|
||||
"POST",
|
||||
&format!("/devices/{}/upload", device),
|
||||
Some(json!({"path": remote_path, "content_base64": b64})),
|
||||
30,
|
||||
);
|
||||
println!(
|
||||
"Uploaded {} → {} on {}.",
|
||||
local_path.display(),
|
||||
remote_path,
|
||||
device
|
||||
);
|
||||
}
|
||||
|
||||
Commands::Download {
|
||||
device,
|
||||
remote_path,
|
||||
local_path,
|
||||
} => {
|
||||
validate_label(&device);
|
||||
let encoded = urlencoding::encode(&remote_path);
|
||||
let data = req(
|
||||
&cfg,
|
||||
"GET",
|
||||
&format!("/devices/{}/download?path={}", device, encoded),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
let b64 = data["content_base64"].as_str().unwrap_or("");
|
||||
if b64.is_empty() {
|
||||
eprintln!("[helios-remote] ERROR: No content in download response.");
|
||||
process::exit(1);
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("[helios-remote] ERROR: Failed to decode base64: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
if let Some(parent) = local_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(&local_path, &bytes).unwrap_or_else(|e| {
|
||||
eprintln!("[helios-remote] ERROR: Failed to write file: {}", e);
|
||||
process::exit(1);
|
||||
});
|
||||
let size = data["size"].as_u64().unwrap_or(bytes.len() as u64);
|
||||
println!(
|
||||
"Downloaded {} → {} ({} bytes).",
|
||||
remote_path,
|
||||
local_path.display(),
|
||||
size
|
||||
);
|
||||
}
|
||||
|
||||
Commands::Version { device } => {
|
||||
validate_label(&device);
|
||||
|
||||
// Relay version
|
||||
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!("{}", e),
|
||||
};
|
||||
|
||||
// CLI commit
|
||||
let cli_commit = GIT_COMMIT;
|
||||
|
||||
// Client version
|
||||
let client_commit =
|
||||
match std::panic::catch_unwind(|| {
|
||||
req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10)
|
||||
}) {
|
||||
Ok(data) => data["commit"]
|
||||
.as_str()
|
||||
.unwrap_or("?")
|
||||
.to_string(),
|
||||
Err(_) => "unreachable".to_string(),
|
||||
};
|
||||
|
||||
let all_same = relay_commit == cli_commit && cli_commit == client_commit;
|
||||
println!(" relay {}", relay_commit);
|
||||
println!(" cli {}", cli_commit);
|
||||
println!(" client {}", client_commit);
|
||||
println!(
|
||||
" {}",
|
||||
if all_same {
|
||||
"✅ all in sync"
|
||||
} else {
|
||||
"⚠️ OUT OF SYNC"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Commands::Logs { device, lines } => {
|
||||
validate_label(&device);
|
||||
let data = req(
|
||||
&cfg,
|
||||
"GET",
|
||||
&format!("/devices/{}/logs?lines={}", device, lines),
|
||||
None,
|
||||
30,
|
||||
);
|
||||
if let Some(err) = data["error"].as_str() {
|
||||
eprintln!("[helios-remote] {}", err);
|
||||
process::exit(1);
|
||||
}
|
||||
println!(
|
||||
"# {} (last {} lines)",
|
||||
data["log_path"].as_str().unwrap_or("?"),
|
||||
lines
|
||||
);
|
||||
println!("{}", data["content"].as_str().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue