diff --git a/npm/deskctl/README.md b/npm/deskctl/README.md index 7bb42a9..81f07f4 100644 --- a/npm/deskctl/README.md +++ b/npm/deskctl/README.md @@ -14,6 +14,18 @@ After install, run: deskctl --help ``` +To upgrade version: + +```bash +deskctl upgrade +``` + +For non-interactive use: + +```bash +deskctl upgrade --yes +``` + One-shot usage is also supported: ```bash diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx index dc9c578..934cdb8 100644 --- a/site/src/pages/commands.mdx +++ b/site/src/pages/commands.mdx @@ -13,6 +13,7 @@ reads, grouped waits, selector-driven actions, and a few input primitives. ```sh deskctl doctor +deskctl upgrade deskctl snapshot deskctl snapshot --annotate deskctl list-windows @@ -26,7 +27,9 @@ deskctl get-screen-size deskctl get-mouse-position ``` -`doctor` checks the runtime before daemon startup. `snapshot` produces a +`doctor` checks the runtime before daemon startup. `upgrade` checks for a newer +published version, shows a short confirmation prompt when an update is +available, and supports `--yes` for non-interactive use. `snapshot` produces a screenshot plus window refs. `list-windows` is the same window tree without the side effect of writing a screenshot. The grouped `get` commands are the preferred read surface for focused state queries. diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md index 244a1fb..67a77c5 100644 --- a/skills/deskctl/SKILL.md +++ b/skills/deskctl/SKILL.md @@ -18,6 +18,12 @@ deskctl doctor deskctl snapshot --annotate ``` +If `deskctl` was installed through npm, refresh it later with: + +```bash +deskctl upgrade --yes +``` + ## Agent loop Every desktop interaction follows: **observe -> wait -> act -> verify**. diff --git a/skills/deskctl/references/commands.md b/skills/deskctl/references/commands.md index 77b9513..27b4310 100644 --- a/skills/deskctl/references/commands.md +++ b/skills/deskctl/references/commands.md @@ -7,6 +7,7 @@ runtime contract. ```bash deskctl doctor +deskctl upgrade deskctl snapshot deskctl snapshot --annotate deskctl list-windows diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b24465a..28092d7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,4 +1,5 @@ pub mod connection; +pub mod upgrade; use anyhow::Result; use clap::{Args, Parser, Subcommand}; @@ -121,6 +122,9 @@ pub enum Command { /// Diagnose X11 runtime, screenshot, and daemon health #[command(after_help = DOCTOR_EXAMPLES)] Doctor, + /// Upgrade deskctl using the current install channel + #[command(after_help = UPGRADE_EXAMPLES)] + Upgrade(UpgradeOpts), /// Query runtime state #[command(subcommand)] Get(GetCmd), @@ -231,6 +235,8 @@ const GET_SCREEN_SIZE_EXAMPLES: &str = const GET_MOUSE_POSITION_EXAMPLES: &str = "Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position"; const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor"; +const UPGRADE_EXAMPLES: &str = + "Examples:\n deskctl upgrade\n deskctl upgrade --yes\n deskctl --json upgrade --yes"; const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100"; const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200"; const SCREENSHOT_EXAMPLES: &str = @@ -284,6 +290,13 @@ pub struct WaitSelectorOpts { pub poll_ms: u64, } +#[derive(Args)] +pub struct UpgradeOpts { + /// Skip confirmation and upgrade non-interactively + #[arg(long)] + pub yes: bool, +} + pub fn run() -> Result<()> { let app = App::parse(); @@ -300,6 +313,22 @@ pub fn run() -> Result<()> { return connection::run_doctor(&app.global); } + if let Command::Upgrade(ref upgrade_opts) = app.command { + let response = upgrade::run_upgrade(&app.global, upgrade_opts)?; + let success = response.success; + + if app.global.json { + println!("{}", serde_json::to_string_pretty(&response)?); + if !success { + std::process::exit(1); + } + } else { + print_response(&app.command, &response)?; + } + + return Ok(()); + } + // All other commands need a daemon connection let request = build_request(&app.command)?; let response = connection::send_command(&app.global, &request)?; @@ -363,6 +392,7 @@ fn build_request(cmd: &Command) -> Result { Command::GetScreenSize => Request::new("get-screen-size"), Command::GetMousePosition => Request::new("get-mouse-position"), Command::Doctor => unreachable!(), + Command::Upgrade(_) => unreachable!(), Command::Get(sub) => match sub { GetCmd::ActiveWindow => Request::new("get-active-window"), GetCmd::Monitors => Request::new("get-monitors"), @@ -422,6 +452,7 @@ fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Resu Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data), Command::GetScreenSize => vec![render_screen_size_line(data)], Command::GetMousePosition => vec![render_mouse_position_line(data)], + Command::Upgrade(_) => render_upgrade_lines(data), Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate), Command::Click { .. } => vec![render_click_line(data, false)], Command::Dblclick { .. } => vec![render_click_line(data, true)], @@ -526,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec { lines.push("No focused window is available.".to_string()); } } + "upgrade_failed" => { + if let Some(reason) = data.get("io_error").and_then(|value| value.as_str()) { + lines.push(format!("Reason: {reason}")); + } + if let Some(reason) = data.get("reason").and_then(|value| value.as_str()) { + lines.push(format!("Reason: {reason}")); + } + if let Some(command) = data.get("command").and_then(|value| value.as_str()) { + lines.push(format!("Command: {command}")); + } + if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) { + lines.push(format!("Hint: {hint}")); + } + } + "upgrade_unsupported" => { + if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) { + lines.push(format!("Hint: {hint}")); + } + } + "upgrade_confirmation_required" => { + if let Some(current_version) = + data.get("current_version").and_then(|value| value.as_str()) + { + if let Some(latest_version) = + data.get("latest_version").and_then(|value| value.as_str()) + { + lines.push(format!( + "Update available: {current_version} -> {latest_version}" + )); + } + } + if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) { + lines.push(format!("Hint: {hint}")); + } + } _ => {} } @@ -723,6 +789,36 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec Vec { + match data.get("status").and_then(|value| value.as_str()) { + Some("up_to_date") => { + let version = data + .get("latest_version") + .and_then(|value| value.as_str()) + .or_else(|| data.get("current_version").and_then(|value| value.as_str())) + .unwrap_or("unknown"); + vec![format!( + "✔ You're already on the latest version! ({version})" + )] + } + Some("upgraded") => { + let current_version = data + .get("current_version") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let latest_version = data + .get("latest_version") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + vec![format!( + "✔ Upgraded deskctl from {current_version} -> {latest_version}" + )] + } + Some("cancelled") => vec!["No changes made.".to_string()], + _ => vec!["Upgrade completed.".to_string()], + } +} + fn render_click_line(data: &serde_json::Value, double: bool) -> String { let action = if double { "Double-clicked" } else { "Clicked" }; let key = if double { "double_clicked" } else { "clicked" }; @@ -978,7 +1074,7 @@ fn truncate_display(value: &str, max_chars: usize) -> String { mod tests { use super::{ render_error_lines, render_screen_size_line, render_success_lines, target_summary, - truncate_display, App, Command, Response, + truncate_display, App, Command, Response, UpgradeOpts, }; use clap::CommandFactory; use serde_json::json; @@ -1104,4 +1200,22 @@ mod tests { let input = format!("fire{}fox", '\u{00E9}'); assert_eq!(truncate_display(&input, 7), "fire..."); } + + #[test] + fn upgrade_success_text_is_neat() { + let lines = render_success_lines( + &Command::Upgrade(UpgradeOpts { yes: false }), + Some(&json!({ + "status": "up_to_date", + "current_version": "0.1.8", + "latest_version": "0.1.8" + })), + ) + .unwrap(); + + assert_eq!( + lines, + vec!["✔ You're already on the latest version! (0.1.8)"] + ); + } } diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs new file mode 100644 index 0000000..acc844e --- /dev/null +++ b/src/cli/upgrade.rs @@ -0,0 +1,465 @@ +use std::io::{self, IsTerminal, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::cli::{GlobalOpts, UpgradeOpts}; +use crate::core::protocol::Response; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InstallMethod { + Npm, + Cargo, + Nix, + Source, + Unknown, +} + +impl InstallMethod { + fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::Cargo => "cargo", + Self::Nix => "nix", + Self::Source => "source", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug)] +struct UpgradePlan { + install_method: InstallMethod, + program: &'static str, + args: Vec<&'static str>, +} + +impl UpgradePlan { + fn command_line(&self) -> String { + std::iter::once(self.program) + .chain(self.args.iter().copied()) + .collect::>() + .join(" ") + } +} + +#[derive(Debug)] +struct VersionInfo { + current: String, + latest: String, +} + +pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result { + let current_exe = std::env::current_exe().context("Failed to determine executable path")?; + let install_method = detect_install_method(¤t_exe); + + let Some(plan) = upgrade_plan(install_method) else { + return Ok(Response::err_with_data( + format!( + "deskctl upgrade is not supported for {} installs.", + install_method.as_str() + ), + json!({ + "kind": "upgrade_unsupported", + "install_method": install_method.as_str(), + "current_exe": current_exe.display().to_string(), + "hint": upgrade_hint(install_method), + }), + )); + }; + + if !opts.json { + println!("- Checking for updates..."); + } + + let versions = match resolve_versions(&plan) { + Ok(versions) => versions, + Err(response) => return Ok(response), + }; + + if versions.current == versions.latest { + return Ok(Response::ok(json!({ + "action": "upgrade", + "status": "up_to_date", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + }))); + } + + if !upgrade_opts.yes { + if opts.json || !io::stdin().is_terminal() { + return Ok(Response::err_with_data( + format!( + "Upgrade confirmation required for {} -> {}.", + versions.current, versions.latest + ), + json!({ + "kind": "upgrade_confirmation_required", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + "command": plan.command_line(), + "hint": "Re-run with --yes to upgrade non-interactively.", + }), + )); + } + + if !confirm_upgrade(&versions)? { + return Ok(Response::ok(json!({ + "action": "upgrade", + "status": "cancelled", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + }))); + } + } + + if !opts.json { + println!( + "- Upgrading deskctl from {} -> {}...", + versions.current, versions.latest + ); + } + + let output = match Command::new(plan.program).args(&plan.args).output() { + Ok(output) => output, + Err(error) => return Ok(upgrade_spawn_error_response(&plan, &versions, &error)), + }; + + if output.status.success() { + return Ok(Response::ok(json!({ + "action": "upgrade", + "status": "upgraded", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + "command": plan.command_line(), + "exit_code": output.status.code(), + }))); + } + + Ok(upgrade_command_failed_response(&plan, &versions, &output)) +} + +fn resolve_versions(plan: &UpgradePlan) -> std::result::Result { + let current = env!("CARGO_PKG_VERSION").to_string(); + let latest = match plan.install_method { + InstallMethod::Npm => query_npm_latest_version()?, + InstallMethod::Cargo => query_cargo_latest_version()?, + InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => { + return Err(Response::err_with_data( + "Could not determine the latest published version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": plan.install_method.as_str(), + "reason": "Could not determine the latest published version for this install method.", + "command": plan.command_line(), + "hint": upgrade_hint(plan.install_method), + }), + )); + } + }; + + Ok(VersionInfo { current, latest }) +} + +fn query_npm_latest_version() -> std::result::Result { + let output = Command::new("npm") + .args(["view", "deskctl", "version", "--json"]) + .output() + .map_err(|error| { + Response::err_with_data( + "Failed to check the latest npm version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Npm.as_str(), + "reason": "Failed to run npm view deskctl version --json.", + "io_error": error.to_string(), + "command": "npm view deskctl version --json", + "hint": upgrade_hint(InstallMethod::Npm), + }), + ) + })?; + + if !output.status.success() { + return Err(Response::err_with_data( + "Failed to check the latest npm version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Npm.as_str(), + "reason": command_failure_reason(&output), + "command": "npm view deskctl version --json", + "hint": upgrade_hint(InstallMethod::Npm), + }), + )); + } + + serde_json::from_slice::(&output.stdout).map_err(|_| { + Response::err_with_data( + "Failed to parse the latest npm version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Npm.as_str(), + "reason": "npm view returned an unexpected version payload.", + "command": "npm view deskctl version --json", + "hint": upgrade_hint(InstallMethod::Npm), + }), + ) + }) +} + +fn query_cargo_latest_version() -> std::result::Result { + let output = Command::new("cargo") + .args(["search", "deskctl", "--limit", "1"]) + .output() + .map_err(|error| { + Response::err_with_data( + "Failed to check the latest crates.io version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Cargo.as_str(), + "reason": "Failed to run cargo search deskctl --limit 1.", + "io_error": error.to_string(), + "command": "cargo search deskctl --limit 1", + "hint": upgrade_hint(InstallMethod::Cargo), + }), + ) + })?; + + if !output.status.success() { + return Err(Response::err_with_data( + "Failed to check the latest crates.io version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Cargo.as_str(), + "reason": command_failure_reason(&output), + "command": "cargo search deskctl --limit 1", + "hint": upgrade_hint(InstallMethod::Cargo), + }), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let latest = stdout + .split('"') + .nth(1) + .map(str::to_string) + .filter(|value| !value.is_empty()); + + latest.ok_or_else(|| { + Response::err_with_data( + "Failed to determine the latest crates.io version.".to_string(), + json!({ + "kind": "upgrade_failed", + "install_method": InstallMethod::Cargo.as_str(), + "reason": "cargo search did not return a published deskctl crate version.", + "command": "cargo search deskctl --limit 1", + "hint": upgrade_hint(InstallMethod::Cargo), + }), + ) + }) +} + +fn confirm_upgrade(versions: &VersionInfo) -> Result { + print!( + "Upgrade deskctl from {} -> {}? [y/N] ", + versions.current, versions.latest + ); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + let trimmed = input.trim(); + Ok(matches!(trimmed, "y" | "Y" | "yes" | "YES" | "Yes")) +} + +fn upgrade_command_failed_response( + plan: &UpgradePlan, + versions: &VersionInfo, + output: &std::process::Output, +) -> Response { + Response::err_with_data( + format!("Upgrade command failed: {}", plan.command_line()), + json!({ + "kind": "upgrade_failed", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + "command": plan.command_line(), + "exit_code": output.status.code(), + "reason": command_failure_reason(output), + "hint": upgrade_hint(plan.install_method), + }), + ) +} + +fn upgrade_spawn_error_response( + plan: &UpgradePlan, + versions: &VersionInfo, + error: &std::io::Error, +) -> Response { + Response::err_with_data( + format!("Failed to run {}", plan.command_line()), + json!({ + "kind": "upgrade_failed", + "install_method": plan.install_method.as_str(), + "current_version": versions.current, + "latest_version": versions.latest, + "command": plan.command_line(), + "io_error": error.to_string(), + "hint": upgrade_hint(plan.install_method), + }), + ) +} + +fn command_failure_reason(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + stderr + .lines() + .chain(stdout.lines()) + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + output + .status + .code() + .map(|code| format!("Command exited with status {code}.")) + .unwrap_or_else(|| "Command exited unsuccessfully.".to_string()) + }) +} + +fn upgrade_plan(install_method: InstallMethod) -> Option { + match install_method { + InstallMethod::Npm => Some(UpgradePlan { + install_method, + program: "npm", + args: vec!["install", "-g", "deskctl@latest"], + }), + InstallMethod::Cargo => Some(UpgradePlan { + install_method, + program: "cargo", + args: vec!["install", "deskctl", "--locked"], + }), + InstallMethod::Nix | InstallMethod::Source | InstallMethod::Unknown => None, + } +} + +fn upgrade_hint(install_method: InstallMethod) -> &'static str { + match install_method { + InstallMethod::Nix => { + "Use nix profile upgrade or update the flake reference you installed from." + } + InstallMethod::Source => { + "Rebuild from source or reinstall deskctl through npm, cargo, or nix." + } + InstallMethod::Unknown => { + "Reinstall deskctl through a supported channel such as npm, cargo, or nix." + } + InstallMethod::Npm => "Retry with --yes or run npm install -g deskctl@latest directly.", + InstallMethod::Cargo => "Retry with --yes or run cargo install deskctl --locked directly.", + } +} + +fn detect_install_method(current_exe: &Path) -> InstallMethod { + if looks_like_npm_install(current_exe) { + return InstallMethod::Npm; + } + if looks_like_nix_install(current_exe) { + return InstallMethod::Nix; + } + if looks_like_cargo_install(current_exe) { + return InstallMethod::Cargo; + } + if looks_like_source_tree(current_exe) { + return InstallMethod::Source; + } + InstallMethod::Unknown +} + +fn looks_like_npm_install(path: &Path) -> bool { + let value = normalize(path); + value.contains("/node_modules/deskctl/") && value.contains("/vendor/") +} + +fn looks_like_nix_install(path: &Path) -> bool { + normalize(path).starts_with("/nix/store/") +} + +fn looks_like_cargo_install(path: &Path) -> bool { + let Some(home) = std::env::var_os("HOME") else { + return false; + }; + + let cargo_home = std::env::var_os("CARGO_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(home).join(".cargo")); + path == cargo_home.join("bin").join("deskctl") +} + +fn looks_like_source_tree(path: &Path) -> bool { + let value = normalize(path); + value.contains("/target/debug/deskctl") || value.contains("/target/release/deskctl") +} + +fn normalize(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use std::os::unix::process::ExitStatusExt; + use std::path::Path; + + use super::{command_failure_reason, detect_install_method, upgrade_plan, InstallMethod}; + + #[test] + fn detects_npm_install_path() { + let method = detect_install_method(Path::new( + "/usr/local/lib/node_modules/deskctl/vendor/deskctl-linux-x86_64", + )); + assert_eq!(method, InstallMethod::Npm); + } + + #[test] + fn detects_nix_install_path() { + let method = detect_install_method(Path::new("/nix/store/abc123-deskctl/bin/deskctl")); + assert_eq!(method, InstallMethod::Nix); + } + + #[test] + fn detects_source_tree_path() { + let method = + detect_install_method(Path::new("/Users/example/src/deskctl/target/debug/deskctl")); + assert_eq!(method, InstallMethod::Source); + } + + #[test] + fn npm_upgrade_plan_uses_global_install() { + let plan = upgrade_plan(InstallMethod::Npm).expect("npm installs should support upgrade"); + assert_eq!(plan.command_line(), "npm install -g deskctl@latest"); + } + + #[test] + fn nix_install_has_no_upgrade_plan() { + assert!(upgrade_plan(InstallMethod::Nix).is_none()); + } + + #[test] + fn failure_reason_prefers_stderr() { + let output = std::process::Output { + status: std::process::ExitStatus::from_raw(1 << 8), + stdout: b"".to_vec(), + stderr: b"boom\n".to_vec(), + }; + + assert_eq!(command_failure_reason(&output), "boom"); + } +}