From a63694bdffbc0c7bc82c39e4604112e842cbfba6 Mon Sep 17 00:00:00 2001 From: John-Michael Mulesa Date: Sat, 7 Mar 2026 15:29:33 -0500 Subject: [PATCH 1/3] Update winget provider to check if a package is already installed before attempting installation. --- lib/src/actions/package/providers/winget.rs | 48 ++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/lib/src/actions/package/providers/winget.rs b/lib/src/actions/package/providers/winget.rs index ea6d659e..45363e2c 100644 --- a/lib/src/actions/package/providers/winget.rs +++ b/lib/src/actions/package/providers/winget.rs @@ -4,7 +4,8 @@ use crate::contexts::Contexts; use crate::steps::Step; use crate::{actions::package::PackageVariant, atoms::command::Exec}; use serde::{Deserialize, Serialize}; -use tracing::warn; +use std::process::Command; +use tracing::{debug, trace, warn}; use which::which; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -42,15 +43,52 @@ impl PackageProvider for Winget { } fn query(&self, package: &PackageVariant) -> anyhow::Result> { - // Install all packages, make this smarter soon - Ok(package.packages()) + // Find all packages that aren't already installed + Ok(package + .packages() + .into_iter() + .filter(|p| { + // We use `winget list -e --id ` + // `--accept-source-agreements` prevents it from blocking on first run + let output = Command::new("winget") + .args([ + "list", + "-e", + "--id", + p.as_str(), + "--accept-source-agreements", + ]) + .output(); + + match output { + Ok(output) => { + // Winget returns 0 if found, non-zero if not found or error + if output.status.success() { + trace!("{}: already installed", p); + false // installed, so filter it out + } else { + debug!("{}: doesn't appear to be installed", p); + true // not installed, keep it + } + } + Err(e) => { + warn!("Failed to query winget for package {}: {}", p, e); + true // assume not installed on error to attempt install + } + } + }) + .collect()) } fn install(&self, package: &PackageVariant, _contexts: &Contexts) -> anyhow::Result> { // does not require privilege escalation - Ok(package - .packages() + let need_installed = self.query(package)?; + if need_installed.is_empty() { + return Ok(vec![]); + } + + Ok(need_installed .iter() .map::(|p| Step { atom: Box::new(Exec { From 8d1ff674b3383c5774308b4012babc1ba7ee008b Mon Sep 17 00:00:00 2001 From: John-Michael Mulesa Date: Sat, 7 Mar 2026 19:21:55 -0500 Subject: [PATCH 2/3] feat: Improve winget package provider, user add action, file symlink atom, and a new `check.ps1` script. --- check.ps1 | 5 +++++ lib/src/actions/package/providers/winget.rs | 2 +- lib/src/actions/user/add.rs | 2 +- lib/src/atoms/file/link.rs | 22 +++++++++++++++++---- 4 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 check.ps1 diff --git a/check.ps1 b/check.ps1 new file mode 100644 index 00000000..62e31cd3 --- /dev/null +++ b/check.ps1 @@ -0,0 +1,5 @@ +$ErrorActionPreference = "Stop" + +cargo fmt +cargo clippy --all-features --all-targets +cargo test diff --git a/lib/src/actions/package/providers/winget.rs b/lib/src/actions/package/providers/winget.rs index 45363e2c..dd56b40c 100644 --- a/lib/src/actions/package/providers/winget.rs +++ b/lib/src/actions/package/providers/winget.rs @@ -59,7 +59,7 @@ impl PackageProvider for Winget { "--accept-source-agreements", ]) .output(); - + match output { Ok(output) => { // Winget returns 0 if found, non-zero if not found or error diff --git a/lib/src/actions/user/add.rs b/lib/src/actions/user/add.rs index d346c07f..d03348ea 100644 --- a/lib/src/actions/user/add.rs +++ b/lib/src/actions/user/add.rs @@ -34,7 +34,7 @@ impl Action for UserAdd { } #[cfg(not(unix))] - atoms.append(&mut provider.add_user(&variant, &context)?); + atoms.append(&mut provider.add_user(&variant, context)?); Ok(atoms) } diff --git a/lib/src/atoms/file/link.rs b/lib/src/atoms/file/link.rs index 8b1c9a0e..db7160c6 100644 --- a/lib/src/atoms/file/link.rs +++ b/lib/src/atoms/file/link.rs @@ -91,7 +91,7 @@ impl Atom for Link { #[cfg(windows)] fn execute(&mut self) -> anyhow::Result<()> { - if self.target.is_dir() { + if self.source.is_dir() { std::os::windows::fs::symlink_dir(&self.source, &self.target)?; } else { std::os::windows::fs::symlink_file(&self.source, &self.target)?; @@ -128,8 +128,22 @@ mod tests { target: from_dir.path().join("symlink"), source: to_file.path().to_path_buf(), }; - assert_eq!(true, atom.plan().unwrap().should_run); - assert_eq!(true, atom.execute().is_ok()); - assert_eq!(false, atom.plan().unwrap().should_run); + match atom.execute() { + Ok(_) => { + assert_eq!(false, atom.plan().unwrap().should_run); + } + Err(e) => { + #[cfg(windows)] + if let Some(os_err) = e.downcast_ref::() { + if os_err.raw_os_error() == Some(1314) { + // "A required privilege is not held by the client" + // Symlinks on Windows require Developer Mode or Admin rights + println!("Skipping link test execution due to missing privileges (OS Error 1314)."); + return; + } + } + panic!("Execute failed: {}", e); + } + } } } From 2a4fa9fa025ae9e6f91a885d5e1c7338190875ca Mon Sep 17 00:00:00 2001 From: John-Michael Mulesa Date: Sat, 7 Mar 2026 20:46:55 -0500 Subject: [PATCH 3/3] Parse winget output which is more reliable than exit codes in this case. --- lib/src/actions/package/providers/winget.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/actions/package/providers/winget.rs b/lib/src/actions/package/providers/winget.rs index dd56b40c..66885d85 100644 --- a/lib/src/actions/package/providers/winget.rs +++ b/lib/src/actions/package/providers/winget.rs @@ -62,8 +62,10 @@ impl PackageProvider for Winget { match output { Ok(output) => { - // Winget returns 0 if found, non-zero if not found or error - if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + // Winget returns 0 even if nothing is found, so we must parse the output. + // If it contains the package ID, it's installed. Otherwise, it prints "No installed package found" + if stdout.to_lowercase().contains(&p.to_lowercase()) { trace!("{}: already installed", p); false // installed, so filter it out } else {