fix(uninstall): stop deleting files in the caller's directory (#901)

* fix(uninstall): stop deleting files in the caller's directory

The curl uninstall command runs uninstall.sh from $PWD, and the script
treated that directory as a source checkout. It deleted venv, build, dist,
and Python bytecode there, then reported success.

Those paths are removed only when uninstall.sh itself lives in a Vocalinux
checkout, and only inside that checkout.

Fixes #897

* fix(uninstall): keep dev metadata and drop the false cleanup error

Tests put a pgrep and kill stub first on PATH, so they cannot signal a
Vocalinux process that is already running. egg-info cleanup stays under
src/ and the checkout root, and the bytecode walk prunes .venv. Artifact
cleanup runs before the curl clone is deleted, so an uninstall from
~/.local/share/vocalinux-install no longer prints an error and then success.
This commit is contained in:
Jatin K Malik
2026-10-07 00:06:37 -07:00
committed by GitHub
parent 1d4dbe33cc
commit 758f640787
4 changed files with 544 additions and 33 deletions
+12 -1
View File
@@ -251,13 +251,24 @@ Switch engines in Settings → Speech Model (Advanced), or edit `~/.config/vocal
## Uninstall
From a source checkout, `./uninstall.sh` removes the install and that checkout's `venv/`, `build/`, `dist/`, and Python bytecode. `--keep-config` and `--keep-data` leave `~/.config/vocalinux` and `~/.local/share/vocalinux` in place.
```bash
./uninstall.sh
./uninstall.sh --keep-config
./uninstall.sh --keep-data
```
Manual cleanup:
The website command downloads this same script and runs it:
```bash
curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/uninstall.sh -o /tmp/vul.sh
bash /tmp/vul.sh
```
That removes Vocalinux from your home directory (config, data, launchers, desktop entry, icons). It does not delete `venv/`, `build/`, `dist/`, or `.pyc` files in the directory where you ran it.
From the source checkout, the equivalent by hand:
```bash
rm -rf venv ~/.config/vocalinux ~/.local/share/vocalinux
+6
View File
@@ -55,7 +55,13 @@ python3 -c "import vocalinux; print(vocalinux.version.__version__)"
Clean reinstall (keeps config and models by default):
```bash
# Source checkout
./uninstall.sh --keep-config --keep-data
# Curl install (safe to run from any directory)
curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/uninstall.sh -o /tmp/vul.sh
bash /tmp/vul.sh --keep-config --keep-data
curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh -o /tmp/vl.sh
bash /tmp/vl.sh
```
+425
View File
@@ -0,0 +1,425 @@
"""uninstall.sh must not delete the caller's working directory.
The website runs ``bash /tmp/vul.sh`` from whatever directory the terminal is
in. ``venv/``, ``build/``, ``dist/``, and Python bytecode are removed only when
the script itself lives in a Vocalinux source checkout, and only inside that
checkout.
"""
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
UNINSTALL_SH = REPO_ROOT / "uninstall.sh"
def _write_script(dest: Path) -> None:
dest.write_bytes(UNINSTALL_SH.read_bytes())
dest.chmod(0o755)
def _decoy_tree(root: Path) -> None:
"""Files a careless ``find .`` / ``rm -rf venv`` would destroy."""
package = root / "proj" / "pkg"
cache = package / "__pycache__"
cache.mkdir(parents=True)
(package / "mod.py").write_text("x = 1\n", encoding="utf-8")
(cache / "mod.cpython-312.pyc").write_bytes(b"pyc")
(root / "bytecode-only.pyc").write_bytes(b"pyc")
(root / "bytecode-only.pyo").write_bytes(b"pyo")
(root / "venv" / "bin").mkdir(parents=True)
(root / "venv" / "bin" / "python").write_text("#!/bin/sh\n", encoding="utf-8")
(root / "build").mkdir()
(root / "build" / "out").write_text("build", encoding="utf-8")
(root / "dist").mkdir()
(root / "dist" / "pkg.whl").write_text("wheel", encoding="utf-8")
(root / "notes.txt").write_text("keep", encoding="utf-8")
(root / ".pytest_cache").mkdir()
(root / ".coverage").write_text("cov", encoding="utf-8")
(root / "src" / "pkg.egg-info").mkdir(parents=True)
(root / "activate-vocalinux.sh").write_text("echo hi\n", encoding="utf-8")
(root / "vocalinux-run.py").write_text("print(1)\n", encoding="utf-8")
def _assert_decoy_intact(root: Path) -> None:
assert (root / "notes.txt").read_text(encoding="utf-8") == "keep"
assert (root / "proj" / "pkg" / "mod.py").is_file()
assert (root / "proj" / "pkg" / "__pycache__" / "mod.cpython-312.pyc").is_file()
assert (root / "bytecode-only.pyc").is_file()
assert (root / "bytecode-only.pyo").is_file()
assert (root / "venv" / "bin" / "python").is_file()
assert (root / "build" / "out").is_file()
assert (root / "dist" / "pkg.whl").is_file()
assert (root / ".pytest_cache").is_dir()
assert (root / ".coverage").is_file()
assert (root / "src" / "pkg.egg-info").is_dir()
assert (root / "activate-vocalinux.sh").is_file()
assert (root / "vocalinux-run.py").is_file()
def _make_checkout(root: Path) -> Path:
checkout = root / "vocalinux"
(checkout / "src" / "vocalinux").mkdir(parents=True)
(checkout / "pyproject.toml").write_text(
'[project]\nname = "vocalinux"\n',
encoding="utf-8",
)
(checkout / "src" / "vocalinux" / "__init__.py").write_text("", encoding="utf-8")
_write_script(checkout / "uninstall.sh")
return checkout
def _write_stub(path: Path, body: str) -> None:
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
def _run(script: Path, cwd: Path, home: Path, *args: str) -> subprocess.CompletedProcess[str]:
"""Run uninstall.sh against a fake home, without the host's pgrep or kill.
The uninstaller looks up this uid's processes and can TERM then KILL a
running Vocalinux. A stub earlier on PATH records the call and matches
nothing, so the suite cannot stop the app a developer has open.
"""
bin_dir = home.parent / "uninstall-test-bin"
bin_dir.mkdir(exist_ok=True)
pgrep_log = home.parent / "pgrep.log"
kill_log = home.parent / "kill.log"
_write_stub(
bin_dir / "pgrep",
"#!/bin/sh\n" 'printf \'%s\\n\' "$*" >> "$VOCALINUX_TEST_PGREP_LOG"\n' "exit 1\n",
)
_write_stub(
bin_dir / "kill",
"#!/bin/sh\n" 'printf \'%s\\n\' "$*" >> "$VOCALINUX_TEST_KILL_LOG"\n' "exit 0\n",
)
env = os.environ.copy()
env["HOME"] = str(home)
env["PATH"] = os.pathsep.join((str(bin_dir), env.get("PATH", "")))
env["VOCALINUX_TEST_PGREP_LOG"] = str(pgrep_log)
env["VOCALINUX_TEST_KILL_LOG"] = str(kill_log)
env.pop("XDG_DATA_HOME", None)
env.pop("XDG_CONFIG_HOME", None)
env.pop("VIRTUAL_ENV", None)
result = subprocess.run(
["bash", str(script), "-y", *args],
cwd=cwd,
env=env,
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
check=False,
)
if "Checking for running Vocalinux processes" in result.stdout:
logged = pgrep_log.read_text(encoding="utf-8") if pgrep_log.is_file() else ""
assert "vocalinux" in logged, "uninstall.sh did not use the test pgrep stub"
if kill_log.is_file():
assert kill_log.read_text(encoding="utf-8").strip() == ""
return result
def _output(result: subprocess.CompletedProcess[str]) -> str:
return result.stdout + result.stderr
def test_curl_style_run_leaves_the_callers_directory_alone(tmp_path: Path) -> None:
"""The reported repro: script in /tmp, junk under $PWD, fake home."""
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
data = home / ".local" / "share" / "vocalinux"
config = home / ".config" / "vocalinux"
data.mkdir(parents=True)
(data / "marker").write_text("data", encoding="utf-8")
(data / "venv").mkdir()
(data / "venv" / "pyvenv.cfg").write_text("home = /usr\n", encoding="utf-8")
config.mkdir(parents=True)
(config / "config.json").write_text("{}\n", encoding="utf-8")
bindir = home / ".local" / "bin"
bindir.mkdir(parents=True)
(bindir / "vocalinux").write_text("#!/bin/sh\n", encoding="utf-8")
(bindir / "vocalinux-gui").symlink_to("vocalinux")
(bindir / "activate-vocalinux.sh").write_text("echo\n", encoding="utf-8")
desktop = home / ".local" / "share" / "applications"
desktop.mkdir(parents=True)
(desktop / "vocalinux.desktop").write_text("[Desktop Entry]\n", encoding="utf-8")
result = _run(script, cwd, home)
assert result.returncode == 0, _output(result)
assert "skipping build-artifact cleanup" in result.stdout
assert "Failed to remove some" not in _output(result)
_assert_decoy_intact(cwd)
assert not data.exists()
assert not config.exists()
assert not (bindir / "vocalinux").exists()
assert not (bindir / "vocalinux-gui").exists()
assert not (bindir / "activate-vocalinux.sh").exists()
assert not (desktop / "vocalinux.desktop").exists()
def test_keep_flags_still_do_not_touch_the_working_directory(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
config = home / ".config" / "vocalinux"
data = home / ".local" / "share" / "vocalinux"
config.mkdir(parents=True)
data.mkdir(parents=True)
(config / "config.json").write_text("{}\n", encoding="utf-8")
(data / "marker").write_text("data", encoding="utf-8")
result = _run(script, cwd, home, "--keep-config", "--keep-data")
assert result.returncode == 0, _output(result)
_assert_decoy_intact(cwd)
assert (config / "config.json").is_file()
assert (data / "marker").is_file()
def test_source_checkout_cleans_itself_and_leaves_the_caller_alone(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
cwd = tmp_path / "cwd"
cwd.mkdir()
_decoy_tree(cwd)
result = _run(checkout / "uninstall.sh", cwd, home)
assert result.returncode == 0, _output(result)
assert "Failed to remove some" not in _output(result)
_assert_decoy_intact(cwd)
assert (checkout / "notes.txt").is_file()
assert (checkout / "proj" / "pkg" / "mod.py").is_file()
assert (checkout / "src" / "vocalinux" / "__init__.py").is_file()
assert (checkout / "pyproject.toml").is_file()
assert not (checkout / "venv").exists()
assert not (checkout / "build").exists()
assert not (checkout / "dist").exists()
assert not (checkout / "bytecode-only.pyc").exists()
assert not (checkout / "bytecode-only.pyo").exists()
assert not (checkout / ".coverage").exists()
assert not (checkout / ".pytest_cache").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
assert not (checkout / "src" / "pkg.egg-info").exists()
assert not (checkout / "activate-vocalinux.sh").exists()
assert not (checkout / "vocalinux-run.py").exists()
def test_dev_venv_records_survive_checkout_cleanup(tmp_path: Path) -> None:
"""`.venv` is the dev environment. Uninstall must not strip its metadata."""
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
dev_egg = checkout / ".venv" / "lib" / "python3.12" / "site-packages" / "dep.egg-info"
dev_egg.mkdir(parents=True)
(dev_egg / "PKG-INFO").write_text("Metadata-Version: 2.1\n", encoding="utf-8")
dev_cache = checkout / ".venv" / "lib" / "python3.12" / "site-packages" / "dep" / "__pycache__"
dev_cache.mkdir(parents=True)
(dev_cache / "mod.cpython-312.pyc").write_bytes(b"pyc")
(checkout / ".venv" / ".coverage").write_text("keep", encoding="utf-8")
(checkout / "vocalinux.egg-info").mkdir()
(checkout / "vocalinux.egg-info" / "PKG-INFO").write_text("name\n", encoding="utf-8")
result = _run(checkout / "uninstall.sh", tmp_path, home)
assert result.returncode == 0, _output(result)
assert "Failed to remove some" not in _output(result)
assert (dev_egg / "PKG-INFO").is_file()
assert (dev_cache / "mod.cpython-312.pyc").is_file()
assert (checkout / ".venv" / ".coverage").is_file()
assert not (checkout / "src" / "pkg.egg-info").exists()
assert not (checkout / "vocalinux.egg-info").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
assert not (checkout / ".coverage").exists()
def test_curl_clone_uninstall_does_not_print_a_cleanup_error(tmp_path: Path) -> None:
"""The cloned repo is a checkout, and uninstall deletes that same directory."""
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
(cwd / "notes.txt").write_text("keep", encoding="utf-8")
clone = home / ".local" / "share" / "vocalinux-install"
(clone / "src" / "vocalinux").mkdir(parents=True)
(clone / "pyproject.toml").write_text('[project]\nname = "vocalinux"\n', encoding="utf-8")
(clone / "src" / "vocalinux" / "__init__.py").write_text("", encoding="utf-8")
(clone / "src" / "vocalinux.egg-info").mkdir()
(clone / "build").mkdir()
(clone / "build" / "out").write_text("build", encoding="utf-8")
_write_script(clone / "uninstall.sh")
result = _run(clone / "uninstall.sh", cwd, home)
assert result.returncode == 0, _output(result)
output = _output(result)
assert "Refusing to clean build artifacts" not in output
assert "[ERROR]" not in output
assert "Failed to remove some" not in output
assert "Uninstallation completed successfully" in result.stdout
assert not clone.exists()
assert (cwd / "notes.txt").read_text(encoding="utf-8") == "keep"
def test_running_inside_the_checkout_still_cleans_it(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
_decoy_tree(checkout)
result = _run(checkout / "uninstall.sh", checkout, home)
assert result.returncode == 0, _output(result)
assert (checkout / "proj" / "pkg" / "mod.py").is_file()
assert not (checkout / "venv").exists()
assert not (checkout / "bytecode-only.pyc").exists()
assert not (checkout / "proj" / "pkg" / "__pycache__").exists()
def test_another_projects_tree_is_not_treated_as_a_checkout(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
project = tmp_path / "other"
(project / "src" / "vocalinux").mkdir(parents=True)
(project / "pyproject.toml").write_text('name = "not-vocalinux"\n', encoding="utf-8")
_write_script(project / "uninstall.sh")
_decoy_tree(project)
result = _run(project / "uninstall.sh", project, home)
assert result.returncode == 0, _output(result)
assert "skipping build-artifact cleanup" in result.stdout
_assert_decoy_intact(project)
def test_relative_venv_dir_outside_a_checkout_is_refused(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
script = tmp_path / "vul.sh"
_write_script(script)
result = _run(script, cwd, home, "--venv-dir=venv")
assert result.returncode == 1
assert "Refusing relative --venv-dir" in result.stdout
_assert_decoy_intact(cwd)
def test_relative_venv_dir_resolves_inside_the_checkout(tmp_path: Path) -> None:
home = tmp_path / "home"
home.mkdir()
checkout = _make_checkout(tmp_path)
(checkout / "custom-venv" / "bin").mkdir(parents=True)
(checkout / "custom-venv" / "bin" / "python").write_text("x", encoding="utf-8")
cwd = tmp_path / "cwd"
cwd.mkdir()
(cwd / "custom-venv" / "bin").mkdir(parents=True)
(cwd / "custom-venv" / "bin" / "python").write_text("keep", encoding="utf-8")
(cwd / "notes.txt").write_text("keep", encoding="utf-8")
result = _run(checkout / "uninstall.sh", cwd, home, "--venv-dir=custom-venv")
assert result.returncode == 0, _output(result)
assert not (checkout / "custom-venv").exists()
assert (cwd / "custom-venv" / "bin" / "python").read_text(encoding="utf-8") == "keep"
assert (cwd / "notes.txt").is_file()
def test_absolute_venv_dir_is_removed_and_the_caller_is_not(tmp_path: Path) -> None:
home = tmp_path / "home"
cwd = tmp_path / "cwd"
home.mkdir()
cwd.mkdir()
_decoy_tree(cwd)
extra = tmp_path / "custom-venv"
(extra / "bin").mkdir(parents=True)
(extra / "bin" / "python").write_text("x", encoding="utf-8")
script = tmp_path / "vul.sh"
_write_script(script)
result = _run(script, cwd, home, f"--venv-dir={extra}")
assert result.returncode == 0, _output(result)
assert not extra.exists()
_assert_decoy_intact(cwd)
def test_help_does_not_default_to_a_cwd_relative_venv() -> None:
result = subprocess.run(
["bash", str(UNINSTALL_SH), "--help"],
capture_output=True,
text=True,
timeout=10,
check=False,
)
assert result.returncode == 0, _output(result)
assert "default: venv" not in result.stdout
assert "source checkout" in result.stdout
def test_build_artifact_search_stays_on_one_filesystem() -> None:
source = UNINSTALL_SH.read_text(encoding="utf-8")
cleanup = source.split("cleanup_build_artifacts() {", 1)[1].split("\n}", 1)[0]
finds = [line.strip() for line in cleanup.splitlines() if line.strip().startswith("find ")]
assert finds
assert all("-xdev" in line for line in finds)
def test_uninstaller_is_valid_bash() -> None:
syntax = subprocess.run(
["bash", "-n", str(UNINSTALL_SH)],
capture_output=True,
text=True,
check=False,
)
assert syntax.returncode == 0, syntax.stderr
@pytest.mark.parametrize(
"pyproject",
[
"",
'name = "vocalinux"\n',
],
)
def test_checkout_detection_needs_both_the_name_and_the_package_dir(
tmp_path: Path, pyproject: str
) -> None:
"""A pyproject alone, or the package dir without the project name, is not enough."""
home = tmp_path / "home"
home.mkdir()
project = tmp_path / "partial"
project.mkdir()
if pyproject:
(project / "pyproject.toml").write_text(pyproject, encoding="utf-8")
else:
(project / "src" / "vocalinux").mkdir(parents=True)
_write_script(project / "uninstall.sh")
(project / "venv").mkdir()
(project / "venv" / "marker").write_text("keep", encoding="utf-8")
result = _run(project / "uninstall.sh", tmp_path, home)
assert result.returncode == 0, _output(result)
assert (project / "venv" / "marker").is_file()
assert "skipping build-artifact cleanup" in result.stdout
+101 -32
View File
@@ -205,9 +205,20 @@ echo ""
# Parse command line arguments
KEEP_CONFIG="no"
KEEP_DATA="no"
VENV_DIR="venv"
NON_INTERACTIVE="no"
# Repo-relative cleanup is only safe when this script lives in a source checkout.
# The website command is `curl ... -o /tmp/vul.sh && bash /tmp/vul.sh`, so $PWD is
# the user's directory and must not be walked.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_CHECKOUT=""
VENV_DIR=""
if [[ -f "$SCRIPT_DIR/pyproject.toml" && -d "$SCRIPT_DIR/src/vocalinux" ]] \
&& grep -q 'name = "vocalinux"' "$SCRIPT_DIR/pyproject.toml" 2>/dev/null; then
SOURCE_CHECKOUT="$SCRIPT_DIR"
VENV_DIR="$SCRIPT_DIR/venv"
fi
# Detect if running non-interactively
if [ ! -t 0 ]; then
NON_INTERACTIVE="yes"
@@ -237,7 +248,10 @@ while [[ $# -gt 0 ]]; do
echo "Options:"
echo " --keep-config Keep configuration files"
echo " --keep-data Keep application data (models, etc.)"
echo " --venv-dir=PATH Specify custom virtual environment directory (default: venv)"
echo " --venv-dir=PATH Virtual environment to remove. Absolute, or relative to a"
echo " source checkout. Default: <checkout>/venv when this script"
echo " lives in a Vocalinux tree; otherwise only the curl install"
echo " at ~/.local/share/vocalinux/venv is removed."
echo " -y, --yes Non-interactive mode (no confirmation prompts)"
echo " --help Show this help message"
exit 0
@@ -250,6 +264,18 @@ while [[ $# -gt 0 ]]; do
esac
done
# A relative path is the checkout's venv, never $PWD. `bash /tmp/vul.sh --venv-dir=venv`
# from $HOME must not delete ~/venv.
if [[ -n "$VENV_DIR" && "$VENV_DIR" != /* ]]; then
if [[ -n "$SOURCE_CHECKOUT" ]]; then
VENV_DIR="$SOURCE_CHECKOUT/$VENV_DIR"
else
print_error "Refusing relative --venv-dir outside a source checkout: $VENV_DIR"
print_error "Pass an absolute path. A curl uninstall already removes ~/.local/share/vocalinux/venv."
exit 1
fi
fi
# Define XDG directories
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/vocalinux"
DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/vocalinux"
@@ -264,7 +290,11 @@ CURL_VENV_DIR="$HOME/.local/share/vocalinux/venv"
CURL_BIN_DIR="$HOME/.local/bin"
echo "Uninstallation options:"
echo "- Local virtual environment: $VENV_DIR"
if [[ -n "$VENV_DIR" ]]; then
echo "- Local virtual environment: $VENV_DIR"
else
echo "- Local virtual environment: (none; this script is not in a source checkout)"
fi
echo "- Curl-installed venv: $CURL_VENV_DIR"
echo "- Curl-installed repo: $CURL_INSTALL_DIR"
echo
@@ -300,8 +330,10 @@ remove_virtual_environment() {
}
fi
# Remove local venv (if running from repo)
if [ -d "$VENV_DIR" ]; then
# Checkout venv only. A curl install is removed via CURL_VENV_DIR below.
if [[ -z "$VENV_DIR" ]]; then
print_info "No source-checkout virtual environment to remove."
elif [ -d "$VENV_DIR" ]; then
print_info "Removing local virtual environment..."
safe_remove "$VENV_DIR" "local virtual environment directory"
else
@@ -337,8 +369,11 @@ remove_curl_install_files() {
# Function to remove application files
remove_application_files() {
# Remove local activation script (if running from repo)
safe_remove "activate-vocalinux.sh" "local activation script"
# Only the copy inside a source checkout. A relative path would delete
# whatever activate-vocalinux.sh happens to sit in $PWD.
if [[ -n "$SOURCE_CHECKOUT" ]]; then
safe_remove "$SOURCE_CHECKOUT/activate-vocalinux.sh" "local activation script"
fi
# Remove launcher wrappers in ~/.local/bin created by install.sh. These are
# regular files in a repo/dev install and may be symlinks in a curl install;
@@ -393,34 +428,61 @@ remove_config_and_data() {
safe_remove "$IBUS_DATA_DIR" "IBus runtime data directory"
}
# Function to clean up build artifacts
# Clean build artifacts inside a Vocalinux source checkout.
# $1 must be an absolute directory. A relative path would follow the caller's
# working directory ($HOME, when the website command runs this script from
# /tmp). -xdev stays on one filesystem. -prune removes a directory without
# find walking back into it afterwards.
cleanup_build_artifacts() {
local root="$1"
local egg_info_status=0
# A relative path would follow the caller's working directory. A checkout
# that an earlier step already removed (the curl clone) is not an error.
if [[ "$root" != /* ]]; then
print_error "Refusing to clean build artifacts outside a source checkout."
return 1
fi
if [[ ! -d "$root" ]]; then
print_info "Source checkout already removed; skipping build-artifact cleanup."
return 0
fi
print_info "Cleaning up build artifacts..."
# Remove Python package build directories
safe_remove "build/" "build directory"
safe_remove "dist/" "distribution directory"
safe_remove "*.egg-info/" "egg-info directory"
# Find and remove egg-info directories in src
find src -name "*.egg-info" -type d -exec rm -rf {} \; 2>/dev/null || {
safe_remove "$root/build" "build directory"
safe_remove "$root/dist" "distribution directory"
# vocalinux metadata lives in src/ (setuptools src layout) and sometimes as
# *.egg-info at the checkout root. Do not search the whole tree: .venv is
# a separate dev environment and its egg-info has to stay.
find "$root/src" -xdev -name "*.egg-info" -type d -prune -exec rm -rf {} + \
2>/dev/null || egg_info_status=1
find "$root" -xdev -mindepth 1 -maxdepth 1 -name "*.egg-info" -type d \
-prune -exec rm -rf {} + 2>/dev/null || egg_info_status=1
if [[ "$egg_info_status" -ne 0 ]]; then
print_warning "Failed to remove some egg-info directories."
fi
# -prune on .venv: same reason. Bytecode under the project is removed;
# the dev environment is not. The parens keep -xdev on both sides of -o.
find "$root" -xdev \( \
-name .venv -prune -o \
\( -name "__pycache__" -o -name ".pytest_cache" \) -type d -prune -exec rm -rf {} + \
\) 2>/dev/null || {
print_warning "Failed to remove some cache directories."
}
# Find and remove __pycache__ directories
find . -name "__pycache__" -type d -exec rm -rf {} \; 2>/dev/null || {
print_warning "Failed to remove some __pycache__ directories."
}
# Clean up any temporary or generated files
print_info "Cleaning up temporary files..."
find . -name "*.pyc" -delete 2>/dev/null
find . -name "*.pyo" -delete 2>/dev/null
find . -name ".pytest_cache" -type d -exec rm -rf {} \; 2>/dev/null
find . -name ".coverage" -delete 2>/dev/null
# Remove wrapper script if it exists
safe_remove "vocalinux-run.py" "wrapper script"
# -delete implies -depth, which disables -prune, so .venv would be walked.
find "$root" -xdev \( \
-name .venv -prune -o \
\( -name "*.pyc" -o -name "*.pyo" -o -name ".coverage" \) -type f -exec rm -f {} + \
\) 2>/dev/null || {
print_warning "Failed to remove some temporary files."
}
safe_remove "$root/vocalinux-run.py" "wrapper script"
}
# Function to verify uninstallation
@@ -429,7 +491,7 @@ verify_uninstallation() {
local ISSUES=0
# Check if local virtual environment still exists
if [ -d "$VENV_DIR" ]; then
if [[ -n "$VENV_DIR" && -d "$VENV_DIR" ]]; then
print_warning "Local virtual environment still exists: $VENV_DIR"
((ISSUES++))
fi
@@ -541,10 +603,17 @@ kill_vocalinux_processes
# Perform uninstallation steps
remove_virtual_environment
# Clean the checkout before remove_curl_install_files. A script that lives in
# ~/.local/share/vocalinux-install is both the checkout and the curl clone, and
# that step deletes the directory.
if [[ -n "$SOURCE_CHECKOUT" ]]; then
cleanup_build_artifacts "$SOURCE_CHECKOUT"
else
print_info "Not running from a source checkout; skipping build-artifact cleanup."
fi
remove_curl_install_files
remove_application_files
remove_config_and_data
cleanup_build_artifacts
# Verify uninstallation
verify_uninstallation