mod host;
mod inventory;
mod steps;
use std::io::Write as _;
use anyhow::{Context, Result, bail};
use arcbox_constants::paths::{ArcboxProfile, HostLayout, labels};
use arcbox_docker::DockerContextManager;
use clap::Args;
use self::host::{Host, Mac};
use self::inventory::{Residue, Roots};
use self::steps::Outcome;
use super::{kubernetes, setup, ssh};
#[derive(Debug, Args)]
pub struct UninstallArgs {
#[arg(long)]
pub yes: bool,
#[arg(long)]
pub keep_data: bool,
}
pub async fn execute(args: UninstallArgs) -> Result<()> {
let profile = ArcboxProfile::from_env_or_default();
validate_uninstall_scope(profile)?;
if unsafe { libc::geteuid() } == 0 {
bail!(
"run `abctl uninstall` as yourself, not under sudo: it removes your Docker context, shell integration and keychain trust, and asks for sudo itself"
);
}
let roots = Roots::from_env()?;
let residue = inventory::scan(&roots, args.keep_data);
print_plan(&roots, &residue);
if !args.yes {
print!("Continue? [y/N] ");
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(());
}
}
println!();
let host = Mac;
if residue.needs_root() {
let status = std::process::Command::new("sudo")
.arg("-v")
.status()
.context("could not run sudo")?;
if !status.success() {
bail!("sudo authentication failed");
}
}
let report = run(&host, &roots, &residue, &mut |step| println!("{step}")).await;
println!();
if report.iter().any(Step::failed) {
bail!(
"{} step(s) failed; fix the cause and run `abctl uninstall` again",
report.iter().filter(|step| step.failed()).count()
);
}
println!("ArcBox has been removed.");
if args.keep_data {
println!(
"Container data kept at {}",
roots.data_dir.join("data").display()
);
}
if residue.app_left_to_homebrew {
println!("The app was installed by Homebrew; finish with: brew uninstall --cask arcbox");
}
Ok(())
}
fn print_plan(roots: &Roots, residue: &Residue) {
println!("This will remove ArcBox from this Mac:\n");
println!(" • Quit the Desktop app and stop the daemon (and its VM)");
for found in &residue.files {
let root = if found.needs_root { " [sudo]" } else { "" };
println!(" • Remove {} {}{root}", found.what, found.path.display());
}
if residue.hosts_alias {
println!(" • Remove the ArcBox line from /etc/hosts [sudo]");
}
println!(" • Remove the Docker context, shell, kubectl and ssh integration");
println!(" • Remove trust in the ArcBox local CA, if granted");
if residue.preferences {
println!(" • Remove the Desktop app's preferences");
}
if residue.app_left_to_homebrew {
println!(
" • Leave {} to Homebrew (brew uninstall --cask arcbox)",
roots.app_bundle().display()
);
}
println!();
}
struct Step {
label: String,
outcome: Result<Outcome>,
}
impl Step {
fn failed(&self) -> bool {
self.outcome.is_err()
}
}
impl std::fmt::Display for Step {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:<60} ", self.label)?;
match &self.outcome {
Ok(Outcome::Done) => write!(f, "done"),
Ok(Outcome::Skipped(reason)) => write!(f, "skipped ({reason})"),
Err(error) => write!(f, "FAILED: {error:#}"),
}
}
}
async fn run(
host: &dyn Host,
roots: &Roots,
residue: &Residue,
report: &mut (dyn FnMut(&Step) + Send),
) -> Vec<Step> {
let mut steps = Vec::new();
let mut step = |label: String, outcome: Result<Outcome>| {
let step = Step { label, outcome };
report(&step);
steps.push(step);
};
let layout = HostLayout::new(roots.data_dir.clone());
step(
format!("Quitting {}", roots.profile.app_name()),
steps::quit_app(host, roots.profile.app_bundle_id()),
);
step(
"Stopping the daemon".into(),
steps::stop_daemon(
host,
&layout,
&[roots.profile.daemon_label(), labels::LEGACY_SCRIPT_DAEMON],
)
.await,
);
if residue.helper_registered {
step(
"Unregistering the helper".into(),
steps::bootout_helper(host),
);
}
step(
format!("Unmounting {}", roots.data_export_mount().display()),
steps::remove_data_export(host, &roots.data_export_mount()),
);
step(
"Removing the Docker context".into(),
remove_docker_context(roots),
);
step(
"Removing shell integration".into(),
remove_shell_integration(roots).await,
);
step(
"Removing kubectl integration".into(),
remove_kubernetes_integration(roots).await,
);
step(
"Removing the ssh config Include".into(),
remove_ssh_include(roots, &layout),
);
step(
"Removing trust in the ArcBox local CA".into(),
steps::untrust_local_ca(host, roots),
);
if residue.hosts_alias {
step(
"Removing the ArcBox line from /etc/hosts".into(),
steps::remove_hosts_alias(host, &roots.hosts()),
);
}
for found in &residue.files {
step(
format!("Removing {} {}", found.what, found.path.display()),
steps::remove_path(host, &found.path).map(|()| Outcome::Done),
);
}
if residue.preferences {
step(
"Removing the Desktop app's preferences".into(),
steps::delete_preferences(host, roots),
);
}
steps
}
fn remove_docker_context(roots: &Roots) -> Result<Outcome> {
let manager = DockerContextManager::with_context_name_and_config_dir(
HostLayout::new(roots.data_dir.clone()).docker_socket,
roots.docker_context.clone(),
roots.docker_config.clone(),
);
if !manager.context_exists() {
return Ok(Outcome::Skipped("absent".into()));
}
manager.remove_context()?;
Ok(Outcome::Done)
}
async fn remove_shell_integration(roots: &Roots) -> Result<Outcome> {
let integration =
setup::Integration::under(&roots.home, &roots.data_dir, roots.docker_config.clone())
.await?;
let removed = setup::remove_integration(&integration).await?;
if let Some(error) = removed.plugin_error {
bail!("Docker CLI plugins: {error}");
}
Ok(Outcome::Done)
}
async fn remove_kubernetes_integration(roots: &Roots) -> Result<Outcome> {
let paths = kubernetes::HostPaths {
home: roots.home.clone(),
data_dir: roots.data_dir.clone(),
};
if kubernetes::remove_host_integration(&paths).await? {
Ok(Outcome::Done)
} else {
Ok(Outcome::Skipped("not enabled".into()))
}
}
fn remove_ssh_include(roots: &Roots, layout: &HostLayout) -> Result<Outcome> {
let user_config = roots.home.join(".ssh").join("config");
if !user_config.exists() {
return Ok(Outcome::Skipped("absent".into()));
}
if ssh::uninstall(&user_config, &layout.ssh_config, &roots.home)? {
Ok(Outcome::Done)
} else {
Ok(Outcome::Skipped("not included".into()))
}
}
fn validate_uninstall_scope(profile: ArcboxProfile) -> Result<()> {
if profile == ArcboxProfile::Development {
bail!(
"Development instances must be removed by the Desktop app; refusing the machine-wide uninstall command."
);
}
Ok(())
}
pub(super) async fn brew_hook() -> Result<()> {
let roots = Roots::from_env()?;
let host = Mac;
let layout = HostLayout::new(roots.data_dir.clone());
steps::stop_daemon(
&host,
&layout,
&[roots.profile.daemon_label(), labels::LEGACY_SCRIPT_DAEMON],
)
.await?;
for label in [roots.profile.daemon_label(), labels::LEGACY_SCRIPT_DAEMON] {
steps::remove_path(&host, &roots.launch_agent(label))?;
}
remove_docker_context(&roots)?;
remove_shell_integration(&roots).await?;
steps::unlink_cli_tools_through_helper(&roots).await;
steps::remove_path(&host, &layout.run_dir)
}
#[cfg(test)]
mod tests;