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,
Bun,
Pnpm,
Yarn,
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::Bun => Channel::Bun,
TargetChannel::Pnpm => Channel::Pnpm,
TargetChannel::Yarn => Channel::Yarn,
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 converge(&exe, dry_run, yes);
}
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 = target.install_sources();
let install = target.install_argv();
let uninstall = current.uninstall_argv();
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.)"));
}
}
if let Some(argv) = &install {
spawn(argv)?;
}
output::print_success(&format!("Installed through {}.", target.label()));
if let Some(argv) = uninstall {
#[cfg(windows)]
let removed = if crate::commands::uninstall::schedule_manager_uninstall(current) {
Ok(true)
} else {
Err(anyhow::anyhow!(
"it could not be scheduled to run after this command exits"
))
};
#[cfg(not(windows))]
let removed = spawn(&argv).map(|()| false);
match removed {
Ok(true) => output::print_success(&format!(
"The {} copy is removed a few seconds after this command exits.",
current.label()
)),
Ok(false) => 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 converge(exe: &std::path::Path, dry_run: bool, yes: bool) -> Result<()> {
use crate::commands::uninstall::{canon_key, find_stray_copies, group_by_channel};
let here = exe.parent().map(canon_key);
let others: Vec<_> = find_stray_copies()
.into_iter()
.filter(|s| s.path.parent().map(canon_key) != here)
.collect();
println!();
if others.is_empty() {
output::print_info("No other copy of dev-prune is on this machine.");
return Ok(());
}
output::print_warning(&format!(
"{} other cop{} of dev-prune {} on this machine:",
others.len(),
if others.len() == 1 { "y" } else { "ies" },
if others.len() == 1 { "is" } else { "are" }
));
println!();
for stray in &others {
println!(" {}", output::clean_path(&stray.path));
match stray.channel.uninstall_argv() {
Some(argv) => println!(" {}: {}", stray.channel.label(), argv.join(" ")),
None => println!(" {}: delete the file", stray.channel.label()),
}
}
println!();
if dry_run {
output::print_info("`--dry-run`: nothing was run.");
return Ok(());
}
if Registry::load().is_ok_and(|r| r.settings.version_lock) {
anyhow::bail!(
"Removing another copy would change which version answers on PATH. {}",
super::update::locked_notice(None)
);
}
if !confirm(yes) {
output::print_info("Left in place. Nothing was changed.");
return Ok(());
}
let mut removed = 0usize;
let mut failed: Vec<(std::path::PathBuf, String)> = Vec::new();
for (channel, paths) in group_by_channel(others) {
let Some(argv) = channel.uninstall_argv() else {
for path in paths {
match std::fs::remove_file(&path) {
Ok(()) => removed += 1,
Err(e) => failed.push((path, e.to_string())),
}
}
continue;
};
match spawn(&argv) {
Ok(()) => removed += paths.len(),
Err(e) => {
for path in paths {
failed.push((path, format!("{e:#}")));
}
}
}
}
println!();
if removed > 0 {
output::print_success(&format!(
"Removed {removed} other cop{}.",
if removed == 1 { "y" } else { "ies" }
));
}
for (path, why) in &failed {
output::print_warning(&format!(
"{} is still there: {why}",
output::clean_path(path)
));
}
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!();
let names = TargetChannel::value_variants()
.iter()
.filter_map(|t| t.to_possible_value())
.map(|v| v.get_name().to_string())
.collect::<Vec<_>>()
.join(", ");
output::print_info(&format!(
"Move it to another package manager with `devp install --channel <name>`:\n \
{names}."
));
output::print_info("`--dry-run` prints the whole plan without running any of it.");
Ok(())
}
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() {
assert!(
target.channel().install_argv().is_some(),
"`--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::Bun,
Channel::Pnpm,
Channel::Yarn,
Channel::UvTool,
Channel::Pipx,
Channel::Pip,
Channel::WinGet,
Channel::Scoop,
Channel::Homebrew,
] {
assert!(channel.owns_its_files());
assert!(
channel.uninstall_argv().is_some(),
"{channel:?} keeps a record but has no uninstall command"
);
}
assert!(Channel::Installer.uninstall_argv().is_none());
assert!(Channel::Unknown.uninstall_argv().is_none());
}
}