mirror of
https://github.com/harivansh-afk/deskctl.git
synced 2026-04-15 05:02:08 +00:00
deskctl upgrade (#13)
* deskctl upgrade * interactive update as well as --yes flag
This commit is contained in:
parent
2b02513d6e
commit
a64b46b479
6 changed files with 603 additions and 2 deletions
|
|
@ -14,6 +14,18 @@ After install, run:
|
||||||
deskctl --help
|
deskctl --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To upgrade version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
For non-interactive use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade --yes
|
||||||
|
```
|
||||||
|
|
||||||
One-shot usage is also supported:
|
One-shot usage is also supported:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ reads, grouped waits, selector-driven actions, and a few input primitives.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
deskctl doctor
|
deskctl doctor
|
||||||
|
deskctl upgrade
|
||||||
deskctl snapshot
|
deskctl snapshot
|
||||||
deskctl snapshot --annotate
|
deskctl snapshot --annotate
|
||||||
deskctl list-windows
|
deskctl list-windows
|
||||||
|
|
@ -26,7 +27,9 @@ deskctl get-screen-size
|
||||||
deskctl get-mouse-position
|
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
|
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
|
side effect of writing a screenshot. The grouped `get` commands are the
|
||||||
preferred read surface for focused state queries.
|
preferred read surface for focused state queries.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ deskctl doctor
|
||||||
deskctl snapshot --annotate
|
deskctl snapshot --annotate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If `deskctl` was installed through npm, refresh it later with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
deskctl upgrade --yes
|
||||||
|
```
|
||||||
|
|
||||||
## Agent loop
|
## Agent loop
|
||||||
|
|
||||||
Every desktop interaction follows: **observe -> wait -> act -> verify**.
|
Every desktop interaction follows: **observe -> wait -> act -> verify**.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ runtime contract.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deskctl doctor
|
deskctl doctor
|
||||||
|
deskctl upgrade
|
||||||
deskctl snapshot
|
deskctl snapshot
|
||||||
deskctl snapshot --annotate
|
deskctl snapshot --annotate
|
||||||
deskctl list-windows
|
deskctl list-windows
|
||||||
|
|
|
||||||
116
src/cli/mod.rs
116
src/cli/mod.rs
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod connection;
|
pub mod connection;
|
||||||
|
pub mod upgrade;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Args, Parser, Subcommand};
|
use clap::{Args, Parser, Subcommand};
|
||||||
|
|
@ -121,6 +122,9 @@ pub enum Command {
|
||||||
/// Diagnose X11 runtime, screenshot, and daemon health
|
/// Diagnose X11 runtime, screenshot, and daemon health
|
||||||
#[command(after_help = DOCTOR_EXAMPLES)]
|
#[command(after_help = DOCTOR_EXAMPLES)]
|
||||||
Doctor,
|
Doctor,
|
||||||
|
/// Upgrade deskctl using the current install channel
|
||||||
|
#[command(after_help = UPGRADE_EXAMPLES)]
|
||||||
|
Upgrade(UpgradeOpts),
|
||||||
/// Query runtime state
|
/// Query runtime state
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Get(GetCmd),
|
Get(GetCmd),
|
||||||
|
|
@ -231,6 +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 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 =
|
||||||
|
|
@ -284,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();
|
||||||
|
|
||||||
|
|
@ -300,6 +313,22 @@ pub fn run() -> Result<()> {
|
||||||
return connection::run_doctor(&app.global);
|
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
|
// All other commands need a daemon connection
|
||||||
let request = build_request(&app.command)?;
|
let request = build_request(&app.command)?;
|
||||||
let response = connection::send_command(&app.global, &request)?;
|
let response = connection::send_command(&app.global, &request)?;
|
||||||
|
|
@ -363,6 +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::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"),
|
||||||
|
|
@ -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::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::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)],
|
||||||
|
|
@ -526,6 +557,41 @@ fn render_error_lines(response: &Response) -> Vec<String> {
|
||||||
lines.push("No focused window is available.".to_string());
|
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<Stri
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_upgrade_lines(data: &serde_json::Value) -> Vec<String> {
|
||||||
|
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 {
|
fn render_click_line(data: &serde_json::Value, double: bool) -> String {
|
||||||
let action = if double { "Double-clicked" } else { "Clicked" };
|
let action = if double { "Double-clicked" } else { "Clicked" };
|
||||||
let key = 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 {
|
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;
|
||||||
|
|
@ -1104,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)"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
465
src/cli/upgrade.rs
Normal file
465
src/cli/upgrade.rs
Normal file
|
|
@ -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::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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<VersionInfo, Response> {
|
||||||
|
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<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(
|
||||||
|
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<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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue