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>
This commit is contained in:
pentago 2026-05-17 21:25:02 +02:00
parent b5d323762b
commit 817a1adef3
No known key found for this signature in database
12 changed files with 551 additions and 0 deletions

View File

@ -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)

View File

@ -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)

View File

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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)

View File

@ -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'

View File

@ -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"
}
}
}

View File

@ -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']