feat(doctor): one installation inventory; doctor --installs, --fix tidy, --restore

New codewhale_release::installs classifies every codewhale, codew,
codewhale-tui, deepseek and deepseek-tui in PATH, the running binary's
directory, ~/.local/bin and ~/.cargo/bin as receipt-owned, unreceipted
Codewhale, backup leftover (nine backup naming schemes, stale
.codewhale-backup-*/-update-* entries), package-managed (exact manager
command; Cargo bins only when .crates.toml lists them), desktop app, or
not ours (symlinks, other users, other programs such as codewhale-go).
Nothing found is executed: ownership is name grammar plus native magic
plus a compiled-in marker string, read with a bounded scan.

- `codewhale doctor` gains an Installations section; `doctor --installs`
  (`--json`, `--brief`) prints only that, before config loads.
- `doctor --fix` adds MoveInstallLeftover, AlignAlias, AdoptInstall and
  PruneRecovery to the one existing plan. Moves go to
  ~/.codewhale/recovery/<UTC stamp>/ with a manifest, each printing its
  undo; recovery folders older than 30 days are deleted by a later --fix.
  The plan prompt is now [Y/n] (EOF answers no).
- `doctor --restore [STAMP]` moves a folder back; an occupant at the
  original path is itself moved into a new recovery folder.
- `uninstall` lists other copies through the inventory; its private
  manager_command moved to installs::manager_command (report_path_copies
  replaced).

Receipt adoption refuses a group- or world-writable directory, where
`uninstall`'s ownership checks would later reject the receipt.

Verified on Linux Boat vm16 (debug build), temp HOMEs only:
- this commit's tree: cargo check -p codewhale-cli -p codewhale-tui clean;
  the full series: cargo build -p codewhale-cli and
  cargo check --workspace --all-targets --locked with 0 errors and
  0 warnings; codewhale-release tests 57 passed; feature_registry 2 passed.
- fixture mirroring the founder's machine (7 backup schemes plus
  .codewhale-old-pid, a stale .codewhale-backup-* dir, a divergent codew,
  codewhale-go, a symlink, a Cargo root listing codew/codewhale with a
  stray backup, a Homebrew Cellar symlink, a marker-bearing script
  sentinel): `doctor --installs` and `--json` classify every entry as
  designed; `doctor --fix --yes` moved 9 items (6.2 GB) into one recovery
  folder with per-item undo lines, aligned codew and wrote the receipt; a
  second --fix plans no installation work; package-managed, foreign and
  symlink entries hash-identical before/after; the sentinel never ran;
  `doctor --restore` put all 9 back byte-identical.
Not run here: the tui doctor_fix unit tests (the VM ran out of memory
linking the tui test binary).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 20:15:10 -07:00
1 parent 3c5c6054df
commit 39c446a164
10 files changed
+1588 -87

No files matched your search

Generated
+1
View File
@@ -1258,6 +1258,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2 0.11.1",
"tempfile",
"webpki-roots",
]
+15 -75
View File
@@ -2,12 +2,9 @@ use super::*;
use codewhale_tui::delivery_files::GuardedFile;
use std::collections::BTreeMap;
const RECEIPT: &str = ".codewhale-install.sha256";
const RECEIPT: &str = codewhale_release::installs::RECEIPT_FILE;
const MAX_RECEIPT: usize = 512;
#[cfg(windows)]
const COMMANDS: &[&str] = &["codewhale.exe", "codew.exe", "codewhale.bat"];
#[cfg(not(windows))]
const COMMANDS: &[&str] = &["codewhale", "codew"];
pub(super) const COMMANDS: &[&str] = codewhale_release::installs::RECEIPT_COMMANDS;
pub(super) struct CommandReceipt {
directory: PathBuf,
@@ -139,7 +136,7 @@ fn remove(yes: bool) -> Result<()> {
println!(
"This copy belongs to {}. Remove it with: {}",
method.label(),
manager_command(method, &identity.path)
codewhale_release::installs::manager_command(method, &identity.path)
);
return Ok(());
}
@@ -339,44 +336,6 @@ fn remove(yes: bool) -> Result<()> {
Ok(())
}
fn quoted(path: &Path) -> String {
format!("\"{}\"", path.display())
}
fn manager_command(method: InstallMethod, executable: &Path) -> String {
let real = std::fs::canonicalize(executable).unwrap_or_else(|_| executable.to_path_buf());
let names: Vec<_> = real.components().map(|part| part.as_os_str()).collect();
let position = |marker: &str| {
names
.iter()
.position(|name| name.eq_ignore_ascii_case(marker))
};
let prefix = |count: usize| names[..count].iter().collect::<PathBuf>();
match method {
InstallMethod::Cargo => match real.parent().and_then(Path::parent) {
Some(root) => format!("cargo uninstall codewhale-cli --root {}", quoted(root)),
None => method.uninstall_command().to_string(),
},
InstallMethod::Homebrew => {
match position("Cellar").and_then(|index| names.get(index + 1)) {
Some(formula) => format!("brew uninstall {}", formula.to_string_lossy()),
None => method.uninstall_command().to_string(),
}
}
InstallMethod::Npm => match position("node_modules") {
Some(index) if index > 0 => {
let mut root = prefix(index);
if !cfg!(windows) && root.file_name().is_some_and(|name| name == "lib") {
root.pop();
}
format!("npm uninstall -g codewhale --prefix {}", quoted(&root))
}
_ => method.uninstall_command().to_string(),
},
_ => method.uninstall_command().to_string(),
}
}
fn curl_install_entries(
identity: &UpdateExecutableIdentity,
) -> Option<(PathBuf, Vec<(String, String)>)> {
@@ -535,37 +494,18 @@ fn purge(yes: bool) -> Result<()> {
}
fn report_path_copies(selected: Option<&Path>) {
let extensions: &[&str] = if cfg!(windows) {
&[".exe", ".cmd", ".bat", ".ps1"]
} else {
&[""]
};
let mut seen = std::collections::HashSet::new();
let mut remaining = false;
for directory in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
for name in [
"codewhale",
"codew",
"codewhale-tui",
"deepseek",
"deepseek-tui",
] {
for extension in extensions {
let path = directory.join(format!("{name}{extension}"));
if Some(path.as_path()) == selected {
continue;
}
if seen.insert(path.clone()) && std::fs::symlink_metadata(&path).is_ok() {
remaining = true;
println!(
"Another copy remains on PATH (preserved): {}. Run it with uninstall to see who owns it and how to remove it.",
path.display()
);
}
}
}
}
if !remaining {
let inventory = codewhale_release::installs::inventory(selected);
let lines = inventory.summary_lines(false, env!("CARGO_PKG_VERSION"));
if inventory
.entries
.iter()
.all(|entry| Some(entry.path.as_path()) == selected)
{
println!("No other Codewhale command remains on PATH.");
return;
}
println!("Other Codewhale copies on this machine (not changed by uninstall):");
for line in lines {
println!(" {line}");
}
}
+1
View File
@@ -17,6 +17,7 @@ rustls.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
[target.'cfg(target_os = "android")'.dependencies]
File diff suppressed because it is too large. Load diff
+1
View File
@@ -5,6 +5,7 @@ use serde::Deserialize;
pub mod check;
pub mod install;
pub mod installs;
pub mod launch;
pub mod tls;
+109
View File
@@ -625,3 +625,112 @@ fn ds4_probe_error(config: &codewhale_runtime::config::Config, error: &str) -> S
#[cfg(test)]
#[path = "doctor/tests.rs"]
mod tests;
pub(crate) fn installation_lines(brief: bool) -> Vec<String> {
let running = std::env::current_exe().ok();
let inventory = codewhale_release::installs::inventory(running.as_deref());
let mut lines = inventory.summary_lines(brief, env!("CARGO_PKG_VERSION"));
if let Ok(home) = codewhale_config::codewhale_home() {
let now = codewhale_release::check::now_unix();
for folder in codewhale_release::installs::recovery_folders(&home) {
if folder.records.is_empty() {
continue;
}
lines.push(format!(
"Recovery {}: {} item(s), {}, deleted in {} day(s). Undo: codewhale doctor --restore {}",
folder.stamp,
folder.records.len(),
codewhale_release::installs::human_bytes(folder.bytes()),
folder.expires_in_days(now).unwrap_or(0),
folder.stamp
));
}
}
lines
}
pub(crate) fn run_installs_report(json: bool, brief: bool) -> Result<()> {
if !json {
for line in installation_lines(brief) {
println!("{line}");
}
return Ok(());
}
let running = std::env::current_exe().ok();
let inventory = codewhale_release::installs::inventory(running.as_deref());
let rows: Vec<_> = inventory
.entries
.iter()
.map(|entry| {
serde_json::json!({
"path": entry.path,
"class": entry.class.label(),
"reason": match &entry.class {
codewhale_release::installs::InstallClass::NotOurs(reason) => Some(*reason),
_ => None,
},
"owner": match &entry.class {
codewhale_release::installs::InstallClass::PackageManaged(method) => Some(method.label()),
_ => None,
},
"owner_command": entry.owner_command(),
"is_dir": entry.is_dir,
"bytes": entry.bytes,
"active": entry.active,
"running": Some(entry.path.as_path()) == running.as_deref(),
"same_as_running": entry.same_as_running,
})
})
.collect();
let recovery: Vec<_> = codewhale_config::codewhale_home()
.map(|home| codewhale_release::installs::recovery_folders(&home))
.unwrap_or_default()
.into_iter()
.map(|folder| {
serde_json::json!({
"stamp": folder.stamp,
"path": folder.dir,
"items": folder.records.len(),
"bytes": folder.bytes(),
"expires_in_days": folder.expires_in_days(codewhale_release::check::now_unix()),
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"installations": rows,
"leftover_bytes": inventory.leftover_bytes(),
"summary": inventory.summary_lines(brief, env!("CARGO_PKG_VERSION")),
"recovery": recovery,
}))?
);
Ok(())
}
pub(crate) fn run_restore(stamp: &str) -> Result<()> {
let home = codewhale_config::codewhale_home()?;
let stamp = (stamp != "latest").then_some(stamp);
let (stamp, report) = codewhale_release::installs::restore(&home, stamp)?;
for path in &report.restored {
println!("Restored {}", path.display());
}
for (path, reason) in &report.skipped {
println!("Kept in recovery: {} ({reason})", path.display());
}
if let Some(displaced) = &report.displaced {
println!(
"The files that were in those places are kept in recovery {displaced}. Undo: codewhale doctor --restore {displaced}"
);
}
println!(
"Restored {} of {} item(s) from recovery {stamp}.",
report.restored.len(),
report.restored.len() + report.skipped.len()
);
if !report.skipped.is_empty() {
anyhow::bail!("some items could not be restored; they stay in the recovery folder");
}
Ok(())
}
+237 -11
View File
@@ -22,7 +22,7 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use anyhow::{Context, Result};
use crate::McpServerDoctorStatus;
use crate::mcp;
@@ -33,17 +33,41 @@ pub(crate) enum DoctorFixAction {
/// Delete an interrupted-atomic-write leftover. Only ever a regular file
/// whose name starts with `.tmp` directly inside the Codewhale home and
/// whose last modification was more than an hour ago.
DeleteStaleTempFile { path: PathBuf },
DeleteStaleTempFile {
path: PathBuf,
},
/// Restrict a secret-store file to owner-only on Unix.
#[cfg(unix)]
TightenSecretPermissions { path: PathBuf, from_mode: u32 },
TightenSecretPermissions {
path: PathBuf,
from_mode: u32,
},
/// Disable a structurally broken MCP entry by name in the given config.
DisableMcpServer {
config_path: PathBuf,
server: String,
},
/// Create a missing user-global discovery directory.
ScaffoldDirectory { path: PathBuf },
ScaffoldDirectory {
path: PathBuf,
},
MoveInstallLeftover {
path: PathBuf,
bytes: u64,
},
AlignAlias {
alias: PathBuf,
primary: PathBuf,
sha256: String,
},
AdoptInstall {
dir: PathBuf,
sha256: String,
},
PruneRecovery {
stamps: Vec<String>,
bytes: u64,
},
}
/// The full repair plan for one doctor run.
@@ -91,6 +115,29 @@ impl DoctorFixPlan {
codewhale_util::utils::display_path(path)
)
}
DoctorFixAction::MoveInstallLeftover { path, bytes } => format!(
"move old Codewhale backup {} ({}) to the recovery folder (kept {} days)",
codewhale_util::utils::display_path(path),
codewhale_release::installs::human_bytes(*bytes),
codewhale_release::installs::RECOVERY_RETENTION_DAYS
),
DoctorFixAction::AlignAlias { alias, primary, .. } => format!(
"make {} match {} (v{}); the old copy goes to the recovery folder",
codewhale_util::utils::display_path(alias),
codewhale_util::utils::display_path(primary),
env!("CARGO_PKG_VERSION")
),
DoctorFixAction::AdoptInstall { dir, .. } => format!(
"record an install receipt in {} so `codewhale update` and `codewhale uninstall` own it",
codewhale_util::utils::display_path(dir)
),
DoctorFixAction::PruneRecovery { stamps, bytes } => format!(
"delete {} recovery folder(s) older than {} days ({}): {}",
stamps.len(),
codewhale_release::installs::RECOVERY_RETENTION_DAYS,
codewhale_release::installs::human_bytes(*bytes),
stamps.join(", ")
),
})
.collect()
}
@@ -229,9 +276,68 @@ pub(crate) fn plan_fixes(
.into_iter()
.map(|path| DoctorFixAction::ScaffoldDirectory { path }),
);
actions.extend(install_actions());
DoctorFixPlan { actions }
}
fn install_actions() -> Vec<DoctorFixAction> {
use codewhale_release::installs::{self, InstallClass};
let running = std::env::current_exe().ok();
let inventory = installs::inventory(running.as_deref());
let mut actions: Vec<DoctorFixAction> = inventory
.leftovers()
.map(|entry| DoctorFixAction::MoveInstallLeftover {
path: entry.path.clone(),
bytes: entry.bytes,
})
.collect();
let canonical =
|path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if let Some(exe) = &running
&& codewhale_release::InstallMethod::detect(exe).supports_self_update()
&& let Some(dir) = exe.parent().map(canonical)
&& std::fs::symlink_metadata(dir.join(installs::RECEIPT_FILE)).is_err()
&& let Some(entry) = inventory
.entries
.iter()
.find(|entry| canonical(&entry.path) == canonical(exe))
&& entry.class == InstallClass::OursUnreceipted
&& let Ok(sha256) = installs::file_sha256(exe)
{
for alias in inventory.entries.iter().filter(|other| {
other.class == InstallClass::OursUnreceipted
&& !other.same_as_running
&& other.path.parent().map(canonical).as_deref() == Some(dir.as_path())
&& installs::RECEIPT_COMMANDS
.iter()
.any(|name| other.path.file_name().and_then(|n| n.to_str()) == Some(name))
}) {
actions.push(DoctorFixAction::AlignAlias {
alias: alias.path.clone(),
primary: exe.clone(),
sha256: sha256.clone(),
});
}
if installs::directory_is_private(&dir) {
actions.push(DoctorFixAction::AdoptInstall { dir, sha256 });
}
}
if let Ok(home) = codewhale_config::codewhale_home() {
let now = codewhale_release::check::now_unix();
let expired: Vec<_> = installs::recovery_folders(&home)
.into_iter()
.filter(|folder| folder.expires_in_days(now) == Some(0))
.collect();
if !expired.is_empty() {
actions.push(DoctorFixAction::PruneRecovery {
bytes: expired.iter().map(|folder| folder.bytes()).sum(),
stamps: expired.into_iter().map(|folder| folder.stamp).collect(),
});
}
}
actions
}
/// Outcome of applying one action.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DoctorFixOutcome {
@@ -242,14 +348,127 @@ pub(crate) enum DoctorFixOutcome {
/// Apply a plan. Each action reports its own outcome; one failure never
/// blocks the rest. Actions are idempotent, so a re-run converges.
pub(crate) fn apply_fixes(plan: &DoctorFixPlan) -> Vec<(DoctorFixAction, DoctorFixOutcome)> {
plan.actions
let mut batch = None;
let results = plan
.actions
.iter()
.cloned()
.map(|action| {
let outcome = apply_one(&action);
let outcome = match apply_install_action(&action, &mut batch) {
Some(Ok(())) => DoctorFixOutcome::Applied,
Some(Err(error)) => DoctorFixOutcome::Failed(format!("{error:#}")),
None => apply_one(&action),
};
(action, outcome)
})
.collect()
.collect();
if let Some(batch) = batch {
let batch: codewhale_release::installs::RecoveryBatch = batch;
let stamp = batch.stamp.clone();
let dir = batch.dir.clone();
let moves = batch.finish();
if !moves.is_empty() {
println!(
"Moved {} item(s) ({}) to {}. Undo all: codewhale doctor --restore {stamp}",
moves.len(),
codewhale_release::installs::human_bytes(moves.iter().map(|m| m.bytes).sum()),
dir.display()
);
}
}
results
}
fn recovery_batch(
batch: &mut Option<codewhale_release::installs::RecoveryBatch>,
) -> Result<&mut codewhale_release::installs::RecoveryBatch> {
if batch.is_none() {
let home = codewhale_config::codewhale_home()?;
*batch = Some(codewhale_release::installs::RecoveryBatch::open(&home)?);
}
Ok(batch.as_mut().expect("recovery batch was just opened"))
}
fn apply_install_action(
action: &DoctorFixAction,
batch: &mut Option<codewhale_release::installs::RecoveryBatch>,
) -> Option<Result<()>> {
use codewhale_release::installs;
Some(match action {
DoctorFixAction::MoveInstallLeftover { path, .. } => (|| {
let meta = std::fs::symlink_metadata(path)?;
anyhow::ensure!(!meta.is_symlink(), "it became a symlink; not moved");
let moved = recovery_batch(batch)?.move_in(path)?;
println!(
" moved {} -> {}\n undo: {}",
path.display(),
moved.to.display(),
moved.undo_command()
);
Ok(())
})(),
DoctorFixAction::AlignAlias {
alias,
primary,
sha256,
} => (|| {
let bytes = std::fs::read(primary)?;
anyhow::ensure!(
&installs::sha256_hex(&bytes) == sha256,
"{} changed since the plan; not aligned",
primary.display()
);
let dir = alias.parent().context("alias has no parent")?;
let name = alias
.file_name()
.and_then(|name| name.to_str())
.context("alias name is not UTF-8")?;
let moved = recovery_batch(batch)?.move_in(alias)?;
if let Err(error) =
codewhale_release::installs::write_file_atomically(dir, name, &bytes, true)
{
let _ = std::fs::rename(&moved.to, alias);
return Err(error);
}
println!(
" replaced {} with v{}; old copy at {}\n undo: rm {} && {}",
alias.display(),
env!("CARGO_PKG_VERSION"),
moved.to.display(),
alias.display(),
moved.undo_command()
);
Ok(())
})(),
DoctorFixAction::AdoptInstall { dir, sha256 } => (|| {
let entries: Vec<(String, String)> = installs::RECEIPT_COMMANDS
.iter()
.filter(|name| {
installs::file_sha256(&dir.join(name)).is_ok_and(|hash| &hash == sha256)
})
.map(|name| ((*name).to_string(), sha256.clone()))
.collect();
if installs::adopt_receipt(dir, &entries)? {
println!(
" recorded {}\n undo: rm {}",
dir.join(installs::RECEIPT_FILE).display(),
dir.join(installs::RECEIPT_FILE).display()
);
}
Ok(())
})(),
DoctorFixAction::PruneRecovery { .. } => (|| {
let home = codewhale_config::codewhale_home()?;
for (stamp, bytes) in installs::prune_expired(&home) {
println!(
" deleted expired recovery folder {stamp} ({})",
installs::human_bytes(bytes)
);
}
Ok(())
})(),
_ => return None,
})
}
fn apply_one(action: &DoctorFixAction) -> DoctorFixOutcome {
@@ -284,6 +503,10 @@ fn apply_one(action: &DoctorFixAction) -> DoctorFixOutcome {
Ok(()) => DoctorFixOutcome::Applied,
Err(error) => DoctorFixOutcome::Failed(error.to_string()),
},
DoctorFixAction::MoveInstallLeftover { .. }
| DoctorFixAction::AlignAlias { .. }
| DoctorFixAction::AdoptInstall { .. }
| DoctorFixAction::PruneRecovery { .. } => DoctorFixOutcome::Applied,
}
}
@@ -325,13 +548,16 @@ pub(crate) fn confirm_fix(plan: &DoctorFixPlan) -> bool {
use std::io::{BufRead, Write};
println!();
println!("Apply these {} repair(s) now? [y/N] ", plan.len());
print!("Apply these {} repair(s) now? [Y/n] ", plan.len());
let mut answer = String::new();
let _ = std::io::stdout().flush();
if std::io::stdin().lock().read_line(&mut answer).is_err() {
return false;
match std::io::stdin().lock().read_line(&mut answer) {
Ok(0) | Err(_) => false,
Ok(_) => matches!(
answer.trim().to_ascii_lowercase().as_str(),
"" | "y" | "yes"
),
}
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
/// Print apply results and return whether every action succeeded.
+31
View File
@@ -1284,6 +1284,21 @@ struct DoctorArgs {
/// With --repair-sessions: report what would be repaired, change nothing
#[arg(long, default_value_t = false, requires = "repair_sessions")]
dry_run: bool,
/// Report every Codewhale copy on this machine and who owns it (offline; nothing is run or changed)
#[arg(long, default_value_t = false, conflicts_with_all = ["context_json", "repair_sessions", "fix"])]
installs: bool,
/// With --installs: print only what needs attention, nothing on a tidy machine
#[arg(long, default_value_t = false, requires = "installs")]
brief: bool,
/// Move everything from a recovery folder back where it was (the newest when STAMP is omitted)
#[arg(
long,
value_name = "STAMP",
num_args = 0..=1,
default_missing_value = "latest",
conflicts_with_all = ["json", "context_json", "fix", "installs", "repair_sessions"]
)]
restore: Option<String>,
}
#[derive(Args, Debug, Clone)]
@@ -2528,6 +2543,12 @@ async fn run_async_main_dispatch(
if let Some(command) = command {
return match command {
Commands::Doctor(args) => {
if let Some(stamp) = &args.restore {
return crate::doctor::run_restore(stamp);
}
if args.installs {
return crate::doctor::run_installs_report(args.json, args.brief);
}
let config = match load_doctor_config_from_cli(&cli, &args) {
Ok(config) => config,
Err(error) if args.json => return run_doctor_json_config_error(&error),
@@ -5030,6 +5051,16 @@ async fn run_doctor(
crate::doctor::print_update_report(probes).await;
println!();
println!("{}", "Installations:".bold());
let installation_lines =
tokio::task::spawn_blocking(|| crate::doctor::installation_lines(false))
.await
.unwrap_or_default();
for line in installation_lines {
println!(" {line}");
}
println!();
// Configuration summary
let doctor_paths = match crate::doctor::DoctorPathReport::resolve(config_path_override) {
Ok(paths) => paths,
+10
View File
@@ -46,6 +46,16 @@ since = "unreleased"
docs = "docs/INSTALL.md"
owner = "crates/cli/src/update/uninstall.rs"
[[feature]]
id = "install-cleanup"
name = "Installation cleanup"
summary = "doctor --installs lists every copy and owner; doctor --fix moves old backups to recovery; doctor --restore undoes it."
status = "stable"
surfaces = { tui = "stable" }
since = "unreleased"
docs = "docs/INSTALL.md#clean-up-old-copies"
owner = "crates/release/src/installs.rs"
[[feature]]
id = "cloud-computer-dispatch"
name = "Cloud computer conversations"
+1 -1
View File
@@ -204,7 +204,7 @@
"std_fs": 1
},
"crates/tui/src/doctor_fix.rs": {
"std_fs": 6
"std_fs": 11
},
"crates/tui/src/extension_host/mod.rs": {
"std_fs": 7