use std::io::{IsTerminal, Write};
use crate::cli::commands::lifecycle;
use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::UninstallArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
use crate::hooks;
pub(crate) const PURGEABLE_STATE_DIR_NAMES: [&str; 2] = ["openlatch", ".openlatch"];
pub(crate) fn is_purgeable_state_dir_name(name: &str) -> bool {
PURGEABLE_STATE_DIR_NAMES.contains(&name)
}
fn resolve_purge_target() -> Result<Option<std::path::PathBuf>, OlError> {
let ol_dir = config::openlatch_dir();
if !ol_dir.exists() {
return Ok(None);
}
let canonical = std::fs::canonicalize(&ol_dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot canonicalize openlatch directory: {e}"),
)
})?;
let dir_name = canonical.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !is_purgeable_state_dir_name(dir_name) {
return Err(OlError::new(
ERR_INVALID_CONFIG,
format!(
"Unexpected openlatch directory name '{}' — refusing to delete for safety",
canonical.display()
),
)
.with_suggestion(format!(
"`--purge` only deletes a directory named {}. Point OPENLATCH_DIR at one, or \
delete this directory by hand. Nothing has been changed.",
PURGEABLE_STATE_DIR_NAMES
.map(|n| format!("'{n}'"))
.join(" or ")
)));
}
Ok(Some(canonical))
}
fn is_machine_default_install() -> bool {
same_directory(&config::openlatch_dir(), &config::default_openlatch_dir())
}
fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool {
let canonical =
|p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
canonical(a) == canonical(b)
}
pub fn run_uninstall(args: &UninstallArgs, output: &OutputConfig) -> Result<(), OlError> {
let purge_target = if args.purge {
resolve_purge_target()?
} else {
None
};
let purge_machine_global = args.purge && is_machine_default_install();
if !args.yes {
let is_tty = std::io::stdout().is_terminal();
if is_tty && output.format == OutputFormat::Human {
let purge_note = if args.purge {
" and DELETE all OpenLatch data"
} else {
""
};
eprint!("This will remove OpenLatch hooks{purge_note} and stop the daemon. Continue? [y/N] ");
let _ = std::io::stderr().flush();
let mut line = String::new();
let _ = std::io::stdin().read_line(&mut line);
if !line.trim().eq_ignore_ascii_case("y") {
output.print_info("Aborted.");
return Ok(());
}
}
}
let selected = match hooks::select_agents(hooks::detect_agents(), &args.agent) {
Ok(v) => v,
Err(e) => {
output.print_error(&e);
return Err(e);
}
};
if selected.is_empty() {
output.print_info("Agent not detected — skipping hook removal");
}
for agent in &selected {
match hooks::remove_hooks(&*agent.binding) {
Ok(()) => {
let settings_path = agent.settings_path();
output.print_step(&format!("Hooks removed from {}", settings_path.display()));
#[cfg(feature = "boundary")]
if let Err(e) = hooks::remove_boundary_config(&*agent.binding) {
output.print_info(&format!(
"Warning: could not remove boundary wiring: {} ({})",
e.message, e.code
));
}
}
Err(e) => {
output.print_info(&format!(
"Warning: could not remove hooks for {}: {} ({})",
agent.display_name(),
e.message,
e.code
));
}
}
}
if !crate::supervision::owns_machine_supervision() {
output.print_info("Isolated install — the machine's supervisor unit is left in place");
} else if let Some(supervisor) = crate::supervision::select_supervisor() {
match supervisor.uninstall() {
Ok(()) => output.print_step("Supervision removed"),
Err(e) => output.print_info(&format!(
"Warning: could not remove supervision: {} ({})",
e.message, e.code
)),
}
}
let config_path = config::openlatch_dir().join("config.toml");
if config_path.exists() {
let _ = config::persist_supervision_state(
&config_path,
&crate::supervision::SupervisionMode::Disabled,
&crate::supervision::SupervisorKind::None,
Some("uninstalled"),
);
}
lifecycle::run_stop(output)?;
if args.purge {
if let Some(canonical) = purge_target {
std::fs::remove_dir_all(&canonical).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!(
"Cannot delete openlatch directory '{}': {e}",
canonical.display()
),
)
.with_suggestion("Check that you have write permission.")
})?;
output.print_step(&format!("Data directory removed: {}", canonical.display()));
} else {
output.print_info("Data directory does not exist — nothing to purge");
}
if purge_machine_global {
match crate::auth::CredentialStore::delete(&crate::auth::KeyringCredentialStore::new())
{
Ok(()) => output.print_step("Credentials cleared from the OS keychain"),
Err(e) => output.print_info(&format!(
"Warning: could not clear the OS keychain credential: {} ({})",
e.message, e.code
)),
}
} else {
output.print_info(
"Isolated install — the machine's OS-keychain credential is left in place",
);
}
}
crate::telemetry::capture_global(crate::telemetry::Event::uninstalled(1));
if output.format == OutputFormat::Json {
let json = serde_json::json!({
"status": "ok",
"purged": args.purge,
});
output.print_json(&json);
} else if !output.quiet {
eprintln!();
eprintln!("OpenLatch uninstalled successfully.");
if args.purge {
eprintln!("All data removed.");
} else {
eprintln!(
"Data directory preserved at {}. Use --purge to remove it.",
config::openlatch_dir().display()
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn a_redirected_state_dir_is_the_default_one_only_when_it_resolves_there() {
let home = tempfile::tempdir().unwrap();
let default = home.path().join(".openlatch");
std::fs::create_dir_all(&default).unwrap();
let elsewhere = home.path().join("sandbox").join("openlatch");
std::fs::create_dir_all(&elsewhere).unwrap();
assert!(
!same_directory(&elsewhere, &default),
"a sandbox state directory must not pass for the machine's install"
);
let via_symlink = home.path().join("link");
std::os::unix::fs::symlink(&default, &via_symlink).unwrap();
assert!(
same_directory(&via_symlink, &default),
"a redirection that resolves to the default IS the default"
);
}
#[test]
fn the_state_directory_olbox_creates_is_purgeable() {
assert!(
is_purgeable_state_dir_name("openlatch"),
"`olbox` names a sandbox's state directory `openlatch`; `--purge` must accept it"
);
}
}