diff --git a/npm/deskctl/README.md b/npm/deskctl/README.md index 7bb42a9..f0145eb 100644 --- a/npm/deskctl/README.md +++ b/npm/deskctl/README.md @@ -14,6 +14,12 @@ After install, run: deskctl --help ``` +To upgrade version: + +```bash +deskctl upgrade +``` + One-shot usage is also supported: ```bash diff --git a/site/src/pages/commands.mdx b/site/src/pages/commands.mdx index dc9c578..bf42679 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,10 +27,11 @@ deskctl get-screen-size deskctl get-mouse-position ``` -`doctor` checks the runtime before daemon startup. `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. +`doctor` checks the runtime before daemon startup. `upgrade` refreshes an npm +install and returns channel-specific guidance for other install methods. +`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. ## Wait for state transitions diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md index 244a1fb..30112bd 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 +``` + ## 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..a135993 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, /// Query runtime state #[command(subcommand)] Get(GetCmd), @@ -231,6 +235,7 @@ 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 --json upgrade"; 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 = @@ -300,6 +305,22 @@ pub fn run() -> Result<()> { return connection::run_doctor(&app.global); } + if let Command::Upgrade = app.command { + let response = upgrade::run_upgrade(&app.global)?; + 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 +384,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 +444,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 +549,28 @@ fn render_error_lines(response: &Response) -> Vec { lines.push("No focused window is available.".to_string()); } } + "upgrade_failed" => { + if let Some(method) = data.get("install_method").and_then(|value| value.as_str()) { + lines.push(format!("Install method: {method}")); + } + if let Some(command) = data.get("command").and_then(|value| value.as_str()) { + lines.push(format!("Command: {command}")); + } + if let Some(reason) = data.get("io_error").and_then(|value| value.as_str()) { + lines.push(format!("Reason: {reason}")); + } + if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) { + lines.push(format!("Hint: {hint}")); + } + } + "upgrade_unsupported" => { + if let Some(method) = data.get("install_method").and_then(|value| value.as_str()) { + lines.push(format!("Install method: {method}")); + } + if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) { + lines.push(format!("Hint: {hint}")); + } + } _ => {} } @@ -723,6 +768,19 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec Vec { + let mut lines = Vec::new(); + let method = data + .get("install_method") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!("Upgrade completed via {method}")); + if let Some(command) = data.get("command").and_then(|value| value.as_str()) { + lines.push(format!("Command: {command}")); + } + lines +} + 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" }; diff --git a/src/cli/upgrade.rs b/src/cli/upgrade.rs new file mode 100644 index 0000000..32152a4 --- /dev/null +++ b/src/cli/upgrade.rs @@ -0,0 +1,246 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::cli::GlobalOpts; +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(" ") + } +} + +pub fn run_upgrade(opts: &GlobalOpts) -> 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 { + let output = match Command::new(plan.program).args(&plan.args).output() { + Ok(output) => output, + Err(error) => return Ok(upgrade_spawn_error_response(&plan, &error)), + }; + + return Ok(upgrade_response( + &plan, + output.status.code(), + output.status.success(), + )); + } + + let status = match Command::new(plan.program) + .args(&plan.args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + { + Ok(status) => status, + Err(error) => return Ok(upgrade_spawn_error_response(&plan, &error)), + }; + + Ok(upgrade_response(&plan, status.code(), status.success())) +} + +fn upgrade_response(plan: &UpgradePlan, exit_code: Option, success: bool) -> Response { + let data = json!({ + "action": "upgrade", + "install_method": plan.install_method.as_str(), + "command": plan.command_line(), + "exit_code": exit_code, + }); + + if success { + Response::ok(data) + } else { + Response::err_with_data( + format!("Upgrade command failed: {}", plan.command_line()), + json!({ + "kind": "upgrade_failed", + "install_method": plan.install_method.as_str(), + "command": plan.command_line(), + "exit_code": exit_code, + "hint": upgrade_hint(plan.install_method), + }), + ) + } +} + +fn upgrade_spawn_error_response(plan: &UpgradePlan, 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(), + "command": plan.command_line(), + "io_error": error.to_string(), + "hint": upgrade_hint(plan.install_method), + }), + ) +} + +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 the npm install command directly if the upgrade failed.", + InstallMethod::Cargo => { + "Retry cargo install directly or confirm the crate is published for this version." + } + } +} + +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::path::Path; + + use super::{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()); + } +}