"""Optional packages the default install carries, and the user's opt-outs. A ``default`` package (see ``Package.default``) joins the installers' PM stage, a bare ``hermes pm install`` and ``hermes update`` on every target it builds for. The user can decline one (``install.sh --skip-browser`` / ``--skip-computer-use``, ``install.ps1 -SkipBrowser`` / ``-SkipComputerUse``, ``hermes pm install --without NAME``). The choice is recorded per installation beside PM's other install state, so a later update or bare install never re-adds it. An explicit ``hermes pm install NAME`` clears it. Stdlib-only apart from PM's own modules: the installers run this before any application dependency exists. """ from __future__ import annotations import json from collections.abc import Iterable from pathlib import Path DECLINED_FILENAME = "declined-packages.json" def declined_path(project_root: Path | None = None) -> Path: from pm.environments import install_state_dir from pm.paths import repo_root return install_state_dir(repo_root() if project_root is None else Path(project_root)) / DECLINED_FILENAME def declined(project_root: Path | None = None) -> frozenset[str]: try: data = json.loads(declined_path(project_root).read_text(encoding="utf-8-sig")) except FileNotFoundError: return frozenset() except (OSError, ValueError) as exc: # Silently treating a damaged record as "nothing declined" would # re-add a package the user explicitly refused. from pm.package import InstallError raise InstallError("defaults", f"cannot read {declined_path(project_root)}: {exc}", "fix or delete the file, then retry") from exc names = data.get("declined") if isinstance(data, dict) else None return frozenset(name for name in names or () if isinstance(name, str)) def record_declined(*, add: Iterable[str] = (), remove: Iterable[str] = (), project_root: Path | None = None) -> frozenset[str]: """Update the recorded opt-outs; returns the new set. Writes only on change.""" from pm.filesystem import durable_write_bytes current = declined(project_root) updated = (current | set(add)) - set(remove) if updated != current: body = json.dumps({"schema": 1, "declined": sorted(updated)}, indent=2) + "\n" durable_write_bytes(declined_path(project_root), body.encode("utf-8")) return frozenset(updated) def default_package_names() -> list[str]: """Every package that may be declined: the ``default`` ones, any target.""" from pm.registry import all_packages, get_package return [name for name in all_packages() if get_package(name).default and not get_package(name).internal] def default_packages(names: list[str], *, target: str | None = None, declined_names: frozenset[str] | None = None) -> list[str]: """The optional defaults among ``names`` this install should carry. Excludes targets the package has no build for (its ``gaps``) and packages the user declined. ``names`` is the lockfile's package list. """ from pm.registry import get_package from pm.store import current_target target = current_target() if target is None else target refused = declined() if declined_names is None else declined_names selected = [] for name in names: try: package = get_package(name) except KeyError: continue # Lockfile/definition skew mid-update; drift() skips these too. if package.default and not package.internal and name not in refused \ and package.missing_reason(target) is None: selected.append(name) return selected