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:
+12
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user