Merge pull request #10613 from spencerbull/hermes-desktop-in-app-updates

Enable Hermes in-app updates and guard removal
This commit is contained in:
Spencer Bull authored and GitHub committed 2026-09-07 04:26:22 -05:00
commit a703092631
6 files changed
+706 -10

No files matched your search

+135 -8
View File
@@ -5,28 +5,155 @@
set -e set -e
# No CLI is installed here on purpose. Hermes Desktop only runs against a if (( EUID == 0 )); then
# runtime built from its own commit, so it provisions one itself under echo "Run this command as your desktop user, without sudo." >&2
# ~/.hermes on first launch, which takes a few minutes and shows its own exit 1
# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and fi
# the version gap fails the app's readiness probe with a 401.
echo "Installing Hermes Desktop..." echo "Installing Hermes Desktop..."
omarchy-pkg-add 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 # 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 # machine, one Hermes. This drops that copy so the terminal, the default agent
# and the app all end up on the app's installation. # and the app all end up on the app's installation.
omarchy-install-hermes-cli || true omarchy-install-hermes-cli || true
# Keep the runtime at the root even when invoked from a Hermes profile.
HERMES_HOME=$(realpath -ms -- "${HERMES_HOME:-$HOME/.hermes}")
home_parent=$(dirname -- "$HERMES_HOME")
if [[ ${home_parent##*/} == [Pp][Rr][Oo][Ff][Ii][Ll][Ee][Ss] ]]; then
HERMES_HOME=$(dirname -- "$home_parent")
fi
export HERMES_HOME
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
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.
if [[ -e $runtime || -L $runtime ]]; then
if [[ $(git -C "$runtime" rev-parse HEAD 2>/dev/null) != "$release_commit" ]] ||
[[ -n $(git -C "$runtime" status --porcelain --untracked-files=all) ]]; 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" --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
fi
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
echo "The Hermes Linux runtime patch conflicts with local changes. Existing files have been kept." >&2
exit 1
fi
fi
if [[ -e $native_app || -L $native_app ]]; then
if [[ ! -f $native_app/Hermes || ! -x $native_app/Hermes || ! -f $native_app/resources/app.asar || ! -f $native_app/resources/install-stamp.json ]]; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
exit 1
fi
else
if [[ $runtime_commit != "$release_commit" ]]; then
echo "The Hermes runtime has moved beyond the packaged desktop release. Run 'hermes desktop --build-only', then try again." >&2
exit 1
fi
desktop_changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json)
if [[ -n $desktop_changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
exit 1
fi
mkdir -p -- "${native_app%/*}"
staging=$(mktemp -d "${native_app%/*}/.linux-unpacked.XXXXXX")
trap 'rm -rf -- "$staging"' EXIT
cp -a /opt/hermes-desktop/. "$staging/"
chmod 0755 "$staging/chrome-sandbox"
mv -T --no-clobber -- "$staging" "$native_app"
if [[ -e $staging ]]; then
echo "A Hermes desktop app appeared during setup. It has been kept; please try again." >&2
exit 1
fi
trap - EXIT
# Record this matching prebuilt app using the CLI's own content hash, so
# subsequent menu launches do not rebuild an app that is already current.
env -u PYTHONPATH -u PYTHONHOME "$runtime/venv/bin/python" - "$runtime" <<'PY'
import sys
from pathlib import Path
sys.path.insert(0, sys.argv[1])
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
fi
echo "Opening Hermes Desktop..." echo "Opening Hermes Desktop..."
setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 & setsid uwsm-app -- /usr/bin/hermes-desktop >/dev/null 2>&1 &
# Only a running Hermes can be told which skin to show, and the first launch # Only a running Hermes can be told which skin to show; a unit outlives this
# takes minutes; a unit outlives this terminal and reports to the journal. # terminal and reports to the journal.
echo "Matching Hermes to the current theme once it is set up..." echo "Matching Hermes to the current theme once it is set up..."
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait systemd-run --user --quiet --collect --unit=omarchy-hermes-theme omarchy-theme-set-hermes --wait
echo "" echo ""
echo "Hermes Desktop has been installed." echo "Hermes Desktop has been installed."
echo "Its first launch installs the Hermes runtime, which takes a few minutes."
+48
View File
@@ -6,6 +6,52 @@
# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. # -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /.
set -euo pipefail set -euo pipefail
ensure_hermes_stopped() {
python3 - "$HOME" <<'PY'
import os
from pathlib import Path
import sys
home = Path(sys.argv[1])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
def belongs_to_hermes(target):
target = target.removesuffix(' (deleted)')
return any(target == root or target.startswith(root + '/') for root in roots)
holders = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
continue
try:
if process.stat().st_uid != os.getuid():
continue
targets = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
entries = [process / 'exe', process / 'cwd']
try:
entries.extend((process / 'fd').iterdir())
except PermissionError:
pass
for entry in entries:
try:
targets.append(os.readlink(entry))
except OSError:
pass
if any(belongs_to_hermes(target) for target in targets):
holders.append(process.name)
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
if holders:
print('Close Hermes and processes using its files before removing it (PIDs: '
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
sys.exit(1)
PY
}
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
ensure_hermes_stopped
omarchy-pkg-drop hermes-desktop omarchy-pkg-drop hermes-desktop
# The installer leaves a unit waiting to hand the app the Omarchy theme. # The installer leaves a unit waiting to hand the app the Omarchy theme.
@@ -19,6 +65,7 @@ systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
# Tolerated here rather than fatal, so the ~/.hermes handling below still runs; # Tolerated here rather than fatal, so the ~/.hermes handling below still runs;
# the failure is answered for at the end instead of being swallowed. # the failure is answered for at the end instead of being swallowed.
cli_removed=true cli_removed=true
ensure_hermes_stopped
omarchy-install-hermes-cli --remove || cli_removed=false omarchy-install-hermes-cli --remove || cli_removed=false
# The app writes this when the runtime it provisions under ~/.hermes has landed, # The app writes this when the runtime it provisions under ~/.hermes has landed,
@@ -78,6 +125,7 @@ if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && omarchy-cm
# turn that into an aborted removal; the size is worth no such thing. # turn that into an aborted removal; the size is worth no such thing.
size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true)
if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then if gum confirm --default=false "Also delete ~/.hermes and ~/.config/Hermes ($size: chats, memories, skills, connections and tokens)?"; then
ensure_hermes_stopped
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true data_removed=true
fi fi
+1
View File
@@ -12,6 +12,7 @@ fcitx5-configtool
fcitx5-wayland-launcher fcitx5-wayland-launcher
foot-server foot-server
footclient footclient
hermes
java-java-openjdk java-java-openjdk
jconsole-java-openjdk jconsole-java-openjdk
jshell-java-openjdk jshell-java-openjdk
+8
View File
@@ -55,6 +55,14 @@ const entries = [
} }
] ]
// Keep the packaged launcher when upstream rebuilds register their own entry.
const configuredHides = new Set(fs.readFileSync(path.join(root, 'default/omarchy/launcher.hides'), 'utf8').trim().split(/\n/))
const hermesEntries = [{ name: 'Hermes', id: 'hermes' }, { name: 'Hermes', id: 'hermes-desktop' }]
for (const query of ['', 'hermes']) {
const visible = search.sortedEntries(hermesEntries, query, entry => configuredHides.has(entry.id))
assertDeepEqual(visible.map(row => row.entry.id), ['hermes-desktop'], 'only the packaged Hermes launcher is visible')
}
const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry))
assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches')
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
for command in git jq python3; do require_command "$command"; done
test_tmp=$(mktemp -d)
trap 'rm -rf -- "$test_tmp"' EXIT
export OMARCHY_TEST_ROOT="$test_tmp"
mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed"
# Real Git exercises patch checks and preservation; all package, desktop and
# service commands are mocks. No command reaches the live user installation.
git -C "$test_tmp/seed" init -q -b main
printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore"
printf 'before\n' >"$test_tmp/seed/runtime.txt"
mkdir -p "$test_tmp/seed/apps/desktop/src"
printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
mkdir -p "$test_tmp/seed/hermes_cli"
cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY'
import os
from pathlib import Path
def _write_desktop_build_stamp(project_root, *, source_mode):
home = Path(os.environ['HERMES_HOME'])
assert project_root == home / 'hermes-agent'
assert source_mode is False
assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file()
(home / 'desktop-build-stamp.json').write_text('upstream build stamp')
with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log:
log.write('build-stamp\n')
PY
git -C "$test_tmp/seed" add .
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture
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"
touch "$test_tmp/package/chrome-sandbox"
chmod 755 "$test_tmp/package/Hermes"
chmod 4755 "$test_tmp/package/chrome-sandbox"
cat >"$test_tmp/share/install.sh" <<'MOCK'
#!/bin/bash
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 --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'
#!/bin/bash
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"
exit 1
MOCK
cat >"$test_tmp/bin/setsid" <<'MOCK'
#!/bin/bash
exec "$@"
MOCK
cat >"$test_tmp/bin/cp" <<'MOCK'
#!/bin/bash
if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then
touch "${@: -1}/partial-copy"
exit 9
fi
exec /usr/bin/cp "$@"
MOCK
cat >"$test_tmp/bin/mv" <<'MOCK'
#!/bin/bash
if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then
mkdir -p "${@: -1}"
fi
exec /usr/bin/mv "$@"
MOCK
cat >"$test_tmp/bin/uwsm-app" <<'MOCK'
#!/bin/bash
[[ $1 == -- ]] || exit 1
shift
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"
else
printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events"
fi
MOCK
cat >"$test_tmp/bin/systemctl" <<'MOCK'
#!/bin/bash
printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events"
MOCK
cat >"$test_tmp/bin/systemd-run" <<'MOCK'
#!/bin/bash
printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events"
# Join the mock asynchronous launch so every test owns its full lifetime.
for (( attempt=0; attempt<100; attempt++ )); do
if grep -q '^launch' "$OMARCHY_TEST_ROOT/events"; then exit 0; fi
sleep 0.01
done
exit 1
MOCK
chmod +x "$test_tmp/bin/"*
# Substitute only system package paths in a scratch copy of the actual script.
python3 - "$ROOT/bin/omarchy-install-ai-hermes" "$test_tmp" <<'PY'
from pathlib import Path
import sys
source, scratch = Path(sys.argv[1]), Path(sys.argv[2])
script = source.read_text()
for original, replacement in {
'/opt/hermes-desktop': str(scratch / 'package'),
'/usr/share/hermes-desktop': str(scratch / 'share'),
'/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'),
}.items():
script = script.replace(original, replacement)
(scratch / 'installer').write_text(script)
PY
new_home() {
test_home="$test_tmp/$1"
hermes_home="$test_home/.hermes"
runtime="$hermes_home/hermes-agent"
native="$runtime/apps/desktop/release/linux-unpacked"
mkdir -p "$test_home"
: >"$test_tmp/events"
}
run_installer() {
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$PATH" \
bash "$test_tmp/installer" >"$test_tmp/output" 2>&1
}
assert_stopped() {
if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi
}
new_home fresh
run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")"
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"
[[ $(sed -n '4p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy and precedes launch"
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build"
[[ $(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"
printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json"
: >"$test_tmp/events"
run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")"
! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again"
! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer"
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged"
[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged"
pass "repeat setup accepts the applied patch and preserves the existing native app"
# Advancing the runtime must never reinstall the release or reapply its patch.
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"
: >"$test_tmp/events"
run_installer && fail "a newer runtime cannot receive an older native app"
[[ ! -e $native ]] || fail "no mismatched native app was copied"
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance"
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
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"
[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp"
[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved"
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance"
assert_stopped "modified desktop sources prevent stamping, launch and theme setup"
pass "a matching commit with modified desktop sources is preserved without seeding or stamping"
for failure in package install marker; do
new_home "$failure-failure"
case "$failure" in
package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;;
install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;;
marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;;
esac
[[ ! -e $native ]] || fail "failed setup does not seed the app"
assert_stopped "failed setup prevents launch and theme setup"
done
pass "package, upstream installer and readiness failures stop before launch"
for failure in copy race; do
new_home "$failure-failure"
if [[ $failure == "copy" ]]; then
OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup"
[[ ! -e $native ]] || fail "partial copy is never published"
else
OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication"
[[ -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"
done
pass "failed copies and concurrent app creation preserve existing work and clean only staging"
new_home incomplete-native
run_installer || fail "incomplete native fixture sets up"
rm "$native/resources/app.asar"
: >"$test_tmp/events"
run_installer && fail "incomplete existing app requires repair"
[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten"
assert_stopped "incomplete native app prevents launch"
pass "an incomplete existing native app is preserved"
new_home patch-conflict
run_installer || fail "patch conflict fixture sets up"
printf 'local edit\n' >"$runtime/runtime.txt"
: >"$test_tmp/events"
run_installer && fail "unexpected patch conflict stops setup"
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved"
assert_stopped "patch conflict prevents launch"
rm "$runtime/.hermes-bootstrap-complete"
: >"$test_tmp/events"
run_installer && fail "incomplete modified runtime cannot be reset by upstream installer"
! 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"
OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds"
[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime"
grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer"
pass "custom profile paths normalize to the shared Hermes home"
+155 -2
View File
@@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin"
test_home="$test_tmp/home" test_home="$test_tmp/home"
mkdir -p "$mock_bin" mkdir -p "$mock_bin"
# Keep package-path checks scoped to the fixture, even with a live app open.
python3 - "$ROOT/bin/omarchy-remove-ai-hermes" "$test_tmp" <<'PY'
from pathlib import Path
import sys
source, scratch = map(Path, sys.argv[1:])
(scratch / 'remover').write_text(source.read_text().replace('/opt/hermes-desktop', str(scratch / 'package')))
PY
cat >"$mock_bin/omarchy-pkg-drop" <<'SH' cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
#!/bin/bash #!/bin/bash
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
@@ -32,6 +40,14 @@ SH
cat >"$mock_bin/gum" <<'SH' cat >"$mock_bin/gum" <<'SH'
#!/bin/bash #!/bin/bash
printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG" printf '%s\0' "$@" >>"$OMARCHY_TEST_GUM_LOG"
if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
touch "$OMARCHY_TEST_PROMPT_GATE.started"
for (( attempt=0; attempt<500; attempt++ )); do
[[ ! -e $OMARCHY_TEST_PROMPT_GATE.continue ]] || exit 0
sleep 0.01
done
exit 1
fi
exit "${OMARCHY_TEST_GUM_STATUS:-1}" exit "${OMARCHY_TEST_GUM_STATUS:-1}"
SH SH
cat >"$mock_bin/systemctl" <<'SH' cat >"$mock_bin/systemctl" <<'SH'
@@ -70,7 +86,7 @@ remove() {
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
HOME="$test_home" PATH="$mock_bin:$PATH" \ HOME="$test_home" PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-remove-ai-hermes" </dev/null >/dev/null 2>&1 bash "$test_tmp/remover" </dev/null >"$test_tmp/output" 2>&1
} }
# script(1) puts the remover on a pty, which is the only way -t 0 answers true # script(1) puts the remover on a pty, which is the only way -t 0 answers true
@@ -85,7 +101,7 @@ remove_tty() {
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
HOME="$test_home" PATH="$mock_bin:$PATH" \ HOME="$test_home" PATH="$mock_bin:$PATH" \
script -qec "bash '$ROOT/bin/omarchy-remove-ai-hermes'" /dev/null >/dev/null 2>&1 script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
} }
# The app brings its own uv and its own node; both are runtime, not data. # The app brings its own uv and its own node; both are runtime, not data.
@@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i
[[ ! -d $test_home/.hermes/hermes-agent ]] || [[ ! -d $test_home/.hermes/hermes-agent ]] ||
fail "a failed CLI teardown does not stop the runtime removal" fail "a failed CLI teardown does not stop the runtime removal"
pass "a failed CLI teardown is reported after the runtime is handled" pass "a failed CLI teardown is reported after the runtime is handled"
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
# Package, service and confirmation commands remain confined to the mocks.
python3 - "$test_tmp" <<'PY'
import os
from pathlib import Path
import pty
import subprocess
import sys
import time
scratch = Path(sys.argv[1])
writer_code = '''import os, sqlite3, sys
c = sqlite3.connect(os.environ['TEST_DB'])
c.execute('pragma journal_mode=wal')
c.execute('create table fixture(value)')
c.execute("insert into fixture values ('keep')")
c.commit()
print('ready', flush=True)
sys.stdin.readline()
c.close()
'''
def setup(name):
home = scratch / name
runtime = home / '.hermes/hermes-agent'
runtime.mkdir(parents=True)
(runtime / '.hermes-bootstrap-complete').touch()
(home / '.config/Hermes').mkdir(parents=True)
env = {**os.environ, 'HOME': str(home), 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
'OMARCHY_TEST_GUM_STATUS': '0'}
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
log = home / (key + '.log')
log.touch()
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
return home, runtime, env
def writer(db):
child = subprocess.Popen([sys.executable, '-u', '-c', writer_code],
env={**os.environ, 'TEST_DB': str(db)},
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
assert child.stdout.readline().strip() == 'ready'
return child
def stop(child):
if child.poll() is None:
child.stdin.write('\n')
child.stdin.flush()
child.wait(timeout=5)
def remove(env):
master, slave = pty.openpty()
try:
return subprocess.run(['bash', str(scratch / 'remover')], env=env,
stdin=slave, capture_output=True, text=True, timeout=10)
finally:
os.close(master)
os.close(slave)
def blocked(result, home, runtime, child):
assert result.returncode != 0 and str(child.pid) in result.stderr, result
assert 'Close Hermes' in result.stderr, result.stderr
assert (runtime / '.hermes-bootstrap-complete').exists()
assert all((home / (name + '.log')).stat().st_size == 0
for name in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'))
assert child.poll() is None, 'remover must not kill sessions'
for deleted in (False, True):
home, runtime, env = setup('deleted-writer' if deleted else 'live-writer')
db = home / '.hermes/state.db'
child = writer(db)
try:
if deleted:
for suffix in ('', '-wal', '-shm'):
Path(str(db) + suffix).unlink()
db.write_bytes(b'new database generation')
blocked(remove(env), home, runtime, child)
if deleted:
assert db.read_bytes() == b'new database generation'
finally:
stop(child)
assert remove(env).returncode == 0, 'removal succeeds once the writer closes'
assert not (home / '.hermes').exists()
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
for kind in ('terminal', 'desktop', 'working-directory'):
home, runtime, env = setup(kind)
executable_name = str(scratch / 'package/Hermes') if kind == 'desktop' else str(runtime / 'hermes')
args = ['sleep', '30'] if kind == 'working-directory' else [executable_name, '30']
child = subprocess.Popen(args, executable='/usr/bin/sleep',
cwd=runtime if kind == 'working-directory' else scratch)
try:
blocked(remove(env), home, runtime, child)
finally:
child.terminate()
child.wait(timeout=5)
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
home, runtime, env = setup('unrelated-writer')
sibling = home / '.hermes-other'
sibling.mkdir()
child = writer(sibling / 'state.db')
try:
assert remove(env).returncode == 0, 'a sibling database does not block Hermes removal'
assert child.poll() is None
finally:
stop(child)
print('ok - unrelated database holders are left alone')
home, runtime, env = setup('prompt-race')
gate = home / 'prompt'
env['OMARCHY_TEST_PROMPT_GATE'] = str(gate)
master, slave = pty.openpty()
remover = subprocess.Popen(['bash', str(scratch / 'remover')], env=env, stdin=slave,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
os.close(slave)
child = None
try:
deadline = time.monotonic() + 5
while not Path(str(gate) + '.started').exists():
assert remover.poll() is None and time.monotonic() < deadline, 'prompt was not reached'
time.sleep(0.01)
child = writer(home / '.hermes/state.db')
Path(str(gate) + '.continue').touch()
stdout, stderr = remover.communicate(timeout=10)
assert remover.returncode != 0 and str(child.pid) in stderr, (stdout, stderr)
assert (home / '.hermes/state.db-wal').exists()
assert (home / '.config/Hermes').exists()
finally:
if child is not None:
stop(child)
if remover.poll() is None:
remover.terminate()
remover.wait(timeout=5)
os.close(master)
print('ok - a writer started during confirmation blocks data deletion')
PY