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