From 7c9fd93d5df5f41743ab3f2cc8261bdca5b2f08c Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Wed, 18 Dec 2024 17:25:44 -0800 Subject: [PATCH] internal: Rework `uninstall` command. (#676) --- CHANGELOG.md | 1 + crates/cli/src/commands/clean.rs | 125 +++++------------ crates/cli/src/commands/outdated.rs | 31 +++-- crates/cli/src/commands/uninstall.rs | 156 ++++++++++++++++------ crates/cli/src/helpers.rs | 17 +-- crates/cli/src/session.rs | 26 +++- crates/cli/src/utils/mod.rs | 1 + crates/cli/src/utils/progress_instance.rs | 26 ++++ crates/cli/tests/clean_test.rs | 82 ------------ crates/cli/tests/uninstall_test.rs | 72 ++++++++++ 10 files changed, 294 insertions(+), 243 deletions(-) create mode 100644 crates/cli/src/utils/progress_instance.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 334a3bffd..6b5c6a3d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ #### 💥 Breaking - Removed the `--global` option from `proto alias`, `unalias`, `pin`, and `unpin`, use `--to` or `--from` instead. +- Removed the `--purge` option from `proto clean`, use `proto uninstall` instead. ## Unreleased diff --git a/crates/cli/src/commands/clean.rs b/crates/cli/src/commands/clean.rs index 714e02d2f..d90374a11 100644 --- a/crates/cli/src/commands/clean.rs +++ b/crates/cli/src/commands/clean.rs @@ -1,15 +1,13 @@ use crate::session::ProtoSession; use clap::Args; use iocraft::prelude::element; -use proto_core::{Id, ProtoError, Tool, VersionSpec, PROTO_PLUGIN_KEY}; +use proto_core::{ProtoError, Tool, VersionSpec, PROTO_PLUGIN_KEY}; use proto_shim::get_exe_file_name; use rustc_hash::FxHashSet; use starbase::AppResult; use starbase_console::ui::*; use starbase_styles::color; use starbase_utils::fs; -use std::io::stdout; -use std::io::IsTerminal; use std::time::{Duration, SystemTime}; use tracing::debug; @@ -21,14 +19,6 @@ pub struct CleanArgs { )] pub days: Option, - #[arg( - long, - help = "Purge and delete the installed tool by ID", - group = "purge-type", - value_name = "TOOL" - )] - pub purge: Option, - #[arg( long, help = "Purge and delete all installed plugins", @@ -147,23 +137,25 @@ pub async fn clean_tool( return Ok(0); } - session - .console - .render_interactive(element! { - Confirm( - label: format!( - "Found {} versions, remove {}?", - count, - versions_to_clean - .iter() - .map(|v| format!("{v}")) - .collect::>() - .join(", ") - ), - value: &mut confirmed, - ) - }) - .await?; + if !yes { + session + .console + .render_interactive(element! { + Confirm( + label: format!( + "Found {} versions, remove {}?", + count, + versions_to_clean + .iter() + .map(|v| format!("{v}")) + .collect::>() + .join(", ") + ), + value: &mut confirmed, + ) + }) + .await?; + } if yes || confirmed { for version in versions_to_clean { @@ -243,63 +235,25 @@ pub async fn clean_proto(session: &ProtoSession, days: u64) -> miette::Result miette::Result { - let tool = session.load_tool(id).await?; - let inventory_dir = tool.get_inventory_dir(); - let mut confirmed = false; - - session - .console - .render_interactive(element! { - Confirm( - label: format!( - "Purge all of {} at {}?", - tool.get_name(), - inventory_dir.display() - ), - value: &mut confirmed, - ) - }) - .await?; - - if yes || confirmed { - // Delete inventory - fs::remove_dir_all(inventory_dir)?; - - // Delete binaries - for bin in tool.resolve_bin_locations(true).await? { - session.env.store.unlink_bin(&bin.path)?; - } - - // Delete shims - for shim in tool.resolve_shim_locations().await? { - session.env.store.remove_shim(&shim.path)?; - } - - println!("Purged {}", tool.get_name()); - } - - Ok(tool.tool) -} - #[tracing::instrument(skip_all)] pub async fn purge_plugins(session: &ProtoSession, yes: bool) -> AppResult { let plugins_dir = &session.env.store.plugins_dir; let mut confirmed = false; - session - .console - .render_interactive(element! { - Confirm( - label: format!( - "Purge all plugins in {}?", - plugins_dir.display() - ), - value: &mut confirmed, - ) - }) - .await?; + if !yes { + session + .console + .render_interactive(element! { + Confirm( + label: format!( + "Purge all plugins in {}?", + plugins_dir.display() + ), + value: &mut confirmed, + ) + }) + .await?; + } if yes || confirmed { fs::remove_dir_all(plugins_dir)?; @@ -373,19 +327,14 @@ pub async fn internal_clean( #[tracing::instrument(skip_all)] pub async fn clean(session: ProtoSession, args: CleanArgs) -> AppResult { - let force_yes = args.yes || !stdout().is_terminal(); - - if let Some(id) = &args.purge { - purge_tool(&session, id, force_yes).await?; - return Ok(None); - } + let skip_prompts = session.skip_prompts(args.yes); if args.purge_plugins { - purge_plugins(&session, force_yes).await?; + purge_plugins(&session, skip_prompts).await?; return Ok(None); } - internal_clean(&session, args, force_yes, true).await?; + internal_clean(&session, args, skip_prompts, true).await?; Ok(None) } diff --git a/crates/cli/src/commands/outdated.rs b/crates/cli/src/commands/outdated.rs index 3b411a61b..f74295be8 100644 --- a/crates/cli/src/commands/outdated.rs +++ b/crates/cli/src/commands/outdated.rs @@ -248,23 +248,26 @@ pub async fn outdated(session: ProtoSession, args: OutdatedArgs) -> AppResult { return Ok(None); } + let skip_prompts = session.skip_prompts(args.yes); let mut confirmed = false; - session - .console - .render_interactive(element! { - Confirm( - label: if args.latest { - "Update config files with latest versions?" - } else { - "Update config files with newest versions?" - }, - value: &mut confirmed, - ) - }) - .await?; + if !skip_prompts { + session + .console + .render_interactive(element! { + Confirm( + label: if args.latest { + "Update config files with latest versions?" + } else { + "Update config files with newest versions?" + }, + value: &mut confirmed, + ) + }) + .await?; + } - if args.yes || confirmed { + if skip_prompts || confirmed { let mut updates: BTreeMap> = BTreeMap::new(); for (id, item) in &items { diff --git a/crates/cli/src/commands/uninstall.rs b/crates/cli/src/commands/uninstall.rs index 048cb21b9..61d4b839b 100644 --- a/crates/cli/src/commands/uninstall.rs +++ b/crates/cli/src/commands/uninstall.rs @@ -1,5 +1,3 @@ -use crate::commands::clean::purge_tool; -use crate::helpers::create_progress_spinner; use crate::session::ProtoSession; use crate::telemetry::{track_usage, Metric}; use clap::Args; @@ -7,6 +5,7 @@ use iocraft::element; use proto_core::{Id, ProtoConfig, Tool, UnresolvedVersionSpec}; use starbase::AppResult; use starbase_console::ui::*; +use starbase_utils::fs; use tracing::debug; #[derive(Args, Clone, Debug)] @@ -47,24 +46,113 @@ fn unpin_version(session: &ProtoSession, args: &UninstallArgs) -> miette::Result Ok(()) } -#[tracing::instrument(skip_all)] -pub async fn uninstall(session: ProtoSession, args: UninstallArgs) -> AppResult { - // Uninstall everything - let Some(spec) = &args.spec else { - let tool = purge_tool(&session, &args.id, args.yes).await?; +async fn track_uninstall(tool: &Tool, all: bool) -> miette::Result<()> { + track_usage( + &tool.proto, + Metric::UninstallTool { + id: tool.id.to_string(), + plugin: tool + .locator + .as_ref() + .map(|loc| loc.to_string()) + .unwrap_or_default(), + version: if all { + "*".into() + } else { + tool.get_resolved_version().to_string() + }, + }, + ) + .await +} + +pub async fn uninstall_all(session: ProtoSession, args: UninstallArgs) -> AppResult { + let tool = session.load_tool(&args.id).await?; + let inventory_dir = tool.get_inventory_dir(); + let version_count = tool.inventory.manifest.installed_versions.len(); + let skip_prompts = session.skip_prompts(args.yes); + let mut confirmed = false; + + if !inventory_dir.exists() { + session.console.render(element! { + Notice(variant: Variant::Caution) { + StyledText( + content: format!( + "{} has not been installed locally", + tool.get_name(), + ), + ) + } + })?; - unpin_version(&session, &args)?; + return Ok(Some(1)); + } - // Track usage metrics - track_uninstall(&tool, true).await?; + if !skip_prompts { + session + .console + .render_interactive(element! { + Confirm( + label: format!( + "Uninstall all {} versions of {} at {}?", + version_count, + tool.get_name(), + inventory_dir.display() + ), + value: &mut confirmed, + ) + }) + .await?; + } + if !skip_prompts && !confirmed { return Ok(None); - }; + } + + let progress = session.render_progress_loader()?; + progress.set_message(format!("Uninstalling {}", tool.get_name())); + + // Delete bins + for bin in tool.resolve_bin_locations(true).await? { + session.env.store.unlink_bin(&bin.path)?; + } + + // Delete shims + for shim in tool.resolve_shim_locations().await? { + session.env.store.remove_shim(&shim.path)?; + } - // Uninstall a tool by version + // Delete inventory + fs::remove_dir_all(inventory_dir)?; + fs::remove_dir_all(tool.get_temp_dir())?; + + progress.stop().await?; + + unpin_version(&session, &args)?; + track_uninstall(&tool, true).await?; + + session.console.render(element! { + Notice(variant: Variant::Success) { + StyledText( + content: format!( + "{} has been completely uninstalled!", + tool.get_name(), + ), + ) + } + })?; + + Ok(None) +} + +pub async fn uninstall_one( + session: ProtoSession, + args: UninstallArgs, + spec: UnresolvedVersionSpec, +) -> AppResult { let mut tool = session.load_tool(&args.id).await?; - if !tool.is_setup(spec).await? { + if !tool.is_setup(&spec).await? { session.console.render(element! { Notice(variant: Variant::Caution) { StyledText( @@ -82,23 +170,19 @@ pub async fn uninstall(session: ProtoSession, args: UninstallArgs) -> AppResult debug!("Uninstalling {} with version {}", tool.get_name(), spec); - let pb = create_progress_spinner(format!( - "Uninstalling {} {}", + let progress = session.render_progress_loader()?; + progress.set_message(format!( + "Uninstalling {} {}", tool.get_name(), tool.get_resolved_version() )); - let uninstalled = tool.teardown().await?; - - unpin_version(&session, &args)?; + let result = tool.teardown().await; - pb.finish_and_clear(); - - if !uninstalled { - return Ok(None); - } + progress.stop().await?; + result?; - // Track usage metrics + unpin_version(&session, &args)?; track_uninstall(&tool, false).await?; session.console.render(element! { @@ -116,22 +200,10 @@ pub async fn uninstall(session: ProtoSession, args: UninstallArgs) -> AppResult Ok(None) } -async fn track_uninstall(tool: &Tool, purged: bool) -> miette::Result<()> { - track_usage( - &tool.proto, - Metric::UninstallTool { - id: tool.id.to_string(), - plugin: tool - .locator - .as_ref() - .map(|loc| loc.to_string()) - .unwrap_or_default(), - version: if purged { - "*".into() - } else { - tool.get_resolved_version().to_string() - }, - }, - ) - .await +#[tracing::instrument(skip_all)] +pub async fn uninstall(session: ProtoSession, args: UninstallArgs) -> AppResult { + match args.spec.clone() { + Some(spec) => uninstall_one(session, args, spec).await, + None => uninstall_all(session, args).await, + } } diff --git a/crates/cli/src/helpers.rs b/crates/cli/src/helpers.rs index 5167279f9..6868c76ee 100644 --- a/crates/cli/src/helpers.rs +++ b/crates/cli/src/helpers.rs @@ -9,7 +9,7 @@ use proto_core::PinLocation; use semver::Version; use starbase_styles::color::{self, Color}; use starbase_utils::env::bool_var; -use std::{io::IsTerminal, time::Duration}; +use std::io::IsTerminal; use tracing::debug; #[derive(Clone, Copy, Debug, Default, ValueEnum)] @@ -148,21 +148,6 @@ pub fn create_progress_bar>(start: S) -> ProgressBar { pb } -pub fn create_progress_spinner>(start: S) -> ProgressBar { - let pb = if is_hidden_progress() { - ProgressBar::hidden() - } else { - ProgressBar::new_spinner() - }; - - pb.set_style(create_progress_spinner_style()); - pb.enable_steady_tick(Duration::from_millis(100)); - - print_progress_state(&pb, start.as_ref().to_owned()); - - pb -} - // When not a TTY, we should display something to the user! pub fn print_progress_state(pb: &ProgressBar, message: String) { if message.is_empty() || pb.message() == message { diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index cec0b3aac..483b9e43a 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -1,6 +1,7 @@ use crate::app::{App as CLI, Commands}; use crate::commands::clean::{internal_clean, CleanArgs}; use crate::systems::*; +use crate::utils::progress_instance::ProgressInstance; use crate::utils::tool_record::ToolRecord; use async_trait::async_trait; use miette::IntoDiagnostic; @@ -13,9 +14,10 @@ use proto_core::{ use rustc_hash::FxHashSet; use semver::Version; use starbase::{AppResult, AppSession}; -use starbase_console::ui::{style_to_color, ConsoleTheme}; +use starbase_console::ui::{style_to_color, ConsoleTheme, ProgressLoader, ProgressReporter}; use starbase_console::{Console, EmptyReporter}; use starbase_styles::Style; +use std::io::IsTerminal; use std::sync::Arc; use tokio::task::JoinSet; use tracing::debug; @@ -188,6 +190,28 @@ impl ProtoSession { ) .await } + + pub fn render_progress_loader(&self) -> miette::Result { + use iocraft::prelude::element; + + let reporter = ProgressReporter::default(); + let reporter_clone = reporter.clone(); + let console = self.console.clone(); + + let handle = tokio::task::spawn(async move { + console + .render_loop(element! { + ProgressLoader(reporter: reporter_clone) + }) + .await + }); + + Ok(ProgressInstance { reporter, handle }) + } + + pub fn skip_prompts(&self, yes: bool) -> bool { + yes || !std::io::stdout().is_terminal() + } } #[async_trait] diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 039d5c773..a0f706ffd 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod install_graph; +pub mod progress_instance; pub mod tool_record; diff --git a/crates/cli/src/utils/progress_instance.rs b/crates/cli/src/utils/progress_instance.rs new file mode 100644 index 000000000..aa1470b16 --- /dev/null +++ b/crates/cli/src/utils/progress_instance.rs @@ -0,0 +1,26 @@ +use miette::IntoDiagnostic; +use starbase_console::ui::ProgressReporter; +use std::ops::Deref; +use tokio::task::JoinHandle; + +pub struct ProgressInstance { + pub handle: JoinHandle>, + pub reporter: ProgressReporter, +} + +impl ProgressInstance { + pub async fn stop(self) -> miette::Result<()> { + self.reporter.exit(); + self.handle.await.into_diagnostic()??; + + Ok(()) + } +} + +impl Deref for ProgressInstance { + type Target = ProgressReporter; + + fn deref(&self) -> &Self::Target { + &self.reporter + } +} diff --git a/crates/cli/tests/clean_test.rs b/crates/cli/tests/clean_test.rs index a1f9bddf0..27d46d091 100644 --- a/crates/cli/tests/clean_test.rs +++ b/crates/cli/tests/clean_test.rs @@ -16,88 +16,6 @@ mod clean { .success(); } - #[test] - fn purges_tool_inventory() { - let sandbox = create_empty_proto_sandbox(); - sandbox.create_file(".proto/tools/node/1.2.3/index.js", ""); - sandbox.create_file(".proto/tools/node/4.5.6/index.js", ""); - - sandbox - .run_bin(|cmd| { - cmd.arg("clean").arg("--yes").arg("--purge").arg("node"); - }) - .success(); - - assert!(!sandbox - .path() - .join(".proto/tools/node/1.2.3/index.js") - .exists()); - assert!(!sandbox - .path() - .join(".proto/tools/node/4.5.6/index.js") - .exists()); - } - - #[cfg(not(windows))] - #[test] - fn purges_tool_bin() { - let sandbox = create_empty_proto_sandbox(); - sandbox.create_file(".proto/tools/node/1.2.3/fake/file", ""); - sandbox.create_file( - ".proto/tools/node/manifest.json", - r#"{ "installed_versions": ["1.2.3"] }"#, - ); - sandbox.create_file(".proto/bin/other", ""); - - let bin1 = sandbox.path().join(".proto/bin/node"); - let bin2 = sandbox.path().join(".proto/bin/node-1"); - let bin3 = sandbox.path().join(".proto/bin/node-1.2"); - let src = sandbox.path().join(".proto/tools/node/1.2.3/fake/file"); - - #[allow(deprecated)] - std::fs::soft_link(&src, &bin1).unwrap(); - #[allow(deprecated)] - std::fs::soft_link(&src, &bin2).unwrap(); - #[allow(deprecated)] - std::fs::soft_link(&src, &bin3).unwrap(); - - sandbox - .run_bin(|cmd| { - cmd.arg("clean").arg("--yes").arg("--purge").arg("node"); - }) - .success(); - - assert!(!bin1.exists()); - assert!(bin1.symlink_metadata().is_err()); - assert!(!bin2.exists()); - assert!(bin2.symlink_metadata().is_err()); - assert!(!bin3.exists()); - assert!(bin3.symlink_metadata().is_err()); - } - - #[test] - fn purges_tool_shims() { - let sandbox = create_empty_proto_sandbox(); - sandbox.create_file(".proto/shims/npm", ""); - sandbox.create_file(".proto/shims/npm.exe", ""); - sandbox.create_file(".proto/shims/npx", ""); - sandbox.create_file(".proto/shims/npx.exe", ""); - - sandbox - .run_bin(|cmd| { - cmd.arg("clean").arg("--yes").arg("--purge").arg("npm"); - }) - .success(); - - if cfg!(windows) { - assert!(!sandbox.path().join(".proto/shims/npm.exe").exists()); - assert!(!sandbox.path().join(".proto/shims/npx.exe").exists()); - } else { - assert!(!sandbox.path().join(".proto/shims/npm").exists()); - assert!(!sandbox.path().join(".proto/shims/npx").exists()); - } - } - #[test] fn purges_plugins() { let sandbox = create_empty_proto_sandbox(); diff --git a/crates/cli/tests/uninstall_test.rs b/crates/cli/tests/uninstall_test.rs index 94f2d94a4..182a0326f 100644 --- a/crates/cli/tests/uninstall_test.rs +++ b/crates/cli/tests/uninstall_test.rs @@ -42,6 +42,19 @@ mod uninstall { .exists()); } + #[test] + fn doesnt_uninstall_all_if_doesnt_exist() { + let sandbox = create_empty_proto_sandbox(); + + let assert = sandbox.run_bin(|cmd| { + cmd.arg("uninstall").arg("node"); + }); + + assert.inner.stdout(predicate::str::contains( + "Node.js has not been installed locally", + )); + } + #[test] fn uninstalls_everything() { let sandbox = create_empty_proto_sandbox(); @@ -92,4 +105,63 @@ mod uninstall { "" ); } + + #[allow(deprecated)] + #[cfg(not(windows))] + #[test] + fn removes_tool_bins() { + let sandbox = create_empty_proto_sandbox(); + sandbox.create_file(".proto/tools/node/1.2.3/fake/file", ""); + sandbox.create_file( + ".proto/tools/node/manifest.json", + r#"{ "installed_versions": ["1.2.3"] }"#, + ); + sandbox.create_file(".proto/bin/other", ""); + + let bin1 = sandbox.path().join(".proto/bin/node"); + let bin2 = sandbox.path().join(".proto/bin/node-1"); + let bin3 = sandbox.path().join(".proto/bin/node-1.2"); + let src = sandbox.path().join(".proto/tools/node/1.2.3/fake/file"); + + std::fs::soft_link(&src, &bin1).unwrap(); + std::fs::soft_link(&src, &bin2).unwrap(); + std::fs::soft_link(&src, &bin3).unwrap(); + + sandbox + .run_bin(|cmd| { + cmd.arg("uninstall").arg("--yes").arg("node"); + }) + .success(); + + assert!(!bin1.exists()); + assert!(bin1.symlink_metadata().is_err()); + assert!(!bin2.exists()); + assert!(bin2.symlink_metadata().is_err()); + assert!(!bin3.exists()); + assert!(bin3.symlink_metadata().is_err()); + } + + #[test] + fn removes_tool_shims() { + let sandbox = create_empty_proto_sandbox(); + sandbox.create_file(".proto/tools/npm/manifest.json", "{}"); + sandbox.create_file(".proto/shims/npm", ""); + sandbox.create_file(".proto/shims/npm.exe", ""); + sandbox.create_file(".proto/shims/npx", ""); + sandbox.create_file(".proto/shims/npx.exe", ""); + + sandbox + .run_bin(|cmd| { + cmd.arg("uninstall").arg("--yes").arg("npm"); + }) + .success(); + + if cfg!(windows) { + assert!(!sandbox.path().join(".proto/shims/npm.exe").exists()); + assert!(!sandbox.path().join(".proto/shims/npx.exe").exists()); + } else { + assert!(!sandbox.path().join(".proto/shims/npm").exists()); + assert!(!sandbox.path().join(".proto/shims/npx").exists()); + } + } }