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 current == Channel::Installer
216 && let Some(receipt) = crate::receipt::load()
217 {
218 println!(" Receipt: {}", crate::receipt::summary(&receipt));
219 }
220 if let Some(cmd) = current.upgrade_command() {
221 println!(" Upgrade: {cmd}");
222 }
223 println!();
224 output::print_info(
225 "Move it to another package manager with `devp install --channel <name>`:\n \
226 installer, cargo, npm, uv, pipx, winget, scoop, homebrew.",
227 );
228 output::print_info("`--dry-run` prints the whole plan without running any of it.");
229 Ok(())
230}
231
232fn install_plan(channel: Channel) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
241 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
242 let sources = match channel {
243 Channel::Scoop => vec![owned(&[
244 "scoop",
245 "bucket",
246 "add",
247 crate::constants::SCOOP_BUCKET_NAME,
248 crate::constants::SCOOP_BUCKET_URL,
249 ])],
250 Channel::Homebrew => vec![owned(&["brew", "tap", crate::constants::HOMEBREW_TAP])],
251 _ => Vec::new(),
252 };
253 (sources, install_argv(channel))
254}
255
256fn install_argv(channel: Channel) -> Vec<Vec<String>> {
258 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
259 match channel {
260 Channel::Cargo => {
263 if crate::adapters::binary_available("cargo-binstall") {
264 vec![owned(&["cargo", "binstall", "dev-prune", "-y"])]
265 } else {
266 vec![owned(&["cargo", "install", "dev-prune"])]
267 }
268 }
269 Channel::Npm => vec![owned(&["npm", "install", "-g", "dev-prune"])],
270 Channel::UvTool => vec![owned(&["uv", "tool", "install", "dev-prune"])],
271 Channel::Pipx => vec![owned(&["pipx", "install", "dev-prune"])],
272 Channel::WinGet => vec![vec![
273 "winget".to_string(),
274 "install".to_string(),
275 "--id".to_string(),
276 crate::constants::WINGET_PACKAGE_ID.to_string(),
277 "--accept-package-agreements".to_string(),
278 "--accept-source-agreements".to_string(),
279 ]],
280 Channel::Scoop => vec![owned(&["scoop", "install", "dev-prune"])],
281 Channel::Homebrew => vec![owned(&["brew", "install", "dev-prune"])],
282 Channel::Installer => {
283 if cfg!(windows) {
284 vec![vec![
285 "powershell".to_string(),
286 "-NoProfile".to_string(),
287 "-Command".to_string(),
288 format!("iwr -useb {} | iex", crate::constants::INSTALL_PS1_URL),
289 ]]
290 } else {
291 vec![vec![
292 "sh".to_string(),
293 "-c".to_string(),
294 format!("curl -fsSL {} | sh", crate::constants::INSTALL_SH_URL),
295 ]]
296 }
297 }
298 Channel::Pip | Channel::Unknown => Vec::new(),
300 }
301}
302
303fn uninstall_argv(channel: Channel) -> Option<Vec<String>> {
306 let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
307 Some(match channel {
308 Channel::Cargo => owned(&["cargo", "uninstall", "dev-prune"]),
309 Channel::Npm => owned(&["npm", "uninstall", "-g", "dev-prune"]),
310 Channel::UvTool => owned(&["uv", "tool", "uninstall", "dev-prune"]),
311 Channel::Pipx => owned(&["pipx", "uninstall", "dev-prune"]),
312 Channel::Pip => owned(&["pip", "uninstall", "-y", "dev-prune"]),
313 Channel::WinGet => vec![
314 "winget".to_string(),
315 "uninstall".to_string(),
316 "--id".to_string(),
317 crate::constants::WINGET_PACKAGE_ID.to_string(),
318 ],
319 Channel::Scoop => owned(&["scoop", "uninstall", "dev-prune"]),
320 Channel::Homebrew => owned(&["brew", "uninstall", "dev-prune"]),
321 Channel::Installer | Channel::Unknown => return None,
322 })
323}
324
325fn spawn(argv: &[String]) -> Result<()> {
328 output::print_info(&format!("Running: {}", argv.join(" ")));
329 let status = crate::spawn::command(crate::adapters::resolve_program(&argv[0]))
330 .args(&argv[1..])
331 .env(crate::constants::ENV_NO_MIGRATE_PROMPT, "1")
338 .status()
339 .with_context(|| format!("could not start `{}`", argv[0]))?;
340 if !status.success() {
341 anyhow::bail!("`{}` exited with {status}", argv.join(" "));
342 }
343 Ok(())
344}
345
346fn confirm(yes: bool) -> bool {
349 use std::io::{IsTerminal, Write};
350 if yes {
351 return true;
352 }
353 if !std::io::stdin().is_terminal() {
354 output::print_info("Not running in a terminal — pass `--yes` to go ahead.");
355 return false;
356 }
357 eprint!("Run this plan? [y/N]: ");
358 if std::io::stderr().flush().is_err() {
359 return false;
360 }
361 let mut input = String::new();
362 if std::io::stdin().read_line(&mut input).is_err() {
363 return false;
364 }
365 matches!(input.trim().to_lowercase().as_str(), "y" | "yes")
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn every_offered_destination_has_an_install_command() {
374 for target in TargetChannel::value_variants() {
377 let argv = install_argv(target.channel());
378 assert!(
379 !argv.is_empty(),
380 "`--channel {target:?}` has no install command"
381 );
382 }
383 }
384
385 #[test]
386 fn the_old_copy_is_removed_through_the_manager_that_owns_it() {
387 for channel in [
391 Channel::Cargo,
392 Channel::Npm,
393 Channel::UvTool,
394 Channel::Pipx,
395 Channel::Pip,
396 Channel::WinGet,
397 Channel::Scoop,
398 Channel::Homebrew,
399 ] {
400 assert!(channel.owns_its_files());
401 assert!(
402 uninstall_argv(channel).is_some(),
403 "{channel:?} keeps a record but has no uninstall command"
404 );
405 }
406 assert!(uninstall_argv(Channel::Installer).is_none());
407 assert!(uninstall_argv(Channel::Unknown).is_none());
408 }
409}