diff --git a/README.md b/README.md index 2d3abcf..d7708d9 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,13 @@ clickhousectl local which # Show current default # Remove a version clickhousectl local remove 25.12.5.44 +clickhousectl local remove 25.12.5.44 --force # Stop running servers on this version first ``` `local use` also creates a symlink at `~/.local/bin/clickhouse` pointing to the selected version's binary, so the plain `clickhouse` command (e.g. `clickhouse local`, `clickhouse client`) is on PATH. Pass `--no-global` to skip. If a regular file already exists at that path it is left alone with a warning. `local remove` of the active default version also clears the symlink. +`local remove` refuses to delete a version while a local server is running on it (it would leave the server pointing at a deleted binary), failing with the running server names. Stop the server first, or pass `--force` to stop the running server(s) and then remove the version. + #### ClickHouse binary storage ClickHouse binaries are stored in a global repository, so they can be used by multiple projects without duplicating storage. Binaries are stored in `~/.clickhouse/`: diff --git a/crates/clickhousectl/src/error.rs b/crates/clickhousectl/src/error.rs index 50ceb4e..b42bc33 100644 --- a/crates/clickhousectl/src/error.rs +++ b/crates/clickhousectl/src/error.rs @@ -25,6 +25,9 @@ pub enum Error { #[error("Version {0} is already installed")] VersionAlreadyInstalled(String), + #[error("Version {version} is in use by running server(s): {servers}. Stop them first, or pass --force.")] + VersionInUse { version: String, servers: String }, + #[error("Unsupported platform: {os}/{arch}")] UnsupportedPlatform { os: String, arch: String }, @@ -114,5 +117,13 @@ mod tests { assert_eq!(Error::Cloud("boom".into()).exit_code(), 1); assert_eq!(Error::NoVersionsInstalled.exit_code(), 1); assert_eq!(Error::VersionNotFound("25.12".into()).exit_code(), 1); + assert_eq!( + Error::VersionInUse { + version: "25.12".into(), + servers: "default".into(), + } + .exit_code(), + 1 + ); } } diff --git a/crates/clickhousectl/src/local/cli.rs b/crates/clickhousectl/src/local/cli.rs index d5f08a5..906fc5a 100644 --- a/crates/clickhousectl/src/local/cli.rs +++ b/crates/clickhousectl/src/local/cli.rs @@ -61,10 +61,16 @@ CONTEXT FOR AGENTS: Removes an installed ClickHouse version from ~/.clickhouse/versions/. Takes an exact version string as shown by `clickhousectl local list` (e.g., \"25.12.5.44\"). Does NOT accept keywords like \"stable\" — use the exact version number. + Fails if a local server is currently running on this version; stop it first, or pass + --force to stop the running server(s) before removing. Related: `clickhousectl local list` to see installed versions.")] Remove { /// Version to remove version: String, + + /// Stop any running servers using this version, then remove it + #[arg(long)] + force: bool, }, /// Show the current default version @@ -450,6 +456,27 @@ mod tests { local.command } + #[test] + fn parses_remove_without_force() { + let LocalCommands::Remove { version, force } = local_command(&["remove", "25.12.5.44"]) + else { + panic!("expected remove"); + }; + assert_eq!(version, "25.12.5.44"); + assert!(!force); + } + + #[test] + fn parses_remove_with_force() { + let LocalCommands::Remove { version, force } = + local_command(&["remove", "25.12.5.44", "--force"]) + else { + panic!("expected remove"); + }; + assert_eq!(version, "25.12.5.44"); + assert!(force); + } + #[test] fn parses_server_start_config_file() { let LocalCommands::Server { diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index e94cdc5..4da1a6a 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -25,7 +25,7 @@ pub async fn run(cmd: LocalCommands, json: bool) -> Result<()> { } } LocalCommands::Use { version, no_global } => use_version(&version, no_global, json).await, - LocalCommands::Remove { version } => remove(&version, json), + LocalCommands::Remove { version, force } => remove(&version, force, json), LocalCommands::Which => which(json), LocalCommands::Init => { init::init()?; @@ -175,13 +175,37 @@ async fn use_version(version_spec: &str, no_global: bool, json: bool) -> Result< Ok(()) } -fn remove(version: &str, json: bool) -> Result<()> { +fn remove(version: &str, force: bool, json: bool) -> Result<()> { let version_dir = paths::version_dir(version)?; if !version_dir.exists() { return Err(Error::VersionNotFound(version.to_string())); } + // Recover orphaned servers so we detect a running process even when its + // metadata file is missing, then refuse to pull the binary out from under + // a server running on this version. + server::recover_current_project_servers(); + let in_use: Vec = server::list_running_servers() + .into_iter() + .filter(|i| i.version == version) + .map(|i| i.name) + .collect(); + if !in_use.is_empty() { + if !force { + return Err(Error::VersionInUse { + version: version.to_string(), + servers: in_use.join(", "), + }); + } + for name in &in_use { + server::kill_server(name)?; + if !json { + println!("Stopped server '{}'", name); + } + } + } + // Check if this is the default version if let Ok(default) = version_manager::get_default_version() && default == version