mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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:
1 parent
3c5c6054df
commit
39c446a164
10 files changed
+1588
-87
No files matched your search
Generated
+1
@@ -1258,6 +1258,7 @@ dependencies = [
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -5,6 +5,7 @@ use serde::Deserialize;
|
||||
|
||||
pub mod check;
|
||||
pub mod install;
|
||||
pub mod installs;
|
||||
pub mod launch;
|
||||
pub mod tls;
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user