Add native Windows compatibility for config, bootstrap, and CLI.
Support profile-based config resolution with auto-selection of a sole profile, native IPA.exe -n bootstrap on Windows, platform-aware process cleanup, and Windows-friendly userdata backup path inference. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -10,12 +11,22 @@ from typing import Any
|
||||
LOCAL_INSTANCES_ROOT = Path.home() / ".local/share/BSManager/BSInstances"
|
||||
DEFAULT_INSTANCES_ROOT = LOCAL_INSTANCES_ROOT
|
||||
LOCAL_CONFIG_NAME = "plugin-helper.local.toml"
|
||||
WINDOWS_CONFIG_NAME = "plugin-helper.windows.toml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
id: str
|
||||
label: str | None
|
||||
instances_roots: list[Path]
|
||||
state_root: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalConfig:
|
||||
instances_roots: list[Path] | None
|
||||
state_root: Path | None
|
||||
profiles: tuple[Profile, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -24,6 +35,12 @@ class RuntimeConfig:
|
||||
state_root: Path
|
||||
config_path: Path
|
||||
config_loaded: bool
|
||||
profile_id: str | None = None
|
||||
profile_label: str | None = None
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
@@ -38,7 +55,7 @@ def instances_roots(value: str | None = None, *, base: Path | None = None) -> li
|
||||
raw = value or os.environ.get("PLUGIN_HELPER_INSTANCES_ROOT")
|
||||
if raw:
|
||||
return _resolve_path_list(raw, base or repo_root())
|
||||
return [DEFAULT_INSTANCES_ROOT]
|
||||
return _default_instances_roots()
|
||||
|
||||
|
||||
def state_root(value: str | None = None) -> Path:
|
||||
@@ -47,13 +64,16 @@ def state_root(value: str | None = None) -> Path:
|
||||
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"
|
||||
return _default_state_root()
|
||||
|
||||
|
||||
def default_config_path(root: Path | None = None) -> Path:
|
||||
return (root or repo_root()) / LOCAL_CONFIG_NAME
|
||||
repo = root or repo_root()
|
||||
if is_windows():
|
||||
windows_config = repo / WINDOWS_CONFIG_NAME
|
||||
if windows_config.is_file():
|
||||
return windows_config
|
||||
return repo / LOCAL_CONFIG_NAME
|
||||
|
||||
|
||||
def _resolve_path(value: str | Path, base: Path) -> Path:
|
||||
@@ -67,13 +87,34 @@ 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 _parse_profiles(data: dict[str, Any], base: Path) -> tuple[Profile, ...]:
|
||||
profiles: list[Profile] = []
|
||||
for entry in data.get("profiles", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
profile_id = entry.get("id")
|
||||
instances_value = entry.get("instances_root")
|
||||
state_value = entry.get("state_dir")
|
||||
if not profile_id or not instances_value or not state_value:
|
||||
raise ValueError(f"profile {profile_id!r} needs id, instances_root, and state_dir")
|
||||
profiles.append(
|
||||
Profile(
|
||||
id=str(profile_id),
|
||||
label=entry.get("label"),
|
||||
instances_roots=_resolve_path_list(str(instances_value), base),
|
||||
state_root=_resolve_path(str(state_value), base),
|
||||
)
|
||||
)
|
||||
return tuple(profiles)
|
||||
|
||||
|
||||
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 LocalConfig(instances_roots=None, state_root=None), path, False
|
||||
return LocalConfig(instances_roots=None, state_root=None, profiles=()), path, False
|
||||
|
||||
with path.open("rb") as handle:
|
||||
data: dict[str, Any] = tomllib.load(handle)
|
||||
@@ -81,10 +122,12 @@ def load_local_config(config_path: str | Path | None = None, *, root: Path | Non
|
||||
base = path.parent
|
||||
instances_value = data.get("instances_root")
|
||||
state_value = data.get("state_dir")
|
||||
profiles = _parse_profiles(data, base)
|
||||
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,
|
||||
profiles=profiles,
|
||||
),
|
||||
path,
|
||||
True,
|
||||
@@ -102,28 +145,57 @@ def _env_state_root(root: Path) -> Path | None:
|
||||
|
||||
|
||||
def _default_state_root() -> Path:
|
||||
if is_windows():
|
||||
localappdata = os.environ.get("LOCALAPPDATA")
|
||||
if localappdata:
|
||||
return Path(localappdata) / "plugin-helper"
|
||||
return Path.home() / "AppData" / "Local" / "plugin-helper"
|
||||
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]:
|
||||
if is_windows():
|
||||
return [Path.home() / "BSManager" / "BSInstances"]
|
||||
return [DEFAULT_INSTANCES_ROOT]
|
||||
|
||||
|
||||
def _select_profile(local_config: LocalConfig, profile_id: str | None) -> Profile | None:
|
||||
if profile_id:
|
||||
for profile in local_config.profiles:
|
||||
if profile.id == profile_id:
|
||||
return profile
|
||||
available = ", ".join(profile.id for profile in local_config.profiles) or "(none)"
|
||||
raise ValueError(f"unknown profile {profile_id!r}; available profiles: {available}")
|
||||
|
||||
if (
|
||||
local_config.profiles
|
||||
and local_config.instances_roots is None
|
||||
and local_config.state_root is None
|
||||
and len(local_config.profiles) == 1
|
||||
):
|
||||
return local_config.profiles[0]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_runtime_config(
|
||||
*,
|
||||
instances_root_value: str | None = None,
|
||||
state_dir_value: str | None = None,
|
||||
config_path_value: str | None = None,
|
||||
profile_id: str | None = None,
|
||||
root: Path | None = None,
|
||||
) -> RuntimeConfig:
|
||||
repo = root or repo_root()
|
||||
local_config, loaded_path, loaded = load_local_config(root=repo)
|
||||
local_config, loaded_path, loaded = load_local_config(config_path_value, root=repo)
|
||||
profile = _select_profile(local_config, profile_id)
|
||||
|
||||
resolved_instances = (
|
||||
_resolve_path_list(instances_root_value, repo)
|
||||
if instances_root_value
|
||||
else _env_instances_roots(repo)
|
||||
or (profile.instances_roots if profile else None)
|
||||
or local_config.instances_roots
|
||||
or _default_instances_roots()
|
||||
)
|
||||
@@ -131,6 +203,7 @@ def resolve_runtime_config(
|
||||
_resolve_path(state_dir_value, repo)
|
||||
if state_dir_value
|
||||
else _env_state_root(repo)
|
||||
or (profile.state_root if profile else None)
|
||||
or local_config.state_root
|
||||
or _default_state_root()
|
||||
)
|
||||
@@ -140,4 +213,6 @@ def resolve_runtime_config(
|
||||
state_root=resolved_state,
|
||||
config_path=loaded_path,
|
||||
config_loaded=loaded,
|
||||
profile_id=profile.id if profile else None,
|
||||
profile_label=profile.label if profile else None,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user