From be3843710dde4cbd83c7980593f0235413b45d62 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Mon, 26 Oct 2020 14:36:52 +0200 Subject: [PATCH] Add uninstall command (#292) --- src/alias.rs | 4 + src/choose_version_for_user_input.rs | 2 +- src/cli.rs | 57 +++++++--- src/commands/install.rs | 2 +- src/commands/ls_local.rs | 17 +-- src/commands/mod.rs | 1 + src/commands/uninstall.rs | 100 ++++++++++++++++++ src/commands/use.rs | 6 +- src/installed_versions.rs | 4 + src/user_version.rs | 33 ++++-- src/version.rs | 22 ++++ tests/feature_tests/mod.rs | 1 + tests/feature_tests/uninstall.rs | 14 +++ .../e2e__feature_tests__uninstall__Bash.snap | 16 +++ .../e2e__feature_tests__uninstall__Fish.snap | 13 +++ ..._feature_tests__uninstall__PowerShell.snap | 14 +++ .../e2e__feature_tests__uninstall__Zsh.snap | 14 +++ 17 files changed, 279 insertions(+), 41 deletions(-) create mode 100644 src/commands/uninstall.rs create mode 100644 tests/feature_tests/uninstall.rs create mode 100644 tests/snapshots/e2e__feature_tests__uninstall__Bash.snap create mode 100644 tests/snapshots/e2e__feature_tests__uninstall__Fish.snap create mode 100644 tests/snapshots/e2e__feature_tests__uninstall__PowerShell.snap create mode 100644 tests/snapshots/e2e__feature_tests__uninstall__Zsh.snap diff --git a/src/alias.rs b/src/alias.rs index 4233b03..0b2e9bd 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -70,4 +70,8 @@ impl StoredAlias { .to_str() .unwrap() } + + pub fn path(&self) -> &std::path::Path { + &self.alias_path + } } diff --git a/src/choose_version_for_user_input.rs b/src/choose_version_for_user_input.rs index 066bc8b..39a2c2e 100644 --- a/src/choose_version_for_user_input.rs +++ b/src/choose_version_for_user_input.rs @@ -50,7 +50,7 @@ pub fn choose_version_for_user_input<'a>( version: Version::Alias(alias_name), }) } else { - let current_version = requested_version.to_version(&all_versions); + let current_version = requested_version.to_version(&all_versions, &config); current_version.map(|version| { info!("Using Node {}", version.to_string().cyan()); let path = config diff --git a/src/cli.rs b/src/cli.rs index 9e2f884..49d8f76 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -5,29 +5,59 @@ use structopt::StructOpt; #[derive(StructOpt, Debug)] pub enum SubCommand { - #[structopt(name = "ls-remote", about = "List all remote Node.js versions")] + /// List all remote Node.js versions + #[structopt(name = "ls-remote")] LsRemote(commands::ls_remote::LsRemote), - #[structopt(name = "ls", about = "List all local Node.js versions")] + + /// List all locally installed Node.js versions + #[structopt(name = "ls")] LsLocal(commands::ls_local::LsLocal), - #[structopt(name = "install", about = "Install a new Node.js version")] + + /// Install a new Node.js version + #[structopt(name = "install")] Install(commands::install::Install), - #[structopt(name = "use", about = "Change Node.js version")] + + /// Change Node.js version + #[structopt(name = "use")] Use(commands::r#use::Use), - #[structopt( - name = "env", - about = "Print and setup required environment variables for fnm" - )] + + /// Print and set up required environment variables for fnm + #[structopt(name = "env")] Env(commands::env::Env), - #[structopt(name = "completions", about = "Create completions file")] + + /// Print shell completions to stdout + #[structopt(name = "completions")] Completions(commands::completions::Completions), - #[structopt(name = "alias", about = "alias a version to a common name")] + + /// Alias a version to a common name + #[structopt(name = "alias")] Alias(commands::alias::Alias), - #[structopt(name = "default", about = "set a version as the default version")] + + /// Set a version as the default version + /// + /// This is a shorthand for `fnm alias VERSION default` + #[structopt(name = "default")] Default(commands::default::Default), - #[structopt(name = "current", about = "The current version")] + + /// Print the current Node.js version + #[structopt(name = "current")] Current(commands::current::Current), - #[structopt(name = "exec", about = "Run a command with in fnm context")] + + /// Run a command within fnm context + /// + /// Example: + /// -------- + /// fnm exec --using=v12.0.0 -- node --version + /// => v12.0.0 + #[structopt(name = "exec")] Exec(commands::exec::Exec), + + /// Uninstall a Node.js version + /// + /// > Warning: when providing an alias, it will remove the Node version the alias + /// is pointing to, along with the other aliases that point to the same version. + #[structopt(name = "uninstall")] + Uninstall(commands::uninstall::Uninstall), } impl SubCommand { @@ -43,6 +73,7 @@ impl SubCommand { Self::Default(cmd) => cmd.call(config), Self::Current(cmd) => cmd.call(config), Self::Exec(cmd) => cmd.call(config), + Self::Uninstall(cmd) => cmd.call(config), } } } diff --git a/src/commands/install.rs b/src/commands/install.rs index b92c029..a3f9c74 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -82,7 +82,7 @@ impl super::command::Command for Install { .collect(); current_version - .to_version(&available_versions) + .to_version(&available_versions, &config) .context(CantFindNodeVersion { requested_version: current_version, })? diff --git a/src/commands/ls_local.rs b/src/commands/ls_local.rs index ce635bf..2080730 100644 --- a/src/commands/ls_local.rs +++ b/src/commands/ls_local.rs @@ -16,17 +16,8 @@ impl super::command::Command for LsLocal { fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> { let base_dir = config.installations_dir(); - let mut versions: Vec<_> = std::fs::read_dir(&base_dir) - .context(CantListLocallyInstalledVersion)? - .filter_map(|x| { - if let Ok(version_dir) = x { - let file_name = version_dir.file_name(); - file_name.to_str().and_then(|x| Version::parse(x).ok()) - } else { - None - } - }) - .collect(); + let mut versions = + crate::installed_versions::list(base_dir).context(CantListLocallyInstalledVersion)?; versions.insert(0, Version::Bypassed); versions.sort(); let aliases_hash = generate_aliases_hash(&config).context(CantReadAliases)?; @@ -73,7 +64,9 @@ fn generate_aliases_hash(config: &FnmConfig) -> std::io::Result, +} + +impl Command for Uninstall { + type Error = Error; + + fn apply(self, config: &FnmConfig) -> Result<(), Self::Error> { + let all_versions = + installed_versions::list(config.installations_dir()).context(VersionListingError)?; + let requested_version = self + .version + .or_else(|| { + let current_dir = std::env::current_dir().unwrap(); + get_user_version_from_file(current_dir) + }) + .context(CantInferVersion)?; + + ensure!( + !matches!(requested_version, UserVersion::Full(Version::Bypassed)), + CantUninstallSystemVersion + ); + + let available_versions: Vec<&Version> = all_versions + .iter() + .filter(|v| requested_version.matches(v, &config)) + .collect(); + + ensure!( + available_versions.len() < 2, + PleaseBeMoreSpecificToDelete { + matched_versions: available_versions + .iter() + .map(|x| x.v_str()) + .collect::>() + } + ); + + let version = requested_version + .to_version(&all_versions, &config) + .context(CantFindVersion)?; + + let matching_aliases = version.find_aliases(&config).context(IoError)?; + let root_path = version + .root_path(&config) + .with_context(|| RootPathNotFound { + version: version.clone(), + })?; + + debug!("Removing Node version from {:?}", root_path); + std::fs::remove_dir_all(root_path).context(CantDeleteNodeVersion)?; + outln!(config#Info, "Node version {} was removed successfuly", version.v_str().cyan()); + + for alias in matching_aliases { + debug!("Removing alias from {:?}", alias.path()); + remove_symlink_dir(alias.path()).context(CantDeleteSymlink)?; + outln!(config#Info, "Alias {} was removed successfuly", alias.name().cyan()); + } + + Ok(()) + } +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Can't get locally installed versions: {}", source))] + VersionListingError { source: installed_versions::Error }, + #[snafu(display( + "Can't find version in dotfiles. Please provide a version manually to the command." + ))] + CantInferVersion, + #[snafu(display("Can't uninstall system version"))] + CantUninstallSystemVersion, + #[snafu(display("Too many versions had matched, please be more specific.\nFound {} matching versions, expected 1:\n{}", matched_versions.len(), matched_versions.iter().map(|v| format!("* {}", v)).collect::>().join("\n")))] + PleaseBeMoreSpecificToDelete { matched_versions: Vec }, + #[snafu(display("Can't find a matching version"))] + CantFindVersion, + #[snafu(display("Root path not found for version {}", version))] + RootPathNotFound { version: Version }, + #[snafu(display("io error: {}", source))] + IoError { source: std::io::Error }, + #[snafu(display("Can't delete Node.js version: {}", source))] + CantDeleteNodeVersion { source: std::io::Error }, + #[snafu(display("Can't delete symlink: {}", source))] + CantDeleteSymlink { source: std::io::Error }, +} diff --git a/src/commands/use.rs b/src/commands/use.rs index d897da2..5320c0c 100644 --- a/src/commands/use.rs +++ b/src/commands/use.rs @@ -50,7 +50,7 @@ impl Command for Use { outln!(config#Info, "Using Node for alias {}", alias_name.cyan()); alias_path } else { - let current_version = requested_version.to_version(&all_versions); + let current_version = requested_version.to_version(&all_versions, &config); match current_version { Some(version) => { outln!(config#Info, "Using Node {}", version.to_string().cyan()); @@ -123,9 +123,7 @@ pub enum Error { #[snafu(display("{}", source))] InstallError { source: ::Error }, #[snafu(display("Can't get locally installed versions: {}", source))] - VersionListingError { - source: crate::installed_versions::Error, - }, + VersionListingError { source: installed_versions::Error }, #[snafu(display("Requested version {} is not currently installed", version))] CantFindVersion { version: UserVersion }, #[snafu(display( diff --git a/src/installed_versions.rs b/src/installed_versions.rs index 90cc1d7..d9ee222 100644 --- a/src/installed_versions.rs +++ b/src/installed_versions.rs @@ -6,6 +6,10 @@ pub fn list>(installations_dir: P) -> Result, Error> let mut vec = vec![]; for result_entry in installations_dir.as_ref().read_dir().context(IoError)? { let entry = result_entry.context(IoError)?; + if entry.file_name() == ".downloads" { + continue; + } + let path = entry.path(); let filename = path .file_name() diff --git a/src/user_version.rs b/src/user_version.rs index 74f234c..2c3ac18 100644 --- a/src/user_version.rs +++ b/src/user_version.rs @@ -8,11 +8,18 @@ pub enum UserVersion { } impl UserVersion { - pub fn to_version<'a, T>(&self, available_versions: T) -> Option<&'a Version> + pub fn to_version<'a, T>( + &self, + available_versions: T, + config: &crate::config::FnmConfig, + ) -> Option<&'a Version> where T: IntoIterator, { - available_versions.into_iter().filter(|x| &self == x).max() + available_versions + .into_iter() + .filter(|x| self.matches(x, config)) + .max() } pub fn alias_name(&self) -> Option { @@ -21,13 +28,19 @@ impl UserVersion { _ => None, } } -} -impl PartialEq for UserVersion { - fn eq(&self, other: &Version) -> bool { - match (self, other) { + pub fn matches(&self, version: &Version, config: &crate::config::FnmConfig) -> bool { + match (self, version) { (Self::Full(a), b) if a == b => true, - (Self::Full(_), _) => false, + (Self::Full(user_version), maybe_alias) => { + match (user_version.alias_name(), maybe_alias.find_aliases(&config)) { + (None, _) | (_, Err(_)) => false, + (Some(user_alias), Ok(aliases)) => aliases + .iter() + .find(|alias| alias.name() == user_alias) + .is_some(), + } + } (_, Version::Bypassed) => false, (_, Version::Lts(_)) => false, (_, Version::Alias(_)) => false, @@ -84,7 +97,7 @@ mod tests { expected.clone(), Version::parse("7.0.1").unwrap(), ]; - let result = UserVersion::OnlyMajor(6).to_version(&versions); + let result = UserVersion::OnlyMajor(6).to_version(&versions, &Default::default()); assert_eq!(result, Some(&expected)); } @@ -98,7 +111,7 @@ mod tests { expected.clone(), Version::parse("7.0.1").unwrap(), ]; - let result = UserVersion::MajorMinor(6, 0).to_version(&versions); + let result = UserVersion::MajorMinor(6, 0).to_version(&versions, &Default::default()); assert_eq!(result, Some(&expected)); } @@ -112,7 +125,7 @@ mod tests { Version::parse("6.0.1").unwrap(), Version::parse("7.0.1").unwrap(), ]; - let result = UserVersion::Full(expected.clone()).to_version(&versions); + let result = UserVersion::Full(expected.clone()).to_version(&versions, &Default::default()); assert_eq!(result, Some(&expected)); } diff --git a/src/version.rs b/src/version.rs index 6a6d1b6..0bd7481 100644 --- a/src/version.rs +++ b/src/version.rs @@ -38,6 +38,17 @@ impl Version { } } + pub fn find_aliases( + &self, + config: &crate::config::FnmConfig, + ) -> std::io::Result> { + let aliases = crate::alias::list_aliases(&config)? + .drain(..) + .filter(|alias| alias.s_ver() == self.v_str()) + .collect(); + Ok(aliases) + } + pub fn v_str(&self) -> String { format!("{}", self) } @@ -59,6 +70,17 @@ impl Version { ), } } + + pub fn root_path(&self, config: &crate::config::FnmConfig) -> Option { + match self.installation_path(&config) { + None => None, + Some(path) => { + let mut canon_path = path.canonicalize().ok()?; + canon_path.pop(); + Some(canon_path) + } + } + } } impl<'de> serde::Deserialize<'de> for Version { diff --git a/tests/feature_tests/mod.rs b/tests/feature_tests/mod.rs index 577216f..fa415e8 100644 --- a/tests/feature_tests/mod.rs +++ b/tests/feature_tests/mod.rs @@ -1,5 +1,6 @@ mod aliases; mod current; +mod uninstall; use crate::shellcode::*; diff --git a/tests/feature_tests/uninstall.rs b/tests/feature_tests/uninstall.rs new file mode 100644 index 0000000..f263365 --- /dev/null +++ b/tests/feature_tests/uninstall.rs @@ -0,0 +1,14 @@ +#[allow(unused_imports)] +use crate::shellcode::*; + +test_shell!(Bash, Zsh, Fish, PowerShell; { + EvalFnmEnv::default() + .then(Call::new("fnm", vec!["install", "12.0.0"])) + .then(Call::new("fnm", vec!["alias", "12.0.0", "hello"])) + .then(OutputContains::new( + OutputContains::new(Call::new("fnm", vec!["ls"]), "v12.0.0"), + "hello", + )) + .then(Call::new("fnm", vec!["uninstall", "hello"])) + .then(ExpectCommandOutput::new(Call::new("fnm", vec!["ls"]), "* system", "fnm ls")) +}); diff --git a/tests/snapshots/e2e__feature_tests__uninstall__Bash.snap b/tests/snapshots/e2e__feature_tests__uninstall__Bash.snap new file mode 100644 index 0000000..bae5649 --- /dev/null +++ b/tests/snapshots/e2e__feature_tests__uninstall__Bash.snap @@ -0,0 +1,16 @@ +--- +source: tests/e2e.rs +expression: "&source.trim()" +--- +set -e +shopt -s expand_aliases + +eval "$(fnm env)" +fnm install 12.0.0 +fnm alias 12.0.0 hello +fnm ls | grep v12.0.0 | grep hello +fnm uninstall hello +if [ "$(fnm ls)" != "* system" ]; then + echo 'Expected fnm ls to be "* system", Got: '"$(fnm ls)" + exit 1 +fi diff --git a/tests/snapshots/e2e__feature_tests__uninstall__Fish.snap b/tests/snapshots/e2e__feature_tests__uninstall__Fish.snap new file mode 100644 index 0000000..417ad58 --- /dev/null +++ b/tests/snapshots/e2e__feature_tests__uninstall__Fish.snap @@ -0,0 +1,13 @@ +--- +source: tests/e2e.rs +expression: "&source.trim()" +--- +fnm env | source +fnm install 12.0.0 +fnm alias 12.0.0 hello +fnm ls | grep v12.0.0 | grep hello +fnm uninstall hello +if test (fnm ls) != "* system" + echo 'Expected fnm ls to be "* system", Got: '(fnm ls) + exit 1 +end diff --git a/tests/snapshots/e2e__feature_tests__uninstall__PowerShell.snap b/tests/snapshots/e2e__feature_tests__uninstall__PowerShell.snap new file mode 100644 index 0000000..01ec645 --- /dev/null +++ b/tests/snapshots/e2e__feature_tests__uninstall__PowerShell.snap @@ -0,0 +1,14 @@ +--- +source: tests/e2e.rs +expression: "&source.trim()" +--- +$ErrorActionPreference = "Stop" +fnm env | Out-String | Invoke-Expression +fnm install 12.0.0 +fnm alias 12.0.0 hello +$($__out__ = $($($__out__ = $(fnm ls | Select-String 'v12.0.0'); echo $__out__; if ($__out__ -eq $null){ exit 1 } else { $__out__ }) | Select-String 'hello'); echo $__out__; if ($__out__ -eq $null){ exit 1 } else { $__out__ }) +fnm uninstall hello +If ("$(fnm ls)" -ne "* system") { + Write-Output ('Expected fnm ls to be "* system", Got: ' + $(fnm ls)) + exit 1 +} diff --git a/tests/snapshots/e2e__feature_tests__uninstall__Zsh.snap b/tests/snapshots/e2e__feature_tests__uninstall__Zsh.snap new file mode 100644 index 0000000..ca2cfce --- /dev/null +++ b/tests/snapshots/e2e__feature_tests__uninstall__Zsh.snap @@ -0,0 +1,14 @@ +--- +source: tests/e2e.rs +expression: "&source.trim()" +--- +set -e +eval "$(fnm env)" +fnm install 12.0.0 +fnm alias 12.0.0 hello +fnm ls | grep v12.0.0 | grep hello +fnm uninstall hello +if [ "$(fnm ls)" != "* system" ]; then + echo 'Expected fnm ls to be "* system", Got: '"$(fnm ls)" + exit 1 +fi