interactive update

as well as --yes flag
This commit is contained in:
Harivansh Rathi 2026-03-26 11:46:35 -04:00
parent 7f0c462385
commit 3f422f9717
5 changed files with 360 additions and 78 deletions

View file

@ -20,6 +20,12 @@ To upgrade version:
deskctl upgrade deskctl upgrade
``` ```
For non-interactive use:
```bash
deskctl upgrade --yes
```
One-shot usage is also supported: One-shot usage is also supported:
```bash ```bash

View file

@ -27,11 +27,12 @@ deskctl get-screen-size
deskctl get-mouse-position deskctl get-mouse-position
``` ```
`doctor` checks the runtime before daemon startup. `upgrade` refreshes an npm `doctor` checks the runtime before daemon startup. `upgrade` checks for a newer
install and returns channel-specific guidance for other install methods. published version, shows a short confirmation prompt when an update is
`snapshot` produces a screenshot plus window refs. `list-windows` is the same available, and supports `--yes` for non-interactive use. `snapshot` produces a
window tree without the side effect of writing a screenshot. The grouped `get` screenshot plus window refs. `list-windows` is the same window tree without the
commands are the preferred read surface for focused state queries. side effect of writing a screenshot. The grouped `get` commands are the
preferred read surface for focused state queries.
## Wait for state transitions ## Wait for state transitions

View file

@ -21,7 +21,7 @@ deskctl snapshot --annotate
If `deskctl` was installed through npm, refresh it later with: If `deskctl` was installed through npm, refresh it later with:
```bash ```bash
deskctl upgrade deskctl upgrade --yes
``` ```
## Agent loop ## Agent loop

View file

@ -124,7 +124,7 @@ pub enum Command {
Doctor, Doctor,
/// Upgrade deskctl using the current install channel /// Upgrade deskctl using the current install channel
#[command(after_help = UPGRADE_EXAMPLES)] #[command(after_help = UPGRADE_EXAMPLES)]
Upgrade, Upgrade(UpgradeOpts),
/// Query runtime state /// Query runtime state
#[command(subcommand)] #[command(subcommand)]
Get(GetCmd), Get(GetCmd),
@ -235,7 +235,8 @@ const GET_SCREEN_SIZE_EXAMPLES: &str =
const GET_MOUSE_POSITION_EXAMPLES: &str = const GET_MOUSE_POSITION_EXAMPLES: &str =
"Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position"; "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 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_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 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 = const SCREENSHOT_EXAMPLES: &str =
@ -289,6 +290,13 @@ pub struct WaitSelectorOpts {
pub poll_ms: u64, pub poll_ms: u64,
} }
#[derive(Args)]
pub struct UpgradeOpts {
/// Skip confirmation and upgrade non-interactively
#[arg(long)]
pub yes: bool,
}
pub fn run() -> Result<()> { pub fn run() -> Result<()> {
let app = App::parse(); let app = App::parse();
@ -305,8 +313,8 @@ pub fn run() -> Result<()> {
return connection::run_doctor(&app.global); return connection::run_doctor(&app.global);
} }
if let Command::Upgrade = app.command { if let Command::Upgrade(ref upgrade_opts) = app.command {
let response = upgrade::run_upgrade(&app.global)?; let response = upgrade::run_upgrade(&app.global, upgrade_opts)?;
let success = response.success; let success = response.success;
if app.global.json { if app.global.json {
@ -384,7 +392,7 @@ fn build_request(cmd: &Command) -> Result<Request> {
Command::GetScreenSize => Request::new("get-screen-size"), Command::GetScreenSize => Request::new("get-screen-size"),
Command::GetMousePosition => Request::new("get-mouse-position"), Command::GetMousePosition => Request::new("get-mouse-position"),
Command::Doctor => unreachable!(), Command::Doctor => unreachable!(),
Command::Upgrade => unreachable!(), Command::Upgrade(_) => unreachable!(),
Command::Get(sub) => match sub { Command::Get(sub) => match sub {
GetCmd::ActiveWindow => Request::new("get-active-window"), GetCmd::ActiveWindow => Request::new("get-active-window"),
GetCmd::Monitors => Request::new("get-monitors"), 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::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data),
Command::GetScreenSize => vec![render_screen_size_line(data)], Command::GetScreenSize => vec![render_screen_size_line(data)],
Command::GetMousePosition => vec![render_mouse_position_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::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate),
Command::Click { .. } => vec![render_click_line(data, false)], Command::Click { .. } => vec![render_click_line(data, false)],
Command::Dblclick { .. } => vec![render_click_line(data, true)], Command::Dblclick { .. } => vec![render_click_line(data, true)],
@ -550,22 +558,35 @@ fn render_error_lines(response: &Response) -> Vec<String> {
} }
} }
"upgrade_failed" => { "upgrade_failed" => {
if let Some(method) = data.get("install_method").and_then(|value| value.as_str()) { if let Some(reason) = data.get("io_error").and_then(|value| value.as_str()) {
lines.push(format!("Install method: {method}")); 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()) { if let Some(command) = data.get("command").and_then(|value| value.as_str()) {
lines.push(format!("Command: {command}")); 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()) { if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
lines.push(format!("Hint: {hint}")); lines.push(format!("Hint: {hint}"));
} }
} }
"upgrade_unsupported" => { "upgrade_unsupported" => {
if let Some(method) = data.get("install_method").and_then(|value| value.as_str()) { if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
lines.push(format!("Install method: {method}")); 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()) { if let Some(hint) = data.get("hint").and_then(|value| value.as_str()) {
lines.push(format!("Hint: {hint}")); lines.push(format!("Hint: {hint}"));
@ -769,16 +790,33 @@ fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec<Stri
} }
fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> { fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> {
let mut lines = Vec::new(); match data.get("status").and_then(|value| value.as_str()) {
let method = data Some("up_to_date") => {
.get("install_method") 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()) .and_then(|value| value.as_str())
.unwrap_or("unknown"); .unwrap_or("unknown");
lines.push(format!("Upgrade completed via {method}")); let latest_version = data
if let Some(command) = data.get("command").and_then(|value| value.as_str()) { .get("latest_version")
lines.push(format!("Command: {command}")); .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 { 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 { mod tests {
use super::{ use super::{
render_error_lines, render_screen_size_line, render_success_lines, target_summary, 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 clap::CommandFactory;
use serde_json::json; use serde_json::json;
@ -1162,4 +1200,22 @@ mod tests {
let input = format!("fire{}fox", '\u{00E9}'); let input = format!("fire{}fox", '\u{00E9}');
assert_eq!(truncate_display(&input, 7), "fire..."); 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)"]
);
}
} }

View file

@ -1,10 +1,11 @@
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::Command;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::json; use serde_json::json;
use crate::cli::GlobalOpts; use crate::cli::{GlobalOpts, UpgradeOpts};
use crate::core::protocol::Response; use crate::core::protocol::Response;
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -44,7 +45,13 @@ impl UpgradePlan {
} }
} }
pub fn run_upgrade(opts: &GlobalOpts) -> Result<Response> { #[derive(Debug)]
struct VersionInfo {
current: String,
latest: String,
}
pub fn run_upgrade(opts: &GlobalOpts, upgrade_opts: &UpgradeOpts) -> Result<Response> {
let current_exe = std::env::current_exe().context("Failed to determine executable path")?; let current_exe = std::env::current_exe().context("Failed to determine executable path")?;
let install_method = detect_install_method(&current_exe); let install_method = detect_install_method(&current_exe);
@ -63,63 +70,246 @@ pub fn run_upgrade(opts: &GlobalOpts) -> Result<Response> {
)); ));
}; };
if opts.json { if !opts.json {
let output = match Command::new(plan.program).args(&plan.args).output() { println!("- Checking for updates...");
Ok(output) => output, }
Err(error) => return Ok(upgrade_spawn_error_response(&plan, &error)),
let versions = match resolve_versions(&plan) {
Ok(versions) => versions,
Err(response) => return Ok(response),
}; };
return Ok(upgrade_response( if versions.current == versions.latest {
&plan, return Ok(Response::ok(json!({
output.status.code(), "action": "upgrade",
output.status.success(), "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.",
}),
)); ));
} }
let status = match Command::new(plan.program) if !confirm_upgrade(&versions)? {
.args(&plan.args) return Ok(Response::ok(json!({
.stdin(Stdio::inherit()) "action": "upgrade",
.stdout(Stdio::inherit()) "status": "cancelled",
.stderr(Stdio::inherit()) "install_method": plan.install_method.as_str(),
.status() "current_version": versions.current,
{ "latest_version": versions.latest,
Ok(status) => status, })));
Err(error) => return Ok(upgrade_spawn_error_response(&plan, &error)), }
}
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)),
}; };
Ok(upgrade_response(&plan, status.code(), status.success())) 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 upgrade_response(plan: &UpgradePlan, exit_code: Option<i32>, success: bool) -> Response { fn resolve_versions(plan: &UpgradePlan) -> std::result::Result<VersionInfo, Response> {
let data = json!({ let current = env!("CARGO_PKG_VERSION").to_string();
"action": "upgrade", 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(), "install_method": plan.install_method.as_str(),
"reason": "Could not determine the latest published version for this install method.",
"command": plan.command_line(), "command": plan.command_line(),
"exit_code": exit_code, "hint": upgrade_hint(plan.install_method),
}); }),
));
}
};
if success { Ok(VersionInfo { current, latest })
Response::ok(data) }
} else {
fn query_npm_latest_version() -> std::result::Result<String, Response> {
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::<String>(&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<String, Response> {
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<bool> {
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( Response::err_with_data(
format!("Upgrade command failed: {}", plan.command_line()), format!("Upgrade command failed: {}", plan.command_line()),
json!({ json!({
"kind": "upgrade_failed", "kind": "upgrade_failed",
"install_method": plan.install_method.as_str(), "install_method": plan.install_method.as_str(),
"current_version": versions.current,
"latest_version": versions.latest,
"command": plan.command_line(), "command": plan.command_line(),
"exit_code": exit_code, "exit_code": output.status.code(),
"reason": command_failure_reason(output),
"hint": upgrade_hint(plan.install_method), "hint": upgrade_hint(plan.install_method),
}), }),
) )
}
} }
fn upgrade_spawn_error_response(plan: &UpgradePlan, error: &std::io::Error) -> Response { fn upgrade_spawn_error_response(
plan: &UpgradePlan,
versions: &VersionInfo,
error: &std::io::Error,
) -> Response {
Response::err_with_data( Response::err_with_data(
format!("Failed to run {}", plan.command_line()), format!("Failed to run {}", plan.command_line()),
json!({ json!({
"kind": "upgrade_failed", "kind": "upgrade_failed",
"install_method": plan.install_method.as_str(), "install_method": plan.install_method.as_str(),
"current_version": versions.current,
"latest_version": versions.latest,
"command": plan.command_line(), "command": plan.command_line(),
"io_error": error.to_string(), "io_error": error.to_string(),
"hint": upgrade_hint(plan.install_method), "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<UpgradePlan> { fn upgrade_plan(install_method: InstallMethod) -> Option<UpgradePlan> {
match install_method { match install_method {
InstallMethod::Npm => Some(UpgradePlan { InstallMethod::Npm => Some(UpgradePlan {
@ -154,10 +363,8 @@ fn upgrade_hint(install_method: InstallMethod) -> &'static str {
InstallMethod::Unknown => { InstallMethod::Unknown => {
"Reinstall deskctl through a supported channel such as npm, cargo, or nix." "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::Npm => "Retry with --yes or run npm install -g deskctl@latest directly.",
InstallMethod::Cargo => { InstallMethod::Cargo => "Retry with --yes or run cargo install deskctl --locked directly.",
"Retry cargo install directly or confirm the crate is published for this version."
}
} }
} }
@ -208,9 +415,10 @@ fn normalize(path: &Path) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::os::unix::process::ExitStatusExt;
use std::path::Path; use std::path::Path;
use super::{detect_install_method, upgrade_plan, InstallMethod}; use super::{command_failure_reason, detect_install_method, upgrade_plan, InstallMethod};
#[test] #[test]
fn detects_npm_install_path() { fn detects_npm_install_path() {
@ -243,4 +451,15 @@ mod tests {
fn nix_install_has_no_upgrade_plan() { fn nix_install_has_no_upgrade_plan() {
assert!(upgrade_plan(InstallMethod::Nix).is_none()); 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");
}
} }