use anyhow::{Context, Result};
use clap::ValueEnum;
use crate::channel::Channel;
use crate::config::Registry;
use crate::output;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
pub enum TargetChannel {
Installer,
Cargo,
Npm,
Uv,
Pipx,
Winget,
Scoop,
Homebrew,
}
impl TargetChannel {
fn channel(self) -> Channel {
match self {
TargetChannel::Installer => Channel::Installer,
TargetChannel::Cargo => Channel::Cargo,
TargetChannel::Npm => Channel::Npm,
TargetChannel::Uv => Channel::UvTool,
TargetChannel::Pipx => Channel::Pipx,
TargetChannel::Winget => Channel::WinGet,
TargetChannel::Scoop => Channel::Scoop,
TargetChannel::Homebrew => Channel::Homebrew,
}
}
}
pub fn run(channel: Option<TargetChannel>, dry_run: bool, yes: bool) -> Result<()> {
let exe = std::env::current_exe().context("could not locate the running binary")?;
let managed = crate::setup::managed_exe_path().ok();
let current = Channel::detect_at(&exe, managed.as_deref());
let Some(target) = channel.map(TargetChannel::channel) else {
return report(current, &exe);
};
output::print_header("dev-prune install channel");
if target == current {
output::print_success(&format!(
"This copy already came from {} — nothing to move.",
current.label()
));
if let Some(cmd) = current.upgrade_command() {
output::print_info(&format!("Upgrade it in place with: {cmd}"));
}
return Ok(());
}
if Registry::load().is_ok_and(|r| r.settings.version_lock) {
anyhow::bail!(
"Moving to {} would install the latest release through it. {}",
target.label(),
super::update::locked_notice(None)
);
}
let (sources, install) = install_plan(target);
let uninstall = uninstall_argv(current);
println!();
println!(" From: {} ({})", current.label(), exe.display());
println!(" To: {}", target.label());
println!();
let mut step = 0;
for argv in sources.iter().chain(install.iter()) {
step += 1;
println!(" {step}. {}", argv.join(" "));
}
step += 1;
match &uninstall {
Some(argv) => println!(" {step}. {}", argv.join(" ")),
None => println!(
" {step}. nothing to uninstall — {}",
match current {
Channel::Installer =>
"the managed copy stays, and refreshes itself from the new binary",
_ =>
"this copy was not installed by a package manager, so remove the \
file yourself if you want it gone",
}
),
}
println!();
if dry_run {
output::print_info("`--dry-run`: nothing was run.");
return Ok(());
}
if !confirm(yes) {
output::print_info("Nothing was changed.");
return Ok(());
}
for argv in &sources {
if let Err(e) = spawn(argv) {
output::print_dimmed(&format!(" ({e:#} — continuing.)"));
}
}
for argv in &install {
spawn(argv)?;
}
output::print_success(&format!("Installed through {}.", target.label()));
if let Some(argv) = uninstall {
#[cfg(windows)]
let aside = {
let aside = exe.with_extension("exe.old");
let _ = std::fs::remove_file(&aside);
std::fs::rename(&exe, &aside).ok().map(|_| aside)
};
let removed = spawn(&argv);
#[cfg(windows)]
if let Some(aside) = aside
&& removed.is_err()
&& !exe.exists()
{
let _ = std::fs::rename(&aside, &exe);
}
match removed {
Ok(()) => output::print_success(&format!("Removed the {} copy.", current.label())),
Err(e) => output::print_warning(&format!(
"The new copy is installed, but removing the old one failed ({e:#}).\n\
Run it yourself when convenient: {}",
argv.join(" ")
)),
}
}
println!();
output::print_info(
"Your configuration, repository registry and undo history are unchanged — they \
live in the config directory, which no channel owns.",
);
output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
Ok(())
}
fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
output::print_header("dev-prune install channel");
println!();
println!(" Installed by: {}", current.label());
println!(" Binary: {}", exe.display());
if current == Channel::Installer
&& let Some(receipt) = crate::receipt::load()
{
println!(" Receipt: {}", crate::receipt::summary(&receipt));
}
if let Some(cmd) = current.upgrade_command() {
println!(" Upgrade: {cmd}");
}
println!();
output::print_info(
"Move it to another package manager with `devp install --channel <name>`:\n \
installer, cargo, npm, uv, pipx, winget, scoop, homebrew.",
);
output::print_info("`--dry-run` prints the whole plan without running any of it.");
Ok(())
}
fn install_plan(channel: Channel) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let sources = match channel {
Channel::Scoop => vec![owned(&[
"scoop",
"bucket",
"add",
crate::constants::SCOOP_BUCKET_NAME,
crate::constants::SCOOP_BUCKET_URL,
])],
Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
_ => Vec::new(),
};
(sources, install_argv(channel))
}
fn install_argv(channel: Channel) -> Vec<Vec<String>> {
let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
match channel {
Channel::Cargo => {
if crate::adapters::binary_available("cargo-binstall") {
vec![owned(&["cargo", "binstall", "dev-prune", "-y"])]
} else {
vec![owned(&["cargo", "install", "dev-prune"])]
}
}
Channel::Npm => vec![owned(&["npm", "install", "-g", "dev-prune"])],
Channel::UvTool => vec![owned(&["uv", "tool", "install", "dev-prune"])],
Channel::Pipx => vec![owned(&["pipx", "install", "dev-prune"])],
Channel::WinGet => vec![vec![
"winget".to_string(),
"install".to_string(),
"--id".to_string(),
crate::constants::WINGET_PACKAGE_ID.to_string(),
"--accept-package-agreements".to_string(),
"--accept-source-agreements".to_string(),
]],
Channel::Scoop => vec![owned(&["scoop", "install", "dev-prune"])],
Channel::Homebrew => vec![owned(&["brew", "install", "dev-prune"])],
Channel::Installer => {
if cfg!(windows) {
vec![vec![
"powershell".to_string(),
"-NoProfile".to_string(),
"-Command".to_string(),
format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
]]
} else {
vec![vec![
"sh".to_string(),
"-c".to_string(),
format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
]]
}
}
Channel::Pip | Channel::Unknown => Vec::new(),
}
}
fn uninstall_argv(channel: Channel) -> Option<Vec<String>> {
let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
Some(match channel {
Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
Channel::WinGet => vec![
"winget".to_string(),
"uninstall".to_string(),
"--id".to_string(),
crate::constants::WINGET_PACKAGE_ID.to_string(),
],
Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
Channel::Installer | Channel::Unknown => return None,
})
}
fn spawn(argv: &[String]) -> Result<()> {
output::print_info(&format!("Running: {}", argv.join(" ")));
let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
.args(&argv[1..])
.env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
.status()
.with_context(|| format!("could not start `{}`", argv[0]))?;
if !status.success() {
anyhow::bail!("`{}` exited with {status}", argv.join(" "));
}
Ok(())
}
fn confirm(yes: bool) -> bool {
use std::io::{IsTerminal, Write};
if yes {
return true;
}
if !std::io::stdin().is_terminal() {
output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
return false;
}
eprint!("Run this plan? [y/N]: ");
if std::io::stderr().flush().is_err() {
return false;
}
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_offered_destination_has_an_install_command() {
for target in TargetChannel::value_variants() {
let argv = install_argv(target.channel());
assert!(
!argv.is_empty(),
"`--channel {target:?}` has no install command"
);
}
}
#[test]
fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
for channel in [
Channel::Cargo,
Channel::Npm,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
] {
assert!(channel.owns_its_files());
assert!(
uninstall_argv(channel).is_some(),
"{channel:?} keeps a record but has no uninstall command"
);
}
assert!(uninstall_argv(Channel::Installer).is_none());
assert!(uninstall_argv(Channel::Unknown).is_none());
}
}