mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 04:03:28 +00:00
deskctl upgrade
This commit is contained in:
parent
2b02513d6e
commit
7f0c462385
6 changed files with 323 additions and 4 deletions
|
|
@ -14,6 +14,12 @@ After install, run:
|
|||
deskctl --help
|
||||
```
|
||||
|
||||
To upgrade version:
|
||||
|
||||
```bash
|
||||
deskctl upgrade
|
||||
```
|
||||
|
||||
One-shot usage is also supported:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ runtime contract.
|
|||
|
||||
```bash
|
||||
deskctl doctor
|
||||
deskctl upgrade
|
||||
deskctl snapshot
|
||||
deskctl snapshot --annotate
|
||||
deskctl list-windows
|
||||
|
|
|
|||
|
|
@ -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<Request> {
|
|||
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<String> {
|
|||
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<Stri
|
|||
lines
|
||||
}
|
||||
|
||||
fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> {
|
||||
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" };
|
||||
|
|
|
|||
246
src/cli/upgrade.rs
Normal file
246
src/cli/upgrade.rs
Normal file
|
|
@ -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::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_upgrade(opts: &GlobalOpts) -> Result<Response> {
|
||||
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<i32>, 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<UpgradePlan> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue