Add purge and an uninstaller, and move to the stores org

`purge` is what `remove` does for one game, for all of them at once, plus the
directories the store made: the shell script that wraps it has no way of knowing
which ROMs, box art, Ports payloads and gamelist entries were ours, and
state.json does. uninstall.sh runs it first and only then removes the Ports
entry, the launcher and the store home; if the engine cannot finish, nothing
else is touched.

The gamelists are merged rather than deleted — they belong to the box, and the
user's own games, play counts and favourites stay in them. Two things the tests
caught:

- The backup was being made on the *uninstall's* first touch of a gamelist we
  had created ourselves, so a purge produced a backup of our own file. It is now
  skipped when purging: by then any copy worth having was made on the first sync.
- With that fixed, "no backup next to it" means we created the file, so a
  gamelist that is left empty is deleted too. The box ends up as it was.
- The Ports entry cannot be derived from the config: a store repository sets
  BATOCERA_PORT_NAME to whatever it likes (ours is "Teletype Games Store" while
  store.name is "Teletype Games"), so the uninstaller looked for a file that was
  never there. It now finds the entry by looking inside the Ports scripts for
  this store's home — which also works for boxes installed before this existed,
  and picks up entries left over from an earlier name.

install.sh and uninstall.sh are POSIX sh now, checked with dash, so `curl … | sh`
works where /bin/sh is not bash. The repository moved from the tools org to
stores; the old raw URLs still redirect, but every reference is updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-18 08:32:12 +02:00
co-authored by Claude Opus 5
parent 0f1045b59e
commit e2551d5fd3
4 changed files with 285 additions and 16 deletions
+56 -7
View File
@@ -10,7 +10,7 @@ about any particular site: the host, the store's name and where its games land
all come from a `config.json` that lives in a separate **store repository**. all come from a `config.json` that lives in a separate **store repository**.
The part that is not about Batocera — catalog, releases, host matching, state, The part that is not about Batocera — catalog, releases, host matching, state,
HTTP — is [`warpstore`](https://git.teletypegames.org/engines/warpstore), shared HTTP — is [`warpstore`](https://git.teletypegames.org/engines/warpstore), shared
with the [RetroArch engine](https://git.teletypegames.org/tools/warp-engine-retroarch-store). with the [RetroArch engine](https://git.teletypegames.org/stores/warp-engine-retroarch-store).
``` ```
warpstore the shared core — warpstore.py warpstore the shared core — warpstore.py
@@ -28,7 +28,7 @@ teletypegames.org my.example other.example
``` ```
The reference store is The reference store is
[`ttg-batocera-store`](https://git.teletypegames.org/tools/ttg-batocera-store). [`ttg-batocera-store`](https://git.teletypegames.org/stores/ttg-batocera-store).
``` ```
Ports ▸ "<store name>" Ports ▸ "<store name>"
@@ -48,8 +48,8 @@ Python 3 standard library only — Batocera ships python3 and no pip.
The installer takes the store as a parameter, so it works with any config: The installer takes the store as a parameter, so it works with any config:
```sh ```sh
STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \ curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/install.sh |
bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master/install.sh) STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json sh
``` ```
From a checkout, with a local config: From a checkout, with a local config:
@@ -75,12 +75,13 @@ Installer knobs, all environment variables: `STORE_CONFIG` (required),
## Writing a store repository ## Writing a store repository
Three files: Four small files:
``` ```
my-batocera-store/ my-batocera-store/
├── config.json the store: URL, name, subfolder, platform mapping ├── config.json the store: URL, name, subfolder, platform mapping
├── install.sh a wrapper that hands that config to the engine installer ├── install.sh a wrapper that hands that config to the engine installer
├── uninstall.sh the same for the engine's uninstaller
└── README.md └── README.md
``` ```
@@ -89,10 +90,18 @@ my-batocera-store/
```sh ```sh
#!/bin/bash #!/bin/bash
set -euo pipefail set -euo pipefail
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master}" ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}"
export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}" export STORE_CONFIG="${STORE_CONFIG:-https://git.example.org/tools/my-batocera-store/raw/branch/master/config.json}"
export BATOCERA_PORT_NAME="My Store" export BATOCERA_PORT_NAME="My Store"
curl -fsSL "$ENGINE_RAW_BASE/install.sh" | bash curl -fsSL "$ENGINE_RAW_BASE/install.sh" | sh
```
`uninstall.sh` is the same shape, except that it passes `STORE_ID` — no config
fetch is needed to take a store away, and the id is what names its home:
```sh
export STORE_ID="${STORE_ID:-example}"
curl -fsSL "$ENGINE_RAW_BASE/uninstall.sh" | sh
``` ```
Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps Pick a `store.id` and a `paths.subfolder` nobody else uses — they are what keeps
@@ -114,11 +123,51 @@ $S sync # download everything new, refresh gamelists
$S sync blessingofra # just one title (never prunes) $S sync blessingofra # just one title (never prunes)
$S -n sync # dry run $S -n sync # dry run
$S remove c64:demo # uninstall (bare name works too) $S remove c64:demo # uninstall (bare name works too)
$S purge # uninstall everything this store installed
$S config # effective configuration $S config # effective configuration
``` ```
Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`. Global flags go **before** the subcommand: `$S --roms-root /tmp/roms sync`.
## Uninstalling
```sh
ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/uninstall.sh | sh'
```
or `./uninstall.sh` from a checkout on the box. It removes the games first,
through the engine — `purge` — and only then the Ports entry, the launcher and the
store home: the shell script has no way of knowing which ROMs, box art, Ports
payloads and gamelist entries were the store's, and the engine's `state.json`
does. If the engine cannot finish — an unmounted ROMs root, say — nothing else is
touched, so you are never left with the files but not the engine that knows about
them.
| Environment variable | Meaning |
|---|---|
| `STORE_ID` | which store to remove; needed only when several are installed |
| `STORE_CONFIG` | alternative to `STORE_ID` — the id is read out of the config |
| `DRY_RUN` | `1` to print what would go and remove nothing |
| `KEEP_HOME` | `1` to keep the store home (state, catalog cache, log) for a reinstall |
| `BATOCERA_STORE_ROOT`, `BATOCERA_PORTS_DIR`, `BATOCERA_PORT_NAME` | as for the installer |
What it deliberately leaves behind:
- **ROMs you put in the store's subfolder yourself.** Directories are removed only when empty, so one stray file keeps its directory. `ports/.data` itself also stays: another store may still keep a payload there.
- **your gamelists.** They belong to the box: our `<game>` and `<folder>` nodes go, the rest — your own games, their play counts and favourites — is written back untouched. A gamelist we *created* and that is left empty is deleted, since it was never yours.
- **`gamelist.xml.<store id>-backup`**, listed at the end of the run: your gamelists as we first found them. Deleting them is your call.
A dry run cannot predict which directories will end up empty, so it lists the
files but not the directory removals.
To take **every** store off a machine — both engines, the shared core, the
launchers — there is one script for that in
[`warpstore`](https://git.teletypegames.org/engines/warpstore):
```sh
curl -fsSL https://git.teletypegames.org/engines/warpstore/raw/branch/master/uninstall.sh | sh
```
## Where things land ## Where things land
``` ```
+4 -4
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/sh
# Install a WarpEngine store on a Batocera box. # Install a WarpEngine store on a Batocera box.
# #
# This installer belongs to the engine, so it takes the store as a parameter: # This installer belongs to the engine, so it takes the store as a parameter:
@@ -8,13 +8,13 @@
# #
# STORE_CONFIG=./config.json ./install.sh # STORE_CONFIG=./config.json ./install.sh
# STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \ # STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json \
# bash <(curl -fsSL https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master/install.sh) # curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/install.sh | sh
# #
set -euo pipefail set -eu
STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}" STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}"
PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}" PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}"
ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/tools/warp-engine-batocera-store/raw/branch/master}" ENGINE_RAW_BASE="${ENGINE_RAW_BASE:-https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master}"
# The shared core lives in its own repository, because both store engines use it. # The shared core lives in its own repository, because both store engines use it.
WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-https://git.teletypegames.org/engines/warpstore/raw/branch/master}" WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-https://git.teletypegames.org/engines/warpstore/raw/branch/master}"
STORE_CONFIG="${STORE_CONFIG:-}" STORE_CONFIG="${STORE_CONFIG:-}"
+94 -5
View File
@@ -40,7 +40,7 @@ except ImportError:
"(https://git.teletypegames.org/engines/warpstore)") "(https://git.teletypegames.org/engines/warpstore)")
from warpstore import debug, die, log from warpstore import debug, die, log
VERSION = "3.0.0" VERSION = "3.1.0"
# Every store keeps its code, config, state and log in one directory, so a box # Every store keeps its code, config, state and log in one directory, so a box
# can carry several stores side by side without them treading on each other. # can carry several stores side by side without them treading on each other.
@@ -367,12 +367,15 @@ def normalize_path(text):
return text[2:] if text.startswith("./") else text return text[2:] if text.startswith("./") else text
def merge_gamelist(cfg, system, records, dry_run=False): def merge_gamelist(cfg, system, records, dry_run=False, backup=True):
"""Rewrite only the <game>/<folder> nodes under our subfolder. """Rewrite only the <game>/<folder> nodes under our subfolder.
Everything else in the file — the user's own scraped ROMs, their play Everything else in the file — the user's own scraped ROMs, their play
counts, favourites, and whatever another store installed under its own counts, favourites, and whatever another store installed under its own
subfolder — is parsed and written back untouched. subfolder — is parsed and written back untouched.
`backup=False` is for the uninstall: by then any copy worth having was made
on the first sync, and backing up again would only preserve our own entries.
""" """
path = gamelist_path(cfg, system) path = gamelist_path(cfg, system)
prefix = subfolder(cfg) + "/" prefix = subfolder(cfg) + "/"
@@ -384,9 +387,9 @@ def merge_gamelist(cfg, system, records, dry_run=False):
log(f"warning: {path} is not valid XML ({exc}) — starting a fresh gamelist") log(f"warning: {path} is not valid XML ({exc}) — starting a fresh gamelist")
root = ET.Element("gameList") root = ET.Element("gameList")
else: else:
backup = f"{path}.{cfg['store']['id']}-backup" backup_path = f"{path}.{cfg['store']['id']}-backup"
if not os.path.exists(backup) and not dry_run: if backup and not os.path.exists(backup_path) and not dry_run:
shutil.copy2(path, backup) shutil.copy2(path, backup_path)
else: else:
root = ET.Element("gameList") root = ET.Element("gameList")
@@ -660,6 +663,88 @@ def cmd_remove(cfg, args):
return 0 return 0
def drop_empty_gamelist(cfg, system, dry_run=False):
"""Delete a gamelist that is empty and that we created.
No `.<id>-backup` next to it means we never found a gamelist there — it is
ours alone, and with our nodes gone there is nothing in it. Leaving the empty
shell behind would mean the uninstall did not quite put the box back.
"""
path = gamelist_path(cfg, system)
if not os.path.isfile(path) or os.path.exists(f"{path}.{cfg['store']['id']}-backup"):
return
try:
root = ET.parse(path).getroot()
except ET.ParseError:
return
if len(root):
return
if dry_run:
log(f"would remove the now-empty {path}")
else:
os.unlink(path)
log(f"removed the now-empty {path}")
def gamelist_backups(cfg, systems):
"""The `gamelist.xml.<id>-backup` copies we made, if any are still around."""
paths = [f"{gamelist_path(cfg, system)}.{cfg['store']['id']}-backup" for system in systems]
return sorted(p for p in paths if os.path.isfile(p))
def cmd_purge(cfg, args):
"""Uninstall every game, and take the store's own folders with it.
What `remove` does for one game, for all of them at once — then the empty
directories, so an uninstall leaves the box looking as it did before. This is
what `uninstall.sh` runs before deleting the store itself: the shell script
has no way of knowing which files were ours.
The gamelists are merged, not deleted: they belong to the box, and after our
nodes are gone the rest of the user's library is still in them.
"""
state = ws.load_state()
installed = state["installed"]
if not installed:
log("nothing is installed by this store")
systems = set()
for key, record in list(installed.items()):
remove_game(cfg, record, dry_run=args.dry_run)
systems.add(ws.record_scope(record))
if not args.dry_run:
del installed[key]
if not args.dry_run:
ws.save_state(state)
for system in sorted(systems):
merge_gamelist(cfg, system, [], dry_run=args.dry_run, backup=False)
drop_empty_gamelist(cfg, system, dry_run=args.dry_run)
dirs = []
for system in sorted(systems):
base = system_dir(cfg, system)
dirs += [os.path.join(base, subfolder(cfg), "images"),
os.path.join(base, subfolder(cfg)),
# `.data` itself stays: it is shared ground, another store may
# still be keeping a payload there.
os.path.join(base, ".data", subfolder(cfg))]
ws.prune_empty_dirs(dirs, root=roms_root(cfg), dry_run=args.dry_run)
leftovers = gamelist_backups(cfg, systems)
if leftovers:
log("left in place — these are copies of your gamelists as we first found them:")
for path in leftovers:
log(f" {path}")
if args.dry_run:
log("dry run — nothing removed")
return 0
if cfg["emulationstation"].get("restart") and not args.no_restart and es_pid():
restart_es()
return 0
def cmd_config(cfg, args): def cmd_config(cfg, args):
if args.write: if args.write:
if os.path.exists(args.config) and not args.force: if os.path.exists(args.config) and not args.force:
@@ -712,6 +797,10 @@ def main(argv=None):
p_rm.add_argument("name", nargs="+") p_rm.add_argument("name", nargs="+")
p_rm.set_defaults(func=cmd_remove) p_rm.set_defaults(func=cmd_remove)
p_purge = sub.add_parser("purge", help="uninstall everything this store installed")
p_purge.add_argument("--no-restart", action="store_true", help="never restart EmulationStation")
p_purge.set_defaults(func=cmd_purge)
p_cfg = sub.add_parser("config", help="print the effective config") p_cfg = sub.add_parser("config", help="print the effective config")
p_cfg.add_argument("--write", action="store_true", help="write a template config file") p_cfg.add_argument("--write", action="store_true", help="write a template config file")
p_cfg.add_argument("--force", action="store_true") p_cfg.add_argument("--force", action="store_true")
Executable
+131
View File
@@ -0,0 +1,131 @@
#!/bin/sh
# Remove a WarpEngine store from a Batocera box.
#
# The engine does the removing, not this script: it is the only thing that knows
# which ROMs, box art, Ports payloads and gamelist entries were the store's — so
# it runs `purge` first, and only then do the launcher, the Ports entry and the
# store home go.
#
# ./uninstall.sh # the only store installed, or say which one
# STORE_ID=example ./uninstall.sh
# DRY_RUN=1 ./uninstall.sh # show what would go, remove nothing
# ssh root@batocera 'curl -fsSL https://git.teletypegames.org/stores/warp-engine-batocera-store/raw/branch/master/uninstall.sh | sh'
#
set -eu
STORE_ROOT="${BATOCERA_STORE_ROOT:-/userdata/system/batocera-store}"
PORTS_DIR="${BATOCERA_PORTS_DIR:-/userdata/roms/ports}"
STORE_ID="${STORE_ID:-}"
STORE_CONFIG="${STORE_CONFIG:-}"
DRY_RUN="${DRY_RUN:-0}"
# Keep the store home — its state.json, catalog cache and log — and take only the
# games, the launcher and the Ports entry. Useful before a reinstall.
KEEP_HOME="${KEEP_HOME:-0}"
say() { echo "[uninstall] $*"; }
die() { echo "[uninstall] error: $*" >&2; exit 1; }
run() {
if [ "$DRY_RUN" = "1" ]; then echo "[uninstall] would: $*"; else "$@"; fi
}
command -v python3 >/dev/null 2>&1 || die "python3 is required"
# Which store? An explicit id wins, then the id in a config, then — if exactly
# one store is installed — that one. Guessing between several would be worse than
# asking.
if [ -z "$STORE_ID" ] && [ -n "$STORE_CONFIG" ]; then
TMP_CFG="$(mktemp)"
trap 'rm -f "$TMP_CFG"' EXIT
case "$STORE_CONFIG" in
http://*|https://*) curl -fsSL "$STORE_CONFIG" -o "$TMP_CFG" ;;
*) [ -f "$STORE_CONFIG" ] || die "no such file: $STORE_CONFIG"; cp "$STORE_CONFIG" "$TMP_CFG" ;;
esac
STORE_ID="$(python3 -c 'import json,sys; print((json.load(open(sys.argv[1])).get("store") or {}).get("id") or "")' "$TMP_CFG")"
[ -n "$STORE_ID" ] || die "no store.id in $STORE_CONFIG"
fi
if [ -z "$STORE_ID" ]; then
[ -d "$STORE_ROOT" ] || die "nothing is installed under $STORE_ROOT"
FOUND=""
COUNT=0
for d in "$STORE_ROOT"/*/; do
if [ -f "$d/config.json" ]; then
FOUND="$FOUND $(basename "$d")"
COUNT=$((COUNT + 1))
fi
done
case "$COUNT" in
0) die "nothing is installed under $STORE_ROOT" ;;
1) STORE_ID="${FOUND# }"; say "the only store installed is '$STORE_ID'" ;;
*) die "several stores are installed ($FOUND) — pick one: STORE_ID=<id> $0" ;;
esac
fi
STORE_HOME="$STORE_ROOT/$STORE_ID"
LAUNCHER="$STORE_ROOT/$STORE_ID-store"
[ -d "$STORE_HOME" ] || die "no store '$STORE_ID' in $STORE_ROOT"
# The Ports entry cannot be derived from the config: a store repository may set
# BATOCERA_PORT_NAME to something else entirely (ours is "Teletype Games Store",
# while store.name is "Teletype Games"). What is certain is that the entry runs
# this store's launcher, so the entry is found by looking inside the scripts —
# which also works for stores installed before this uninstaller existed.
PORTS_ENTRIES=""
if [ -n "${BATOCERA_PORT_NAME:-}" ]; then
[ -f "$PORTS_DIR/$BATOCERA_PORT_NAME.sh" ] && PORTS_ENTRIES="$PORTS_DIR/$BATOCERA_PORT_NAME.sh"
elif [ -d "$PORTS_DIR" ]; then
for entry in "$PORTS_DIR"/*.sh; do
[ -f "$entry" ] || continue
if grep -q -F "$STORE_HOME" "$entry" 2>/dev/null; then
PORTS_ENTRIES="$PORTS_ENTRIES
$entry"
fi
done
fi
say "removing the store '$STORE_ID' from $STORE_HOME"
# The games first, through the engine. If it cannot finish — an unmounted ROMs
# root, say — stop here rather than delete the engine that knows what it
# installed and leave the files orphaned.
if [ -f "$STORE_HOME/store.py" ] && [ -f "$STORE_HOME/config.json" ]; then
DRY=""
[ "$DRY_RUN" = "1" ] && DRY="--dry-run"
# shellcheck disable=SC2086 — the flag is ours and deliberately word-split
BATOCERA_STORE_HOME="$STORE_HOME" python3 "$STORE_HOME/store.py" \
--config "$STORE_HOME/config.json" $DRY purge \
|| die "the engine could not remove the games (see above) — nothing else was touched."
else
say "warning: no engine in $STORE_HOME — the ROMs and gamelist entries have to go by hand"
fi
# Newline-separated so a Ports entry with spaces in its name survives the loop.
if [ -n "$PORTS_ENTRIES" ]; then
printf '%s\n' "$PORTS_ENTRIES" | while IFS= read -r entry; do
[ -n "$entry" ] || continue
if [ "$DRY_RUN" = "1" ]; then
echo "[uninstall] would: rm -f $entry"
else
rm -f "$entry"
say "removed the Ports entry $entry"
fi
done
else
say "no Ports entry found in $PORTS_DIR"
fi
run rm -f "$LAUNCHER"
if [ "$KEEP_HOME" = "1" ]; then
say "keeping $STORE_HOME (KEEP_HOME=1)"
else
run rm -rf "$STORE_HOME"
# Only if this was the last store on the box.
rmdir "$STORE_ROOT" 2>/dev/null && say "removed the now-empty $STORE_ROOT" || true
fi
if [ "$DRY_RUN" = "1" ]; then
say "dry run — nothing was removed."
else
say "done. EmulationStation was restarted if it was running; if not, the games are"
say "already gone from the gamelists."
fi