Add a machine-wide uninstaller and the empty-directory rule

A store's own engine can take that store away. What was missing was a way to
take the framework off a machine without having to remember what is on it:
uninstall.sh finds every store home under both known roots, has each store's
own engine purge what it installed, and then removes the store, its launcher,
its Ports entry and the engine files.

It delegates the removing rather than repeating it, because only the engine's
state.json knows which ROMs, playlists, thumbnails or gamelist entries were a
store's. If an engine cannot finish — RetroArch running, a ROMs root unmounted
— the run stops there instead of deleting the engine that knew what it had
installed.

POSIX sh, not bash, so `curl … | sh` works on a machine whose /bin/sh is dash;
checked with dash. `prune_empty_dirs` moves here for the same reason the delete
guard `within()` did: both engines need it, and both need it to be careful. It
removes only directories that are actually empty, and only inside the subtree
the store owns, so one surprise file is enough to keep a directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:31:52 +02:00
co-authored by Claude Opus 5
parent 5b02633fb8
commit a39a29ffde
3 changed files with 186 additions and 7 deletions
+36 -4
View File
@@ -59,7 +59,7 @@ can carry a game of the same name without colliding.
| Area | Functions |
|---|---|
| Logging | `set_tag`, `set_verbose`, `log`, `debug`, `die` |
| Files | `load_json`, `write_json`, `write_atomic`, `within` |
| Files | `load_json`, `write_json`, `write_atomic`, `within`, `prune_empty_dirs` |
| Config | `deep_merge`, `load_config` |
| Store home | `init`, then `HOME`, `CONFIG_PATH`, `STATE_PATH`, `CATALOG_CACHE` |
| HTTP | `http_get`, `http_download`, `api_url`, `download_url`, `user_agent` |
@@ -67,7 +67,7 @@ can carry a game of the same name without colliding.
| Catalog | `fetch_catalog`, `select_games`, `pick_release`, `asset_basename`, `download_image` |
| State | `load_state`, `save_state`, `game_key`, `record_scope`, `records_by_scope`, `match_keys`, `limit_to_names` |
Three of them carry decisions worth knowing about.
Four of them carry decisions worth knowing about.
**`resolve_for_host(value, host)`** — a config value that may depend on the
machine. A plain string is the same everywhere (that is what a `cartridge` is:
@@ -80,10 +80,42 @@ skipped rather than installing something that cannot run.
store may only remove files from the subtree it owns, never from the user's own
library and never from another store's.
**`prune_empty_dirs(dirs, root)`** — the uninstall side of the same rule. A store
that has removed everything should not leave its folders behind, but only empty
directories go, and only inside `root`: one surprise file is enough to keep a
directory.
**`write_atomic`** — nothing is ever written in place. A store interrupted
mid-sync would otherwise leave a half-written playlist or gamelist, which is
worse than an old one.
## Taking every store off a machine
```sh
curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh
```
A single store is better removed by its own engine's `uninstall.sh`, which knows
that host's extras — a Batocera Ports entry, say. This one is for taking the
whole framework off a machine without having to remember what is installed: it
finds every store home under the known roots, has each store's **own engine**
purge what it installed, and then deletes the store, its launcher and the engine
files.
| Environment variable | Meaning |
|---|---|
| `DRY_RUN` | `1` to print what would go and remove nothing |
| `KEEP_GAMES` | `1` to remove only the scripts and leave the installed games |
| `FORCE` | `1` to purge even while RetroArch is running |
| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR` | where to look on a Batocera box |
| `STORE_ROOT`, `BIN_DIR` | where to look on a desktop |
If any engine cannot finish — RetroArch running, a ROMs root unmounted — the run
stops there rather than deleting the engine that knows what it installed.
The script is POSIX `sh`, not bash, so `| sh` works on a machine whose `/bin/sh`
is dash. The engines' own installers and uninstallers are too.
## State
`state.json`, version 2, keyed `<scope>:<name>`:
@@ -104,8 +136,8 @@ keyed by bare software name, is re-keyed on first run.
## Users
- [`warp-engine-batocera-store`](https://git.teletypegames.org/tools/warp-engine-batocera-store) — EmulationStation ROM folders, `gamelist.xml`, Ports launchers
- [`warp-engine-retroarch-store`](https://git.teletypegames.org/tools/warp-engine-retroarch-store) — RetroArch `.lpl` playlists and thumbnail folders
- [`warp-engine-batocera-store`](https://git.teletypegames.org/stores/warp-engine-batocera-store) — EmulationStation ROM folders, `gamelist.xml`, Ports launchers
- [`warp-engine-retroarch-store`](https://git.teletypegames.org/stores/warp-engine-retroarch-store) — RetroArch `.lpl` playlists and thumbnail folders
Both pin nothing: they fetch `warpstore.py` from this repository's `master` at
install time. A change here therefore reaches every store on its next install —
Executable
+118
View File
@@ -0,0 +1,118 @@
#!/bin/sh
# Remove every WarpEngine store from this machine — the games, the store
# engines, this shared core and the launchers.
#
# curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh
#
# A single store is better removed by its own engine's uninstaller, which knows
# that host's extras (a Batocera Ports entry, say). This one is for taking the
# whole framework off a machine without having to remember what is installed:
# it finds every store home under the known roots, has each store's own engine
# purge what it installed, and then deletes the store.
#
# DRY_RUN=1 print what would go, remove nothing
# KEEP_GAMES=1 leave the installed games alone, remove only the scripts
# FORCE=1 purge even while RetroArch is running
#
# POSIX sh on purpose: `| sh` has to work on a machine whose /bin/sh is dash.
set -eu
BATOCERA_STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}"
BATOCERA_PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}"
STORE_ROOT="${STORE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/warp-engine-store}"
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
DRY_RUN="${DRY_RUN:-0}"
KEEP_GAMES="${KEEP_GAMES:-0}"
FORCE="${FORCE:-0}"
say() { echo "[uninstall-all] $*"; }
run() {
if [ "$DRY_RUN" = "1" ]; then echo "[uninstall-all] would: $*"; else "$@"; fi
}
command -v python3 >/dev/null 2>&1 || {
echo "[uninstall-all] error: python3 is required to ask the engines what they installed" >&2
exit 1
}
FOUND=0
# `purge` is the engine's own "remove everything I installed": the shell has no
# way of knowing which ROMs, playlists, thumbnails or gamelist entries were a
# store's, and state.json does.
purge_store() { # purge_store <store home> <engine file> <home env var> <extra flag>
home="$1"; engine="$2"; homevar="$3"; extra="$4"
if [ "$KEEP_GAMES" = "1" ]; then
say "keeping the games of '$(basename "$home")' (KEEP_GAMES=1)"
return 0
fi
if [ ! -f "$home/$engine" ] || [ ! -f "$home/config.json" ]; then
say "warning: no engine in $home — its games have to go by hand"
return 0
fi
dry=""
[ "$DRY_RUN" = "1" ] && dry="--dry-run"
# shellcheck disable=SC2086
env "$homevar=$home" python3 "$home/$engine" --config "$home/config.json" $dry purge $extra || {
say "error: the engine in $home could not finish — stopping before anything else is removed."
say " Close RetroArch (or FORCE=1), fix what it reported, and run this again."
exit 1
}
}
# --- Batocera stores -------------------------------------------------------
if [ -d "$BATOCERA_STORE_ROOT" ]; then
for home in "$BATOCERA_STORE_ROOT"/*/; do
[ -f "$home/config.json" ] || continue
id="$(basename "$home")"
FOUND=$((FOUND + 1))
say "batocera store '$id' in $home"
purge_store "${home%/}" store.py BATOCERA_STORE_HOME ""
# The Ports entry's name is whatever the store repository chose, not
# store.name — but it always runs this store's launcher, so it is found by
# looking inside the scripts rather than by guessing a file name.
if [ -d "$BATOCERA_PORTS_DIR" ]; then
for entry in "$BATOCERA_PORTS_DIR"/*.sh; do
[ -f "$entry" ] || continue
if grep -q -F "${home%/}" "$entry" 2>/dev/null; then
run rm -f "$entry"
fi
done
fi
run rm -f "$BATOCERA_STORE_ROOT/$id-store"
run rm -rf "${home%/}"
done
[ "$DRY_RUN" = "1" ] || rmdir "$BATOCERA_STORE_ROOT" 2>/dev/null || true
fi
# --- RetroArch stores ------------------------------------------------------
if [ -d "$STORE_ROOT" ]; then
extra=""
[ "$FORCE" = "1" ] && extra="--force"
for home in "$STORE_ROOT"/*/; do
[ -f "$home/config.json" ] || continue
id="$(basename "$home")"
FOUND=$((FOUND + 1))
say "retroarch store '$id' in $home"
purge_store "${home%/}" retroarch_store.py RETROARCH_STORE_HOME "$extra"
run rm -f "$BIN_DIR/$id-retroarch-store"
run rm -rf "${home%/}"
done
[ "$DRY_RUN" = "1" ] || rmdir "$STORE_ROOT" 2>/dev/null || true
fi
if [ "$FOUND" = "0" ]; then
say "no WarpEngine store found. Looked in:"
say " $BATOCERA_STORE_ROOT"
say " $STORE_ROOT"
say "Point at them with BATOCERA_STORE_ROOT / STORE_ROOT if they live elsewhere."
exit 0
fi
if [ "$DRY_RUN" = "1" ]; then
say "dry run — nothing was removed ($FOUND store(s) found)."
else
say "done: $FOUND store(s) removed, engines and the shared core with them."
say "Restart EmulationStation or RetroArch to see the games go."
fi
+32 -3
View File
@@ -8,8 +8,8 @@ matching the machine we are running on, remembering what we put where — is her
Two engines use it today:
tools/warp-engine-batocera-store EmulationStation ROM folders + gamelist.xml
tools/warp-engine-retroarch-store RetroArch .lpl playlists + thumbnails
stores/warp-engine-batocera-store EmulationStation ROM folders + gamelist.xml
stores/warp-engine-retroarch-store RetroArch .lpl playlists + thumbnails
An adapter supplies three things: a `DEFAULT_CONFIG` describing its own host, an
`accept()` callback deciding which catalog entries that host can run, and the
@@ -28,7 +28,7 @@ import urllib.error
import urllib.parse
import urllib.request
VERSION = "1.0.0"
VERSION = "1.1.0"
# Bumped when the on-disk shape of state.json changes. v1 keyed `installed` by
# bare software name; v2 keys by `<scope>:<name>`.
@@ -131,6 +131,35 @@ def within(path, root):
return path == root or path.startswith(root + os.sep)
def prune_empty_dirs(dirs, root=None, dry_run=False):
"""Remove those of `dirs` that are now empty, deepest first.
A store that has uninstalled everything should not leave its folders behind.
Only empty directories go, and only inside `root` when one is given — so one
surprise file is enough to keep a directory, and a wrong path cannot reach
outside the store's own subtree.
"""
ordered = sorted({os.path.abspath(d) for d in dirs if d},
key=lambda p: p.count(os.sep), reverse=True)
for path in ordered:
if root and not within(path, root):
log(f"warning: refusing to remove {path} (outside {root})")
continue
if not os.path.isdir(path):
continue
if os.listdir(path):
debug(f"keeping {path} (not empty)")
continue
if dry_run:
log(f"would remove the empty {path}")
continue
try:
os.rmdir(path)
debug(f"removed the empty {path}")
except OSError as exc:
debug(f"cannot remove {path}: {exc}")
# --------------------------------------------------------------------------
# config
# --------------------------------------------------------------------------