From 407abfe6ec57be54ad479ae8644b2fbf9102fac0 Mon Sep 17 00:00:00 2001 From: pleb Date: Wed, 1 Jul 2026 13:43:39 -0700 Subject: [PATCH] Simplify plugin-helper state configuration --- README.md | 84 ++++++++++++------------ plugin-helper.toml.example | 15 ++--- src/plugin_helper/cli.py | 61 +++-------------- src/plugin_helper/config.py | 115 ++++++++++++++++---------------- src/plugin_helper/tui.py | 10 +-- tests/test_plugin_helper.py | 127 +++++++++++++++++++++++++----------- 6 files changed, 213 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index 8a67657..73e64b5 100644 --- a/README.md +++ b/README.md @@ -11,55 +11,67 @@ The first implementation focuses on safe local workflows: - apply exactly that plan and record install state - uninstall only files recorded in install state -Default BSManager instance roots: +Default BSManager instance root: ```text -/home/pleb/Windows/Users/pleb/BSManager/BSInstances /home/pleb/.local/share/BSManager/BSInstances ``` -Override with `--instances-root` or `PLUGIN_HELPER_INSTANCES_ROOT`. To search +Default plugin-helper state directory: + +```text +$XDG_STATE_HOME/plugin-helper +``` + +If `XDG_STATE_HOME` is not set, the state directory defaults to: + +```text +~/.local/state/plugin-helper +``` + +Override the instance root with `--instances-root`, +`PLUGIN_HELPER_INSTANCES_ROOT`, or `plugin-helper.local.toml`. To search multiple explicit roots, separate them with `:`. -## Managing Multiple Installs +Override the state directory with `--state-dir`, `PLUGIN_HELPER_STATE_DIR`, or +`plugin-helper.local.toml`. -The helper is intended to manage both the local Linux BSManager install and the -mounted Windows install. Lockfiles and registry entries are shared by Beat Saber -version, but install state is target-specific. When the same instance name -exists under both roots, such as `1.44.1`, give each install profile its own -state directory. +## Local Configuration + +This checkout is intended to manage the local Linux BSManager install. If you +also manage a Windows install, use a separate clone on that partition and point +both clones at the same state directory only when you intentionally want one +shared source of truth. + +Copy the example config and adjust paths if needed: -Copy the example profile config and adjust paths if needed: ```sh cp plugin-helper.toml.example plugin-helper.local.toml ``` -`plugin-helper.local.toml` is ignored by git. The default example uses this -repo-local convention: +`plugin-helper.local.toml` is ignored by git and uses top-level fields: -```text -.state/ local Linux BSManager state -.state-windows/ mounted Windows BSManager state +```toml +instances_root = "~/.local/share/BSManager/BSInstances" +state_dir = "~/.local/state/plugin-helper" ``` -Examples: +For repo-local state, set: -```sh -PYTHONPATH=src python -m plugin_helper \ - --profile linux \ - installed --instance 1.44.1 - -PYTHONPATH=src python -m plugin_helper \ - --profile windows \ - installed --instance 1.44.1 +```toml +state_dir = ".state" ``` -Explicit `--instances-root` and `--state-dir` still work and override profile -values. Do not reuse the same state directory for both targets when their -instance names match. The current state layout is keyed by instance name, so -sharing one state directory would mix bootstrap records, generated plans, -backups, and installed file records for different game trees. +For a shared Windows-partition state directory, set the same `state_dir` in both +clones, for example: + +```toml +state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state" +``` + +CLI flags override environment variables, environment variables override local +config, and local config overrides built-in defaults. ## Commands @@ -79,21 +91,13 @@ disable all currently enabled managed plugins and `e` to enable all currently disabled managed plugins. The individual subcommands are mostly for automation and debugging. If you use -them, prefer `--profile linux` or `--profile windows`. Pass `--state-dir` -directly only when you intentionally want to override profile state or use the -default live state outside this repo. +them, pass `--state-dir` directly only when you intentionally want to override +the configured state directory for one command. Install assets are currently expected to already exist locally, usually under: ```text -.state/instances//downloads// -``` - -For a second target-specific state directory, copy or re-download the same -locked assets under that state root before planning. For example: - -```text -.state-windows/instances//downloads// +/instances//downloads// ``` ## Beat Saber Data Backups diff --git a/plugin-helper.toml.example b/plugin-helper.toml.example index c150215..caab0a8 100644 --- a/plugin-helper.toml.example +++ b/plugin-helper.toml.example @@ -1,11 +1,8 @@ -[[profiles]] -id = "linux" -label = "Local Linux BSManager" instances_root = "~/.local/share/BSManager/BSInstances" -state_dir = ".state" +state_dir = "~/.local/state/plugin-helper" -[[profiles]] -id = "windows" -label = "Mounted Windows BSManager" -instances_root = "~/Windows/Users/pleb/BSManager/BSInstances" -state_dir = ".state-windows" +# To keep state inside this checkout instead: +# state_dir = ".state" +# +# To share one state directory between Linux and a Windows-partition checkout: +# state_dir = "~/Windows/Users/pleb/ops/plugin-helper/.state" diff --git a/src/plugin_helper/cli.py b/src/plugin_helper/cli.py index 3683336..0178d55 100644 --- a/src/plugin_helper/cli.py +++ b/src/plugin_helper/cli.py @@ -6,7 +6,7 @@ import sys from pathlib import Path from typing import Any -from .config import Profile, repo_root, resolve_runtime_config +from .config import repo_root, resolve_runtime_config from .bootstrap import run_bootstrap from .bsipa import check_bsipa_health from .checker import check_lock @@ -63,8 +63,6 @@ def _add_common(parser: argparse.ArgumentParser, *, suppress_default: bool = Fal default = argparse.SUPPRESS if suppress_default else None parser.add_argument("--instances-root", default=default, help="BSManager instances root") parser.add_argument("--state-dir", default=default, help="plugin-helper state directory") - parser.add_argument("--profile", default=default, help="Configured profile id from plugin-helper.local.toml") - parser.add_argument("--config", default=default, help="Path to plugin-helper TOML config") def build_parser() -> argparse.ArgumentParser: @@ -225,37 +223,6 @@ def _common_parent() -> argparse.ArgumentParser: return parent -def _menu_profiles( - *, - profiles: tuple[Profile, ...], - selected_profile: Profile | None, - instances_roots: list[Path], - state_root: Path, - use_config_profiles: bool, -) -> list[Profile]: - if selected_profile: - return [ - Profile( - id=selected_profile.id if len(instances_roots) == 1 else f"{selected_profile.id}-{index}", - label=selected_profile.label, - instances_root=root, - state_dir=state_root, - ) - for index, root in enumerate(instances_roots, start=1) - ] - if use_config_profiles and profiles: - return list(profiles) - return [ - Profile( - id=f"root-{index}", - label=str(root), - instances_root=root, - state_dir=state_root, - ) - for index, root in enumerate(instances_roots, start=1) - ] - - def _run_menu( *, runtime: Any, @@ -269,35 +236,29 @@ def _run_menu( print("Install project dependencies, for example: python -m pip install -e .") return 1 - profiles = _menu_profiles( - profiles=runtime.profiles, - selected_profile=runtime.selected_profile, - instances_roots=runtime.instances_roots, - state_root=runtime.state_root, - use_config_profiles=runtime.config_loaded and not explicit_instances_root and not explicit_state_dir, - ) choices: list[InstallationChoice] = [] - for profile in profiles: - for instance in list_instances(profile.instances_root): + for index, root in enumerate(runtime.instances_roots, start=1): + install_label = str(root) if len(runtime.instances_roots) > 1 else "Default" + for instance in list_instances(root): choices.append( InstallationChoice( - profile_id=profile.id, - profile_label=profile.label, + install_id=f"root-{index}", + install_label=install_label, instance_name=instance.name, instance_path=instance.path, - state_root=profile.state_dir, + state_root=runtime.state_root, ) ) if not choices: - searched = ", ".join(str(profile.instances_root) for profile in profiles) + searched = ", ".join(str(root) for root in runtime.instances_roots) print(f"No Beat Saber instances found under {searched}") return 1 setup_hint = None - if not runtime.config_loaded and not runtime.selected_profile and not explicit_instances_root and not explicit_state_dir: + if not runtime.config_loaded and not explicit_instances_root and not explicit_state_dir: setup_hint = ( f"No {runtime.config_path.name} found; using default roots and default state. " - f"Copy plugin-helper.toml.example to {runtime.config_path.name} to map each install to a state dir." + f"Copy plugin-helper.toml.example to {runtime.config_path.name} to set a custom state dir." ) app = PluginHelperTui(choices=choices, repo_root=repo_root(), setup_hint=setup_hint) result = app.run() @@ -321,8 +282,6 @@ def run(argv: list[str] | None = None) -> int: runtime = resolve_runtime_config( instances_root_value=getattr(args, "instances_root", None), state_dir_value=getattr(args, "state_dir", None), - profile_id=getattr(args, "profile", None), - config_path=getattr(args, "config", None), ) inst_roots = runtime.instances_roots st_root = runtime.state_root diff --git a/src/plugin_helper/config.py b/src/plugin_helper/config.py index 0c5cd2a..6832c89 100644 --- a/src/plugin_helper/config.py +++ b/src/plugin_helper/config.py @@ -7,27 +7,21 @@ from pathlib import Path from typing import Any -WINDOWS_INSTANCES_ROOT = Path("/home/pleb/Windows/Users/pleb/BSManager/BSInstances") LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances" -DEFAULT_INSTANCES_ROOTS = (WINDOWS_INSTANCES_ROOT, LOCAL_INSTANCES_ROOT) -DEFAULT_INSTANCES_ROOT = WINDOWS_INSTANCES_ROOT +DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT LOCAL_CONFIG_NAME = "plugin-helper.local.toml" @dataclass(frozen=True) -class Profile: - id: str - label: str - instances_root: Path - state_dir: Path +class LocalConfig: + instances_roots: list[Path] | None + state_root: Path | None @dataclass(frozen=True) class RuntimeConfig: instances_roots: list[Path] state_root: Path - profiles: tuple[Profile, ...] - selected_profile: Profile | None config_path: Path config_loaded: bool @@ -40,16 +34,19 @@ def instances_root(value: str | None = None) -> Path: return instances_roots(value)[0] -def instances_roots(value: str | None = None) -> list[Path]: +def instances_roots(value: str | None = None, *, base: Path | None = None) -> list[Path]: raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") if raw: - return [Path(item).expanduser() for item in raw.split(os.pathsep) if item] - return list(DEFAULT_INSTANCES_ROOTS) + return _resolve_path_list(raw, base or repo_root()) + return [DEFAULT_INSTANCES_ROOT] def state_root(value: str | None = None) -> Path: if value: return _resolve_path(value, repo_root()) + env_state = os.environ.get("PLUGIN_HELPER_STATE_DIR") + if env_state: + return _resolve_path(env_state, repo_root()) xdg_state = os.environ.get("XDG_STATE_HOME") base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" return base / "plugin-helper" @@ -66,75 +63,81 @@ def _resolve_path(value: str | Path, base: Path) -> Path: return (base / path).resolve() -def load_profiles(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[tuple[Profile, ...], Path, bool]: +def _resolve_path_list(value: str, base: Path) -> list[Path]: + return [_resolve_path(item, base) for item in value.split(os.pathsep) if item] + + +def load_local_config(config_path: str | Path | None = None, *, root: Path | None = None) -> tuple[LocalConfig, Path, bool]: repo = root or repo_root() path = _resolve_path(config_path, repo) if config_path else default_config_path(repo) if not path.exists(): if config_path: raise FileNotFoundError(f"plugin-helper config not found: {path}") - return (), path, False + return LocalConfig(instances_roots=None, state_root=None), path, False with path.open("rb") as handle: data: dict[str, Any] = tomllib.load(handle) - profiles: list[Profile] = [] - seen: set[str] = set() base = path.parent - for item in data.get("profiles", []): - profile_id = item["id"] - if profile_id in seen: - raise ValueError(f"{path}: duplicate profile id: {profile_id}") - seen.add(profile_id) - profiles.append( - Profile( - id=profile_id, - label=item.get("label", profile_id), - instances_root=_resolve_path(item["instances_root"], base), - state_dir=_resolve_path(item["state_dir"], base), - ) - ) - return tuple(profiles), path, True + instances_value = data.get("instances_root") + state_value = data.get("state_dir") + return ( + LocalConfig( + instances_roots=_resolve_path_list(instances_value, base) if instances_value else None, + state_root=_resolve_path(state_value, base) if state_value else None, + ), + path, + True, + ) -def profile_by_id(profiles: tuple[Profile, ...], profile_id: str) -> Profile: - for profile in profiles: - if profile.id == profile_id: - return profile - available = ", ".join(profile.id for profile in profiles) or "(none)" - raise KeyError(f"unknown profile: {profile_id}; available profiles: {available}") +def _env_instances_roots(root: Path) -> list[Path] | None: + value = os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT") + return _resolve_path_list(value, root) if value else None + + +def _env_state_root(root: Path) -> Path | None: + value = os.environ.get("PLUGIN_HELPER_STATE_DIR") + return _resolve_path(value, root) if value else None + + +def _default_state_root() -> Path: + xdg_state = os.environ.get("XDG_STATE_HOME") + base = Path(xdg_state).expanduser() if xdg_state else Path.home() / ".local" / "state" + return base / "plugin-helper" + + +def _default_instances_roots() -> list[Path]: + return [DEFAULT_INSTANCES_ROOT] def resolve_runtime_config( *, instances_root_value: str | None = None, state_dir_value: str | None = None, - profile_id: str | None = None, - config_path: str | Path | None = None, root: Path | None = None, ) -> RuntimeConfig: repo = root or repo_root() - profiles, loaded_path, loaded = load_profiles(config_path, root=repo) - selected = profile_by_id(profiles, profile_id) if profile_id else None + local_config, loaded_path, loaded = load_local_config(root=repo) - if instances_root_value: - resolved_instances = instances_roots(instances_root_value) - elif selected: - resolved_instances = [selected.instances_root] - else: - resolved_instances = instances_roots(None) - - if state_dir_value: - resolved_state = _resolve_path(state_dir_value, repo) - elif selected: - resolved_state = selected.state_dir - else: - resolved_state = state_root(None) + resolved_instances = ( + _resolve_path_list(instances_root_value, repo) + if instances_root_value + else _env_instances_roots(repo) + or local_config.instances_roots + or _default_instances_roots() + ) + resolved_state = ( + _resolve_path(state_dir_value, repo) + if state_dir_value + else _env_state_root(repo) + or local_config.state_root + or _default_state_root() + ) return RuntimeConfig( instances_roots=resolved_instances, state_root=resolved_state, - profiles=profiles, - selected_profile=selected, config_path=loaded_path, config_loaded=loaded, ) diff --git a/src/plugin_helper/tui.py b/src/plugin_helper/tui.py index 0343dd9..0ceed4c 100644 --- a/src/plugin_helper/tui.py +++ b/src/plugin_helper/tui.py @@ -18,8 +18,8 @@ from .state import load_installed_state @dataclass(frozen=True) class InstallationChoice: - profile_id: str - profile_label: str + install_id: str + install_label: str instance_name: str instance_path: Path state_root: Path @@ -189,10 +189,10 @@ class PluginHelperTui(App[int]): self._set_title("Choose Beat Saber Installation") table = self.query_one(DataTable) table.clear(columns=True) - table.add_columns("Profile", "Version", "Instance Path", "State Dir") + table.add_columns("Install", "Version", "Instance Path", "State Dir") for choice in self.choices: table.add_row( - choice.profile_label, + choice.install_label, choice.instance_name, str(choice.instance_path), str(choice.state_root), @@ -208,7 +208,7 @@ class PluginHelperTui(App[int]): return target = self.selected_installation self.mode = "plugins" - self._set_title(f"{target.profile_label} / {target.instance_name}") + self._set_title(f"{target.install_label} / {target.instance_name}") table = self.query_one(DataTable) table.clear(columns=True) table.add_columns("Status", "Name", "ID", "Version", "Files", "Asset") diff --git a/tests/test_plugin_helper.py b/tests/test_plugin_helper.py index 9b31d5a..887730d 100644 --- a/tests/test_plugin_helper.py +++ b/tests/test_plugin_helper.py @@ -17,7 +17,7 @@ from plugin_helper.bootstrap import _run_ipa from plugin_helper.beatmods import by_version_id, normalize_mods from plugin_helper.checker import check_lock from plugin_helper.cli import installed_plugins_report, run -from plugin_helper.config import load_profiles, profile_by_id, resolve_runtime_config +from plugin_helper.config import load_local_config, resolve_runtime_config from plugin_helper.fsutil import sha256_file from plugin_helper.installer import apply_plan, disable_plugin, uninstall_plugin from plugin_helper.instances import get_instance, list_instances @@ -125,61 +125,112 @@ class PluginHelperTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "ambiguous"): get_instance([windows, local], "1.44.1") - def test_profile_config_loads_and_resolves_paths(self) -> None: + def test_local_config_loads_top_level_paths(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) config = root / "plugin-helper.local.toml" config.write_text( """ -[[profiles]] -id = "linux" -label = "Linux" instances_root = "~/BSInstances" state_dir = ".state" - -[[profiles]] -id = "windows" -label = "Windows" -instances_root = "mounted/BSInstances" -state_dir = ".state-windows" """.lstrip(), encoding="utf-8", ) - profiles, loaded_path, loaded = load_profiles(config, root=root) + local_config, loaded_path, loaded = load_local_config(config, root=root) self.assertTrue(loaded) self.assertEqual(loaded_path, config) - self.assertEqual(profile_by_id(profiles, "linux").instances_root, Path("~/BSInstances").expanduser()) - self.assertEqual(profile_by_id(profiles, "windows").instances_root, root / "mounted" / "BSInstances") - self.assertEqual(profile_by_id(profiles, "windows").state_dir, root / ".state-windows") + self.assertEqual(local_config.instances_roots, [Path("~/BSInstances").expanduser()]) + self.assertEqual(local_config.state_root, root / ".state") - def test_profile_runtime_explicit_overrides(self) -> None: + def test_runtime_explicit_overrides_env_and_config(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - config = root / "plugin-helper.local.toml" - config.write_text( + (root / "plugin-helper.local.toml").write_text( """ -[[profiles]] -id = "linux" -label = "Linux" -instances_root = "profile-root" -state_dir = ".state" +instances_root = "config-root" +state_dir = "config-state" """.lstrip(), encoding="utf-8", ) - runtime = resolve_runtime_config( - instances_root_value=str(root / "explicit-root"), - state_dir_value="explicit-state", - profile_id="linux", - config_path=config, - root=root, - ) + with patch.dict( + os.environ, + { + "PLUGIN_HELPER_INSTANCES_ROOT": str(root / "env-root"), + "PLUGIN_HELPER_STATE_DIR": str(root / "env-state"), + }, + clear=True, + ): + runtime = resolve_runtime_config( + instances_root_value=str(root / "explicit-root"), + state_dir_value="explicit-state", + root=root, + ) self.assertEqual(runtime.instances_roots, [root / "explicit-root"]) self.assertEqual(runtime.state_root, root / "explicit-state") - self.assertEqual(runtime.selected_profile.id, "linux") + + def test_runtime_env_overrides_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "plugin-helper.local.toml").write_text( + """ +instances_root = "config-root" +state_dir = "config-state" +""".lstrip(), + encoding="utf-8", + ) + + with patch.dict( + os.environ, + { + "PLUGIN_HELPER_INSTANCES_ROOT": f"{root / 'env-root-a'}{os.pathsep}{root / 'env-root-b'}", + "PLUGIN_HELPER_STATE_DIR": str(root / "env-state"), + }, + clear=True, + ): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.instances_roots, [root / "env-root-a", root / "env-root-b"]) + self.assertEqual(runtime.state_root, root / "env-state") + + def test_runtime_uses_local_config_before_defaults(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "plugin-helper.local.toml").write_text( + """ +instances_root = "config-root" +state_dir = "config-state" +""".lstrip(), + encoding="utf-8", + ) + + with patch.dict(os.environ, {}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.instances_roots, [root / "config-root"]) + self.assertEqual(runtime.state_root, root / "config-state") + + def test_runtime_default_state_uses_xdg_state_home(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + xdg = root / "xdg-state" + + with patch.dict(os.environ, {"XDG_STATE_HOME": str(xdg)}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.state_root, xdg / "plugin-helper") + + def test_runtime_default_state_uses_home_local_state(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + with patch.dict(os.environ, {}, clear=True): + runtime = resolve_runtime_config(root=root) + + self.assertEqual(runtime.state_root, Path.home() / ".local" / "state" / "plugin-helper") def test_no_args_prints_help_when_not_interactive(self) -> None: output = StringIO() @@ -196,7 +247,7 @@ state_dir = ".state" patch("sys.stdout.isatty", return_value=True), patch("plugin_helper.cli._run_menu", return_value=0) as run_menu, ): - status = run(["--profile", "linux"]) + status = run([]) self.assertEqual(status, 0) run_menu.assert_called_once() @@ -1063,8 +1114,8 @@ sha256 = "{sha256_file(asset)}" {"instance": "1.40.8", "plugins": plugins, "disabledPlugins": disabled_plugins}, ) choice = InstallationChoice( - profile_id="test", - profile_label="Test Profile", + install_id="test", + install_label="Test Install", instance_name="1.40.8", instance_path=instance, state_root=state, @@ -1076,15 +1127,15 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase): async def test_installation_picker_shows_duplicate_instances_with_state_dirs(self) -> None: choices = [ InstallationChoice( - profile_id="linux", - profile_label="Linux", + install_id="linux", + install_label="Linux", instance_name="1.44.1", instance_path=Path("/tmp/linux/1.44.1"), state_root=Path("/tmp/state-linux"), ), InstallationChoice( - profile_id="windows", - profile_label="Windows", + install_id="windows", + install_label="Windows", instance_name="1.44.1", instance_path=Path("/tmp/windows/1.44.1"), state_root=Path("/tmp/state-windows"),