diff --git a/ruyi/cli/__init__.py b/ruyi/cli/__init__.py index d42f98c..bb7c1a2 100644 --- a/ruyi/cli/__init__.py +++ b/ruyi/cli/__init__.py @@ -21,7 +21,7 @@ def is_called_as_ruyi(argv0: str) -> bool: return os.path.basename(argv0).lower() in ALLOWED_RUYI_ENTRYPOINT_NAMES -CLIEntrypoint = Callable[[argparse.Namespace], int] +CLIEntrypoint = Callable[[GlobalConfig, argparse.Namespace], int] def init_argparse() -> argparse.ArgumentParser: @@ -368,6 +368,6 @@ def main(argv: List[str]) -> int: gc.telemetry.record("cli:invocation-v1", key=telemetry_key) try: - return func(args) + return func(gc, args) except Exception: raise diff --git a/ruyi/cli/self_cli.py b/ruyi/cli/self_cli.py index e1132bc..bcc1f6d 100644 --- a/ruyi/cli/self_cli.py +++ b/ruyi/cli/self_cli.py @@ -20,11 +20,18 @@ """ -def cli_self_uninstall(args: argparse.Namespace) -> int: +def cli_self_uninstall(cfg: config.GlobalConfig, args: argparse.Namespace) -> int: purge: bool = args.purge consent: bool = args.consent log.D(f"ruyi self uninstall: purge={purge}, consent={consent}") + if cfg.is_installation_externally_managed: + log.F( + "this [yellow]ruyi[/] is externally managed, for example, by the system package manager, and cannot be uninstalled this way" + ) + log.I("please uninstall via the external manager instead") + return 1 + if not ruyi.IS_PACKAGED: log.F( "this [yellow]ruyi[/yellow] is not in standalone form, and cannot be uninstalled this way" @@ -40,8 +47,6 @@ def cli_self_uninstall(args: argparse.Namespace) -> int: log.I("uninstallation consent given over CLI, proceeding") if purge: - cfg = config.GlobalConfig.load_from_config() - log.I("removing installed packages") shutil.rmtree(cfg.data_root, True) diff --git a/ruyi/cli/version_cli.py b/ruyi/cli/version_cli.py index 8ec9b03..49f689a 100644 --- a/ruyi/cli/version_cli.py +++ b/ruyi/cli/version_cli.py @@ -1,13 +1,20 @@ import argparse +from ..config import GlobalConfig from .. import log from ..version import COPYRIGHT_NOTICE, RUYI_SEMVER -def cli_version(_: argparse.Namespace) -> int: +def cli_version(gc: GlobalConfig, args: argparse.Namespace) -> int: from ..ruyipkg.host import get_native_host - print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.\n") + print(f"Ruyi {RUYI_SEMVER}\n\nRunning on {get_native_host()}.") + + if gc.is_installation_externally_managed: + print("This Ruyi installation is externally managed.") + + print() + log.stdout(COPYRIGHT_NOTICE) return 0 diff --git a/ruyi/config/__init__.py b/ruyi/config/__init__.py index adcc7e2..062000b 100644 --- a/ruyi/config/__init__.py +++ b/ruyi/config/__init__.py @@ -2,6 +2,7 @@ import os.path from os import PathLike import pathlib +import sys import tomllib from typing import Any, Iterable, NotRequired, Self, TypedDict @@ -10,6 +11,16 @@ from ..utils.xdg_basedir import XDGBaseDir from .news import NewsReadStatusStore +PRESET_GLOBAL_CONFIG_LOCATIONS: list[str] = [] + +if sys.platform == "linux": + PRESET_GLOBAL_CONFIG_LOCATIONS = [ + # TODO: enable distro packagers to customize the $PREFIX to suit their + # particular FS layout if necessary. + "/usr/share/ruyi/config.toml", + "/usr/local/share/ruyi/config.toml", + ] + DEFAULT_APP_NAME = "ruyi" DEFAULT_REPO_URL = "https://github.com/ruyisdk/packages-index.git" DEFAULT_REPO_BRANCH = "main" @@ -40,11 +51,21 @@ class GlobalConfigRepoType(TypedDict): branch: NotRequired[str] +class GlobalConfigInstallationType(TypedDict): + # Undocumented: whether this Ruyi installation is externally managed. + # + # Can be used by distro packagers (by placing a config file in /etc/xdg/ruyi) + # to signify this status to an official Ruyi build (where IS_PACKAGED is + # True), to prevent e.g. accidental self-uninstallation. + externally_managed: NotRequired[bool] + + class GlobalConfigTelemetryType(TypedDict): mode: NotRequired[str] class GlobalConfigRootType(TypedDict): + installation: NotRequired[GlobalConfigInstallationType] packages: NotRequired[GlobalConfigPackagesType] repo: NotRequired[GlobalConfigRepoType] telemetry: NotRequired[GlobalConfigTelemetryType] @@ -57,6 +78,7 @@ def __init__(self) -> None: self.override_repo_url: str | None = None self.override_repo_branch: str | None = None self.include_prereleases = False + self.is_installation_externally_managed = False self._news_read_status_store: NewsReadStatusStore | None = None self._telemetry_store: TelemetryStore | None = None @@ -68,6 +90,12 @@ def __init__(self) -> None: self._telemetry_mode: str | None = None def apply_config(self, config_data: GlobalConfigRootType) -> None: + if ins_cfg := config_data.get("installation"): + self.is_installation_externally_managed = ins_cfg.get( + "externally_managed", + False, + ) + if pkgs_cfg := config_data.get("packages"): self.include_prereleases = pkgs_cfg.get("prereleases", False) @@ -178,6 +206,16 @@ def ensure_state_dir(self) -> os.PathLike[Any]: p.mkdir(parents=True, exist_ok=True) return p + def iter_preset_configs(self) -> Iterable[os.PathLike[Any]]: + """ + Yields possible Ruyi config files in all preset config path locations, + sorted by precedence from lowest to highest (so that each file may be + simply applied consecutively). + """ + + for path in PRESET_GLOBAL_CONFIG_LOCATIONS: + yield pathlib.Path(path) + def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]: """ Yields possible Ruyi config files in all XDG config paths, sorted by precedence @@ -187,20 +225,27 @@ def iter_xdg_configs(self) -> Iterable[os.PathLike[Any]]: for config_dir in reversed(list(self._dirs.app_config_dirs)): yield config_dir / "config.toml" + def try_apply_config_file(self, path: os.PathLike[Any]) -> None: + try: + with open(path, "rb") as fp: + data: Any = tomllib.load(fp) + except FileNotFoundError: + return + + log.D(f"applying config: {data}") + self.apply_config(data) + @classmethod def load_from_config(cls) -> Self: obj = cls() + for config_path in obj.iter_preset_configs(): + log.D(f"trying config file from preset location: {config_path}") + obj.try_apply_config_file(config_path) + for config_path in obj.iter_xdg_configs(): - log.D(f"trying config file: {config_path}") - try: - with open(config_path, "rb") as fp: - data: Any = tomllib.load(fp) - except FileNotFoundError: - continue - - log.D(f"applying config: {data}") - obj.apply_config(data) + log.D(f"trying config file from XDG path: {config_path}") + obj.try_apply_config_file(config_path) # let environment variable take precedence if is_env_var_truthy(ENV_TELEMETRY_OPTOUT_KEY): diff --git a/ruyi/device/provision_cli.py b/ruyi/device/provision_cli.py index b696713..20d9e0f 100644 --- a/ruyi/device/provision_cli.py +++ b/ruyi/device/provision_cli.py @@ -26,17 +26,16 @@ from ..utils import prereqs -def cli_device_provision(args: argparse.Namespace) -> int: +def cli_device_provision(gc: GlobalConfig, args: argparse.Namespace) -> int: try: - return do_provision_interactive() + return do_provision_interactive(gc) except KeyboardInterrupt: log.stdout("\n\nKeyboard interrupt received, exiting.", end="\n\n") return 1 -def do_provision_interactive() -> int: +def do_provision_interactive(config: GlobalConfig) -> int: # ensure ruyi repo is present, for good out-of-the-box experience - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) mr.ensure_git_repo() diff --git a/ruyi/mux/venv/venv_cli.py b/ruyi/mux/venv/venv_cli.py index 7563cbb..2854f70 100644 --- a/ruyi/mux/venv/venv_cli.py +++ b/ruyi/mux/venv/venv_cli.py @@ -12,7 +12,7 @@ from .provision import render_template_str, VenvMaker -def cli_venv(args: argparse.Namespace) -> int: +def cli_venv(config: GlobalConfig, args: argparse.Namespace) -> int: profile_name: str = args.profile dest = pathlib.Path(args.dest) with_sysroot: bool = args.with_sysroot @@ -30,7 +30,6 @@ def cli_venv(args: argparse.Namespace) -> int: ) return 1 - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) profile = mr.get_profile(profile_name) diff --git a/ruyi/ruyipkg/news_cli.py b/ruyi/ruyipkg/news_cli.py index abd11d5..fba767e 100644 --- a/ruyi/ruyipkg/news_cli.py +++ b/ruyi/ruyipkg/news_cli.py @@ -34,10 +34,9 @@ def print_news_item_titles( log.stdout(tbl) -def cli_news_list(args: argparse.Namespace) -> int: +def cli_news_list(config: GlobalConfig, args: argparse.Namespace) -> int: only_unread = args.new - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) store = mr.news_store() newsitems = store.list(only_unread) @@ -58,11 +57,10 @@ def cli_news_list(args: argparse.Namespace) -> int: return 0 -def cli_news_read(args: argparse.Namespace) -> int: +def cli_news_read(config: GlobalConfig, args: argparse.Namespace) -> int: quiet = args.quiet items_strs = args.item - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) store = mr.news_store() diff --git a/ruyi/ruyipkg/pkg_cli.py b/ruyi/ruyipkg/pkg_cli.py index a92931e..3e84d7e 100644 --- a/ruyi/ruyipkg/pkg_cli.py +++ b/ruyi/ruyipkg/pkg_cli.py @@ -18,10 +18,9 @@ from .unpack import ensure_unpack_cmd_for_method -def cli_list(args: argparse.Namespace) -> int: +def cli_list(config: GlobalConfig, args: argparse.Namespace) -> int: verbose = args.verbose - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) augmented_pkgs = list(AugmentedPkg.yield_from_repo(mr)) @@ -210,12 +209,11 @@ def is_root_likely_populated(root: str) -> bool: return False -def cli_extract(args: argparse.Namespace) -> int: +def cli_extract(config: GlobalConfig, args: argparse.Namespace) -> int: host = args.host atom_strs: set[str] = set(args.atom) log.D(f"about to extract for host {host}: {atom_strs}") - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) for a_str in atom_strs: @@ -273,13 +271,12 @@ def cli_extract(args: argparse.Namespace) -> int: return 0 -def cli_install(args: argparse.Namespace) -> int: +def cli_install(config: GlobalConfig, args: argparse.Namespace) -> int: host = args.host atom_strs: set[str] = set(args.atom) fetch_only = args.fetch_only reinstall = args.reinstall - config = GlobalConfig.load_from_config() mr = MetadataRepo(config) return do_install_atoms( diff --git a/ruyi/ruyipkg/profile_cli.py b/ruyi/ruyipkg/profile_cli.py index 0504846..c2c5b06 100644 --- a/ruyi/ruyipkg/profile_cli.py +++ b/ruyi/ruyipkg/profile_cli.py @@ -5,8 +5,7 @@ from .repo import MetadataRepo -def cli_list_profiles(args: argparse.Namespace) -> int: - config = GlobalConfig.load_from_config() +def cli_list_profiles(config: GlobalConfig, args: argparse.Namespace) -> int: mr = MetadataRepo(config) for arch in mr.get_supported_arches(): diff --git a/ruyi/ruyipkg/update_cli.py b/ruyi/ruyipkg/update_cli.py index 15344aa..9002e9d 100644 --- a/ruyi/ruyipkg/update_cli.py +++ b/ruyi/ruyipkg/update_cli.py @@ -6,8 +6,7 @@ from .repo import MetadataRepo -def cli_update(args: argparse.Namespace) -> int: - config = GlobalConfig.load_from_config() +def cli_update(config: GlobalConfig, args: argparse.Namespace) -> int: mr = MetadataRepo(config) mr.sync()