From 817a1adef32fecb96951f8b273c012748e219693 Mon Sep 17 00:00:00 2001 From: pentago <876756+pentago@users.noreply.github.com> Date: Sun, 17 May 2026 21:25:02 +0200 Subject: [PATCH] Add AUR helper installation support Adds an "Enable AUR" entry to the global menu allowing the user to choose between paru and yay. The selected helper is built from source inside the chroot, running as the first sudo user under a temporary NOPASSWD-pacman sudoers entry that is removed in a finally block. Source builds avoid the libalpm soname mismatch that can occur with prebuilt -bin packages. The feature gracefully no-ops when no AUR helper is configured. The menu item is gated behind the presence of a regular user account; attempting --silent installs without one raises RequirementError. Signed-off-by: pentago <876756+pentago@users.noreply.github.com> --- archinstall/applications/aur_helper.py | 69 ++++++++ archinstall/lib/args.py | 11 ++ archinstall/lib/aur/__init__.py | 0 archinstall/lib/aur/aur_handler.py | 40 +++++ archinstall/lib/aur/aur_menu.py | 77 +++++++++ archinstall/lib/global_menu.py | 26 +++ archinstall/lib/models/aur.py | 57 +++++++ archinstall/locales/base.pot | 24 +++ archinstall/scripts/guided.py | 3 + tests/conftest.py | 5 + tests/data/test_config_aur.json | 214 +++++++++++++++++++++++++ tests/test_configuration_output.py | 25 +++ 12 files changed, 551 insertions(+) create mode 100644 archinstall/applications/aur_helper.py create mode 100644 archinstall/lib/aur/__init__.py create mode 100644 archinstall/lib/aur/aur_handler.py create mode 100644 archinstall/lib/aur/aur_menu.py create mode 100644 archinstall/lib/models/aur.py create mode 100644 tests/data/test_config_aur.json diff --git a/archinstall/applications/aur_helper.py b/archinstall/applications/aur_helper.py new file mode 100644 index 00000000..90aee9a6 --- /dev/null +++ b/archinstall/applications/aur_helper.py @@ -0,0 +1,69 @@ +import shlex +from typing import TYPE_CHECKING + +from archinstall.lib.exceptions import PackageError, SysCallError +from archinstall.lib.models.aur import AURHelperConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug, info +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHelperApp: + """Bootstraps an AUR helper (paru / yay) inside the chroot. + + The helper package name comes from a controlled ``AURHelper`` enum value, + and the build username is validated upstream by archinstall's user-creation + path, so shell interpolation of these values is safe. Dynamic file system + paths are still passed through ``shlex.quote``. + """ + + _SUDOERS_FILE = 'etc/sudoers.d/99_aur_build' + + def install( + self, + install_session: Installer, + helper_config: AURHelperConfiguration, + build_user: User, + ) -> None: + install_session.add_additional_packages(['base-devel', 'git']) + + sudoers_path = install_session.target / self._SUDOERS_FILE + sudoers_path.write_text(f'{build_user.username} ALL=(ALL) NOPASSWD: /usr/bin/pacman\n') + sudoers_path.chmod(0o440) + + helper_pkg = helper_config.helper.value + # Build inside the user's $HOME rather than /tmp: arch-chroot -S spawns a + # transient systemd-run unit and the chroot's /tmp is the on-disk + # directory (mode 0755, root-owned) since no boot-time tmpfs mount has + # happened, so non-root writes to /tmp fail. Build tools like Go also + # default TMPDIR to /tmp, so we redirect TMPDIR for the same reason. + build_subdir = f'.cache/aur-build/{helper_pkg}' + quoted_build = shlex.quote(build_subdir) + tmp_env = 'TMPDIR="$HOME/.cache/aur-build/tmp"' + + try: + info(tr('Installing AUR helper {}').format(helper_config.helper.value)) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} && mkdir -p -- "$HOME/.cache/aur-build/tmp" "$(dirname -- {quoted_build})"', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'git clone https://aur.archlinux.org/{helper_pkg}.git {quoted_build}', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'cd {quoted_build} && {tmp_env} makepkg -si --noconfirm', + run_as=build_user.username, + ) + install_session.arch_chroot( + f'rm -rf -- {quoted_build} "$HOME/.cache/aur-build/tmp"', + run_as=build_user.username, + ) + except SysCallError as e: + debug(f'AUR helper install failed: {e}') + raise PackageError(tr('Failed to install AUR helper: {}').format(e)) from e + finally: + sudoers_path.unlink(missing_ok=True) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 14aff6c6..fafa4cdb 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -16,6 +16,7 @@ from pydantic.dataclasses import dataclass as p_dataclass from archinstall.lib.crypt import decrypt from archinstall.lib.menu.util import get_password from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.config import SubConfig @@ -76,6 +77,7 @@ class ArchConfigType(StrEnum): NETWORK_CONFIG = 'network_config' BOOTLOADER_CONFIG = 'bootloader_config' APP_CONFIG = 'app_config' + AUR_CONFIG = 'aur_config' AUTH_CONFIG = 'auth_config' SWAP = 'swap' USERS = 'users' @@ -112,6 +114,8 @@ class ArchConfigType(StrEnum): return tr('Bootloader') case ArchConfigType.APP_CONFIG: return tr('Application') + case ArchConfigType.AUR_CONFIG: + return tr('Enable AUR') case ArchConfigType.AUTH_CONFIG: return tr('Authentication') case ArchConfigType.SWAP: @@ -152,6 +156,7 @@ class ArchConfig: network_config: NetworkConfiguration | None = None bootloader_config: BootloaderConfiguration | None = None app_config: ApplicationConfiguration | None = None + aur_config: AURConfiguration | None = None auth_config: AuthenticationConfiguration | None = None swap: ZramConfiguration | None = None hostname: str = 'archlinux' @@ -240,6 +245,9 @@ class ArchConfig: if self.app_config: cfg[ArchConfigType.APP_CONFIG] = self.app_config + if self.aur_config: + cfg[ArchConfigType.AUR_CONFIG] = self.aur_config + return cfg @classmethod @@ -307,6 +315,9 @@ class ArchConfig: if audio_config_args is not None or app_config_args is not None: arch_config.app_config = ApplicationConfiguration.parse_arg(app_config_args, audio_config_args) + if aur_config_args := args_config.get('aur_config', None): + arch_config.aur_config = AURConfiguration.parse_arg(aur_config_args) + if auth_config_args := args_config.get('auth_config', None): arch_config.auth_config = AuthenticationConfiguration.parse_arg(auth_config_args) diff --git a/archinstall/lib/aur/__init__.py b/archinstall/lib/aur/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/archinstall/lib/aur/aur_handler.py b/archinstall/lib/aur/aur_handler.py new file mode 100644 index 00000000..21bef4d5 --- /dev/null +++ b/archinstall/lib/aur/aur_handler.py @@ -0,0 +1,40 @@ +from typing import TYPE_CHECKING + +from archinstall.applications.aur_helper import AURHelperApp +from archinstall.lib.exceptions import RequirementError +from archinstall.lib.models.aur import AURConfiguration +from archinstall.lib.models.users import User +from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr + +if TYPE_CHECKING: + from archinstall.lib.installer import Installer + + +class AURHandler: + """Coordinates AUR helper installation as part of a guided install. + + The build user is resolved deterministically: first user with ``sudo=True``, + falling back to the first user in ``users``. If no users are configured the + handler raises ``RequirementError`` so ``--silent`` runs surface the missing + dependency immediately. + """ + + def install_aur( + self, + install_session: Installer, + aur_config: AURConfiguration | None, + users: list[User], + ) -> None: + if aur_config is None or aur_config.helper_config is None: + debug('AUR: no helper configured, skipping') + return + + build_user = next((u for u in users if u.sudo), None) or (users[0] if users else None) + + if build_user is None: + raise RequirementError( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + + AURHelperApp().install(install_session, aur_config.helper_config, build_user) diff --git a/archinstall/lib/aur/aur_menu.py b/archinstall/lib/aur/aur_menu.py new file mode 100644 index 00000000..f12fb854 --- /dev/null +++ b/archinstall/lib/aur/aur_menu.py @@ -0,0 +1,77 @@ +from typing import override + +from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.menu.helpers import Selection +from archinstall.lib.models.aur import ( + AURConfiguration, + AURHelper, + AURHelperConfiguration, +) +from archinstall.lib.translationhandler import tr +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType + + +class AURMenu(AbstractSubMenu[AURConfiguration]): + def __init__( + self, + preset: AURConfiguration | None = None, + ): + if preset: + self._aur_config = preset + else: + self._aur_config = AURConfiguration() + + menu_options = self._define_menu_options() + self._item_group = MenuItemGroup(menu_options, checkmarks=True) + + super().__init__( + self._item_group, + config=self._aur_config, + allow_reset=True, + ) + + @override + async def show(self) -> AURConfiguration | None: + _ = await super().show() + return self._aur_config + + def _define_menu_options(self) -> list[MenuItem]: + return [ + MenuItem( + text=tr('Enable AUR Helper'), + action=select_aur_helper, + value=self._aur_config.helper_config, + preview_action=self._prev_helper, + key='helper_config', + ), + ] + + def _prev_helper(self, item: MenuItem) -> str | None: + if item.value is not None: + config: AURHelperConfiguration = item.value + return f'{tr("AUR helper")}: {config.helper.value}' + return None + + +async def select_aur_helper(preset: AURHelperConfiguration | None = None) -> AURHelperConfiguration | None: + items = [MenuItem(h.value, value=h) for h in AURHelper] + group = MenuItemGroup(items) + + if preset: + group.set_focus_by_value(preset.helper) + + result = await Selection[AURHelper]( + group, + header=tr('Enable AUR Helper'), + allow_skip=True, + allow_reset=True, + ).show() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return AURHelperConfiguration(helper=result.get_value()) + case ResultType.Reset: + return None diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index eac936bd..17362549 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -3,6 +3,7 @@ from typing import override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu from archinstall.lib.args import ArchConfig +from archinstall.lib.aur.aur_menu import AURMenu from archinstall.lib.authentication.authentication_menu import AuthenticationMenu from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu from archinstall.lib.bootloader.utils import validate_bootloader_layout @@ -16,6 +17,7 @@ from archinstall.lib.menu.abstract_menu import AbstractMenu, SpecialMenuKey from archinstall.lib.mirror.mirror_handler import MirrorListHandler from archinstall.lib.mirror.mirror_menu import MirrorMenu from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration +from archinstall.lib.models.aur import AURConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, PartitionModification @@ -136,6 +138,12 @@ class GlobalMenu(AbstractMenu[None]): preview_action=self._prev_applications, key='app_config', ), + MenuItem( + text=tr('Enable AUR'), + action=self._select_aur, + preview_action=self._prev_aur, + key='aur_config', + ), MenuItem( text=tr('Network configuration'), action=select_network, @@ -235,6 +243,13 @@ class GlobalMenu(AbstractMenu[None]): if not check(item.key): missing.add(item.text) + aur_item: MenuItem = self._item_group.find_by_key('aur_config') + aur_config: AURConfiguration | None = aur_item.value + if aur_config and aur_config.helper_config and not (auth_config and auth_config.has_regular_user()): + missing.add( + tr('AUR helper requires a non-root user account. Configure one under Authentication.'), + ) + return list(missing) @override @@ -267,6 +282,17 @@ class GlobalMenu(AbstractMenu[None]): app_config = await ApplicationMenu(preset).show() return app_config + async def _select_aur(self, preset: AURConfiguration | None) -> AURConfiguration | None: + aur_config = await AURMenu(preset).show() + return aur_config + + def _prev_aur(self, item: MenuItem) -> str | None: + if item.value is not None: + aur_config: AURConfiguration = item.value + if aur_config.helper_config: + return f'{tr("AUR helper")}: {aur_config.helper_config.helper.value}' + return None + async def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: auth_config = await AuthenticationMenu(preset).show() return auth_config diff --git a/archinstall/lib/models/aur.py b/archinstall/lib/models/aur.py new file mode 100644 index 00000000..29c8eb2d --- /dev/null +++ b/archinstall/lib/models/aur.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, NotRequired, Self, TypedDict, override + +from archinstall.lib.models.config import SubConfig +from archinstall.lib.translationhandler import tr + + +class AURHelper(StrEnum): + PARU = 'paru' + YAY = 'yay' + + +class AURHelperConfigSerialization(TypedDict): + helper: str + + +class AURConfigSerialization(TypedDict, total=False): + helper_config: NotRequired[AURHelperConfigSerialization] + + +@dataclass +class AURHelperConfiguration: + helper: AURHelper + + def json(self) -> AURHelperConfigSerialization: + return {'helper': self.helper.value} + + @classmethod + def parse_arg(cls, arg: dict[str, Any]) -> Self: + return cls(helper=AURHelper(arg['helper'])) + + +@dataclass +class AURConfiguration(SubConfig): + helper_config: AURHelperConfiguration | None = None + + @classmethod + def parse_arg(cls, args: dict[str, Any] | None = None) -> Self: + cfg = cls() + if args and (helper_config := args.get('helper_config')) is not None: + cfg.helper_config = AURHelperConfiguration.parse_arg(helper_config) + return cfg + + @override + def json(self) -> AURConfigSerialization: + config: AURConfigSerialization = {} + if self.helper_config: + config['helper_config'] = self.helper_config.json() + return config + + @override + def summary(self) -> list[str]: + out: list[str] = [] + if self.helper_config: + out.append(tr('AUR helper "{}"').format(self.helper_config.helper.value)) + return out diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index e1681526..383a166d 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -2463,3 +2463,27 @@ msgstr "" msgid "Enter a repository name" msgstr "" + +msgid "Enable AUR" +msgstr "" + +msgid "Enable AUR Helper" +msgstr "" + +msgid "AUR helper" +msgstr "" + +msgid "AUR helper requires a non-root user account. Configure one under Authentication." +msgstr "" + +#, python-brace-format +msgid "Installing AUR helper {}" +msgstr "" + +#, python-brace-format +msgid "Failed to install AUR helper: {}" +msgstr "" + +#, python-brace-format +msgid "AUR helper \"{}\"" +msgstr "" diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index aab02cfd..e373e336 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -4,6 +4,7 @@ import time from archinstall.lib.applications.application_handler import ApplicationHandler from archinstall.lib.args import ArchConfig, ArchConfigHandler +from archinstall.lib.aur.aur_handler import AURHandler from archinstall.lib.authentication.authentication_handler import AuthenticationHandler from archinstall.lib.bootloader.utils import validate_bootloader_layout from archinstall.lib.configuration import ConfigurationOutput @@ -138,6 +139,8 @@ def perform_installation( if app_config := config.app_config: application_handler.install_applications(installation, app_config) + AURHandler().install_aur(installation, config.aur_config, users or []) + if profile_config := config.profile_config: profile_handler.install_profile_config(installation, profile_config) diff --git a/tests/conftest.py b/tests/conftest.py index 819c8397..d3efe8af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,11 @@ def config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config.json' +@pytest.fixture(scope='session') +def aur_config_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_config_aur.json' + + @pytest.fixture(scope='session') def btrfs_config_fixture() -> Path: return Path(__file__).parent / 'data' / 'test_config_btrfs.json' diff --git a/tests/data/test_config_aur.json b/tests/data/test_config_aur.json new file mode 100644 index 00000000..15d37b68 --- /dev/null +++ b/tests/data/test_config_aur.json @@ -0,0 +1,214 @@ +{ + "archinstall-language": "English", + "script": "test_script", + "app_config": { + "bluetooth_config": { + "enabled": true + }, + "audio_config": { + "audio": "pipewire" + }, + "print_service_config": { + "enabled": true + } + }, + "auth_config": { + "u2f_config": { + "passwordless_sudo": true, + "u2f_login_method": "passwordless" + } + }, + "audio_config": { + "audio": "pipewire" + }, + "bootloader_config": { + "bootloader": "Systemd-boot", + "uki": false, + "removable": false + }, + "services": [ + "service_1", + "service_2" + ], + "disk_config": { + "config_type": "default_layout", + "btrfs_options": { + "snapshot_config": { + "type": "Snapper" + } + }, + "device_modifications": [ + { + "device": "/dev/sda", + "partitions": [ + { + "btrfs": [], + "dev_path": null, + "flags": [ + "boot" + ], + "fs_type": "fat32", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 512 + }, + "mount_options": [], + "mountpoint": "/boot", + "obj_id": "2c3fa2d5-2c79-4fab-86ec-22d0ea1543c0", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 1 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/", + "obj_id": "3e7018a0-363b-4d05-ab83-8e82d13db208", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 513 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "dev_path": null, + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "GiB", + "value": 32 + }, + "mount_options": [], + "mountpoint": "/home", + "obj_id": "ce58b139-f041-4a06-94da-1f8bad775d3f", + "start": { + "sector_size": { + "unit": "B", + "value": 512 + }, + "unit": "MiB", + "value": 33281 + }, + "status": "create", + "type": "primary" + } + ], + "wipe": true + } + ] + }, + "hostname": "archy", + "kernels": [ + "linux-zen" + ], + "locale_config": { + "kb_layout": "us", + "sys_enc": "UTF-8", + "sys_lang": "en_US" + }, + "mirror_config": { + "custom_servers": [ + { + "url": "https://mymirror.com/$repo/os/$arch" + } + ], + "mirror_regions": { + "Australia": [ + "http://archlinux.mirror.digitalpacific.com.au/$repo/os/$arch" + ] + }, + "optional_repositories": [ + "testing" + ], + "custom_repositories": [ + { + "name": "myrepo", + "url": "https://myrepo.com/$repo/os/$arch", + "sign_check": "Required", + "sign_option": "TrustAll" + } + ] + }, + "network_config": { + "type": "manual", + "nics": [ + { + "iface": "eno1", + "ip": "192.168.1.15/24", + "dhcp": true, + "gateway": "192.168.1.1", + "dns": [ + "192.168.1.1", + "9.9.9.9" + ] + } + ] + }, + "ntp": true, + "packages": [ + "firefox" + ], + "parallel_downloads": 66, + "profile_config": { + "gfx_driver": "All open-source", + "greeter": "lightdm-gtk-greeter", + "profile": { + "custom_settings": { + "Hyprland": { + "seat_access": "polkit" + }, + "Sway": { + "seat_access": "seatd" + } + }, + "details": [ + "Sway", + "Hyprland" + ], + "main": "Desktop" + } + }, + "custom_commands": [ + "echo 'Hello, World!'" + ], + "swap": false, + "timezone": "UTC", + "version": "3.0.2", + "aur_config": { + "helper_config": { + "helper": "paru" + } + } +} \ No newline at end of file diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py index 7d9f1f97..8454b8f4 100644 --- a/tests/test_configuration_output.py +++ b/tests/test_configuration_output.py @@ -66,3 +66,28 @@ def test_creds_roundtrip( expected = json.loads(creds_fixture.read_text()) assert sorted(result.items()) == sorted(expected.items()) + + +def test_aur_config_roundtrip( + monkeypatch: MonkeyPatch, + aur_config_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', ['archinstall', '--config', str(aur_config_fixture)]) + + handler = ArchConfigHandler() + arch_config = handler.config + arch_config.version = '3.0.2' + + assert arch_config.aur_config is not None + assert arch_config.aur_config.helper_config is not None + assert arch_config.aur_config.helper_config.helper.value == 'paru' + + config_output = ConfigurationOutput(arch_config) + test_out_dir = Path('/tmp/') + test_out_file = test_out_dir / config_output.user_configuration_file + config_output.save(test_out_dir) + + result = json.loads(test_out_file.read_text()) + expected = json.loads(aur_config_fixture.read_text()) + + assert result['aur_config'] == expected['aur_config']