1use anyhow::{Context, Result};
26use clap::ValueEnum;
27
28use crate::channel::Channel;
29use crate::output;
30
31#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
38pub enum TargetChannel {
39 Installer,
41 Cargo,
43 Npm,
45 Uv,
47 Pipx,
49 Winget,
51 Scoop,
53 Homebrew,
55}
56
57impl TargetChannel {
58 fn channel(self) -> Channel {
59 match self {
60 TargetChannel::Installer => Channel::Installer,
61 TargetChannel::Cargo => Channel::Cargo,
62 TargetChannel::Npm => Channel::Npm,
63 TargetChannel::Uv => Channel::UvTool,
64 TargetChannel::Pipx => Channel::Pipx,
65 TargetChannel::Winget => Channel::WinGet,
66 TargetChannel::Scoop => Channel::Scoop,
67 TargetChannel::Homebrew => Channel::Homebrew,
68 }
69 }
70}
71
72pub fn run(channel: Option<TargetChannel>, dry_run: bool, yes: bool) -> Result<()> {
73 let exe = std::env::current_exe().context("could not locate the running binary")?;
74 let managed = crate::setup::managed_exe_path().ok();
75 let current = Channel::detect_at(&exe, managed.as_deref());
76
77 let Some(target) = channel.map(TargetChannel::channel) else {
78 return report(current, &exe);
79 };
80
81 output::print_header("dev-prune install channel");
82
83 if target == current {
84 output::print_success(&format!(
85 "This copy already came from {} — nothing to move.",
86 current.label()
87 ));
88 if let Some(cmd) = current.upgrade_command() {
89 output::print_info(&format!("Upgrade it in place with: {cmd}"));
90 }
91 return Ok(());
92 }
93
94 let (sources, install) = install_plan(target);
95 let uninstall = uninstall_argv(current);
96
97 println!();
98 println!(" From: {} ({})", current.label(), exe.display());
99 println!(" To: {}", target.label());
100 println!();
101 let mut step = 0;
102 for argv in sources.iter().chain(install.iter()) {
103 step += 1;
104 println!(" {step}. {}", argv.join(" "));
105 }
106 step += 1;
107 match &uninstall {
108 Some(argv) => println!(" {step}. {}", argv.join(" ")),
109 None => println!(
110 " {step}. nothing to uninstall — {}",
111 match current {
112 Channel::Installer =>
116 "the managed copy stays, and refreshes itself from the new binary",
117 _ =>
118 "this copy was not installed by a package manager, so remove the \
119 file yourself if you want it gone",
120 }
121 ),
122 }
123 println!();
124
125 if dry_run {
126 output::print_info("`--dry-run`: nothing was run.");
127 return Ok(());
128 }
129
130 if !confirm(yes) {
131 output::print_info("Nothing was changed.");
132 return Ok(());
133 }
134
135 for argv in &sources {
136 if let Err(e) = spawn(argv) {
139 output::print_dimmed(&format!(" ({e:#} — continuing.)"));
140 }
141 }
142
143 for argv in &install {
146 spawn(argv)?;
147 }
148 output::print_success(&format!("Installed through {}.", target.label()));
149
150 if let Some(argv) = uninstall {
151 #[cfg(windows)]
156 let aside = {
157 let aside = exe.with_extension("exe.old");
158 let _ = std::fs::remove_file(&aside);
159 std::fs::rename(&exe, &aside).ok().map(|_| aside)
160 };
161
162 let removed = spawn(&argv);
163
164 #[cfg(windows)]
165 if let Some(aside) = aside
166 && removed.is_err()
167 && !exe.exists()
168 {
169 let _ = std::fs::rename(&aside, &exe);
172 }
173
174 match removed {
175 Ok(()) => output::print_success(&format!("Removed the {} copy.", current.label())),
176 Err(e) => output::print_warning(&format!(
177 "The new copy is installed, but removing the old one failed ({e:#}).\n\
178 Run it yourself when convenient: {}",
179 argv.join(" ")
180 )),
181 }
182 }
183
184 println!();
185 output::print_info(
186 "Your configuration, repository registry and undo history are unchanged — they \
187 live in the config directory, which no channel owns.",
188 );
189 output::print_info("Open a new shell, then `devp update` to confirm which copy it finds.");
190 Ok(())
191}
192
193fn report(current: Channel, exe: &std::path::Path) -> Result<()> {
196 output::print_header("dev-prune install channel");
197 println!();
198 println!(" Installed by: {}", current.label());
199 println!(" Binary: {}", exe.display());
200 if let Some(cmd) = current.upgrade_command() {
201 println!(" Upgrade: {cmd}");
202 }
203 println!();
204 output::print_info(
205 "Move it to another package manager with `devp install --channel <name>`:\n \
206 installer, cargo, npm, uv, pipx, winget, scoop, homebrew.",
207 );
208 output::print_info("`--dry-run` prints the whole plan without running any of it.");
209 Ok(())
210}
211
212fn install_plan(channel: Channel) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
221 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
222 let sources = match channel {
223 Channel::Scoop => vec![owned(&[
224 "scoop",
225 "bucket",
226 "add",
227 crate::constants::SCOOP_BUCKET_NAME,
228 crate::constants::SCOOP_BUCKET_URL,
229 ])],
230 Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
231 _ => Vec::new(),
232 };
233 (sources, install_argv(channel))
234}
235
236fn install_argv(channel: Channel) -> Vec<Vec<String>> {
238 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
239 match channel {
240 Channel::Cargo => {
243 if crate::adapters::binary_available("cargo-binstall") {
244 vec![owned(&["cargo", "binstall", "dev-prune", "-y"])]
245 } else {
246 vec![owned(&["cargo", "install", "dev-prune"])]
247 }
248 }
249 Channel::Npm => vec![owned(&["npm", "install", "-g", "dev-prune"])],
250 Channel::UvTool => vec![owned(&["uv", "tool", "install", "dev-prune"])],
251 Channel::Pipx => vec![owned(&["pipx", "install", "dev-prune"])],
252 Channel::WinGet => vec![vec![
253 "winget".to_string(),
254 "install".to_string(),
255 "--id".to_string(),
256 crate::constants::WINGET_PACKAGE_ID.to_string(),
257 "--accept-package-agreements".to_string(),
258 "--accept-source-agreements".to_string(),
259 ]],
260 Channel::Scoop => vec![owned(&["scoop", "install", "dev-prune"])],
261 Channel::Homebrew => vec![owned(&["brew", "install", "dev-prune"])],
262 Channel::Installer => {
263 if cfg!(windows) {
264 vec![vec![
265 "powershell".to_string(),
266 "-NoProfile".to_string(),
267 "-Command".to_string(),
268 format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
269 ]]
270 } else {
271 vec![vec![
272 "sh".to_string(),
273 "-c".to_string(),
274 format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
275 ]]
276 }
277 }
278 Channel::Pip | Channel::Unknown => Vec::new(),
280 }
281}
282
283fn uninstall_argv(channel: Channel) -> Option<Vec<String>> {
286 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
287 Some(match channel {
288 Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
289 Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
290 Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
291 Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
292 Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
293 Channel::WinGet => vec![
294 "winget".to_string(),
295 "uninstall".to_string(),
296 "--id".to_string(),
297 crate::constants::WINGET_PACKAGE_ID.to_string(),
298 ],
299 Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
300 Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
301 Channel::Installer | Channel::Unknown => return None,
302 })
303}
304
305fn spawn(argv: &[String]) -> Result<()> {
308 output::print_info(&format!("Running: {}", argv.join(" ")));
309 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
310 .args(&argv[1..])
311 .status()
312 .with_context(|| format!("could not start `{}`", argv[0]))?;
313 if !status.success() {
314 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
315 }
316 Ok(())
317}
318
319fn confirm(yes: bool) -> bool {
322 use std::io::{IsTerminal, Write};
323 if yes {
324 return true;
325 }
326 if !std::io::stdin().is_terminal() {
327 output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
328 return false;
329 }
330 eprint!("Run this plan? [y/N]: ");
331 if std::io::stderr().flush().is_err() {
332 return false;
333 }
334 let mut input = String::new();
335 if std::io::stdin().read_line(&mut input).is_err() {
336 return false;
337 }
338 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 #[test]
346 fn every_offered_destination_has_an_install_command() {
347 for target in TargetChannel::value_variants() {
350 let argv = install_argv(target.channel());
351 assert!(
352 !argv.is_empty(),
353 "`--channel {target:?}` has no install command"
354 );
355 }
356 }
357
358 #[test]
359 fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
360 for channel in [
364 Channel::Cargo,
365 Channel::Npm,
366 Channel::UvTool,
367 Channel::Pipx,
368 Channel::Pip,
369 Channel::WinGet,
370 Channel::Scoop,
371 Channel::Homebrew,
372 ] {
373 assert!(channel.owns_its_files());
374 assert!(
375 uninstall_argv(channel).is_some(),
376 "{channel:?} keeps a record but has no uninstall command"
377 );
378 }
379 assert!(uninstall_argv(Channel::Installer).is_none());
380 assert!(uninstall_argv(Channel::Unknown).is_none());
381 }
382}