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
+94 -5
View File
@@ -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")