diff --git a/npm/deskctl/README.md b/npm/deskctl/README.md index f0145eb..81f07f4 100644 --- a/npm/deskctl/README.md +++ b/npm/deskctl/README.md @@ -20,6 +20,12 @@ To upgrade version: 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 bf42679..934cdb8 100644 --- a/site/src/pages/commands.mdx +++ b/site/src/pages/commands.mdx @@ -27,11 +27,12 @@ deskctl get-screen-size deskctl get-mouse-position ``` -`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. +`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. ## Wait for state transitions diff --git a/skills/deskctl/SKILL.md b/skills/deskctl/SKILL.md index 30112bd..67a77c5 100644 --- a/skills/deskctl/SKILL.md +++ b/skills/deskctl/SKILL.md @@ -21,7 +21,7 @@ deskctl snapshot --annotate If `deskctl` was installed through npm, refresh it later with: ```bash -deskctl upgrade +deskctl upgrade --yes ``` ## Agent loop diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a135993..28092d7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -124,7 +124,7 @@ pub enum Command { Doctor, /// Upgrade deskctl using the current install channel #[command(after_help = UPGRADE_EXAMPLES)] - Upgrade, + Upgrade(UpgradeOpts), /// Query runtime state #[command(subcommand)] Get(GetCmd), @@ -235,7 +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 --json upgrade"; +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 = @@ -289,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(); @@ -305,8 +313,8 @@ pub fn run() -> Result<()> { return connection::run_doctor(&app.global); } - if let Command::Upgrade = app.command { - let response = upgrade::run_upgrade(&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 { @@ -384,7 +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::Upgrade(_) => unreachable!(), Command::Get(sub) => match sub { GetCmd::ActiveWindow => Request::new("get-active-window"), GetCmd::Monitors => Request::new("get-monitors"), @@ -444,7 +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::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)], @@ -550,22 +558,35 @@ fn render_error_lines(response: &Response) -> Vec { } } "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(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(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}")); + } + } + "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}")); @@ -769,16 +790,33 @@ 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}")); + 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()], } - lines } fn render_click_line(data: &serde_json::Value, double: bool) -> String { @@ -1036,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; @@ -1162,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 index 32152a4..acc844e 100644 --- a/src/cli/upgrade.rs +++ b/src/cli/upgrade.rs @@ -1,10 +1,11 @@ +use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Command; use anyhow::{Context, Result}; use serde_json::json; -use crate::cli::GlobalOpts; +use crate::cli::{GlobalOpts, UpgradeOpts}; use crate::core::protocol::Response; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -44,7 +45,13 @@ impl UpgradePlan { } } -pub fn run_upgrade(opts: &GlobalOpts) -> Result { +#[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); @@ -63,63 +70,246 @@ pub fn run_upgrade(opts: &GlobalOpts) -> Result { )); }; - 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)), - }; + if !opts.json { + println!("- Checking for updates..."); + } - return Ok(upgrade_response( - &plan, - output.status.code(), - output.status.success(), + 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), + }), )); } - 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 { + serde_json::from_slice::(&output.stdout).map_err(|_| { Response::err_with_data( - format!("Upgrade command failed: {}", plan.command_line()), + "Failed to parse the latest npm version.".to_string(), 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), + "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 upgrade_spawn_error_response(plan: &UpgradePlan, error: &std::io::Error) -> Response { +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), @@ -127,6 +317,25 @@ fn upgrade_spawn_error_response(plan: &UpgradePlan, error: &std::io::Error) -> R ) } +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 { @@ -154,10 +363,8 @@ fn upgrade_hint(install_method: InstallMethod) -> &'static str { 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." - } + 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.", } } @@ -208,9 +415,10 @@ fn normalize(path: &Path) -> String { #[cfg(test)] mod tests { + use std::os::unix::process::ExitStatusExt; use std::path::Path; - use super::{detect_install_method, upgrade_plan, InstallMethod}; + use super::{command_failure_reason, detect_install_method, upgrade_plan, InstallMethod}; #[test] fn detects_npm_install_path() { @@ -243,4 +451,15 @@ mod tests { 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"); + } }