feat(uninstall): Windows receipts, package-manager commands and --purge-data (Refs #7091, CW-7525)

`codewhale uninstall` now covers every supported install route:

- Windows archive install: install.ps1 writes the
  .codewhale-install.sha256 receipt, which joins its existing rollback.
  Uninstall removes codewhale.exe, codew.exe and codewhale.bat by
  receipt. The running exe is renamed through its open handle
  (NtSetInformationFile) and marked delete-on-close; if that fails, a
  detached powershell deletes it after the process exits. Self-update
  re-captures and renews the receipt after replacing the exe, because a
  held handle would block the MoveFileExW replace.
- Package-managed copies are never deleted. They print the exact
  command: npm (with --prefix), cargo (with --root), the Homebrew
  formula from Cellar, scoop, winget, or the NSIS Uninstall.exe.
  InstallMethod gains Scoop and WinGet detection.
- Every other codewhale/codew still on PATH is listed as preserved.
- --purge-data previews, then deletes with --yes, only ~/.codewhale
  (or an explicit CODEWHALE_HOME named .codewhale) and
  ~/.deepseek/snapshots. It refuses symlinks, home and its ancestors,
  and other users' directories.

Two pre-existing bugs fixed along the way:
- Unix: remove_checked's retirement tempdir inherited the umask (0755),
  so unlink_opened refused it and left `codew` stranded in
  .codewhale-uninstall-*. It is now created 0700.
- Windows: codewhale-util (from the S14 split) did not build. It lacked
  the `windows` dependency and referred to itself as codewhale_util::.

Verified:
- macOS arm64 temp-HOME script against install.sh with a file:// release
  (binary bdd94d1bcfbd): 36 passed, 0 failed. It covers preview,
  --yes, a repeat no-op, reinstall and re-removal; two receipted copies;
  a tampered alias refused; a manual copy preserved; the cargo,
  cargo-root, npm, brew, brew-legacy, scoop, winget and nsis layouts
  printing their exact command; and purge preview, --yes, a symlink
  refusal and a CODEWHALE_HOME=HOME refusal.
- check -p codewhale-cli --target x86_64-pc-windows-msvc passes,
  using stand-in CC/AR because there is no Windows SDK; check never
  links.
- cargo test -p codewhale-release: 57 passed, 0 failed.
- cargo test -p codewhale-tui --test feature_registry: 2 passed.

Not verified: nothing ran on a Windows host. install.ps1 was not
executed (no pwsh). Scoop, WinGet and NSIS are detection-only checks
against synthetic layouts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 07:18:03 -07:00
1 parent 3824cdea0c
commit e66998bec3
15 files changed
+386 -61

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Image-heavy session recovery:** ordinary image reads use a 256 KiB delivery budget. Requests measure serialized bytes, omit older images from an outbound copy with receipts, and retry an initial HTTP 413 once only with a smaller body. Saved images and executed tool actions remain intact. (CW-7550)
- **Unix uninstall:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers record command ownership; self-update renews existing receipts. User data, modified/unreceipted files and package-managed installations are preserved. Windows removal remains pending. (Refs #7091)
- **Uninstall on every install route:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers and the Windows archive installer record command ownership; self-update renews existing receipts on both. On Windows the running `codewhale.exe` is renamed and deleted once the command exits. Package-managed copies are left alone, and the command prints their exact removal command: npm with its prefix, Cargo with its root, the Homebrew formula, `scoop uninstall codewhale`, `winget uninstall --id HunterBown.CodeWhale`, or the Windows setup's `Uninstall.exe`. Every other `codewhale`/`codew` still on PATH is listed. User data is kept unless you add `--purge-data`, which previews and then deletes only `~/.codewhale` (or a `CODEWHALE_HOME` named `.codewhale`) and `~/.deepseek/snapshots`, refusing symlinks, home ancestors and directories owned by another user. Windows removal is compile-checked but not yet exercised on a Windows host. (Refs #7091)
- **GitHub Action reviews:** analysis can export a structured Engine review for a separate trusted publisher job. The publisher checks the current PR revision and line anchors, deduplicates its findings, and keeps uncertain or preexisting claims out of inline comments.
- **Telegram recovery:** prompts and their initiating identity are durable before the polling cursor advances. Restart recovery reuses the original Engine operation, questions preserve human ownership, and uncertain final replies remain available through `/status` without an automatic resend.
Generated
+1
View File
@@ -1521,6 +1521,7 @@ dependencies = [
"tokio",
"tracing",
"uuid",
"windows",
"windows-sys 0.61.2",
]
+14 -7
View File
@@ -465,13 +465,20 @@ The command prints the completion script to stdout; redirect it to a path your s
after_help = "GitHub Releases is the default source. Supported mirrors are explicit overrides or manifest-failure fallbacks. Checksums are required; older releases never replace a newer build.\n\nThe command prints the executable it will update. If you have multiple installs, run the intended binary by its full path.\n\nNew macOS/Linux install: curl -fsSL https://codewhale.net/install.sh | sh\nInstallation and PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md"
)]
Update(UpdateArgs),
#[command(about = "Remove receipt-owned Unix release commands; preserve all user data")]
#[command(
about = "Remove this receipt-owned release install, or print its package manager's removal command; user data is kept unless --purge-data"
)]
Uninstall {
#[arg(
long,
help = "Remove the preflighted files; without this flag, print the plan"
)]
yes: bool,
#[arg(
long,
help = "Also delete Codewhale's own data directory (config, secrets file, sessions); previewed unless --yes"
)]
purge_data: bool,
},
/// Export the route catalog (`providers export --json`).
Providers(ProvidersArgs),
@@ -2290,14 +2297,14 @@ fn run() -> Result<()> {
|| matches!(cli.command.as_ref(), Some(Commands::AppServer(args)) if args.http),
"An inherited Runtime listener requires serve --http or app-server --http"
);
if let Some(Commands::Uninstall { yes }) = cli.command.as_ref() {
#[cfg(all(unix, not(target_env = "ohos")))]
return update::uninstall::run(*yes);
#[cfg(any(not(unix), target_env = "ohos"))]
if let Some(Commands::Uninstall { yes, purge_data }) = cli.command.as_ref() {
#[cfg(any(all(unix, not(target_env = "ohos")), windows))]
return update::uninstall::run(*yes, *purge_data);
#[cfg(not(any(all(unix, not(target_env = "ohos")), windows)))]
{
let _ = yes;
let _ = (yes, purge_data);
bail!(
"Automatic uninstall is currently supported only for receipt-owned macOS/Linux release installs. Use the installer or package manager that owns this copy; user data is preserved."
"Automatic uninstall is supported for receipt-owned macOS, Linux and Windows release installs. Use the installer or package manager that owns this copy; user data is preserved."
);
}
}
+17 -3
View File
@@ -29,7 +29,7 @@ use std::time::Duration;
use crate::hashing::sha256_hex;
mod compiled_host;
#[cfg(unix)]
#[cfg(any(unix, windows))]
pub(crate) mod uninstall;
const GITHUB_LATEST_RELEASE_PAGE_URL: &str =
@@ -209,6 +209,8 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
// compatibility path can change, then the identity is checked just in time.
#[cfg(unix)]
let mut command_receipt = uninstall::CommandReceipt::capture(&current_exe)?;
#[cfg(windows)]
let had_receipt = uninstall::CommandReceipt::capture(&current_exe)?.is_some();
let mut host_update = compiled_host::prepare(
&download,
latest_tag,
@@ -231,12 +233,24 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
.as_mut()
.map(|receipt| receipt.renew(&bytes))
.transpose();
#[cfg(windows)]
let receipt_renewal = if had_receipt {
uninstall::CommandReceipt::capture_accepting(&current_exe, Some(&sha256_hex(&bytes)))
.and_then(|receipt| {
receipt
.context("command ownership receipt disappeared during update")?
.renew(&bytes)
})
.map(Some)
} else {
Ok(None)
};
if let Err(error) = replaced {
if let Some(host) = &mut host_update {
host.rollback()
.context("compiled-host rollback failed; retained backup path is reported below")?;
}
#[cfg(unix)]
#[cfg(any(unix, windows))]
if let Err(renewal) = receipt_renewal {
return Err(error.context(format!(
"command ownership receipt could not be renewed after partial update: {renewal:#}"
@@ -244,7 +258,7 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
}
return Err(error);
}
#[cfg(unix)]
#[cfg(any(unix, windows))]
receipt_renewal.context("binaries updated, but command ownership receipt renewal failed; preserved receipt/recovery paths require attention")?;
if let Some(host) = &host_update {
println!(
+3 -2
View File
@@ -265,7 +265,7 @@ impl Prepared {
}
}
#[cfg(unix)]
#[cfg(any(unix, windows))]
pub(super) fn owned_for_uninstall(directory: &Path, target: &str) -> Result<Vec<GuardedFile>> {
let Some(mut receipt) = GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)? else {
return Ok(Vec::new());
@@ -278,8 +278,9 @@ pub(super) fn owned_for_uninstall(directory: &Path, target: &str) -> Result<Vec<
.find(|host| host.target == target)
.context("installed compiled-host receipt has no matching target")?;
let mut files = Vec::new();
let host_name = format!("codewhale-extension-host{}", std::env::consts::EXE_SUFFIX);
for (name, expected) in [
("codewhale-extension-host", host.sha256.as_str()),
(host_name.as_str(), host.sha256.as_str()),
(
"codewhale-extension-host.LICENSES.txt",
host.notices_sha256.as_str(),
+265 -32
View File
@@ -4,6 +4,10 @@ use std::collections::BTreeMap;
const RECEIPT: &str = ".codewhale-install.sha256";
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) struct CommandReceipt {
directory: PathBuf,
@@ -13,10 +17,18 @@ pub(super) struct CommandReceipt {
impl CommandReceipt {
pub(super) fn capture(executable: &Path) -> Result<Option<Self>> {
let directory = executable
.parent()
.context("executable has no parent")?
.canonicalize()?;
Self::capture_accepting(executable, None)
}
pub(super) fn capture_accepting(
executable: &Path,
updated: Option<&str>,
) -> Result<Option<Self>> {
let directory = executable.parent().context("executable has no parent")?;
#[cfg(not(windows))]
let directory = directory.canonicalize()?;
#[cfg(windows)]
let directory = directory.to_path_buf();
let Some(mut receipt) = GuardedFile::open_bounded(&directory, RECEIPT, MAX_RECEIPT)? else {
return Ok(None);
};
@@ -25,11 +37,9 @@ impl CommandReceipt {
let text = std::str::from_utf8(&bytes).context("command receipt is not UTF-8")?;
let hashes = parse_checksum_manifest(text)?;
if hashes.is_empty()
|| hashes.len() > 2
|| hashes.len() > COMMANDS.len()
|| text.lines().count() != hashes.len()
|| hashes
.keys()
.any(|name| !matches!(name.as_str(), "codewhale" | "codew"))
|| hashes.keys().any(|name| !COMMANDS.contains(&name.as_str()))
{
bail!(
"Invalid command ownership receipt {}; no files were changed",
@@ -39,7 +49,7 @@ impl CommandReceipt {
for (name, digest) in &hashes {
if let Some(mut file) = GuardedFile::open(&directory, name)? {
file.verify_owned()?;
if file.sha256() != digest.as_str() {
if file.sha256() != digest.as_str() && Some(file.sha256()) != updated {
bail!(
"Preserved modified command {}; receipt no longer matches",
directory.join(name).display()
@@ -103,10 +113,19 @@ impl CommandReceipt {
}
}
pub(crate) fn run(yes: bool) -> Result<()> {
pub(crate) fn run(yes: bool, purge_data: bool) -> Result<()> {
let selected = std::env::current_exe().ok();
let result = remove(yes);
report_path_copies();
result
report_path_copies(selected.as_deref());
result?;
if purge_data {
purge(yes)
} else {
println!(
"User data was not touched. To also delete Codewhale's own data directory, add --purge-data."
);
Ok(())
}
}
fn remove(yes: bool) -> Result<()> {
@@ -114,13 +133,20 @@ fn remove(yes: bool) -> Result<()> {
let method = InstallMethod::detect(&identity.path);
println!("Selected executable: {}", identity.path.display());
println!(
"User state, credentials, sessions, workspaces, shell profiles and services are preserved."
"Program removal preserves user state, credentials, sessions, workspaces, shell profiles and services."
);
if !method.supports_self_update() {
println!(
"This copy belongs to {}. Use: {}",
"This copy belongs to {}. Remove it with: {}",
method.label(),
method.uninstall_command()
manager_command(method, &identity.path)
);
return Ok(());
}
if let Some(uninstaller) = windows_installer_uninstaller(&identity.path) {
println!(
"This copy belongs to the Codewhale Windows installer. Remove it from Settings > Apps > CodeWhale, or run: \"{}\"",
uninstaller.display()
);
return Ok(());
}
@@ -135,9 +161,9 @@ fn remove(yes: bool) -> Result<()> {
.and_then(|name| name.to_str())
.context("executable basename is not UTF-8")?
.to_string();
if !matches!(primary_name.as_str(), "codewhale" | "codew") {
if !COMMANDS.contains(&primary_name.as_str()) {
bail!(
"This command name has no Unix installer ownership contract; preserved {}",
"This command name has no installer ownership contract; preserved {}",
identity.path.display()
);
}
@@ -191,22 +217,45 @@ fn remove(yes: bool) -> Result<()> {
);
return Ok(());
}
let preflight = tempfile::Builder::new()
.prefix(".codewhale-uninstall-preflight-")
.tempdir_in(&directory)?
.keep();
std::fs::remove_dir(&preflight).with_context(|| {
format!(
"uninstall directory preflight failed at {}",
preflight.display()
)
})?;
#[cfg(unix)]
{
let preflight = tempfile::Builder::new()
.prefix(".codewhale-uninstall-preflight-")
.tempdir_in(&directory)?
.keep();
std::fs::remove_dir(&preflight).with_context(|| {
format!(
"uninstall directory preflight failed at {}",
preflight.display()
)
})?;
}
let mut removed = Vec::new();
for file in &mut files {
let name = file.name().to_string();
let mut unlinked = false;
let outcome = validate_primary_update_identity(&identity)
.and_then(|()| file.remove_checked(&mut unlinked).map_err(Into::into));
#[cfg(windows)]
let outcome = match outcome {
Err(error) if unlinked && name == primary_name => {
let retired = directory.join(file.name());
delete_after_exit(&retired)
.map(|()| {
println!(
"The running executable was retired to {}; it is deleted after this process exits. Delete it by hand if it remains.",
retired.display()
)
})
.map_err(|schedule| {
error.context(format!(
"deferred removal of {} could not be scheduled: {schedule:#}",
retired.display()
))
})
}
other => other,
};
if unlinked {
removed.push(name.clone());
}
@@ -234,14 +283,186 @@ fn remove(yes: bool) -> Result<()> {
);
}
}
#[cfg(windows)]
for entry in std::fs::read_dir(&directory)?.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with("codewhale.old-") || name.starts_with("codew.old-") {
println!(
"Preserved a copy left by an earlier self-update; delete it by hand: {}",
entry.path().display()
);
}
}
println!(
"Removed this receipted Unix installation. Data and other installations are unchanged. Run hash -r in your shell."
"Removed this receipted release installation. Data and other installations are unchanged. {}",
if cfg!(windows) {
"Remove its directory from your user PATH if you added it, then open a new terminal."
} else {
"Run hash -r in your shell."
}
);
Ok(())
}
fn report_path_copies() {
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 windows_installer_uninstaller(executable: &Path) -> Option<PathBuf> {
let bin = executable.parent()?;
if !bin
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
{
return None;
}
let root = bin.parent()?;
let uninstaller = root.join("Uninstall.exe");
[&uninstaller, &root.join("update-user-path.ps1")]
.iter()
.all(|path| std::fs::symlink_metadata(path).is_ok_and(|meta| meta.is_file()))
.then_some(uninstaller)
}
#[cfg(windows)]
fn delete_after_exit(path: &Path) -> Result<()> {
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
let system = std::env::var_os("SystemRoot").context("SystemRoot is not set")?;
let powershell = Path::new(&system).join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
let literal = path.display().to_string().replace('\'', "''");
std::process::Command::new(powershell)
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"])
.arg(format!(
"Wait-Process -Id {} -ErrorAction SilentlyContinue; Remove-Item -LiteralPath '{literal}' -Force",
std::process::id()
))
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.creation_flags(DETACHED_PROCESS)
.spawn()
.context("failed to start the deferred deletion helper")?;
Ok(())
}
fn purge(yes: bool) -> Result<()> {
let home = codewhale_config::codewhale_home()?;
let explicit = codewhale_config::codewhale_home_is_explicit();
let mut targets = vec![home.clone()];
if !explicit {
targets.push(codewhale_config::legacy_deepseek_home()?.join("snapshots"));
}
let user_home = dirs::home_dir().context("failed to resolve home directory")?;
println!(
"Data purge scope: Codewhale's config, plaintext secrets file, sessions, logs, skills, plugins, tasks and caches in the directories below. Nothing else is searched for."
);
let mut present = Vec::new();
for target in targets {
let metadata = match std::fs::symlink_metadata(&target) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
println!("Already absent: {}", target.display());
continue;
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to inspect {}", target.display()));
}
};
if !metadata.is_dir() || user_home.starts_with(&target) || target.parent().is_none() {
bail!(
"Preserved {}: it is not a real directory, or it is your home or one of its parents; no data was deleted",
target.display()
);
}
if explicit && target == home && target.file_name().is_none_or(|name| name != ".codewhale")
{
bail!(
"Preserved explicit CODEWHALE_HOME {}: review it and delete it by hand; no data was deleted",
target.display()
);
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.uid()
!= codewhale_config::private_directory::PrivateDirectory::current_user_id()
{
bail!(
"Preserved {}: it belongs to another user; no data was deleted",
target.display()
);
}
}
println!(
"{} {}",
if yes { "Delete" } else { "Would delete" },
target.display()
);
present.push(target);
}
println!(
"Not covered: OS keychain/keyring entries (run codewhale auth clear --provider <id> before removing the program), per-workspace .codewhale directories, shell profile lines and completion files."
);
if !yes {
println!("No data was changed. Add --yes to delete exactly these directories.");
return Ok(());
}
for target in present {
std::fs::remove_dir_all(&target).with_context(|| {
format!(
"data purge stopped at {}; inspect what remains",
target.display()
)
})?;
println!("Deleted {}", target.display());
}
Ok(())
}
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",
@@ -250,10 +471,22 @@ fn report_path_copies() {
"deepseek",
"deepseek-tui",
] {
let path = directory.join(name);
if seen.insert(path.clone()) && std::fs::symlink_metadata(&path).is_ok() {
println!("Remaining PATH entry (preserved): {}", path.display());
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 {
println!("No other Codewhale command remains on PATH.");
}
}
+1 -1
View File
@@ -800,7 +800,7 @@ fn reopen_windows_file_for_owner_security(file: &File, path: &Path) -> Result<Fi
}
#[cfg(windows)]
fn mark_windows_file_handle_for_deletion(file: &File) -> Result<()> {
pub fn mark_windows_file_handle_for_deletion(file: &File) -> Result<()> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Foundation::{
ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, ERROR_NOT_SUPPORTED,
+25 -1
View File
@@ -18,7 +18,8 @@ use std::process::{Command, Stdio};
/// Environment variable that overrides install-method detection.
///
/// Accepts `npm`, `homebrew` (or `brew`), `cargo`, `omarchy`, and `binary`.
/// Accepts `npm`, `homebrew` (or `brew`), `cargo`, `omarchy`, `scoop`,
/// `winget`, and `binary`.
/// Anything else is ignored and detection falls back to automatic detection.
/// Packagers who relocate the binary somewhere the heuristics cannot read —
/// and users debugging a wrong guess — set this. Recognized managed paths
@@ -51,6 +52,10 @@ pub enum InstallMethod {
Cargo,
/// An AUR/pacman package installed on Omarchy.
Omarchy,
/// Scoop — a binary under a `scoop/apps` prefix.
Scoop,
/// A WinGet portable package under `Microsoft/WinGet/Packages`.
WinGet,
/// A release binary the user placed on disk themselves. The default, and
/// the only case where in-place self-update is correct.
Binary,
@@ -133,6 +138,17 @@ impl InstallMethod {
if has("cellar") || has(".linuxbrew") || has("linuxbrew") {
return Self::Homebrew;
}
let pair = |first: &str, second: &str| {
components
.windows(2)
.any(|pair| pair[0] == first && pair[1] == second)
};
if pair("scoop", "apps") {
return Self::Scoop;
}
if pair("winget", "packages") || pair("winget", "links") {
return Self::WinGet;
}
// `.cargo/bin/codewhale` — require the pair so an unrelated `bin`
// directory does not read as a Cargo install.
if components
@@ -150,6 +166,8 @@ impl InstallMethod {
"homebrew" | "brew" => Some(Self::Homebrew),
"cargo" => Some(Self::Cargo),
"omarchy" => Some(Self::Omarchy),
"scoop" => Some(Self::Scoop),
"winget" => Some(Self::WinGet),
"binary" | "release" => Some(Self::Binary),
_ => None,
}
@@ -168,6 +186,8 @@ impl InstallMethod {
Self::Homebrew => "brew upgrade codewhale",
Self::Cargo => "cargo install codewhale-cli --locked --force",
Self::Omarchy => "omarchy update",
Self::Scoop => "scoop update codewhale",
Self::WinGet => "winget upgrade --id HunterBown.CodeWhale",
Self::Binary => "codewhale update",
}
}
@@ -183,6 +203,8 @@ impl InstallMethod {
Self::Omarchy => {
"pacman -Qo <the executable path>, then remove that package with your package manager"
}
Self::Scoop => "scoop uninstall codewhale",
Self::WinGet => "winget uninstall --id HunterBown.CodeWhale",
Self::Binary => "codewhale uninstall --yes",
}
}
@@ -204,6 +226,8 @@ impl InstallMethod {
Self::Homebrew => "Homebrew",
Self::Cargo => "cargo",
Self::Omarchy => "Omarchy",
Self::Scoop => "Scoop",
Self::WinGet => "WinGet",
Self::Binary => "release binary",
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Image-heavy session recovery:** ordinary image reads use a 256 KiB delivery budget. Requests measure serialized bytes, omit older images from an outbound copy with receipts, and retry an initial HTTP 413 once only with a smaller body. Saved images and executed tool actions remain intact. (CW-7550)
- **Unix uninstall:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers record command ownership; self-update renews existing receipts. User data, modified/unreceipted files and package-managed installations are preserved. Windows removal remains pending. (Refs #7091)
- **Uninstall on every install route:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers and the Windows archive installer record command ownership; self-update renews existing receipts on both. On Windows the running `codewhale.exe` is renamed and deleted once the command exits. Package-managed copies are left alone, and the command prints their exact removal command: npm with its prefix, Cargo with its root, the Homebrew formula, `scoop uninstall codewhale`, `winget uninstall --id HunterBown.CodeWhale`, or the Windows setup's `Uninstall.exe`. Every other `codewhale`/`codew` still on PATH is listed. User data is kept unless you add `--purge-data`, which previews and then deletes only `~/.codewhale` (or a `CODEWHALE_HOME` named `.codewhale`) and `~/.deepseek/snapshots`, refusing symlinks, home ancestors and directories owned by another user. Windows removal is compile-checked but not yet exercised on a Windows host. (Refs #7091)
- **GitHub Action reviews:** analysis can export a structured Engine review for a separate trusted publisher job. The publisher checks the current PR revision and line anchors, deduplicates its findings, and keeps uncertain or preexisting claims out of inline comments.
- **Telegram recovery:** prompts and their initiating identity are durable before the polling cursor advances. Restart recovery reuses the original Engine operation, questions preserve human ownership, and uncertain final replies remain available through `/status` without an automatic resend.
+21
View File
@@ -203,6 +203,7 @@ impl GuardedFile {
self.verify_owned()?;
let directory = tempfile::Builder::new()
.prefix(".codewhale-uninstall-")
.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700))
.tempdir_in(&self.directory)?
.keep();
let destination = WorkspaceFile::open_delivery(&directory, Path::new(&self.name))?;
@@ -249,6 +250,26 @@ impl GuardedFile {
Ok(())
}
#[cfg(windows)]
pub fn verify_owned(&mut self) -> io::Result<()> {
self.verify()
}
#[cfg(windows)]
pub fn remove_checked(&mut self, unlinked: &mut bool) -> io::Result<()> {
*unlinked = false;
self.verify()?;
self.move_to_vacant(&Self::recovery_name())?;
*unlinked = true;
codewhale_config::private_directory::mark_windows_file_handle_for_deletion(&self.file)
.map_err(|error| {
io::Error::other(format!(
"{error:#}; retired entry retained at {}",
self.directory.join(&self.name).display()
))
})
}
pub fn recovery_name() -> String {
format!(".codewhale-host-recovery-{}", uuid::Uuid::new_v4())
}
+1
View File
@@ -40,3 +40,4 @@ libc.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
+1 -2
View File
@@ -936,8 +936,7 @@ pub fn rename_windows_opened(
}
// SAFETY: converts the returned NTSTATUS without dereferencing.
let error = io::Error::from_raw_os_error(unsafe { RtlNtStatusToDosError(result) as i32 });
let Some(backoff) = codewhale_util::utils::windows_publish_retry_delay(&error, attempt)
else {
let Some(backoff) = crate::utils::windows_publish_retry_delay(&error, attempt) else {
return Err(error);
};
std::thread::sleep(backoff);
+20 -8
View File
@@ -273,8 +273,8 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
### Upgrade / uninstall
* Upgrade: `codewhale update` (see §7).
* Uninstall: `rm ~/.local/bin/codewhale ~/.local/bin/codew`, then see §12 for
data.
* Uninstall: `~/.local/bin/codewhale uninstall` previews the receipt-owned
files; add `--yes` to remove them. See §12 for data.
---
@@ -874,18 +874,30 @@ codewhale auth clear --provider deepseek
| Installed with | Remove with |
|---|---|
| current Unix installer (§2) | Run the intended executable by its full path with `uninstall` to inspect the plan, then `uninstall --yes` to remove it. |
| Windows release archive (`install.ps1`) | `"%USERPROFILE%\bin\codewhale.exe" uninstall`, then `uninstall --yes`. Remove `%USERPROFILE%\bin` from your user PATH if you added it only for Codewhale. |
| Windows setup (NSIS) | Settings > Apps > CodeWhale > Uninstall, or `%LOCALAPPDATA%\Programs\CodeWhale\Uninstall.exe`. `codewhale uninstall` prints this path. |
| WinGet | `winget uninstall --id HunterBown.CodeWhale` |
| Scoop | `scoop uninstall codewhale` |
| old unreceipted installer or manual (§3) | Inspect the exact command paths and provenance before removing them manually; automatic uninstall preserves them. |
| npm | `npm uninstall -g codewhale` |
| Cargo | `cargo uninstall codewhale-cli` |
| Homebrew | `brew uninstall codewhale && brew untap Hmbown/deepseek-tui` (also removes its node dependency) |
| npm | `npm uninstall -g codewhale` (`codewhale uninstall` prints the `--prefix` for this copy) |
| Cargo | `cargo uninstall codewhale-cli` (`codewhale uninstall` prints the owning `--root`) |
| Homebrew | `brew uninstall codewhale && brew untap Hmbown/deepseek-tui` (also removes its node dependency; `codewhale uninstall` prints the owning formula) |
The Unix installers write `.codewhale-install.sha256` beside the commands. Uninstall accepts only existing regular, single-link files whose bytes match that receipt. Receipts, selected files and their retained parent directory must belong to the current user and must not be writable by group or others. Optional compiled-host files use their existing release receipt. All selected files pass preflight before any removal; the executing command is removed last. Modified files, symlinks, ambiguous receipts, shared writable directories, legacy unreceipted installs and unrelated files are preserved. No recursive directory removal is performed. A failed removal reports which entries were unlinked, any remaining recovery paths, and any durability failure. If the command receipt was unlinked while the executable remains, recovery republishes it only to a vacant receipt name.
Not sure which one you have? Run `codewhale uninstall` (no `--yes`): it names the owner of the copy you ran and the exact command that removes it, changes nothing, and lists every other `codewhale`/`codew` still on `PATH`. Run each listed copy by its full path with `uninstall` until none remain.
The Unix installers and the Windows archive installer write `.codewhale-install.sha256` beside the commands. Uninstall accepts only existing regular, single-link files whose bytes match that receipt. On Unix, receipts, selected files and their retained parent directory must belong to the current user and must not be writable by group or others. Optional compiled-host files use their existing release receipt. All selected files pass preflight before any removal; the executing command is removed last. Modified files, symlinks, ambiguous receipts, shared writable directories, legacy unreceipted installs and unrelated files are preserved. No recursive directory removal is performed. A failed removal reports which entries were unlinked, any remaining recovery paths, and any durability failure. If the command receipt was unlinked while the executable remains, recovery republishes it only to a vacant receipt name.
The command runs before configuration, credential migration and telemetry, so malformed config does not block it. It preserves data, credentials, workspaces, shell profiles, completions and services. Package-managed copies get removal guidance; custom Cargo roots with local Cargo metadata are recognized. Other command copies still on PATH are listed without running them. Refresh the shell command cache with `hash -r` and inspect the remaining entries.
Successful self-update renews an existing command receipt; it does not claim ownership of old unreceipted copies or unknown siblings. Windows direct/PowerShell, NSIS, WinGet and Scoop removal remain pending platform qualification. This Unix command does not remove a remote-setup service or a desktop application.
Successful self-update renews an existing command receipt; it does not claim ownership of old unreceipted copies or unknown siblings. This command does not remove a remote-setup service or a desktop application.
### Step 3: remove data. No uninstaller does this for you.
On Windows, `codewhale uninstall --yes` removes `codewhale.exe`, `codew.exe`, `codewhale.bat`, any receipted compiled-host files and the receipt itself. Windows cannot delete a running program, so the running `codewhale.exe` is renamed in place to a `.codewhale-host-recovery-…` name and deleted by a helper once the command exits; the output names that file in case it remains. `codewhale.old-…` files left by an earlier self-update are listed, not deleted. Archive installs made before this receipt existed are treated as unreceipted (reinstall with the current `install.ps1`, then uninstall). Windows removal has been compile-checked but not yet exercised on a Windows host.
Repeating `uninstall` after removal is safe: the removed copy is gone, and any other copy only removes what its own receipt owns. Reinstalling into the same directory afterwards writes a fresh receipt.
### Step 3: remove data. Program removal never does this.
`codewhale uninstall --purge-data` previews deleting `~/.codewhale` (or `$CODEWHALE_HOME`) and `~/.deepseek/snapshots`. Add `--yes` to delete exactly those directories. It refuses a path that is a symlink, your home directory or one of its parents, or (on Unix) a directory owned by another user. It also refuses an explicit `CODEWHALE_HOME` not named `.codewhale`; delete that one by hand after you review it. It does not search for per-workspace `.codewhale` directories, keyring entries, shell profile lines or completion files. Run `codewhale auth clear --provider <id>` first to remove keyring entries. To remove data by hand instead:
| Path | What it is | Size seen |
|---|---|---|
+2 -2
View File
@@ -38,8 +38,8 @@
[[feature]]
id = "unix-uninstall"
name = "Receipt-owned Unix uninstall"
summary = "Preview or remove this receipt-owned Unix release installation while preserving user data and other installations."
name = "Receipt-owned uninstall"
summary = "Remove a receipt-owned release install or print its package manager's command; data kept unless --purge-data."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"
+13 -1
View File
@@ -70,13 +70,25 @@ try {
}
$snapshots += [PSCustomObject]@{ Name=$name; Target=$target; Staged=$staged; OldHash=$oldHash; NewHash=$newHash; Backup=$backup }
}
$receiptName = '.codewhale-install.sha256'
$receiptStaged = Join-Path $stage $receiptName
$receiptText = ($snapshots | Where-Object { $_.Name -in @('codewhale.exe', 'codew.exe', 'codewhale.bat') } | ForEach-Object { "$($_.NewHash) $($_.Name)`n" }) -join ''
[IO.File]::WriteAllText($receiptStaged, $receiptText)
$receiptTarget = Join-Path $destination $receiptName
$receiptOld = File-Hash $receiptTarget
$receiptBackup = Join-Path $stage ($receiptName + '.previous')
if ($receiptOld) {
Copy-Item -LiteralPath $receiptTarget -Destination $receiptBackup
if ((File-Hash $receiptBackup) -ne $receiptOld) { throw "Destination changed during backup: $receiptTarget" }
}
$snapshots += [PSCustomObject]@{ Name=$receiptName; Target=$receiptTarget; Staged=$receiptStaged; OldHash=$receiptOld; NewHash=(File-Hash $receiptStaged); Backup=$receiptBackup }
foreach ($row in $snapshots) { if ((File-Hash $row.Target) -ne $row.OldHash) { throw "Destination changed before publication: $($row.Target)" } }
foreach ($row in $snapshots) {
if ((File-Hash $row.Target) -ne $row.OldHash) { throw "Destination changed during publication: $($row.Target)" }
Move-Item -LiteralPath $row.Staged -Destination $row.Target -Force:([bool]$row.OldHash)
$published += $row
}
Write-Host "Installed canonical Codewhale commands to $destination. Add this directory to your user PATH. Node remains the default host."
Write-Host "Installed canonical Codewhale commands to $destination. Add this directory to your user PATH. Node remains the default host. Remove later with: `"$destination\codewhale.exe`" uninstall"
} catch {
$original = $_
[array]::Reverse($published)