From 827266fbed148a004168fed3eab014e152722914 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 03:39:05 -0500 Subject: [PATCH] Prepare a consistent Hermes release for its first update Align main with the packaged release and fetch connected history so the updater detects and rebuilds the first update. Guard local branch work before upstream installation, force only the admitted incomplete release pin, preserve existing command files, and explain incompatible packages. Exercise empty-directory publication races and delayed launches. Co-Authored-By: GPT-6 Codex (xhigh) --- bin/omarchy-install-ai-hermes | 45 ++++++++- test/shell.d/hermes-desktop-install-test.sh | 104 +++++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index abb668aa..5bf6b674 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -13,6 +13,12 @@ fi echo "Installing Hermes Desktop..." omarchy-pkg-add hermes-desktop +if [[ ! -r /usr/share/hermes-desktop/install.sh || ! -r /usr/share/hermes-desktop/runtime.patch ]] || + ! release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json 2>/dev/null); then + echo "The installed Hermes package cannot prepare in-app updates. Run 'omarchy update', then try again." >&2 + exit 1 +fi + # If Hermes was already installed for the terminal, the app supersedes it: one # machine, one Hermes. This drops that copy so the terminal, the default agent # and the app all end up on the app's installation. @@ -28,13 +34,21 @@ export HERMES_HOME runtime="$HERMES_HOME/hermes-agent" native_app="$runtime/apps/desktop/release/linux-unpacked" -release_commit=$(jq -er 'select(.branch == "main") | .commit | select(test("^[0-9a-f]{40}$"))' /opt/hermes-desktop/resources/install-stamp.json) runtime_ready() { [[ -f $runtime/.hermes-bootstrap-complete && -f $runtime/venv/bin/hermes && -x $runtime/venv/bin/hermes && -f $runtime/venv/bin/python && -x $runtime/venv/bin/python ]] && timeout 15 "$runtime/venv/bin/hermes" --version >/dev/null 2>&1 } +check_main() { + local main_commit + main_commit=$(git -C "$runtime" rev-parse --verify refs/heads/main 2>/dev/null || true) + if [[ -n $main_commit && $main_commit != "$release_commit" && $main_commit != "$(git -C "$runtime" rev-parse --verify refs/remotes/origin/main 2>/dev/null)" ]]; then + echo "Hermes main has local commits. Keep that work and prepare the desktop with 'hermes desktop --build-only'." >&2 + return 1 + fi +} + if ! runtime_ready; then # The upstream installer can reset an existing checkout. Do not pin a newer # or modified runtime back to the package release while repairing setup. @@ -44,10 +58,29 @@ if ! runtime_ready; then echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2 exit 1 fi + check_main fi + # Upstream replaces these commands, including foreign files and symlinks. + # Keep their original bytes/links before handing the names to the desktop. + command_backup="" + for command in hermes hermes-agent hermes-acp; do + command_path="$HOME/.local/bin/$command" + if [[ -e $command_path || -L $command_path ]]; then + if [[ ! -f $command_path && ! -L $command_path ]]; then + echo "Cannot replace $command_path: move it aside before installing Hermes Desktop." >&2 + exit 1 + fi + if [[ -z $command_backup ]]; then + command_backup=$(mktemp -d "$HOME/.local/bin/.hermes-before-desktop.XXXXXX") + echo "Saving existing Hermes commands in $command_backup" + fi + cp -a -- "$command_path" "$command_backup/" + fi + done + echo "Setting up the Hermes runtime..." - bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$HERMES_HOME" + bash /usr/share/hermes-desktop/install.sh --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$HERMES_HOME" if ! runtime_ready; then echo "Hermes runtime setup did not complete. Re-run this command after resolving the installer error." >&2 exit 1 @@ -56,6 +89,14 @@ fi runtime_commit=$(git -C "$runtime" rev-parse HEAD) if [[ $runtime_commit == "$release_commit" ]]; then + # The updater switches to main before checking for changes. Start main at + # the packaged release, with enough history for its first fast-forward. + check_main + if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then + git -C "$runtime" fetch --unshallow origin main + fi + git -C "$runtime" switch -C main "$release_commit" + if git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then git -C "$runtime" apply /usr/share/hermes-desktop/runtime.patch elif ! git -C "$runtime" apply --reverse --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1; then diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh index 4ba8abb8..d9d342cf 100644 --- a/test/shell.d/hermes-desktop-install-test.sh +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -38,6 +38,11 @@ release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) printf 'after\n' >"$test_tmp/seed/runtime.txt" git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch" printf 'before\n' >"$test_tmp/seed/runtime.txt" +printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js" +git -C "$test_tmp/seed" add apps/desktop/src/main.js +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main +origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +export OMARCHY_TEST_RELEASE_COMMIT="$release_commit" printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json" printf 'packaged app\n' >"$test_tmp/package/resources/app.asar" printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes" @@ -51,21 +56,40 @@ set -e printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events" printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args" [[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7 +commit=$OMARCHY_TEST_RELEASE_COMMIT +force=false while (( $# )); do case "$1" in --dir) runtime=$2; shift ;; + --commit) commit=$2; shift ;; + --force-commit) force=true ;; --hermes-home) [[ $2 == "$HERMES_HOME" ]] ;; esac shift done mkdir -p -- "${runtime%/*}" -if [[ ! -d $runtime ]]; then git clone -q "$OMARCHY_TEST_ROOT/seed" "$runtime"; fi +if [[ ! -d $runtime ]]; then + git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime" +else + git -C "$runtime" checkout -q main + git -C "$runtime" pull -q --ff-only origin main +fi +git -C "$runtime" fetch -q origin "$commit" +if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then + git -C "$runtime" checkout -q --detach "$commit" +fi mkdir -p "$runtime/venv/bin" +git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit" printf '#!/bin/bash\nexit 0\n' >"$runtime/venv/bin/hermes" chmod +x "$runtime/venv/bin/hermes" printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python" chmod +x "$runtime/venv/bin/python" [[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete" +mkdir -p "$HOME/.local/bin" +for command in hermes hermes-agent hermes-acp; do + rm -f "$HOME/.local/bin/$command" + printf 'native runtime shim\n' >"$HOME/.local/bin/$command" +done MOCK cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' @@ -73,6 +97,11 @@ cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK' printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events" [[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] MOCK +cat >"$test_tmp/bin/git" <<'MOCK' +#!/bin/bash +if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi +exec /usr/bin/git "$@" +MOCK cat >"$test_tmp/bin/omarchy-install-hermes-cli" <<'MOCK' #!/bin/bash printf 'handoff\n' >>"$OMARCHY_TEST_ROOT/events" @@ -94,7 +123,6 @@ cat >"$test_tmp/bin/mv" <<'MOCK' #!/bin/bash if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then mkdir -p "${@: -1}" - printf 'concurrent app\n' >"${@: -1}/keep" fi exec /usr/bin/mv "$@" MOCK @@ -106,6 +134,7 @@ exec "$@" MOCK cat >"$test_tmp/bin/hermes-desktop" <<'MOCK' #!/bin/bash +sleep 0.05 native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked" if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events" @@ -162,7 +191,7 @@ assert_stopped() { new_home fresh run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")" -expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --dir "$runtime" --hermes-home "$hermes_home") +expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home") [[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments" [[ $(head -3 "$test_tmp/events") == $'package hermes-desktop\nhandoff\nbootstrap' ]] || fail "package and CLI handoff precede runtime bootstrap" grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" @@ -171,6 +200,17 @@ grep -qx launch "$test_tmp/events" || fail "native app is copied before launch" [[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch" [[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid" [[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged" +[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release" +[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history" +# Reproduce the updater's checkout/count/pull sequence while origin stays put. +git clone -q "$runtime" "$test_tmp/first-update" +git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed" +git -C "$test_tmp/first-update" fetch -q origin main +git -C "$test_tmp/first-update" checkout -q main +[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install" +git -C "$test_tmp/first-update" pull -q --ff-only origin main +[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin" pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch" printf 'user app\n' >"$native/resources/app.asar" @@ -187,6 +227,7 @@ pass "repeat setup accepts the applied patch and preserves the existing native a printf 'new main\n' >"$runtime/runtime.txt" git -C "$runtime" add runtime.txt git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update +: >"$test_tmp/events" run_installer || fail "a complete updated runtime and native app are reused" [[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched" mv "$native" "$test_tmp/saved-native" @@ -198,7 +239,7 @@ assert_stopped "a missing updated app prevents launch and theme setup" pass "updated runtimes are preserved and never seeded with the old packaged app" new_home dirty-desktop -HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" +HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home" printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js" : >"$test_tmp/events" run_installer && fail "modified desktop sources cannot be certified as the packaged build" @@ -227,7 +268,7 @@ for failure in copy race; do [[ ! -e $native ]] || fail "partial copy is never published" else OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication" - [[ $(cat "$native/keep") == 'concurrent app' ]] || fail "concurrent native app is preserved" + [[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved" fi [[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up" assert_stopped "publication failure prevents launch" @@ -256,6 +297,59 @@ run_installer && fail "incomplete modified runtime cannot be reset by upstream i ! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer" pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely" +new_home full-history-retry +git clone -q "$test_tmp/seed" "$runtime" +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")" +[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies" +[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release" +pass "full-history retries force the guarded release pin before dependency setup" + +new_home local-main +git clone -q "$test_tmp/seed" "$runtime" +printf 'local branch work\n' >"$runtime/keep" +git -C "$runtime" add keep +git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work +local_main=$(git -C "$runtime" rev-parse main) +git -C "$runtime" checkout -q --detach "$release_commit" +run_installer && fail "local main commits cannot be reset by upstream installation" +! grep -qx bootstrap "$test_tmp/events" || fail "local main is checked before upstream installer" +[[ $(git -C "$runtime" rev-parse main) == "$local_main" ]] || fail "local main commit stays referenced" +pass "detached release checkouts do not hide local main work from the installer guard" + +new_home deepen-retry +OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup" +[[ ! -e $native ]] || fail "failed history fetch does not seed the app" +assert_stopped "failed history fetch prevents launch" +: >"$test_tmp/events" +run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")" +! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation" +pass "a history fetch failure can be retried without reinstalling the ready runtime" + +new_home existing-commands +mkdir -p "$test_home/.local/bin" +printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes" +printf 'symlink target\n' >"$test_home/target" +ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent" +ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp" +run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")" +backups=("$test_home/.local/bin/".hermes-before-desktop.*) +[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names" +[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved" +[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links" +[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target" +grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported" +pass "pre-existing command files and symlinks are backed up before replacement" + +new_home old-package +mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json" +run_installer && fail "an old installed package cannot bootstrap" +grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance" +! grep -qx handoff "$test_tmp/events" || fail "old package is rejected before CLI handoff" +! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer" +mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json" +pass "old package fails with upgrade guidance before changing the runtime or CLI" + new_home custom-profile hermes_home="$test_home/custom home" runtime="$hermes_home/hermes-agent"