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:
@@ -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**.
|
||||
The part that is not about Batocera — catalog, releases, host matching, state,
|
||||
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
|
||||
@@ -28,7 +28,7 @@ teletypegames.org my.example other.example
|
||||
```
|
||||
|
||||
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>"
|
||||
@@ -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:
|
||||
|
||||
```sh
|
||||
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 |
|
||||
STORE_CONFIG=https://git.example.org/tools/my-store/raw/branch/master/config.json sh
|
||||
```
|
||||
|
||||
From a checkout, with a local config:
|
||||
@@ -75,12 +75,13 @@ Installer knobs, all environment variables: `STORE_CONFIG` (required),
|
||||
|
||||
## Writing a store repository
|
||||
|
||||
Three files:
|
||||
Four small files:
|
||||
|
||||
```
|
||||
my-batocera-store/
|
||||
├── config.json the store: URL, name, subfolder, platform mapping
|
||||
├── install.sh a wrapper that hands that config to the engine installer
|
||||
├── uninstall.sh the same for the engine's uninstaller
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -89,10 +90,18 @@ my-batocera-store/
|
||||
```sh
|
||||
#!/bin/bash
|
||||
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 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
|
||||
@@ -114,11 +123,51 @@ $S sync # download everything new, refresh gamelists
|
||||
$S sync blessingofra # just one title (never prunes)
|
||||
$S -n sync # dry run
|
||||
$S remove c64:demo # uninstall (bare name works too)
|
||||
$S purge # uninstall everything this store installed
|
||||
$S config # effective configuration
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
# Install a WarpEngine store on a Batocera box.
|
||||
#
|
||||
# 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=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}"
|
||||
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.
|
||||
WARPSTORE_RAW_BASE="${WARPSTORE_RAW_BASE:-https://git.teletypegames.org/engines/warpstore/raw/branch/master}"
|
||||
STORE_CONFIG="${STORE_CONFIG:-}"
|
||||
|
||||
@@ -40,7 +40,7 @@ except ImportError:
|
||||
"(https://git.teletypegames.org/engines/warpstore)")
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Everything else in the file — the user's own scraped ROMs, their play
|
||||
counts, favourites, and whatever another store installed under its own
|
||||
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)
|
||||
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")
|
||||
root = ET.Element("gameList")
|
||||
else:
|
||||
backup = f"{path}.{cfg['store']['id']}-backup"
|
||||
if not os.path.exists(backup) and not dry_run:
|
||||
shutil.copy2(path, backup)
|
||||
backup_path = f"{path}.{cfg['store']['id']}-backup"
|
||||
if backup and not os.path.exists(backup_path) and not dry_run:
|
||||
shutil.copy2(path, backup_path)
|
||||
else:
|
||||
root = ET.Element("gameList")
|
||||
|
||||
@@ -660,6 +663,88 @@ def cmd_remove(cfg, args):
|
||||
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):
|
||||
if args.write:
|
||||
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.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.add_argument("--write", action="store_true", help="write a template config file")
|
||||
p_cfg.add_argument("--force", action="store_true")
|
||||
|
||||
Executable
+131
@@ -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
|
||||
Reference in New Issue
Block a user