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"