From 341299f4774ceb4822018c62e5d7dae3686b48f9 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 01:56:23 -0500 Subject: [PATCH 1/8] Let the Hermes package bootstrap native desktop updates Install Hermes into its writable native layout instead of shipping a frozen /opt desktop. Preserve the native update path, migrate tagged bootstraps, and keep existing CLI launchers until the desktop is ready. Co-Authored-By: GPT-6 Codex (xhigh) --- .github/workflows/test.yml | 3 + pkgbuilds/hermes-desktop/PKGBUILD | 140 ++-------- .../hermes-desktop/hermes-desktop.desktop | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 263 ++++++++++++++++-- test/hermes-desktop-test.py | 211 ++++++++++++++ 5 files changed, 478 insertions(+), 141 deletions(-) create mode 100644 test/hermes-desktop-test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fc270f..9c359c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,9 @@ jobs: with: persist-credentials: false + - name: Test Hermes installation and launch + run: python3 test/hermes-desktop-test.py + # An Arch container for vercmp: version ordering has to be decided by # the same comparator pacman uses on users' machines. - name: Run self-tests diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 472c804..f00eee3 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -1,135 +1,53 @@ # Maintainer: David Heinemeier Hansson -# Nous builds Hermes Desktop for macOS and Windows only -- their download page -# offers a .dmg and an .exe and tells Linux users to install from a terminal -- -# so there is no vendor binary to repackage. Their electron-builder config does -# carry a Linux target, though, and it works; this builds it. -# -# The app only runs against a Hermes runtime built from its own commit, so it -# provisions one itself under ~/.hermes on first launch and the package stays -# on the newest tag. Pairing it with the mise CLI instead was tried and does -# not work: PyPI trails the tags, and the version gap fails the readiness probe -# with a 401. Pinning back to the tag behind PyPI's release does not rescue it -# either -- v2026.7.20's desktop hangs after "backend is ready" without ever -# opening a window, against its own matching runtime. -# -# The app is only a shell: it runs `hermes serve` against a Hermes CLI it does -# not ship, and clones its own copy with the upstream install script when it -# finds none. /usr/bin/hermes-desktop heads that off. See hermes-desktop.sh. - pkgname=hermes-desktop -pkgver=2026.8.18 +pkgver=2026.8.31 pkgrel=2 -pkgdesc='Native desktop shell for Hermes Agent' +pkgdesc='Installer and launcher for the self-updating Hermes desktop' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent' license=('MIT') +# Hermes builds its desktop under the user's runtime so both the app and CLI +# can update it. The compiler and Electron libraries must survive installation. depends=( - 'alsa-lib' - 'at-spi2-core' - 'cairo' - 'dbus' - 'expat' - 'curl' - 'gcc-libs' - 'gdk-pixbuf2' - 'git' - 'glib2' - 'glibc' - 'gtk3' - 'hicolor-icon-theme' - 'libcups' - 'libdrm' - 'libglvnd' - 'libnotify' - 'libsecret' - 'libx11' - 'libxcb' - 'libxcomposite' - 'libxdamage' - 'libxext' - 'libxfixes' - 'libxkbcommon' - 'libxrandr' - 'mesa' - 'nspr' - 'nss' - 'pango' - 'systemd-libs' - 'xdg-utils' + 'alsa-lib' 'at-spi2-core' 'bash' 'cairo' 'curl' 'dbus' 'expat' 'gcc' + 'gdk-pixbuf2' 'git' 'glib2' 'glibc' 'gtk3' 'hicolor-icon-theme' 'libcups' + 'libdrm' 'libglvnd' 'libnotify' 'libsecret' 'libx11' 'libxcb' 'libxcomposite' + 'libxdamage' 'libxext' 'libxfixes' 'libxkbcommon' 'libxrandr' 'make' 'mesa' + 'nodejs' 'npm' 'nspr' 'nss' 'pango' 'python' 'systemd-libs' 'util-linux' + 'xdg-terminal-exec' 'xdg-utils' ) +makedepends=('imagemagick') -optdepends=('omarchy: installs the Hermes CLI the app needs on first launch') - -# The build runs the repo's own npm workspace install, which fetches Electron -# and rebuilds node-pty against it. -makedepends=('git' 'imagemagick' 'nodejs' 'npm' 'python') - -# Electron bundles prebuilt binaries that stripping corrupts. -options=('!strip' '!debug') - -# The tag's commit. apps/desktop/scripts/write-build-stamp.mjs pins the app's -# first-launch bootstrap to a Hermes commit, and resolves it from $GITHUB_SHA -# before falling back to `git rev-parse`. That fallback is wrong here: makepkg -# builds inside this repository, so git ascends out of srcdir and stamps the -# app with an omarchy-pkgs commit that means nothing upstream. -_commit=e624e9fde561e1add9388384012b295fde669ade - -_srcdir="hermes-agent-${pkgver}" -source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz" +# Snapshot the bootstrap installer, including upstream's Linux sandbox fixes. +# Only installation uses it; updates belong to the native runtime on main. +_commit=245e48008fa814b3251f50755eb656bd9fb86cb1 +source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/scripts/install.sh" + "LICENSE-${_commit}::https://raw.githubusercontent.com/NousResearch/hermes-agent/${_commit}/LICENSE" 'hermes-desktop.sh' 'hermes-desktop.desktop' 'hermes-desktop.png') -sha256sums=('1e3d39d3638ec15fa9d31af262568a953e9272090deb1c50c44cd401175f5b80' - 'f5833b969ce451aadee9f08d92db55cce3c7c9213175080590bf37444854d676' - '3ef685bfcf366776b025d26c37d32854d8d4aa2023b2bd07c8e08b001ef1e8c4' +sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' + '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' + '5113ba692fe00b0f4806f799ce16b85a44bb2bd901ca17577ba64c887b1fb33d' + 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') -build() { - cd "${srcdir}/${_srcdir}" - - export GITHUB_SHA="${_commit}" - export GITHUB_REF_NAME="v${pkgver}" - - # The desktop workspace resolves against the repo root, so the install has to - # happen there rather than in apps/desktop. - npm ci - - cd apps/desktop - npm run pack -} - package() { - cd "${srcdir}/${_srcdir}/apps/desktop/release/linux-unpacked" - - install -dm755 "${pkgdir}/opt/${pkgname}" - cp -a . "${pkgdir}/opt/${pkgname}/" - + install -Dm644 "${srcdir}/install-${_commit}.sh" "${pkgdir}/usr/share/${pkgname}/install.sh" + install -Dm644 "${srcdir}/LICENSE-${_commit}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}" - install -Dm644 "${srcdir}/hermes-desktop.desktop" \ - "${pkgdir}/usr/share/applications/${pkgname}.desktop" - - install -Dm644 "${srcdir}/hermes-desktop.png" \ - "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" + # Native registration uses hermes.desktop but omits URI handling. Keep the + # original handler ID hidden, and let the native entry replace the visible one. + install -Dm644 "${srcdir}/hermes-desktop.desktop" "${pkgdir}/usr/share/applications/hermes-desktop.desktop" + printf 'NoDisplay=true\n' >>"${pkgdir}/usr/share/applications/hermes-desktop.desktop" + sed '/^MimeType=/d' "${srcdir}/hermes-desktop.desktop" >"${pkgdir}/usr/share/applications/hermes.desktop" + install -Dm644 "${srcdir}/hermes-desktop.png" "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" local size for size in 512 256 128 64 48; do magick "${srcdir}/hermes-desktop.png" -resize "${size}x${size}" "${srcdir}/icon-${size}.png" - install -Dm644 "${srcdir}/icon-${size}.png" \ - "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" + install -Dm644 "${srcdir}/icon-${size}.png" "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" done - - install -Dm644 "${srcdir}/${_srcdir}/LICENSE" \ - "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" - install -Dm644 "${pkgdir}/opt/${pkgname}/LICENSE.electron.txt" \ - "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE.electron.txt" - - # Chromium's setuid sandbox is only needed where unprivileged user namespaces - # are unavailable; where they work, setuid root is the worse of the two. - if ! { [[ -L /proc/self/ns/user ]] && unshare --user true; }; then - chmod 4755 "${pkgdir}/opt/${pkgname}/chrome-sandbox" - else - chmod 0755 "${pkgdir}/opt/${pkgname}/chrome-sandbox" - fi } diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.desktop b/pkgbuilds/hermes-desktop/hermes-desktop.desktop index 8186b65..b156fcb 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.desktop +++ b/pkgbuilds/hermes-desktop/hermes-desktop.desktop @@ -4,7 +4,7 @@ Type=Application Name=Hermes GenericName=AI Agent Comment=The self-improving AI agent that grows with you -Exec=hermes-desktop %U +Exec=/usr/bin/hermes-desktop %U Icon=hermes-desktop Terminal=false Categories=Development; diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 5c4d35e..e8a1716 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -1,41 +1,246 @@ #!/bin/bash set -euo pipefail -# Hermes Desktop is a shell around a Hermes runtime, and it only works against -# one built from its own commit. A CLI from PyPI is always a different release -# -- PyPI trails the tags -- and the mismatch fails the app's readiness probe -# with 401 Unauthorized. So keep it away from whatever `hermes` is on PATH, -# which on Omarchy is the mise CLI installed for the terminal agent, and let -# the app provision and manage its own runtime under ~/.hermes. That is the -# arrangement upstream ships, and the only one that starts. -export HERMES_DESKTOP_IGNORE_EXISTING=1 +export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HERMES_HOME=$(realpath -ms "$HERMES_HOME") +root="$HERMES_HOME/hermes-agent" +marker="$root/.omarchy-hermes-desktop" +installer=/usr/share/hermes-desktop/install.sh +cli=("$root/venv/bin/python" "$root/hermes") -# Chromium cannot reliably infer the Secret Service password-store backend -# from a Hyprland session, even when GNOME Keyring is already providing it. -# Keep an explicit user choice (such as KWallet), otherwise select the -# libsecret backend that this package depends on. -export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" +die() { + echo "Hermes: $*" >&2 + exit 1 +} -# Reconcile every launch rather than trusting whatever installed us. A plain -# `pacman -S hermes-desktop`, or an install interrupted partway, leaves any -# Hermes the terminal agent had built still sitting there, and by then the -# menu entry that would have tidied it up is disabled because we are present. -if command -v omarchy-install-hermes-cli >/dev/null 2>&1; then - omarchy-install-hermes-cli >/dev/null 2>&1 || true +desktop_executable() { + local executable + for executable in "$root/apps/desktop/release/linux-unpacked/"{Hermes,hermes}; do + if [[ -f $executable && -x $executable ]]; then + printf '%s\n' "$executable" + return 0 + fi + done + return 1 +} + +native_wrapper() { + local wrapper=$1 entry=$2 suffix=${3:-} + [[ -f $wrapper && ! -L $wrapper ]] && cmp -s "$wrapper" <( + printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix" + ) +} + +runtime_ready() { + [[ -f $root/.hermes-bootstrap-complete && -x ${cli[0]} ]] && + [[ -x $HOME/.local/bin/hermes ]] && + native_wrapper "$HOME/.local/bin/hermes" hermes && + desktop_executable >/dev/null && + timeout 15 env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" --version >/dev/null 2>&1 +} + +ready() { + if [[ -f $marker ]]; then + grep -qxF ready "$marker" || return 1 + fi + runtime_ready +} + +restore_launchers() { + local command wrapper entry + for command in hermes hermes-agent hermes-acp; do + [[ -f $install_backup/$command ]] || continue + wrapper="$HOME/.local/bin/$command" + entry=hermes + [[ $command == "hermes-agent" ]] && entry=run_agent.py + if [[ ! -e $wrapper && ! -L $wrapper ]] || native_wrapper "$wrapper" "$entry" || + { [[ -f $wrapper && ! -L $wrapper ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; } || + { [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; }; then + cp -p "$install_backup/$command" "$wrapper" + else + echo "Keeping changed $wrapper; its original is in $install_backup." >&2 + return + fi + done + rm -rf "$install_backup" +} + +stage() { + bash "$installer" --dir "$root" --branch main --non-interactive --stage "$1" +} + +install_desktop() { + (( EUID != 0 )) || die "Run this installer as your desktop user, without sudo." + mkdir -p "$HERMES_HOME" + exec 9>"$HERMES_HOME/.omarchy-hermes-desktop.lock" + flock -n 9 || die "Another Hermes installation is already running." + if ready; then + exec 9>&- + return + fi + + # Upstream replaces these launchers. Admit its exact shims or Omarchy's + # marked predecessor before starting work, never a user's custom command. + local command wrapper entry + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + [[ -e $wrapper || -L $wrapper ]] || continue + entry=hermes + [[ $command == "hermes-agent" ]] && entry=run_agent.py + if native_wrapper "$wrapper" "$entry"; then continue; fi + if [[ $command == "hermes-acp" ]] && native_wrapper "$wrapper" hermes ' acp'; then continue; fi + if [[ $command == "hermes" && -f $wrapper && ! -L $wrapper ]] && + grep -qxF '# Written by omarchy-install-hermes-cli.' "$wrapper"; then continue; fi + die "Keeping the existing $wrapper. Move or update that installation before installing Hermes Desktop." + done + + # The native prerequisite stage may install Node's three user symlinks. + for command in node npm npx; do + wrapper="$HOME/.local/bin/$command" + if [[ -e $wrapper || -L $wrapper ]] && + [[ ! -L $wrapper || $(readlink "$wrapper") != "$HERMES_HOME/node/bin/$command" ]]; then + die "Keeping the existing $wrapper. Move it before installing Hermes Desktop." + fi + done + + local legacy=false + if [[ -e $root || -L $root ]]; then + [[ -d $root/.git && ! -L $root && ! -L $root/.git ]] || die "Keeping $root; it is not a managed checkout." + if [[ -f $marker ]] && grep -qxF updating "$marker"; then + legacy=true + elif [[ ! -f $marker ]]; then + python -I - "$root/.hermes-bootstrap-complete" <<'PY' || die "Keeping the existing checkout; install its desktop with 'hermes desktop'." +import json, sys +try: + stamp = json.load(open(sys.argv[1])) + assert stamp.get('desktopVersion') and stamp.get('pinnedCommit') in { + 'e624e9fde561e1add9388384012b295fde669ade', + '29112bef099274229cadff79cdff7bf7b99c4b77', + } +except (OSError, ValueError, AssertionError, AttributeError): + sys.exit(1) +PY + legacy=true + fi + [[ -z $(git -C "$root" status --porcelain) ]] || die "Keeping local changes in $root. Save them before installing the desktop." + case "$(git -C "$root" remote get-url origin)" in + https://github.com/NousResearch/hermes-agent.git | git@github.com:NousResearch/hermes-agent.git) ;; + *) die "Keeping the checkout's custom origin. Install its desktop with 'hermes desktop'." ;; + esac + if [[ $legacy == true ]]; then + case "$(git -C "$root" rev-parse HEAD)" in + e624e9fde561e1add9388384012b295fde669ade | 29112bef099274229cadff79cdff7bf7b99c4b77) ;; + *) + if [[ $(git -C "$root" symbolic-ref --short -q HEAD) != "main" ]] || + ! git -C "$root" merge-base --is-ancestor HEAD refs/remotes/origin/main; then + die "The legacy checkout has moved. Run 'hermes update', then 'hermes desktop'." + fi + ;; + esac + fi + fi + + install_backup=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-launchers.XXXXXX") + for command in hermes hermes-agent hermes-acp; do + wrapper="$HOME/.local/bin/$command" + if [[ -f $wrapper ]]; then cp -p "$wrapper" "$install_backup/$command"; fi + done + trap restore_launchers EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + local mise_predecessor=false + if [[ -f $install_backup/hermes ]] && grep -qxF '# Written by omarchy-install-hermes-cli.' "$install_backup/hermes"; then + mise_predecessor=true + fi + + if [[ $legacy == true ]]; then + if [[ $mise_predecessor == true ]]; then + printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" + fi + if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + fi + printf 'updating\n' >"$marker" + local refspec='+refs/heads/main:refs/remotes/origin/main' + if ! git -C "$root" config --get-all remote.origin.fetch | grep -qxF "$refspec"; then + git -C "$root" config --add remote.origin.fetch "$refspec" + fi + git -C "$root" fetch origin "$refspec" + env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" update --yes --branch main + elif [[ ! -e $root ]]; then + stage prerequisites + stage repository + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + printf 'pending\n' >"$marker" + fi + + if [[ $mise_predecessor == true ]]; then + printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" + fi + + if [[ $legacy == true ]]; then + env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" desktop --build-only + else + for command in venv python-deps node-deps config desktop; do stage "$command"; done + fi + + # Build first: a failed download leaves the old terminal CLI usable. + stage complete + stage path + runtime_ready || die "The CLI or desktop is not ready. See the installer output above." + if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then + printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" + fi + printf 'ready\n' >"$marker" + trap - EXIT INT TERM + rm -rf "$install_backup" + exec 9>&- + if [[ -f $root/.git/omarchy-mise-predecessor ]] && command -v omarchy-install-hermes-cli >/dev/null 2>&1; then + omarchy-install-hermes-cli || echo 'Run omarchy-install-hermes-cli again to finish the old CLI cleanup.' >&2 + fi + echo "Hermes is ready. Update it in the app or with 'hermes update'." +} + +case "${1:-}" in + --check) ready; exit ;; + --install) install_desktop; exit ;; + --setup) + shift + if /usr/bin/hermes-desktop --install; then + setsid --fork /usr/bin/hermes-desktop "$@" >/dev/null 2>&1 + exit + else + status=$? + echo "Hermes installation failed. Retry with: hermes-desktop --install" >&2 + if [[ -t 0 ]]; then read -r -p 'Press Enter to close.'; fi + exit "$status" + fi + ;; +esac + +if ! ready; then + if [[ -t 0 && -t 1 ]]; then + install_desktop + else + exec xdg-terminal-exec /usr/bin/hermes-desktop --setup "$@" + fi fi -# Chromium's own Ozone detection falls back to XWayland often enough to matter, -# and the result is a blurry window on every scaled display. Ask for Wayland -# directly, unless the user has already picked a platform themselves. -platform_flags=() -if [[ -n "${WAYLAND_DISPLAY:-}" || ${XDG_SESSION_TYPE:-} == wayland ]]; then - platform_flags=(--ozone-platform=wayland) +export HERMES_DESKTOP_HERMES_ROOT="$root" +export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" +unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME +flags=() +if [[ -n ${WAYLAND_DISPLAY:-} || ${XDG_SESSION_TYPE:-} == "wayland" ]]; then + flags=(--ozone-platform=wayland) for flag in "$@"; do - case "$flag" in - --ozone-platform=* | --ozone-platform-hint=*) platform_flags=() ;; - esac + case "$flag" in --ozone-platform=* | --ozone-platform-hint=*) flags=() ;; esac done fi -exec /opt/hermes-desktop/Hermes "${platform_flags[@]}" "$@" +# Disable only the unusable setuid helper; Chromium keeps its namespace sandbox. +if unshare --user --map-root-user true 2>/dev/null; then + flags+=(--disable-setuid-sandbox) +fi +exec "$(desktop_executable)" "${flags[@]}" "$@" diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py new file mode 100644 index 0000000..07b3b58 --- /dev/null +++ b/test/hermes-desktop-test.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Exercise the packaged entry point with a disposable HOME and installer.""" +import json +import os +import signal +import time +from pathlib import Path +import subprocess +import tempfile +import unittest + +SOURCE = Path(__file__).resolve().parents[1] / 'pkgbuilds/hermes-desktop/hermes-desktop.sh' +INSTALLER = r'''#!/bin/bash +set -eu +while (( $# )); do + case $1 in --dir) root=$2; shift;; --stage) stage=$2; shift;; esac + shift +done +printf '%s\n' "$stage" >> "$TEST_LOG" +[[ ${FAIL_STAGE:-} != "$stage" ]] || exit 42 +case $stage in +repository) + mkdir -p "$root/venv/bin" + git init -q "$root" + git -C "$root" remote add origin https://github.com/NousResearch/hermes-agent.git + touch "$root/hermes" + cp "$TEST_PYTHON" "$root/venv/bin/python" + git -C "$root" add hermes venv + git -C "$root" -c user.email=test@example.com -c user.name=Test commit -qm initial + ;; +desktop) + mkdir -p "$root/apps/desktop/release/linux-unpacked" + cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" + printf '/apps/\n/.hermes-bootstrap-complete\n' >> "$root/.git/info/exclude" + ;; +complete) touch "$root/.hermes-bootstrap-complete";; +path) + mkdir -p "$HOME/.local/bin" + for command in hermes hermes-agent hermes-acp; do + entry=hermes; suffix='' + [[ $command != hermes-agent ]] || entry=run_agent.py + [[ $command != hermes-acp ]] || suffix=' acp' + printf '#!/usr/bin/env bash\nunset PYTHONPATH\nunset PYTHONHOME\nexec "%s/venv/bin/python" "%s/%s"%s "$@"\n' "$root" "$root" "$entry" "$suffix" > "$HOME/.local/bin/$command" + chmod +x "$HOME/.local/bin/$command" + done + [[ ${FAIL_AFTER_PATH:-} != 1 ]] || exit 43 + ;; +esac +''' + +class LauncherTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='hermes-package-') + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.home = self.base / 'home with spaces' + self.home.mkdir() + self.root = self.home / '.hermes/hermes-agent' + self.bin = self.base / 'bin' + self.bin.mkdir() + self.log = self.base / 'stages' + self.output = self.base / 'launch.json' + self.env = dict(os.environ, HOME=str(self.home), HERMES_HOME=str(self.home / '.hermes'), + PATH=f'{self.bin}:/usr/bin:/bin', TEST_LOG=str(self.log), + TEST_GUI=str(self.base / 'gui'), TEST_PYTHON=str(self.base / 'python'), + TEST_OUTPUT=str(self.output), GIT_CONFIG_NOSYSTEM='1', + GIT_CONFIG_GLOBAL='/dev/null') + for k in ['FAIL_STAGE', 'FAIL_AFTER_PATH', 'PYTHONHOME', 'PYTHONPATH']: + self.env.pop(k, None) + self.write(self.base / 'installer', INSTALLER) + self.write(self.base / 'python', '#!/bin/bash\n[[ ${FAIL_READY:-} != 1 ]]\n') + self.write(self.base / 'gui', '''#!/usr/bin/env python3 +import json,os,sys,time +json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) +if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) +''') + self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n') + self.write(self.bin / 'xdg-terminal-exec', '#!/bin/bash\nprintf "%s\\n" "$@" > "$TEST_OUTPUT"\n') + source = SOURCE.read_text().replace('/usr/share/hermes-desktop/install.sh', str(self.base / 'installer')) + source = source.replace('/usr/bin/hermes-desktop', str(self.base / 'launcher')) + self.write(self.base / 'launcher', source) + + def write(self, path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + path.chmod(0o755) + + def run_launcher(self, *args, ok=True, **env): + result = subprocess.run(['bash', str(self.base / 'launcher'), *args], env=self.env | env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + self.assertEqual(result.returncode == 0, ok, result.stdout) + return result + + def install(self): + self.run_launcher('--install') + self.run_launcher('--check') + + def test_fresh_install_publishes_cli_after_desktop(self): + self.install() + stages = self.log.read_text().splitlines() + self.assertLess(stages.index('desktop'), stages.index('path')) + self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') + self.assertEqual(subprocess.check_output(['git','-C',str(self.root),'status','--porcelain'],env=self.env),b'') + + def test_warm_launch_and_package_reinstall_leave_runtime_untouched(self): + self.install() + before = self.log.read_bytes() + self.run_launcher('--install') + self.run_launcher('hermes://test?a=b', 'two words', WAYLAND_DISPLAY='wayland-1', ELECTRON_RUN_AS_NODE='1') + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['args'], ['--ozone-platform=wayland','--disable-setuid-sandbox','hermes://test?a=b','two words']) + self.assertEqual(observed['root'],str(self.root)) + self.assertIsNone(observed['node']) + self.assertEqual(observed['password'],'gnome-libsecret') + self.assertEqual(before,self.log.read_bytes()) + + def test_explicit_platform_and_password_are_preserved(self): + self.install() + self.run_launcher('--ozone-platform=x11', WAYLAND_DISPLAY='wayland-1', HERMES_DESKTOP_PASSWORD_STORE='kwallet6') + result=json.loads(self.output.read_text()) + self.assertNotIn('--ozone-platform=wayland',result['args']) + self.assertEqual(result['password'],'kwallet6') + + def test_foreign_command_and_symlink_are_preserved(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\necho custom\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual(before,wrapper.read_bytes()) + self.assertFalse(self.log.exists()) + wrapper.unlink() + wrapper.symlink_to(self.base/'missing') + self.run_launcher('--install',ok=False) + self.assertTrue(wrapper.is_symlink()) + + def test_customized_native_wrapper_is_preserved(self): + self.install() + wrapper=self.home/'.local/bin/hermes' + wrapper.write_text(wrapper.read_text().replace('unset PYTHONPATH','export CUSTOM=yes\nunset PYTHONPATH')) + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual(wrapper.read_bytes(),before) + + def test_failed_build_preserves_old_cli_and_retry_succeeds(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False,FAIL_STAGE='desktop') + self.assertEqual(wrapper.read_bytes(),before) + self.run_launcher('--check',ok=False) + self.install() + self.assertNotEqual(wrapper.read_bytes(),before) + + def test_late_path_failure_restores_old_cli(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False,FAIL_AFTER_PATH='1') + self.assertEqual(wrapper.read_bytes(),before) + self.run_launcher('--check',ok=False) + self.install() + + def test_dirty_pending_checkout_is_preserved(self): + self.run_launcher('--install',ok=False,FAIL_STAGE='desktop') + (self.root/'hermes').write_text('user changes') + before=self.log.read_bytes() + self.run_launcher('--install',ok=False) + self.assertEqual((self.root/'hermes').read_text(),'user changes') + self.assertEqual(self.log.read_bytes(),before) + + def test_cold_graphical_launch_opens_visible_setup(self): + self.run_launcher('hermes://open') + self.assertEqual(self.output.read_text().splitlines(),[str(self.base/'launcher'),'--setup','hermes://open']) + self.assertFalse(self.log.exists()) + + def test_unexecutable_native_cli_is_repaired(self): + self.install() + wrapper=self.home/'.local/bin/hermes' + wrapper.chmod(0o644) + self.run_launcher('--check',ok=False) + self.install() + self.assertTrue(os.access(wrapper,os.X_OK)) + + def test_old_cli_ownership_survives_package_only_setup(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + self.install() + receipt=self.root/'.git/omarchy-mise-predecessor' + self.assertEqual(receipt.read_text(),'pipx:hermes-agent[extras=all]\n') + + def test_graphical_setup_detaches_and_releases_install_lock(self): + started=time.monotonic() + self.run_launcher('--setup', 'hermes://open', TEST_GUI_WAIT='1') + self.assertLess(time.monotonic()-started,5) + for _ in range(100): + if self.output.exists(): break + time.sleep(.02) + observed=json.loads(self.output.read_text()) + self.addCleanup(lambda: os.kill(observed['pid'],signal.SIGTERM)) + self.assertIn('hermes://open',observed['args']) + self.run_launcher('--install') + lock=self.home/'.hermes/.omarchy-hermes-desktop.lock' + self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0) + + def test_pending_marker_overrides_old_completion(self): + self.install() + (self.root/'.omarchy-hermes-desktop').write_text('pending\n') + self.run_launcher('--check',ok=False) + +if __name__=='__main__': + unittest.main() From 01e1a8de0f1d39dfe24711ae4fe0f0ae4a4d8f82 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 01:57:42 -0500 Subject: [PATCH 2/8] Exercise lock release on a cold terminal launch Co-Authored-By: GPT-6 Codex (xhigh) --- test/hermes-desktop-test.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 07b3b58..15f0335 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -2,6 +2,7 @@ """Exercise the packaged entry point with a disposable HOME and installer.""" import json import os +import pty import signal import time from pathlib import Path @@ -202,6 +203,23 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) lock=self.home/'.hermes/.omarchy-hermes-desktop.lock' self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0) + def test_cold_terminal_launch_releases_lock_before_exec(self): + master,slave=pty.openpty() + process=subprocess.Popen(['bash',str(self.base/'launcher')],stdin=slave,stdout=slave,stderr=slave,env=self.env | {'TEST_GUI_WAIT':'1'}) + os.close(slave) + def cleanup(): + if process.poll() is None: process.terminate() + process.wait(timeout=5) + os.close(master) + self.addCleanup(cleanup) + for _ in range(100): + if self.output.exists(): break + time.sleep(.02) + self.assertTrue(self.output.exists()) + self.assertIsNone(process.poll()) + lock=self.home/'.hermes/.omarchy-hermes-desktop.lock' + self.assertEqual(subprocess.run(['flock','--nonblock',str(lock),'true']).returncode,0) + def test_pending_marker_overrides_old_completion(self): self.install() (self.root/'.omarchy-hermes-desktop').write_text('pending\n') From 68a4a6cc220820d0bc4d067f8709d8892fd1275e Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 01:59:58 -0500 Subject: [PATCH 3/8] Keep in-app updates on the verified Hermes CLI --- pkgbuilds/hermes-desktop/PKGBUILD | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 1 + test/hermes-desktop-test.py | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index f00eee3..f75815b 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -30,7 +30,7 @@ source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/h 'hermes-desktop.png') sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - '5113ba692fe00b0f4806f799ce16b85a44bb2bd901ca17577ba64c887b1fb33d' + '259547e64eda2fd6514bc5bd9e6637de063a0ac5e2739d4c873f18838fe26b3a' 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index e8a1716..2de3f09 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -228,6 +228,7 @@ if ! ready; then fi export HERMES_DESKTOP_HERMES_ROOT="$root" +export PATH="$HOME/.local/bin:$PATH" export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 15f0335..05bcd36 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -71,10 +71,11 @@ class LauncherTests(unittest.TestCase): self.write(self.base / 'installer', INSTALLER) self.write(self.base / 'python', '#!/bin/bash\n[[ ${FAIL_READY:-} != 1 ]]\n') self.write(self.base / 'gui', '''#!/usr/bin/env python3 -import json,os,sys,time -json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) +import json,os,sys,time,shutil +json.dump({'selected_cli':shutil.which('hermes'),'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) ''') + self.write(self.bin / 'hermes', '#!/bin/bash\nexit 99\n') self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n') self.write(self.bin / 'xdg-terminal-exec', '#!/bin/bash\nprintf "%s\\n" "$@" > "$TEST_OUTPUT"\n') source = SOURCE.read_text().replace('/usr/share/hermes-desktop/install.sh', str(self.base / 'installer')) @@ -111,6 +112,7 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) observed = json.loads(self.output.read_text()) self.assertEqual(observed['args'], ['--ozone-platform=wayland','--disable-setuid-sandbox','hermes://test?a=b','two words']) self.assertEqual(observed['root'],str(self.root)) + self.assertEqual(observed['selected_cli'],str(self.home/'.local/bin/hermes')) self.assertIsNone(observed['node']) self.assertEqual(observed['password'],'gnome-libsecret') self.assertEqual(before,self.log.read_bytes()) From 2b1edd47b218911ab222bc85b8631dbae4b66e2c Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 02:02:52 -0500 Subject: [PATCH 4/8] Revert "Keep in-app updates on the verified Hermes CLI" This reverts commit 68a4a6cc220820d0bc4d067f8709d8892fd1275e. --- pkgbuilds/hermes-desktop/PKGBUILD | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 1 - test/hermes-desktop-test.py | 6 ++---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index f75815b..f00eee3 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -30,7 +30,7 @@ source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/h 'hermes-desktop.png') sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - '259547e64eda2fd6514bc5bd9e6637de063a0ac5e2739d4c873f18838fe26b3a' + '5113ba692fe00b0f4806f799ce16b85a44bb2bd901ca17577ba64c887b1fb33d' 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 2de3f09..e8a1716 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -228,7 +228,6 @@ if ! ready; then fi export HERMES_DESKTOP_HERMES_ROOT="$root" -export PATH="$HOME/.local/bin:$PATH" export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 05bcd36..15f0335 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -71,11 +71,10 @@ class LauncherTests(unittest.TestCase): self.write(self.base / 'installer', INSTALLER) self.write(self.base / 'python', '#!/bin/bash\n[[ ${FAIL_READY:-} != 1 ]]\n') self.write(self.base / 'gui', '''#!/usr/bin/env python3 -import json,os,sys,time,shutil -json.dump({'selected_cli':shutil.which('hermes'),'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) +import json,os,sys,time +json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) ''') - self.write(self.bin / 'hermes', '#!/bin/bash\nexit 99\n') self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n') self.write(self.bin / 'xdg-terminal-exec', '#!/bin/bash\nprintf "%s\\n" "$@" > "$TEST_OUTPUT"\n') source = SOURCE.read_text().replace('/usr/share/hermes-desktop/install.sh', str(self.base / 'installer')) @@ -112,7 +111,6 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) observed = json.loads(self.output.read_text()) self.assertEqual(observed['args'], ['--ozone-platform=wayland','--disable-setuid-sandbox','hermes://test?a=b','two words']) self.assertEqual(observed['root'],str(self.root)) - self.assertEqual(observed['selected_cli'],str(self.home/'.local/bin/hermes')) self.assertIsNone(observed['node']) self.assertEqual(observed['password'],'gnome-libsecret') self.assertEqual(before,self.log.read_bytes()) From f4adf308f9c5facc132d4727750376da7ced1db1 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 02:17:27 -0500 Subject: [PATCH 5/8] Make native Hermes setup retryable without a late sudo step Stage the initial clone before publishing its ownership marker and build the desktop through the same native CLI used by updates. Keep a concurrent checkout intact and reset pkgrel for the version change. Co-Authored-By: Claude Opus 5 (default) --- pkgbuilds/hermes-desktop/PKGBUILD | 4 +-- pkgbuilds/hermes-desktop/hermes-desktop.sh | 25 +++++++++----- test/hermes-desktop-test.py | 39 ++++++++++++++++++---- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index f00eee3..4f2f795 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -2,7 +2,7 @@ pkgname=hermes-desktop pkgver=2026.8.31 -pkgrel=2 +pkgrel=1 pkgdesc='Installer and launcher for the self-updating Hermes desktop' arch=('x86_64') url='https://github.com/NousResearch/hermes-agent' @@ -30,7 +30,7 @@ source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/h 'hermes-desktop.png') sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - '5113ba692fe00b0f4806f799ce16b85a44bb2bd901ca17577ba64c887b1fb33d' + 'b4c282ead8a5d1a631f702474ea36eb7362ab1f0f87ad2e71f60a8698de07035' 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index e8a1716..4d9b8cd 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -66,7 +66,7 @@ restore_launchers() { } stage() { - bash "$installer" --dir "$root" --branch main --non-interactive --stage "$1" + bash "$installer" --dir "${2:-$root}" --branch main --non-interactive --stage "$1" } install_desktop() { @@ -170,20 +170,29 @@ PY env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" update --yes --branch main elif [[ ! -e $root ]]; then stage prerequisites - stage repository - printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" - printf 'pending\n' >"$marker" + # Publish only a complete clone with its ownership marker. An interrupted + # clone stays aside for inspection and cannot block the next installation. + local repository_dir + repository_dir=$(mktemp -d "$HERMES_HOME/.omarchy-hermes-repository.XXXXXX") + echo "Preparing the Hermes checkout in $repository_dir" + stage repository "$repository_dir/hermes-agent" + printf '/.omarchy-hermes-desktop\n' >>"$repository_dir/hermes-agent/.git/info/exclude" + printf 'pending\n' >"$repository_dir/hermes-agent/.omarchy-hermes-desktop" + mv -T --no-clobber "$repository_dir/hermes-agent" "$root" + [[ ! -e $repository_dir/hermes-agent ]] || die "Keeping the checkout that appeared at $root during installation." + rmdir "$repository_dir" fi if [[ $mise_predecessor == true ]]; then printf '%s\n' 'pipx:hermes-agent[extras=all]' >"$root/.git/omarchy-mise-predecessor" fi - if [[ $legacy == true ]]; then - env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" desktop --build-only - else - for command in venv python-deps node-deps config desktop; do stage "$command"; done + if [[ $legacy == false ]]; then + for command in venv python-deps node-deps config; do stage "$command"; done fi + # The native builder also handles updates and supports the namespace + # sandbox. The shell installer's desktop stage still requires a sudo chown. + env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" desktop --build-only # Build first: a failed download leaves the old terminal CLI usable. stage complete diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 15f0335..78062b6 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -23,16 +23,16 @@ case $stage in repository) mkdir -p "$root/venv/bin" git init -q "$root" + [[ ${FAIL_PARTIAL_CLONE:-} != 1 ]] || exit 44 git -C "$root" remote add origin https://github.com/NousResearch/hermes-agent.git touch "$root/hermes" cp "$TEST_PYTHON" "$root/venv/bin/python" git -C "$root" add hermes venv git -C "$root" -c user.email=test@example.com -c user.name=Test commit -qm initial - ;; -desktop) - mkdir -p "$root/apps/desktop/release/linux-unpacked" - cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" - printf '/apps/\n/.hermes-bootstrap-complete\n' >> "$root/.git/info/exclude" + if [[ ${RACE_CLONE:-} == 1 ]]; then + mkdir -p "$HOME/.hermes/hermes-agent" + printf 'foreign checkout\n' > "$HOME/.hermes/hermes-agent/keep" + fi ;; complete) touch "$root/.hermes-bootstrap-complete";; path) @@ -69,7 +69,20 @@ class LauncherTests(unittest.TestCase): for k in ['FAIL_STAGE', 'FAIL_AFTER_PATH', 'PYTHONHOME', 'PYTHONPATH']: self.env.pop(k, None) self.write(self.base / 'installer', INSTALLER) - self.write(self.base / 'python', '#!/bin/bash\n[[ ${FAIL_READY:-} != 1 ]]\n') + self.write(self.base / 'python', '''#!/bin/bash +set -eu +if [[ ${2:-} == desktop ]]; then + [[ ${3:-} == --build-only ]] + printf 'desktop\\n' >> "$TEST_LOG" + [[ ${FAIL_STAGE:-} != desktop ]] || exit 42 + root=$(dirname "$1") + mkdir -p "$root/apps/desktop/release/linux-unpacked" + cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" + printf '/apps/\\n/.hermes-bootstrap-complete\\n' >> "$root/.git/info/exclude" +else + [[ ${FAIL_READY:-} != 1 ]] +fi +''') self.write(self.base / 'gui', '''#!/usr/bin/env python3 import json,os,sys,time json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) @@ -103,6 +116,20 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') self.assertEqual(subprocess.check_output(['git','-C',str(self.root),'status','--porcelain'],env=self.env),b'') + def test_interrupted_clone_stays_aside_and_retry_succeeds(self): + self.run_launcher('--install', ok=False, FAIL_PARTIAL_CLONE='1') + self.assertFalse(self.root.exists()) + partials=list((self.home/'.hermes').glob('.omarchy-hermes-repository.*/hermes-agent/.git')) + self.assertEqual(len(partials),1) + self.install() + self.assertTrue(partials[0].is_dir()) + + def test_clone_publication_preserves_concurrent_checkout(self): + self.run_launcher('--install', ok=False, RACE_CLONE='1') + self.assertEqual((self.root/'keep').read_text(),'foreign checkout\n') + self.assertFalse((self.root/'.omarchy-hermes-desktop').exists()) + self.assertFalse((self.home/'.local/bin/hermes').exists()) + def test_warm_launch_and_package_reinstall_leave_runtime_untouched(self): self.install() before = self.log.read_bytes() From 351f188d025d7e07d47bd1cb78bd11bada0ea2ac Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 02:21:16 -0500 Subject: [PATCH 6/8] Register Hermes Desktop only after publishing its native CLI Keep build-time registration private, verify the published launcher, and then let the native command register the final desktop entry. Keep that launcher first on the desktop environment PATH for subsequent registrations. Co-Authored-By: GPT-6 Codex (xhigh) --- pkgbuilds/hermes-desktop/PKGBUILD | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 7 +++- test/hermes-desktop-test.py | 40 ++++++++++++++++++---- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 4f2f795..5d32d59 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -30,7 +30,7 @@ source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/h 'hermes-desktop.png') sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - 'b4c282ead8a5d1a631f702474ea36eb7362ab1f0f87ad2e71f60a8698de07035' + '85834b56348529413bbc248282d3a1769a2276a016affe9eafb7dd8773d9e12f' 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 4d9b8cd..77f87ef 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -192,12 +192,15 @@ PY fi # The native builder also handles updates and supports the namespace # sandbox. The shell installer's desktop stage still requires a sudo chown. - env -u PYTHONPATH -u PYTHONHOME "${cli[@]}" desktop --build-only + # The build registers a desktop entry, too. Keep it private until the new + # CLI is published so a different hermes on PATH cannot become its target. + env -u PYTHONPATH -u PYTHONHOME XDG_DATA_HOME="$install_backup/desktop" "${cli[@]}" desktop --build-only # Build first: a failed download leaves the old terminal CLI usable. stage complete stage path runtime_ready || die "The CLI or desktop is not ready. See the installer output above." + env -u PYTHONPATH -u PYTHONHOME PATH="$HOME/.local/bin:$PATH" "${cli[@]}" desktop --skip-build --build-only if ! grep -qxF '/.omarchy-hermes-desktop' "$root/.git/info/exclude"; then printf '/.omarchy-hermes-desktop\n' >>"$root/.git/info/exclude" fi @@ -238,6 +241,8 @@ fi export HERMES_DESKTOP_HERMES_ROOT="$root" export HERMES_DESKTOP_PASSWORD_STORE="${HERMES_DESKTOP_PASSWORD_STORE:-gnome-libsecret}" +# Upstream desktop registration resolves its launcher through PATH. +export PATH="$HOME/.local/bin:$PATH" unset ELECTRON_RUN_AS_NODE PYTHONPATH PYTHONHOME flags=() diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 78062b6..0395895 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -62,6 +62,8 @@ class LauncherTests(unittest.TestCase): self.log = self.base / 'stages' self.output = self.base / 'launch.json' self.env = dict(os.environ, HOME=str(self.home), HERMES_HOME=str(self.home / '.hermes'), + XDG_DATA_HOME=str(self.home / '.local/share'), + XDG_CONFIG_HOME=str(self.home / '.config'), XDG_CACHE_HOME=str(self.home / '.cache'), PATH=f'{self.bin}:/usr/bin:/bin', TEST_LOG=str(self.log), TEST_GUI=str(self.base / 'gui'), TEST_PYTHON=str(self.base / 'python'), TEST_OUTPUT=str(self.output), GIT_CONFIG_NOSYSTEM='1', @@ -72,13 +74,21 @@ class LauncherTests(unittest.TestCase): self.write(self.base / 'python', '''#!/bin/bash set -eu if [[ ${2:-} == desktop ]]; then - [[ ${3:-} == --build-only ]] - printf 'desktop\\n' >> "$TEST_LOG" - [[ ${FAIL_STAGE:-} != desktop ]] || exit 42 root=$(dirname "$1") - mkdir -p "$root/apps/desktop/release/linux-unpacked" - cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" - printf '/apps/\\n/.hermes-bootstrap-complete\\n' >> "$root/.git/info/exclude" + if [[ ${3:-} == --skip-build ]]; then + [[ ${4:-} == --build-only && -x $root/apps/desktop/release/linux-unpacked/Hermes ]] + [[ ${FAIL_FINAL_REGISTER:-} != 1 ]] || exit 45 + else + [[ ${3:-} == --build-only ]] + printf 'desktop\\n' >> "$TEST_LOG" + [[ ${FAIL_STAGE:-} != desktop ]] || exit 42 + mkdir -p "$root/apps/desktop/release/linux-unpacked" + cp "$TEST_GUI" "$root/apps/desktop/release/linux-unpacked/Hermes" + printf '/apps/\\n/.hermes-bootstrap-complete\\n' >> "$root/.git/info/exclude" + fi + entry_dir="${XDG_DATA_HOME:-$HOME/.local/share}/applications" + mkdir -p "$entry_dir" + printf 'Exec=%s desktop\\n' "$(command -v hermes || printf '%s/venv/bin/python -m hermes_cli.main' "$root")" > "$entry_dir/hermes.desktop" else [[ ${FAIL_READY:-} != 1 ]] fi @@ -130,6 +140,24 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) self.assertFalse((self.root/'.omarchy-hermes-desktop').exists()) self.assertFalse((self.home/'.local/bin/hermes').exists()) + def test_desktop_registration_uses_published_cli(self): + self.write(self.bin/'hermes', '#!/bin/bash\necho foreign\n') + self.install() + entry=self.home/'.local/share/applications/hermes.desktop' + self.assertEqual(entry.read_text(),f'Exec={self.home}/.local/bin/hermes desktop\n') + + def test_failed_registration_preserves_previous_launcher(self): + wrapper=self.home/'.local/bin/hermes' + self.write(wrapper,'#!/bin/bash\n# Written by omarchy-install-hermes-cli.\necho old\n') + entry=self.home/'.local/share/applications/hermes.desktop' + self.write(entry,'previous desktop entry\n') + before=wrapper.read_bytes() + self.run_launcher('--install',ok=False,FAIL_FINAL_REGISTER='1') + self.assertEqual(wrapper.read_bytes(),before) + self.assertEqual(entry.read_text(),'previous desktop entry\n') + self.run_launcher('--check',ok=False) + self.install() + def test_warm_launch_and_package_reinstall_leave_runtime_untouched(self): self.install() before = self.log.read_bytes() From beb164a9a218ce680737ef46a94e05786fd9e72c Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 22:29:49 -0500 Subject: [PATCH 7/8] Resolve Hermes profile homes before package setup --- pkgbuilds/hermes-desktop/PKGBUILD | 2 +- pkgbuilds/hermes-desktop/hermes-desktop.sh | 20 ++++--- test/hermes-desktop-test.py | 67 +++++++++++++++++++++- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index 5d32d59..aa4b57d 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -30,7 +30,7 @@ source=("install-${_commit}.sh::https://raw.githubusercontent.com/NousResearch/h 'hermes-desktop.png') sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' '821556e6336796450ab852d375117b48a4887e71d255794fd6318d99982a5ab6' - '85834b56348529413bbc248282d3a1769a2276a016affe9eafb7dd8773d9e12f' + '27040084e691421dc1748e7646ae7ee79d5777e8ee1e1ec7581a68432d4a29c4' 'c37d4cfa4801eccbd769fddaebb2115d54faa49a5b7bc0f305f563e3cefd9bd8' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') diff --git a/pkgbuilds/hermes-desktop/hermes-desktop.sh b/pkgbuilds/hermes-desktop/hermes-desktop.sh index 77f87ef..fe00939 100644 --- a/pkgbuilds/hermes-desktop/hermes-desktop.sh +++ b/pkgbuilds/hermes-desktop/hermes-desktop.sh @@ -1,18 +1,24 @@ #!/bin/bash set -euo pipefail -export HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" -HERMES_HOME=$(realpath -ms "$HERMES_HOME") -root="$HERMES_HOME/hermes-agent" -marker="$root/.omarchy-hermes-desktop" -installer=/usr/share/hermes-desktop/install.sh -cli=("$root/venv/bin/python" "$root/hermes") - die() { echo "Hermes: $*" >&2 exit 1 } +HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}") +# A profile session shares its parent home's installation and launchers. +home_parent=${HERMES_HOME%/*} +if [[ ${home_parent##*/} == "profiles" ]]; then + HERMES_HOME=${home_parent%/*} +fi +[[ $HERMES_HOME == /* && $HERMES_HOME != "/" ]] || die "Use a Hermes data directory other than /." +export HERMES_HOME +root="$HERMES_HOME/hermes-agent" +marker="$root/.omarchy-hermes-desktop" +installer=/usr/share/hermes-desktop/install.sh +cli=("$root/venv/bin/python" "$root/hermes") + desktop_executable() { local executable for executable in "$root/apps/desktop/release/linux-unpacked/"{Hermes,hermes}; do diff --git a/test/hermes-desktop-test.py b/test/hermes-desktop-test.py index 0395895..d4f60c2 100644 --- a/test/hermes-desktop-test.py +++ b/test/hermes-desktop-test.py @@ -95,7 +95,7 @@ fi ''') self.write(self.base / 'gui', '''#!/usr/bin/env python3 import json,os,sys,time -json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) +json.dump({'pid':os.getpid(),'args':sys.argv[1:],'root':os.environ['HERMES_DESKTOP_HERMES_ROOT'],'home':os.environ['HERMES_HOME'],'node':os.environ.get('ELECTRON_RUN_AS_NODE'),'password':os.environ['HERMES_DESKTOP_PASSWORD_STORE']},open(os.environ['TEST_OUTPUT'],'w')) if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) ''') self.write(self.bin / 'unshare', '#!/bin/bash\nexit 0\n') @@ -126,6 +126,71 @@ if os.environ.get('TEST_GUI_WAIT'): time.sleep(30) self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') self.assertEqual(subprocess.check_output(['git','-C',str(self.root),'status','--porcelain'],env=self.env),b'') + def test_profile_setup_installs_in_machine_home(self): + profile = self.home / '.hermes/profiles/coder' + self.write(profile / 'config.yaml', 'profile data\n') + self.env['HERMES_HOME'] = str(profile) + '/./' + self.install() + self.assertEqual((self.root / '.omarchy-hermes-desktop').read_text(), 'ready\n') + self.assertEqual(list(profile.iterdir()), [profile / 'config.yaml']) + self.assertEqual((profile / 'config.yaml').read_text(), 'profile data\n') + + def test_profile_warm_check_install_and_launch_share_machine_runtime(self): + self.install() + before = self.log.read_bytes() + self.env['HERMES_HOME'] = str(self.home / '.hermes/profiles/coder') + self.run_launcher('--check') + self.run_launcher('--install') + self.run_launcher('hermes://profile') + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['root'], str(self.root)) + self.assertEqual(observed['home'], str(self.root.parent)) + self.assertEqual(self.log.read_bytes(), before) + + def test_custom_home_profile_uses_its_own_parent_root(self): + custom_home = self.base / 'custom data' + self.root = custom_home / 'hermes-agent' + self.env['HERMES_HOME'] = str(custom_home / 'profiles/coder') + self.install() + self.run_launcher() + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['root'], str(self.root)) + self.assertEqual(observed['home'], str(custom_home)) + self.assertFalse((self.home / '.hermes').exists()) + self.assertFalse((custom_home / 'profiles').exists()) + + def test_custom_home_with_profiles_ancestor_is_preserved(self): + custom_home = self.base / 'profiles/team/custom data' + self.root = custom_home / 'hermes-agent' + self.env['HERMES_HOME'] = str(custom_home) + self.install() + self.run_launcher() + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['root'], str(self.root)) + self.assertEqual(observed['home'], str(custom_home)) + + def test_profile_resolution_preserves_symlink_home_spelling(self): + target = self.base / 'physical data' + target.mkdir() + alias = self.base / 'data alias' + alias.symlink_to(target, target_is_directory=True) + self.root = alias / 'hermes-agent' + self.env['HERMES_HOME'] = str(alias / 'profiles/coder') + self.install() + self.run_launcher() + observed = json.loads(self.output.read_text()) + self.assertEqual(observed['root'], str(self.root)) + self.assertEqual(observed['home'], str(alias)) + self.assertTrue(alias.is_symlink()) + + def test_filesystem_root_home_is_rejected_before_mutation(self): + self.write(self.bin / 'mkdir', '#!/bin/bash\nprintf called >> "$TEST_LOG"\nexit 42\n') + for home in ['/', '/profiles/coder', '/profiles/coder/./']: + with self.subTest(home=home): + result = self.run_launcher('--install', ok=False, HERMES_HOME=home) + self.assertIn('Use a Hermes data directory other than /.', result.stdout) + self.assertFalse(self.log.exists()) + def test_interrupted_clone_stays_aside_and_retry_succeeds(self): self.run_launcher('--install', ok=False, FAIL_PARTIAL_CLONE='1') self.assertFalse(self.root.exists()) From b36bfce10976acdf463469d81dd13b2499d32c23 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Sun, 6 Sep 2026 22:53:12 -0500 Subject: [PATCH 8/8] Restore Hermes PKGBUILD formatting --- pkgbuilds/hermes-desktop/PKGBUILD | 61 +++++++++++++++++++++++++------ 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/pkgbuilds/hermes-desktop/PKGBUILD b/pkgbuilds/hermes-desktop/PKGBUILD index aa4b57d..5783f0b 100644 --- a/pkgbuilds/hermes-desktop/PKGBUILD +++ b/pkgbuilds/hermes-desktop/PKGBUILD @@ -11,13 +11,47 @@ license=('MIT') # Hermes builds its desktop under the user's runtime so both the app and CLI # can update it. The compiler and Electron libraries must survive installation. depends=( - 'alsa-lib' 'at-spi2-core' 'bash' 'cairo' 'curl' 'dbus' 'expat' 'gcc' - 'gdk-pixbuf2' 'git' 'glib2' 'glibc' 'gtk3' 'hicolor-icon-theme' 'libcups' - 'libdrm' 'libglvnd' 'libnotify' 'libsecret' 'libx11' 'libxcb' 'libxcomposite' - 'libxdamage' 'libxext' 'libxfixes' 'libxkbcommon' 'libxrandr' 'make' 'mesa' - 'nodejs' 'npm' 'nspr' 'nss' 'pango' 'python' 'systemd-libs' 'util-linux' - 'xdg-terminal-exec' 'xdg-utils' + 'alsa-lib' + 'at-spi2-core' + 'bash' + 'cairo' + 'curl' + 'dbus' + 'expat' + 'gcc' + 'gdk-pixbuf2' + 'git' + 'glib2' + 'glibc' + 'gtk3' + 'hicolor-icon-theme' + 'libcups' + 'libdrm' + 'libglvnd' + 'libnotify' + 'libsecret' + 'libx11' + 'libxcb' + 'libxcomposite' + 'libxdamage' + 'libxext' + 'libxfixes' + 'libxkbcommon' + 'libxrandr' + 'make' + 'mesa' + 'nodejs' + 'npm' + 'nspr' + 'nss' + 'pango' + 'python' + 'systemd-libs' + 'util-linux' + 'xdg-terminal-exec' + 'xdg-utils' ) + makedepends=('imagemagick') # Snapshot the bootstrap installer, including upstream's Linux sandbox fixes. @@ -35,19 +69,24 @@ sha256sums=('5854b15670b51a8daae8f59ddfa917062de9f74be261eb73b4b8d719710f8968' 'd60d164e24fdcf6532133b8ea43c77a201e4b9e9dbc396187b58d51d8590ef52') package() { - install -Dm644 "${srcdir}/install-${_commit}.sh" "${pkgdir}/usr/share/${pkgname}/install.sh" - install -Dm644 "${srcdir}/LICENSE-${_commit}" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 "${srcdir}/install-${_commit}.sh" \ + "${pkgdir}/usr/share/${pkgname}/install.sh" + install -Dm644 "${srcdir}/LICENSE-${_commit}" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" install -Dm755 "${srcdir}/hermes-desktop.sh" "${pkgdir}/usr/bin/${pkgname}" # Native registration uses hermes.desktop but omits URI handling. Keep the # original handler ID hidden, and let the native entry replace the visible one. - install -Dm644 "${srcdir}/hermes-desktop.desktop" "${pkgdir}/usr/share/applications/hermes-desktop.desktop" + install -Dm644 "${srcdir}/hermes-desktop.desktop" \ + "${pkgdir}/usr/share/applications/hermes-desktop.desktop" printf 'NoDisplay=true\n' >>"${pkgdir}/usr/share/applications/hermes-desktop.desktop" sed '/^MimeType=/d' "${srcdir}/hermes-desktop.desktop" >"${pkgdir}/usr/share/applications/hermes.desktop" - install -Dm644 "${srcdir}/hermes-desktop.png" "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" + install -Dm644 "${srcdir}/hermes-desktop.png" \ + "${pkgdir}/usr/share/icons/hicolor/1024x1024/apps/${pkgname}.png" local size for size in 512 256 128 64 48; do magick "${srcdir}/hermes-desktop.png" -resize "${size}x${size}" "${srcdir}/icon-${size}.png" - install -Dm644 "${srcdir}/icon-${size}.png" "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" + install -Dm644 "${srcdir}/icon-${size}.png" \ + "${pkgdir}/usr/share/icons/hicolor/${size}x${size}/apps/${pkgname}.png" done }