diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index fb1411c0b97..5bf6b67421c 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -5,28 +5,155 @@ set -e -# No CLI is installed here on purpose. Hermes Desktop only runs against a -# runtime built from its own commit, so it provisions one itself under -# ~/.hermes on first launch, which takes a few minutes and shows its own -# progress. Handing it the mise CLI instead fails: PyPI trails the tags, and -# the version gap fails the app's readiness probe with a 401. +if (( EUID == 0 )); then + echo "Run this command as your desktop user, without sudo." >&2 + exit 1 +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. 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..." 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 -# takes minutes; a unit outlives this terminal and reports to the journal. +# Only a running Hermes can be told which skin to show; a unit outlives this +# terminal and reports to the journal. echo "Matching Hermes to the current theme once it is set up..." 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 echo "" echo "Hermes Desktop has been installed." -echo "Its first launch installs the Hermes runtime, which takes a few minutes." diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index 388746b75fb..da8af34cdc5 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -6,6 +6,52 @@ # -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. 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 # 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; # the failure is answered for at the end instead of being swallowed. cli_removed=true +ensure_hermes_stopped omarchy-install-hermes-cli --remove || cli_removed=false # 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. 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 + ensure_hermes_stopped rm -rf "$HOME/.hermes" "$HOME/.config/Hermes" data_removed=true fi diff --git a/default/omarchy/launcher.hides b/default/omarchy/launcher.hides index 113565c7b14..77a57d3b70f 100644 --- a/default/omarchy/launcher.hides +++ b/default/omarchy/launcher.hides @@ -12,6 +12,7 @@ fcitx5-configtool fcitx5-wayland-launcher foot-server footclient +hermes java-java-openjdk jconsole-java-openjdk jshell-java-openjdk diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh index d0b6a3d1768..5ebd1cfe3ab 100644 --- a/test/shell.d/app-search-test.sh +++ b/test/shell.d/app-search-test.sh @@ -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)) assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh new file mode 100644 index 00000000000..d9d342cf303 --- /dev/null +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -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" diff --git a/test/shell.d/hermes-remove-test.sh b/test/shell.d/hermes-remove-test.sh index 0a06f03a567..f325b47269b 100755 --- a/test/shell.d/hermes-remove-test.sh +++ b/test/shell.d/hermes-remove-test.sh @@ -11,6 +11,14 @@ mock_bin="$test_tmp/bin" test_home="$test_tmp/home" 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' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG" @@ -32,6 +40,14 @@ SH cat >"$mock_bin/gum" <<'SH' #!/bin/bash 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}" SH cat >"$mock_bin/systemctl" <<'SH' @@ -70,7 +86,7 @@ remove() { OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \ OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \ HOME="$test_home" PATH="$mock_bin:$PATH" \ - bash "$ROOT/bin/omarchy-remove-ai-hermes" /dev/null 2>&1 + bash "$test_tmp/remover" "$test_tmp/output" 2>&1 } # 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_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \ 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. @@ -229,3 +245,140 @@ OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces i [[ ! -d $test_home/.hermes/hermes-agent ]] || fail "a failed CLI teardown does not stop the runtime removal" 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