//! The `uninstall` command — remove what the installer exclusively owns, print the rest. //! //! PLAN.md §5 is unusually specific about the shape of this command, and the reason is worth //! keeping in front of whoever edits it: **the installer cannot know what the operator has changed //! in their own ServUO tree since deployment.** A clever automatic revert — deleting the overlay's //! files, reversing the patch hunks — would silently eat work that is not ours to judge. So this //! command draws a hard line: //! //! | | | //! |---|---| //! | Removed | the sidecar binary, its service entry, `install.json` | //! | Kept | `sidecar.toml`, `uo-link.db`, the cached patch set and the pre-patch originals (`--purge` drops them) | //! | Printed, not done | every overlay file in the ServUO tree, and the exact hunks each applied patch added | //! //! ## Why the patch cache outlives the uninstall //! //! PLAN.md's table put the cached patch set under "removed", but the report this command prints //! tells the operator to diff their stock files against the pre-patch copies under //! `patches/originals/` — advice that the same command would have made impossible to follow. The //! cache and the originals are the only offline record of what the tier changed once the release //! tarball is gone, so they survive by default and `--purge` is what removes them, alongside the //! config and the database. The report names every path it left behind. //! //! ## Why the report is a file as well as output //! //! It is the only thing the operator still needs after this command exits, and it arrives at the //! end of the longest output the installer ever produces. A terminal's scrollback is not a place to //! keep the list of files somebody has to go and delete by hand. use std::fmt::Write as _; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::cli::Cli; use crate::diff::HunkLine; use crate::record::{now_rfc3339, InstallRecord, LinkRecord}; use crate::{patch, paths, service, ui, util}; pub fn run(cli: &Cli) -> Result { let layout = paths::layout(); let record_path = layout.install_record(); println!( "\nRunic Gateway installer {} — uninstall", env!("CARGO_PKG_VERSION") ); let Some(record) = InstallRecord::load(&record_path)? else { println!(); ui::warn(&format!( "Nothing to uninstall — no deployment is recorded on this host.\n \ Looked for {}\n \ If this host is installed, this run cannot see its record: run as \ root/Administrator, and set {} to the same value the install used (if any).", record_path.display(), paths::STATE_DIR_ENV )); return Ok(0); }; let link = record.link_record(); print_intent(&record, link.as_ref(), &layout, cli.purge); // Default **no**, because this is the one command that removes a running service and the // listing above is what the operator is being asked about — a defaulted-yes prompt on a // destructive action is answered by reflex rather than read. // // `--yes` is nevertheless a **yes** here, not "take the default". Everywhere else that flag // answers an offer the run made (the patch tier, a detected ServUO root), so taking the safe // default is right. Here the operator typed the destructive verb themselves; reading `--yes` as // "no" would leave an unattended uninstall with no way to express itself at all, and a script // that appeared to succeed while removing nothing is the worse of the two failures. let proceed = if cli.assume_yes { println!("Remove the components listed above? [y/N] (--yes)"); true } else { ui::confirm("Remove the components listed above?", false, false)? }; if !proceed { println!("\nNothing was removed."); return Ok(0); } // ── Remove what is exclusively ours ────────────────────────────────────── println!(); ui::heading("Removing"); let mut done: Vec = Vec::new(); let mut problems: Vec = Vec::new(); if let Some(link) = &link { if let Some(service_record) = &link.service { let removal = service::remove(service_record); done.extend(removal.done); problems.extend(removal.problems); } remove_file(Path::new(&link.binary.path), &mut done, &mut problems); if cli.purge { remove_file(Path::new(&link.config_path), &mut done, &mut problems); // The database is removed with its journal and WAL siblings; SQLite writes those beside // it, and leaving them behind would confuse the next install rather than protect // anything. for suffix in ["", "-journal", "-wal", "-shm"] { let path = PathBuf::from(format!("{}{suffix}", link.db_path)); if path.exists() { remove_file(&path, &mut done, &mut problems); } } } else { done.push(format!( "kept {} and {} (--purge removes them)", link.config_path, link.db_path )); } } // The report is written before the record is, because it is rendered *from* the record. let report = render_report(&record, link.as_ref(), &layout, cli.purge); let report_path = write_report(&report, &layout); if cli.purge { remove_dir(&layout.patches_dir(), &mut done, &mut problems); remove_dir(&layout.backups_dir(), &mut done, &mut problems); } else { if layout.patches_dir().exists() { done.push(format!( "kept {} — the cached patches and the pre-patch originals you need to revert by hand", layout.patches_dir().display() )); } // Same rule and the same reason as the patch cache: a backup is the only copy of what this // host had before an upgrade replaced it, and it outlives the deployment that took it. let backups = crate::backup::list(&layout); if !backups.is_empty() { done.push(format!( "kept {} — {} backup(s) of files earlier runs replaced", layout.backups_dir().display(), backups.len() )); } } remove_file(&record_path, &mut done, &mut problems); for line in &done { println!(" · {line}"); } for problem in &problems { println!(); ui::warn(problem); } // ── What only the operator can do ──────────────────────────────────────── print!("{report}"); match &report_path { Ok(path) => println!("This report is also saved at:\n {}\n", path.display()), Err(error) => ui::warn(&format!( "The report above could not be saved to a file ({error}) — copy it out of this \ terminal before you lose it." )), } // A step that could not be carried out is worth an exit code, for the same reason `doctor` has // one: the run itself succeeded, and only the shell knows whether anybody is reading the // output. Everything that *could* be removed still was. Ok(if problems.is_empty() { 0 } else { 1 }) } /// Says exactly what will happen, before asking. Nothing here touches the disk. fn print_intent( record: &InstallRecord, link: Option<&LinkRecord>, layout: &paths::Layout, purge: bool, ) { println!(); ui::heading("This will remove"); match link { Some(link) => { if let Some(service) = &link.service { println!(" · the {} service", service.name); if let (Some(user), true) = (service.user.as_deref(), service.user_created) { println!(" · the {user} account, which the installer created"); } } println!(" · {}", link.binary.path); if purge { println!(" · {} [--purge]", link.config_path); println!(" · {} [--purge]", link.db_path); } } None => println!(" · (no sidecar is recorded on this host)"), } println!(" · {}", layout.install_record().display()); if purge { println!(" · {} [--purge]", layout.patches_dir().display()); println!(" · {} [--purge]", layout.backups_dir().display()); } println!(); ui::heading("This will NOT touch"); println!(" · your ServUO tree — every deployed file is listed for you to delete"); println!( " · any patched stock file — the hunks to revert are printed with the rung each landed at" ); println!(" · your shard, which is neither stopped nor started"); if !purge { if let Some(link) = link { println!(" · {} (the auth token)", link.config_path); println!(" · {} (event history)", link.db_path); } println!( " · {} (cached patches and pre-patch originals)", layout.patches_dir().display() ); let backups = crate::backup::list(layout); if !backups.is_empty() { println!( " · {} ({} backup(s) of files earlier runs replaced)", layout.backups_dir().display(), backups.len() ); } } let _ = record; println!(); } /// The report: everything the operator has to finish by hand. fn render_report( record: &InstallRecord, link: Option<&LinkRecord>, layout: &paths::Layout, purge: bool, ) -> String { let mut out = String::new(); let _ = writeln!( out, "\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n", "", now_rfc3339(), env!("CARGO_PKG_VERSION"), "" ); let _ = writeln!( out, "ServUO tree {}{}", record.servuo.path, record .servuo .version .as_ref() .map(|v| format!(" ({v})")) .unwrap_or_default() ); if let Some(link) = link { // Stated flatly, with no claim about what this run managed to remove: the report is // rendered from the record and is about what is *left* to do. A line asserting "(removed)" // is a line that can be wrong — a binary locked by a still-running process is exactly the // case where it would be. let _ = writeln!(out, "uo-link {}", link.version); } render_overlay_section(&mut out, record); render_patch_section(&mut out, record, layout, purge); render_backup_section(&mut out, layout, purge); let _ = writeln!( out, "The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\ what you have changed in those files since they were deployed. Both lists above are\n\ yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n" ); out } /// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed. /// /// The flag is the point: an operator deleting this list file by file must not lose their own /// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those /// are. fn render_overlay_section(out: &mut String, record: &InstallRecord) { let Some(overlay) = &record.overlay else { return; }; let root = Path::new(&record.servuo.path); let _ = writeln!( out, "\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\ {} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n", overlay.files.len(), overlay.version ); for (rel, file) in &overlay.files { let path = patch::join(root, rel); let note = match util::sha256_file(&path) { Err(_) => " (already gone)", Ok(actual) if actual == file.on_disk_sha256 => "", Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting", }; let _ = writeln!(out, " {}{note}", path.display()); } let _ = writeln!( out, "\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n" ); } /// The exact hunks each applied patch added, rendered from the cached `.patch` files. /// /// Rendered rather than referenced: the release tarball is long gone by the time somebody reads /// this, and "apply the reverse of the patch" is not something an operator can do from a filename. /// The rung each hunk landed at is printed with it, because a `region-match` apply means the /// surrounding file was already the operator's and deserves a closer look than a stock-hash one. fn render_patch_section( out: &mut String, record: &InstallRecord, layout: &paths::Layout, purge: bool, ) { let features = record.patch_records(); if features.is_empty() { return; } let _ = writeln!( out, "\n── Stock ServUO files this installer patched ────────────────────────────────\n" ); if features.iter().any(|f| f.unsupported_servuo) { let _ = writeln!( out, " ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n", features .iter() .find_map(|f| f.servuo_version.clone()) .unwrap_or_else(|| "unknown".into()) ); } for feature in &features { let _ = writeln!(out, " Feature: {}", feature.feature); for applied in &feature.patches { let _ = writeln!( out, "\n {} → {}\n applied by: {}", applied.name, patch::join(Path::new(&record.servuo.path), &applied.target).display(), applied.rung ); match render_hunks(layout, &applied.name, &applied.sha256) { Some(text) => out.push_str(&text), None => { let _ = writeln!( out, " (the cached copy of this patch could not be read — the hunks it added \ start\n near line {})", applied .hunks .first() .map(|h| h.matched_line) .unwrap_or_default() ); } } } if !feature.companions.is_empty() { let _ = writeln!(out, "\n Companion files added by this feature:"); for companion in &feature.companions { let _ = writeln!( out, " {}", patch::join(Path::new(&record.servuo.path), &companion.path).display() ); } } let _ = writeln!(out); } let originals = layout.patch_originals_dir(); if purge { let _ = writeln!( out, " The pre-patch copies of these files were removed by --purge, so the lines above are\n\ the only record of what changed.\n" ); } else if originals.exists() { let _ = writeln!( out, " Each of those files as it was BEFORE the tier first touched it is kept here:\n \ {}\n Diff against it rather than reversing the hunks by eye — after a region-match \ apply the\n rest of the file was already yours.\n", originals.display() ); } } /// The backups earlier runs took, since this report is the durable record of what was left behind. /// /// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by /// the time this report is read the run that took it is long out of the scrollback. fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) { let backups = crate::backup::list(layout); if backups.is_empty() { return; } if purge { let _ = writeln!( out, " ── Backups ────────────────────────────────────────────────────────────────── {} backup(s) of files earlier runs replaced were removed by --purge. ", backups.len() ); return; } let _ = writeln!( out, " ── Backups ────────────────────────────────────────────────────────────────── Copies of the files earlier runs replaced, newest first. These are kept: " ); for dir in &backups { let count = crate::backup::read_manifest(dir) .map(|m| m.files.len()) .unwrap_or(0); let _ = writeln!(out, " {} ({} file(s))", dir.display(), count); } let _ = writeln!( out, " Each carries a manifest.json naming where every file came from. Restoring is yours to do — this tool will not put an old file back over a newer one. `--purge` removes them. " ); } /// Renders one cached patch's added and removed lines, indented for the report. fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option { let path = layout.patches_dir().join(format!("{name}.patch")); let bytes = std::fs::read(&path).ok()?; if !sha256.is_empty() && util::sha256_bytes(&bytes) != sha256 { // Not fatal — a re-run with a newer release can legitimately have replaced the cache — but // the operator should know the text below is not byte-for-byte what was applied. let parsed = crate::diff::parse(&bytes).ok()?; let file = parsed.single_file().ok()?; let mut out = String::from( " (the cached patch differs from the one recorded; showing the cached copy)\n", ); out.push_str(&hunk_text(file)); return Some(out); } let parsed = crate::diff::parse(&bytes).ok()?; Some(hunk_text(parsed.single_file().ok()?)) } fn hunk_text(file: &crate::diff::FilePatch) -> String { let mut out = String::new(); for hunk in &file.hunks { let _ = writeln!( out, " @@ around line {} @@", if hunk.old_start > 0 { hunk.old_start } else { 1 } ); for line in &hunk.lines { let (sign, bytes) = match line { HunkLine::Context(b) => (' ', b), HunkLine::Added(b) => ('+', b), HunkLine::Removed(b) => ('-', b), }; let _ = writeln!(out, " {sign}{}", String::from_utf8_lossy(bytes)); } } out } /// Writes the report where the operator ran the command, falling back to the state directory. /// /// The working directory is the one place they are certainly looking; `/etc/runicgateway` is being /// emptied by this very command, and a report inside a directory the operator has just been told is /// gone is a report nobody finds. fn write_report(report: &str, layout: &paths::Layout) -> Result { let name = format!( "runicgateway-uninstall-{}.txt", now_rfc3339().replace([':', '-'], "").replace('Z', "") ); let cwd = std::env::current_dir().unwrap_or_else(|_| layout.state_dir.clone()); let primary = cwd.join(&name); if util::write_atomic(&primary, report.as_bytes()).is_ok() { return Ok(primary); } let fallback = layout.state_dir.join(&name); util::write_atomic(&fallback, report.as_bytes()).with_context(|| { format!( "cannot write the uninstall report to {}", fallback.display() ) })?; Ok(fallback) } fn remove_file(path: &Path, done: &mut Vec, problems: &mut Vec) { match std::fs::remove_file(path) { Ok(()) => done.push(format!("removed {}", path.display())), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { done.push(format!("{} was already gone", path.display())) } Err(error) => { // A permission error on the sidecar binary is nearly always a running process holding // it, not an access-control problem: Windows locks a running executable, and a service // this command knows about was already stopped above. Saying so beats sending the // operator to look at ACLs. let hint = if error.kind() == std::io::ErrorKind::PermissionDenied { "\n If something is still running it — a sidecar started by hand, or a service \ this installer did not register — stop that first and delete the file." } else { "" }; problems.push(format!("cannot remove {}: {error}{hint}", path.display())) } } } fn remove_dir(path: &Path, done: &mut Vec, problems: &mut Vec) { if !path.exists() { return; } match std::fs::remove_dir_all(path) { Ok(()) => done.push(format!("removed {}", path.display())), Err(error) => problems.push(format!("cannot remove {}: {error}", path.display())), } } #[cfg(test)] mod tests { use super::*; use crate::patch::{AppliedPatch, FeatureRecord, Rebuild}; use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA}; use std::collections::BTreeMap; const PATCH: &[u8] = b"\ --- a/Scripts/Commands/Logging.cs +++ b/Scripts/Commands/Logging.cs @@ -10,3 +10,4 @@ public static class CommandLogging public static void WriteLine() { + BridgeModerationAudit.Raise(); } "; fn record(root: &Path) -> InstallRecord { InstallRecord { schema: SCHEMA, installer: InstallerInfo { version: "0.1.0".into(), }, updated: "2026-08-05T10:00:00Z".into(), bundle: BundleRef { tag: "2026.08.04".into(), protocol: 3, url: "https://example/current.json".into(), }, servuo: ServUoRef { path: root.display().to_string(), version: Some("57.4".into()), }, overlay: Some(OverlayRecord { repo: "RunicGateway/servuo-plugins".into(), tag: "v0.1.1".into(), version: "0.1.1".into(), commit: "3a52abb".into(), protocol: 3, files: BTreeMap::from([ ( "Scripts/Custom/Bridge/BridgeLink.cs".to_string(), FileRecord { overlay_sha256: util::sha256_bytes(b"deployed"), on_disk_sha256: util::sha256_bytes(b"deployed"), state: "deployed".into(), }, ), ( "Config/Bridge.cfg".to_string(), FileRecord { overlay_sha256: util::sha256_bytes(b"shipped"), on_disk_sha256: util::sha256_bytes(b"mine"), state: "kept-operator-modified".into(), }, ), ]), }), link: None, patches: vec![serde_json::to_value(FeatureRecord { feature: "moderation-audit".into(), rebuild: Rebuild::Scripts, servuo_version: Some("57.4".into()), unsupported_servuo: false, patches: vec![AppliedPatch { name: "commandlogging-event".into(), target: "Scripts/Commands/Logging.cs".into(), rung: "region-match".into(), sha256: util::sha256_bytes(PATCH), hunks: Vec::new(), }], companions: Vec::new(), }) .unwrap()], extra: BTreeMap::new(), } } fn layout_in(dir: &Path) -> paths::Layout { paths::Layout { state_dir: dir.to_path_buf(), data_dir: dir.join("data"), sidecar_bin: dir.join("bin").join("uo-link-sidecar"), rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"), relocated: true, } } #[test] fn the_report_lists_every_overlay_file_and_flags_the_edited_ones() { let dir = util::TempDir::new("rg-test-uninstall").unwrap(); let root = dir.path().join("ServUO"); std::fs::create_dir_all(root.join("Scripts/Custom/Bridge")).unwrap(); std::fs::create_dir_all(root.join("Config")).unwrap(); std::fs::write( root.join("Scripts/Custom/Bridge/BridgeLink.cs"), b"deployed", ) .unwrap(); // Edited after deployment: the operator must be warned before deleting this one. std::fs::write(root.join("Config/Bridge.cfg"), b"changed again").unwrap(); let layout = layout_in(dir.path()); let record = record(&root); let report = render_report(&record, None, &layout, false); assert!(report.contains("BridgeLink.cs"), "{report}"); assert!( report.contains("EDITED SINCE DEPLOYMENT"), "the edited Bridge.cfg must be flagged:\n{report}" ); } #[test] fn the_report_renders_the_hunks_from_the_cached_patch() { // The whole reason the tier caches its patches: this text has to be produceable long after // the release tarball is gone. let dir = util::TempDir::new("rg-test-uninstall-hunks").unwrap(); let layout = layout_in(dir.path()); std::fs::create_dir_all(layout.patches_dir()).unwrap(); std::fs::write( layout.patches_dir().join("commandlogging-event.patch"), PATCH, ) .unwrap(); let report = render_report(&record(&dir.path().join("ServUO")), None, &layout, false); assert!(report.contains("commandlogging-event"), "{report}"); assert!( report.contains("+ BridgeModerationAudit.Raise();"), "the added line must appear verbatim:\n{report}" ); assert!(report.contains("region-match"), "{report}"); } #[test] fn a_missing_patch_cache_degrades_to_a_line_number() { // --purge on an earlier run, or a hand-cleaned /etc: the report still has to say something // useful rather than claim there was nothing to revert. let dir = util::TempDir::new("rg-test-uninstall-nocache").unwrap(); let report = render_report( &record(&dir.path().join("ServUO")), None, &layout_in(dir.path()), false, ); assert!(report.contains("could not be read"), "{report}"); assert!(report.contains("commandlogging-event"), "{report}"); } #[test] fn the_report_is_written_where_the_operator_is_standing() { let dir = util::TempDir::new("rg-test-uninstall-report").unwrap(); let path = write_report("hello", &layout_in(dir.path())).unwrap(); assert!(path.is_file()); assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); assert!( path.file_name() .unwrap() .to_string_lossy() .starts_with("runicgateway-uninstall-"), "{path:?}" ); let _ = std::fs::remove_file(path); } }