Merge pull request #12844 from omacom/hermes-desktop-default-agent

Install Hermes for the default agent as the desktop app's self-updating runtime
This commit is contained in:
Spencer Bull authored and GitHub committed 2026-09-24 23:41:18 -05:00
commit c3e67f5d40
16 files changed
+1648 -1245

No files matched your search

+11 -9
View File
@@ -50,15 +50,15 @@ esac
agent_package=${agent_package:-$agent}
# Hermes reaches mise through its own installer rather than straight from
# here: it needs its interpreter pinned, and a bare `mise use` has nowhere to
# say so. See omarchy-install-hermes-cli. OpenClaw comes from its pacman
# package the same way; see omarchy-install-openclaw-cli.
# Hermes and OpenClaw are not mise tools: Hermes is the desktop app's
# self-updating runtime and OpenClaw comes from its pacman package. Each has
# its own installer with the same --check/--now contract mise-backed agents
# get from mise; see omarchy-install-hermes-cli and omarchy-install-openclaw-cli.
if [[ -n ${agent_installer:-} ]]; then
# Not omarchy-cmd-present: the stub is on PATH from first boot and says
# nothing about whether Hermes is installed behind it. Treating a cold stub
# as installed skips the floating terminal and runs the minute-long install
# inside the menu action instead.
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
# install behind it finished or runs the sessions omarchy-agent starts.
# Treating it as installed would skip the floating terminal and run a
# minutes-long install inside the menu action instead.
agent_present() { "$agent_installer" --check; }
agent_install() { "$agent_installer" --now; }
else
@@ -78,7 +78,9 @@ if [[ $installing == "false" ]] && ! agent_present; then
fi
if ! agent_install; then
if [[ $installing == "true" ]]; then
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
echo "Could not install $name" >&2
elif [[ $installing == "true" ]]; then
echo "Could not install $name with mise" >&2
else
echo "Could not set $name as the default coding agent" >&2
+2 -144
View File
@@ -10,153 +10,11 @@ if (( EUID == 0 )); then
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])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
fi
# The same install the default agent gets, plus opening the window.
omarchy-install-hermes-cli --now
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; 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."
+483 -219
View File
@@ -1,280 +1,544 @@
#!/bin/bash
# omarchy:summary=Install the Hermes CLI as a mise-backed wrapper in ~/.local/bin
# omarchy:args=[--check|--now|--owns|--remove]
# omarchy:examples=omarchy install hermes cli | omarchy install hermes cli --now
# omarchy:summary=Install Hermes Desktop for the default agent; its runtime provides the hermes command
# omarchy:args=<--check|--now|--retire-mise>
# omarchy:examples=omarchy install hermes cli --now | omarchy install hermes cli --check
# omarchy:requires-sudo=true
# Hermes pins every one of its dependencies exactly and declares
# Requires-Python >=3.11,<3.14, so it can neither be built against Arch's
# Python nor share the python-* packages. mise builds it a private environment
# instead.
# There is one Hermes on a machine, and it is the desktop app's. hermes-desktop
# ships upstream's installer and the release it was built from, and that
# installer makes the only Hermes that can update itself: a checkout under
# ~/.hermes with its own venv, which `hermes update` fast-forwards. The mise
# build this replaced had no checkout, so nothing an update could move.
# Choosing Hermes as the default agent therefore installs the app, the way
# Install > AI does, short of opening its window; see omarchy-install-ai-hermes.
# Hermes is only ever installed through the app: --check asks whether the
# app's Hermes is in and its command is what omarchy-agent would run, and
# --now installs the app whatever else answered to hermes before, saving
# whatever held the command's name aside before upstream's installer takes it.
#
# It gets its own installer rather than a line in omarchy-mise-install because
# of the interpreter pin. Given no compatible interpreter to hand, uv builds
# the venv against the system Python in violation of Hermes' own bound,
# reports success, and leaves the breakage to surface later inside a
# dependency -- and omarchy-mise-install writes a fixed stub with nowhere to
# say otherwise.
# The wrapper the retired installer wrote, and the mise environment it built,
# are known here and nowhere else: --retire-mise is what the migration runs,
# and --now runs the same removal once it has replaced the wrapper.
#
# There is only ever one Hermes on a machine. hermes-desktop cannot run against
# this one -- it needs a runtime built from its own commit, and the version gap
# fails its readiness probe -- so it installs its own under ~/.hermes and puts
# that on PATH. When the package is present it therefore owns Hermes outright:
# this installer stands aside and removes its own copy, so the terminal, the
# default agent and the app are all the same installation.
# Each mode is named outright. The app's launcher used to call this with no
# arguments to reconcile a mise copy, and a default of --now would turn every
# launch of the app into an install.
set -euo pipefail
mode=${1:-}
tool='pipx:hermes-agent[extras=all]'
python='3.13'
# 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
# The line that identifies the stub as this installer's; matched whole, so a
# wrapper that merely mentions the command is not mistaken for ours.
marker='# Written by omarchy-install-hermes-cli.'
runtime="$HERMES_HOME/hermes-agent"
native_app="$runtime/apps/desktop/release/linux-unpacked"
command_path="$HOME/.local/bin/hermes"
# The package, not the runtime directory: it is installed before the app has
# ever run, and that is exactly when we must not start building a second copy.
desktop_owns_hermes() {
omarchy-pkg-present hermes-desktop
# The wrapper the retired mise-backed installer wrote, matched whole so a
# wrapper that merely mentions it is not mistaken for it, and the tool it
# built. Running the wrapper built Hermes through mise, so it is never run to
# find out whether Hermes is there.
legacy_marker='# Written by omarchy-install-hermes-cli.'
legacy_tool='pipx:hermes-agent[extras=all]'
legacy_stub() {
[[ -f $command_path ]] && grep -qxF "$legacy_marker" "$command_path"
}
# The venv appears at the python-deps stage, several stages before the one that
# installs the command, so its presence says nothing about being usable. The
# marker is written last, and the command is what the agent actually runs.
desktop_hermes_ready() {
[[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]] || return 1
# An executable of that name proves nothing about whose it is; the app's own
# points into ~/.hermes, and anything else is not the install we are asking
# about. Matched as a plain string, because the path carries a dot and an
# unanchored pattern would also claim a wrapper pointing at ~/xhermes.
[[ -f $HOME/.local/bin/hermes ]] || return 1
grep -qF "$HOME/.hermes" "$HOME/.local/bin/hermes" || return 1
# And a marker left behind by an install whose venv has since gone answers
# for nothing, so the command has to run, exactly as a foreign one must.
hermes_prompt_ready
# Only the wrapper Omarchy wrote proves the environment is Omarchy's to remove:
# the regular file at its path, or the copy a run of this installer saved aside
# before upstream's installer took the name, since a user who chose Hermes
# before the migration ran has it there and the environment still requested.
# A link is someone else's arrangement, even when it lands on the wrapper, and
# so is a linked backup directory: the installer makes real ones.
saved_wrapper() {
[[ ! -L ${1%/hermes} && -f $1 && ! -L $1 ]] && grep -qxF "$legacy_marker" "$1"
}
# Whether Hermes is really installed, not merely whether the stub exists. A
# stub on its own is cold: running it installs Hermes, which takes minutes.
installed() {
[[ -d "$(mise where "$tool" 2>/dev/null)/hermes-agent/lib/python$python" ]]
legacy_owned() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
return 0
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
return 0
fi
done
return 1
}
# The stub is the only thing this installer owns. Anything else at that path
# -- Hermes' official installer, a hand-rolled wrapper, even a dangling link
# -- was put there by the user and is never deleted or overwritten here.
# Symlinks count as foreign even when they resolve to a marked file: the stub
# is written as a regular file, so a link is someone else's arrangement.
ours() {
[[ -f $HOME/.local/bin/hermes && ! -L $HOME/.local/bin/hermes ]] &&
grep -qxF "$marker" "$HOME/.local/bin/hermes"
# Gone means neither installed nor still asked for in the global config, where
# `mise up` would build it again. `mise rm -g` exits 0 whether or not it removed
# anything, so the listing is read instead, and a listing that cannot be read
# -- mise broken, or not there to read it -- is not an answer. The key carries
# the backend and name, with or without the options.
mise_retired() {
local requested
requested=$(mise ls -g --json 2>/dev/null) || return 1
! mise where "$legacy_tool" >/dev/null 2>&1 && ! grep -qE '"pipx:hermes-agent(\[|")' <<<"$requested"
}
foreign_hermes() {
[[ -e $HOME/.local/bin/hermes || -L $HOME/.local/bin/hermes ]] && ! ours
# Removes the environment the retired wrapper built, judged by what is left
# rather than by what the commands claimed. Only for a caller that has proved
# it Omarchy's.
retire_mise() {
if mise_retired; then
return 0
fi
mise rm -g "$legacy_tool" >/dev/null 2>&1 || true
mise uninstall --all "$legacy_tool" >/dev/null 2>&1 || true
if mise_retired; then
return 0
fi
echo "Could not remove the Hermes that mise built. Finish by hand:" >&2
if omarchy-cmd-missing mise; then
echo " omarchy pkg add mise" >&2
fi
echo " mise rm -g '$legacy_tool'" >&2
echo " mise uninstall --all '$legacy_tool'" >&2
return 1
}
# Once the environment is gone the proof has done its work. Left behind it
# would keep --check answering no for an install that is finished, and claim
# an environment the user builds later as Omarchy's. The copies are Omarchy's
# own wrapper, so removing them takes nothing of the user's; a backup directory
# that held nothing else goes with them.
forget_legacy() {
local saved
if legacy_stub && [[ ! -L $command_path ]]; then
rm -f "$command_path"
fi
for saved in "$HOME/.local/bin"/.hermes-before-desktop.*/hermes; do
if saved_wrapper "$saved"; then
rm -f "$saved"
rmdir "${saved%/hermes}" 2>/dev/null || true
fi
done
}
# A hermes at that path is usable when it is a command that runs: a regular
# executable whose --version answers. The executable bit alone proves little -- a directory
# passes -x on search permission, and a wrapper whose interpreter or target is
# gone passes it too. The desktop app applies the same probe with the same 15
# second budget, so what passes here is what it will use.
# executable whose --version answers. The executable bit alone proves little -- a
# directory passes -x on search permission, and a wrapper whose interpreter or
# target is gone passes it too. The desktop app applies the same probe with the
# same 15 second budget, so what passes here is what it will use.
hermes_runs() {
[[ -f $HOME/.local/bin/hermes && -x $HOME/.local/bin/hermes ]] &&
timeout 15 "$HOME/.local/bin/hermes" --version >/dev/null 2>&1
[[ -f $command_path && -x $command_path ]] &&
timeout 15 "$command_path" --version >/dev/null 2>&1
}
# A flag counts only when the help defines it, not whenever it is mentioned:
# what follows must be a shape argparse prints after a definition -- the usage
# line's closing bracket, the gap before same-line help text, an uppercase
# metavar, or the end of the line. Prose like "With --tui: run ..." stays
# prose, and --tui-theme or --tui_mode never answers for --tui. Not probed by
# parsing an actual invocation on purpose: a release that ignores unknown
# arguments would turn the probe into a live session.
# closing its short form, followed by its metavar, or padded to a description
# column. Matched against the help text rather than by parsing an actual
# invocation on purpose: a release that ignores unknown arguments would turn
# the probe into a live session.
help_defines_flag() {
grep -qE -- "$1(]|[[:space:]][[:upper:]]|[[:space:]]{2}|$)" <<<"$2"
}
# Probed for the flags omarchy-agent actually passes -- --query to seed the
# session and --tui to keep it interactive -- rather than a marker standing in
# for them: the old probe keyed on chat carrying --oneshot, which no released
# Hermes did (it lived at the top level until v0.21 added chat's own), so
# every release read as "not ready".
# for them.
hermes_prompt_ready() {
local help
hermes_runs &&
help=$(timeout 15 "$HOME/.local/bin/hermes" chat --help 2>/dev/null) &&
help=$(timeout 15 "$command_path" chat --help 2>/dev/null) &&
help_defines_flag '--tui' "$help" &&
help_defines_flag '--query' "$help"
}
# --owns answers whether the wrapper on PATH is the one this command wrote, so
# the migration and Remove Preinstalls do not each carry their own copy of the
# marker and drift from it.
if [[ $mode == "--owns" ]]; then
if ours; then exit 0; else exit 1; fi
fi
# The command upstream's installer writes execs into the runtime. Anything else
# at that path -- an official install elsewhere, a hand-rolled wrapper, even a
# dangling link -- is the user's. Matched as a plain string with its trailing
# slash, because the path carries a dot and a bare prefix would also claim a
# wrapper pointing at ~/.hermes-old.
ours() {
[[ -f $command_path && ! -L $command_path ]] && grep -qF "$HERMES_HOME/" "$command_path"
}
# --remove tears down a Hermes CLI this installer put in place -- the mise tool
# it installed and the stub it marked -- so Remove Hermes clears a CLI the app
# never superseded (an interrupted install, or the terminal CLI from before the
# app existed) rather than leaving it stranded on PATH. The whole teardown
# turns on the marked stub, exactly as replacement does further down: without
# it nothing proves the mise environment is Omarchy's rather than one the user
# built against the same spec, and a user's stays theirs. The desktop takeover
# removes the environment without asking, but that is its own bargain -- a
# second Hermes has to go whoever built it, and the app still provides the
# command afterwards; here nothing would. Idempotent: nothing owned, nothing
# to do.
if [[ $mode == "--remove" ]]; then
if ours; then
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
rm -f "$HOME/.local/bin/hermes" 2>/dev/null || true
# What omarchy-agent runs is whichever hermes is first on PATH, and Omarchy puts
# mise's shims ahead of ~/.local/bin. So the command the probe vets has to be
# the one PATH finds, or the agent is on a different Hermes than the one set
# up. A PATH that finds none is not in the way: the theme unit asks from a
# service whose PATH has no ~/.local/bin, and runs the command by its path.
on_path() {
local found
found=$(type -P hermes) || return 0
[[ $(realpath -m -- "$found") == "$(realpath -m -- "$command_path")" ]]
}
# Every step is attempted before any is judged, and judged by what is left
# rather than by what the commands claimed: the marked stub still answering
# hermes, or mise still resolving the tool, is a CLI still installed no
# matter how the removal exited.
# Not "run --remove again": once the stub is gone nothing marks the mise
# environment as ours, so a rerun would find nothing it owns and succeed
# without touching what was left. Only the full commands finish the job.
if ours || mise where "$tool" >/dev/null 2>&1; then
echo "Could not remove the Hermes CLI Omarchy installed. Finish by hand:" >&2
echo " rm -f ~/.local/bin/hermes" >&2
echo " mise rm -g '$tool'" >&2
echo " mise uninstall --all '$tool'" >&2
exit 1
shadowing_hermes() {
type -P hermes || true
}
# The installer writes the marker last, so it is the one thing that says a
# runtime finished installing rather than merely started; the venv appears
# several stages earlier. And a marker left behind by an install whose venv has
# since gone answers for nothing, so the command has to run.
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
}
native_app_complete() {
[[ -f $native_app/Hermes && -x $native_app/Hermes && -f $native_app/resources/app.asar && -f $native_app/resources/install-stamp.json ]]
}
# What --check answers, and what --now has nothing left to do about: the app
# is installed and the Hermes on the machine is its own -- the command
# upstream's installer wrote, the runtime's completion marker and the seeded
# packaged app, because the app only runs against a runtime it prepared and
# the terminal has to be on the same one -- and that command is the one PATH
# finds, with nothing of the retired wrapper's left to remove. The two agree
# on purpose: choosing Hermes from the menu asks first and opens a terminal
# only on a no, so a yes here has to mean --now would take no minutes-long
# step where nobody can watch it.
installed() {
on_path || return 1
if legacy_owned; then
return 1
fi
omarchy-pkg-present hermes-desktop && ours && [[ -f $runtime/.hermes-bootstrap-complete ]] && native_app_complete && hermes_prompt_ready
}
# Once a Hermes answers at the command's path, the environment the retired
# wrapper built goes with whatever proof is left of it, and then PATH has to
# agree with the probe: a mise shim, or anything else ahead of ~/.local/bin, is
# what the default agent would run instead.
settle_path() {
if legacy_owned; then
echo "Removing the Hermes that mise built; Hermes now updates itself..."
retire_mise || return 1
forget_legacy
fi
if ! on_path; then
echo "$command_path is ready, but 'hermes' on PATH is $(shadowing_hermes), which is what the default agent runs." >&2
echo "Remove or reorder it, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
}
# The updater switches to main before checking for changes, so main has to
# start at the packaged release. Whatever main pointed at first is kept under
# another name rather than judged: upstream rewrites its history often enough
# that a clone's main sits off origin/main with no local commit on it, and a
# user's own commits are exactly what must not be lost either way.
keep_main() {
local main_commit kept
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
# A run that stopped after this point keeps it again on the next try; one
# branch at that commit is enough.
if [[ -z $(git -C "$runtime" for-each-ref --points-at "$main_commit" 'refs/heads/main-before-omarchy-*') ]]; then
kept="main-before-omarchy-$(date +%s)"
git -C "$runtime" branch "$kept" "$main_commit"
echo "Kept what Hermes main pointed at as the branch $kept; main now starts at the packaged release."
fi
fi
}
# The repository a process's GIT_DIR names, canonical: relative to its working
# directory when relative, and with no trailing slash or dot segments either
# way. Read NUL-delimited as the kernel writes it, never through a pipe: a
# path may carry a newline, and grep stopping at a match would fail a pipe
# under pipefail and call a busy git idle.
git_dir_of() {
local pid=$1 entry dir="" cwd
while IFS= read -r -d '' entry; do
if [[ $entry == GIT_DIR=* ]]; then
dir=${entry#GIT_DIR=}
break
fi
done <"/proc/$pid/environ" 2>/dev/null || return 1
[[ -n $dir ]] || return 1
if [[ $dir != /* ]]; then
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || return 1
dir="$cwd/$dir"
fi
realpath -m -- "$dir"
}
# A git of this user working in the runtime: a fetch Hermes started to work
# out its --version, or its updater. Found by working directory, or by a
# GIT_DIR in its environment, rather than by command line, since the remote
# helpers git spawns carry no repository path on theirs. Compared canonical,
# because /proc resolves every link and the runtime path keeps them.
git_busy_in_runtime() {
local pid cwd dir real
real=$(realpath -e -- "$runtime" 2>/dev/null) || return 1
for pid in $(pgrep -u "$(id -u)" -f '^([^ ]*/)?git(-remote-[a-z]+)?( |$)' 2>/dev/null); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
if [[ $cwd == "$real" || $cwd == "$real/"* ]]; then
return 0
fi
if dir=$(git_dir_of "$pid") && [[ $dir == "$real/.git" ]]; then
return 0
fi
done
return 1
}
# Hermes works out its --version against origin, and the probe's 15 second
# budget can kill it mid-fetch, which leaves git's shallow.lock behind; a fetch
# of ours that a network blip interrupted leaves the same. Git refuses to clear
# a lock it did not take, and a file's age alone cannot say whether anything
# still holds it, so a live git in the runtime is waited for first, and only a
# lock nobody holds and nothing has touched for a minute is cleared. The fetch
# itself gets three tries.
unshallow_runtime() {
local attempt waited lock="$runtime/.git/shallow.lock"
for attempt in 1 2 3; do
waited=0
while git_busy_in_runtime && (( waited < 60 )); do
if (( waited == 0 )); then
echo "Waiting for Hermes to finish working in $runtime..."
fi
sleep 2
waited=$(( waited + 2 ))
done
if git_busy_in_runtime; then
echo "Hermes is still working in $runtime. Let it finish, then run omarchy-install-hermes-cli --now again." >&2
return 1
fi
if [[ -e $lock && -n $(find "$lock" -mmin +1 2>/dev/null) ]]; then
rm -f "$lock"
fi
if git -C "$runtime" fetch --unshallow origin main; then
return 0
fi
if (( attempt < 3 )); then
sleep 2
fi
done
echo "Could not fetch the Hermes history from origin. Check the network, then run omarchy-install-hermes-cli --now again." >&2
return 1
}
case "$mode" in
--check)
if installed; then exit 0; else exit 1; fi
;;
--retire-mise)
# The migration's whole job. The environment goes before the wrapper, since
# once the wrapper is gone nothing marks the environment as Omarchy's and a
# rerun could not finish what a failed removal left behind.
if legacy_owned; then
retire_mise || exit 1
forget_legacy
fi
exit 0
;;
--now) ;;
*)
echo "Usage: omarchy-install-hermes-cli <--check|--now|--retire-mise>" >&2
exit 1
;;
esac
if (( EUID == 0 )); then
echo "Run this command as your desktop user, without sudo." >&2
exit 1
fi
if installed; then
exit 0
fi
# --check lets callers tell a cold stub from a working one before they commit
# to a path that assumes Hermes is ready.
if [[ $mode == "--check" ]]; then
if desktop_owns_hermes; then
if desktop_hermes_ready; then exit 0; else exit 1; fi
fi
# A foreign command is ready only when it also supports prompted sessions;
# since it is not ours to replace, nothing this installer does will update it.
if foreign_hermes; then
if hermes_prompt_ready; then exit 0; else exit 1; fi
fi
if installed && hermes_prompt_ready; then exit 0; else exit 1; fi
if ! omarchy-pkg-present hermes-desktop; then
echo "Installing Hermes Desktop..."
omarchy-pkg-add hermes-desktop
fi
# Hand Hermes over to the app rather than keeping a second copy beside it.
if desktop_owns_hermes; then
# Not gated on that copy being healthy: `mise up` can rebuild it against the
# wrong interpreter and a half-finished install answers to neither test, and
# either way it is still a second Hermes. Removing nothing is harmless.
if mise where "$tool" >/dev/null 2>&1; then
echo "Hermes Desktop provides Hermes; removing the separate CLI install..." >&2
fi
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
# Our own stub has to go with it. Left in place it still answers `hermes`
# until the app's bootstrap overwrites it, and answering means building the
# second Hermes this whole arrangement exists to avoid.
if ours; then
rm -f "$HOME/.local/bin/hermes"
fi
if desktop_hermes_ready; then
exit 0
fi
echo "Hermes Desktop is installed but has not set Hermes up yet." >&2
echo "Launch Hermes Desktop once to finish installing it." >&2
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
# The user already has a hermes of their own. Leave it be: a working one is
# what the default agent will run, and a broken one is theirs to fix.
if foreign_hermes; then
if hermes_prompt_ready; then
exit 0
fi
runtime_present=false
if runtime_ready; then
runtime_present=true
fi
if hermes_runs; then
echo "~/.local/bin/hermes does not support the interactive seeded sessions Omarchy needs." >&2
echo "Update it to a Hermes Agent release with interactive chat queries, then run omarchy-install-hermes-cli again." >&2
# A seeded app has to be whole, whatever state the runtime beside it is in.
refuse_unless_app_whole() {
if [[ -e $native_app || -L $native_app ]] && ! native_app_complete; then
echo "The Hermes desktop app at $native_app is incomplete. Repair it with 'hermes desktop --build-only' before trying again." >&2
return 1
fi
}
# The packaged app can be seeded only beside a runtime at the release it was
# built from, with its desktop sources untouched and the Linux runtime patch
# still able to land. Asked before a command is replaced, so that a refusal
# leaves the user's Hermes exactly as it was, and again before seeding.
# Asked through ||, so nothing in here stops on its own: every git answer is
# checked, and one that cannot be read refuses rather than reading as clean.
refuse_unless_seedable() {
local runtime_commit changes
refuse_unless_app_whole || return 1
if [[ -e $native_app || -L $native_app ]]; then
return 0
fi
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ -z $runtime_commit ]]; then
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
return 1
fi
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
return 1
fi
if ! changes=$(git -C "$runtime" status --porcelain --untracked-files=all -- apps/desktop package.json package-lock.json 2>/dev/null); then
echo "Could not read the Hermes runtime's git state at $runtime. Repair that installation before trying again; existing files have been kept." >&2
return 1
fi
if [[ -n $changes ]]; then
echo "Hermes desktop sources have local changes. Run 'hermes desktop --build-only', then try again; existing files have been kept." >&2
return 1
fi
if ! git -C "$runtime" apply --check /usr/share/hermes-desktop/runtime.patch >/dev/null 2>&1 &&
! 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
return 1
fi
}
if [[ $runtime_present == "true" ]]; then
refuse_unless_seedable || exit 1
elif [[ -e $runtime || -L $runtime ]]; 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,
# and a git state that cannot be read is not a clean one.
refuse_unless_app_whole || exit 1
if ! runtime_commit=$(git -C "$runtime" rev-parse HEAD 2>/dev/null) || [[ $runtime_commit != "$release_commit" ]] ||
! changes=$(git -C "$runtime" status --porcelain --untracked-files=all 2>/dev/null) || [[ -n $changes ]]; then
echo "Hermes setup is incomplete at $runtime. Repair that installation before trying again; existing files have been kept." >&2
exit 1
fi
fi
echo "~/.local/bin/hermes exists but is not runnable, and it was not installed by Omarchy." >&2
echo "Fix or remove it, then run omarchy-install-hermes-cli again." >&2
# Whether this run gave Hermes something new to show the theme to: a runtime
# it set up, or the app it seeded. Putting a command back is neither.
set_up=false
# The command is the runtime's own and runs; anything else at the path gets
# replaced below. The retired wrapper is never run to find out.
command_ready=false
if ! legacy_stub && ours && hermes_runs; then
command_ready=true
fi
# Upstream replaces these commands, including foreign files and symlinks,
# whether it sets the runtime up or only writes the commands again. Keep their
# original bytes/links before handing the names to the runtime.
if [[ $runtime_present == "false" || $command_ready == "false" ]]; then
command_backup=""
for command in hermes hermes-agent hermes-acp; do
existing="$HOME/.local/bin/$command"
if [[ -e $existing || -L $existing ]]; then
if [[ ! -f $existing && ! -L $existing ]]; then
echo "Cannot replace $existing: move it aside before installing Hermes." >&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 -- "$existing" "$command_backup/"
fi
done
fi
if [[ $runtime_present == "false" ]]; then
echo "Setting up the Hermes runtime..."
# npx stops to ask before fetching Playwright for the browser tools, and in
# the terminal the menu opens for this nobody is there to say yes.
npm_config_yes=true 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
set_up=true
elif [[ $command_ready == "false" ]]; then
# The runtime is in place but the command is not its own, or does not run:
# gone, the retired wrapper, one the user put there, or the runtime's own
# with its mode bits stripped. Upstream's path stage writes its commands
# without touching the checkout.
echo "Restoring the Hermes commands..."
bash /usr/share/hermes-desktop/install.sh --stage path --dir "$runtime" --hermes-home "$HERMES_HOME"
fi
runtime_commit=$(git -C "$runtime" rev-parse HEAD)
if [[ $runtime_commit == "$release_commit" ]]; then
# Start main at the packaged release, with enough history for its first
# fast-forward.
keep_main
if [[ $(git -C "$runtime" rev-parse --is-shallow-repository) == "true" ]]; then
unshallow_runtime
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
refuse_unless_seedable || exit 1
if [[ ! -e $native_app && ! -L $native_app ]]; then
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])
if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file():
from hermes_cli.main_desktop import _write_desktop_build_stamp
else:
from hermes_cli.main import _write_desktop_build_stamp
_write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False)
PY
set_up=true
fi
# What omarchy-agent runs is the command, not the venv, so the command is what
# has to answer for the seeded sessions.
if ! hermes_prompt_ready; then
echo "Hermes is installed at $runtime, but $command_path does not run the interactive seeded sessions Omarchy needs." >&2
echo "Update Hermes with 'hermes update', then run omarchy-install-hermes-cli --now again." >&2
exit 1
fi
# Only the marked wrapper proves the matching mise environment is ours to replace.
if installed && ! hermes_prompt_ready; then
if ours; then
echo "Updating Hermes for prompted sessions..." >&2
mise rm -g "$tool" >/dev/null 2>&1 || true
mise uninstall --all "$tool" >/dev/null 2>&1 || true
else
echo "A Hermes mise environment exists without an Omarchy-owned wrapper." >&2
echo "Update or remove it explicitly, then run omarchy-install-hermes-cli again." >&2
exit 1
fi
fi
mkdir -p "$HOME/.local/bin"
rm -f "$HOME/.local/bin/hermes"
cat >"$HOME/.local/bin/hermes" <<EOF
#!/bin/bash
$marker
export UV_PYTHON="$python"
# Exported rather than set on the install line alone, so the version resolved
# to run agrees with the one just installed. Hermes ships several times a week
# and mise's cooldown would otherwise hold a new release back for days.
export MISE_MINIMUM_RELEASE_AGE=0
# mise up -- which omarchy update runs -- reinstalls without that pin, so this
# asks which interpreter is actually there rather than whether anything is.
if ! [[ -d "\$(mise where '$tool' 2>/dev/null)/hermes-agent/lib/python$python" ]]; then
echo "Installing Hermes on Python $python (this takes a minute)..." >&2
# mise's pipx backend shells out to uv, which a stock Omarchy does not have.
# It is fetched here rather than when this stub was written, so setting up a
# machine that never runs Hermes costs nothing.
if omarchy-cmd-missing uv && ! mise where uv >/dev/null 2>&1; then
mise use -g --quiet uv@latest || exit 1
fi
mise use -g --quiet --force '$tool' || exit 1
fi
# The pin belongs to building Hermes, not to everything Hermes then runs.
# Exported it would reach the agent and every command it shells out to, so a
# uv in the user's own project would resolve 3.13 there too -- uv only warns
# when that contradicts the project's requires-python, and builds it anyway.
exec env -u UV_PYTHON mise x '$tool' -- hermes "\$@"
EOF
chmod +x "$HOME/.local/bin/hermes"
# The desktop app resolves a hermes on PATH by running `hermes --version` with
# a 15 second budget, then falls back to cloning its own copy when that times
# out. A first-run mise install does not fit in 15 seconds, so anything that
# hands Hermes to the GUI has to install it here rather than leave it stubbed.
if [[ $mode == "--now" ]]; then
"$HOME/.local/bin/hermes" --version
if ! hermes_prompt_ready; then
echo "Hermes installed without the interactive seeded sessions Omarchy needs." >&2
exit 1
fi
settle_path || exit 1
# Only a running Hermes can be told which skin to show; a unit outlives this
# terminal and reports to the journal.
if [[ $set_up == "true" ]]; then
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
fi
+148 -54
View File
@@ -6,73 +6,173 @@
# -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
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
home = Path(sys.argv[1])
# Only systemd's own word counts as "stopped": a non-zero exit from is-active
# also covers an unreachable user manager, which says nothing about whether
# the process is alive.
unit_stopped() {
local state
state=$(systemctl --user is-active "$1" 2>/dev/null) || true
[[ $state == "inactive" || $state == "failed" ]]
}
# Hermes is closed here rather than asked to be: the agent in its terminal,
# the desktop app and its helpers, a gateway run by hand -- every process
# whose program, by its executable or by what it was started as, lives in the
# runtime about to be deleted or in the package; for an interpreter that is
# the script it runs, or Hermes's own package named with -m, so a gateway
# started by hand as `python .../hermes` or `python -m hermes_cli.main` is
# found too. Judged by the program alone, never by a later argument: an
# editor opened on a file in the runtime is the user's. `check` refuses first if anything else has Hermes's files open, by
# its executable, its working directory or a descriptor, that editor or a
# shell sitting in ~/.hermes, so a refusal touches nothing; that
# one is the user's to close, because removing an open SQLite WAL leaves a
# live writer on a deleted generation. `stop` then ends Hermes's own and
# refuses again over whatever is left.
hermes_holders() {
python3 - "$1" "$HOME" <<'PY'
import os
import re
import signal
import sys
import time
from pathlib import Path
mode, home = sys.argv[1], Path(sys.argv[2])
roots = [str((home / relative).resolve()) for relative in ('.hermes', '.config/Hermes')]
roots.append('/opt/hermes-desktop')
runtime = [str((home / relative).resolve()) for relative in ('.hermes/hermes-agent', '.hermes/node', '.hermes/bin')]
runtime.append('/opt/hermes-desktop')
def belongs_to_hermes(target):
def under(target, directories):
target = target.removesuffix(' (deleted)')
return any(target == root or target.startswith(root + '/') for root in roots)
return any(target == directory or target.startswith(directory + '/') for directory in directories)
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():
def processes():
found = []
for process in Path('/proc').iterdir():
if not process.name.isdigit() or int(process.name) == os.getpid():
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:
if process.stat().st_uid != os.getuid():
continue
argv = [os.fsdecode(arg) for arg in (process / 'cmdline').read_bytes().split(b'\0')]
program = []
try:
targets.append(os.readlink(entry))
program.append(os.readlink(process / 'exe'))
except OSError:
pass
if any(belongs_to_hermes(target) for target in targets):
holders.append(process.name)
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
touched = list(program)
try:
touched.append(os.readlink(process / 'cwd'))
except OSError:
pass
try:
for entry in (process / 'fd').iterdir():
try:
touched.append(os.readlink(entry))
except OSError:
pass
except PermissionError:
pass
started_as = argv[:1]
interpreter = bool(started_as) and re.fullmatch(r'(python[0-9.]*|node|bash|sh)', os.path.basename(started_as[0]))
if interpreter:
started_as = argv[:2]
# Hermes's own package, run as a module from an activated venv.
ours = any(under(target, runtime) for target in started_as + program) or \
(bool(interpreter) and argv[1:3] and argv[1] == '-m' and argv[2].startswith('hermes_cli'))
if ours or any(under(target, roots) for target in touched):
try:
name = (process / 'comm').read_text().strip()
except OSError:
name = '?'
found.append((int(process.name), ours, name))
except (FileNotFoundError, ProcessLookupError, PermissionError):
continue
return found
if holders:
print('Close Hermes and processes using its files before removing it (PIDs: '
+ ', '.join(holders) + '). Then try again.', file=sys.stderr)
def named(entries):
return ', '.join(f'{pid} {name}' for pid, ours, name in entries)
def refuse(entries):
print('Something other than Hermes still has its files open (PIDs: ' + named(entries)
+ '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
strangers = [entry for entry in processes() if not entry[1]]
if strangers:
refuse(strangers)
if mode == 'check':
sys.exit(0)
mine = [entry for entry in processes() if entry[1]]
if mine:
print('Stopping Hermes (PIDs: ' + named(mine) + ')...')
for signum, patience in ((signal.SIGTERM, 10), (signal.SIGKILL, 3)):
for pid, ours, name in mine:
try:
os.kill(pid, signum)
except ProcessLookupError:
pass
deadline = time.monotonic() + patience
while mine and time.monotonic() < deadline:
time.sleep(0.2)
mine = [entry for entry in processes() if entry[1]]
if not mine:
break
if mine:
print('Hermes did not stop (PIDs: ' + named(mine) + '). Close it, then run the removal again.', file=sys.stderr)
sys.exit(1)
left = processes()
if left:
refuse(left)
PY
}
# Removing an open SQLite WAL leaves a live writer on a deleted generation.
ensure_hermes_stopped
omarchy-pkg-drop hermes-desktop
hermes_holders check
# The installer leaves a unit waiting to hand the app the Omarchy theme.
# The gateway unit that upstream's `hermes gateway install` writes starts the
# runtime about to be deleted, so it goes with it, and it is stopped before
# the processes are, or systemd would start the gateway again the moment it
# was killed. Judged by what the unit starts, as the processes are: a unit
# whose ExecStart runs this runtime is its own whatever home it serves, and
# one that runs a Hermes kept elsewhere is somebody else's arrangement even
# when its home sits under ~/.hermes. A unit that will not stop aborts the
# removal: dropping the package would strand a live gateway on deleted code
# with no way to restart it cleanly.
for unit_file in "$unit_dir"/hermes-gateway*.service; do
[[ -f $unit_file ]] || continue
grep '^ExecStart=' "$unit_file" | grep -qF "$HOME/.hermes/hermes-agent/" || continue
unit=${unit_file##*/}
if systemctl --user disable --now "$unit" 2>/dev/null || unit_stopped "$unit"; then
# .bak is what `gateway install --force` leaves behind when it rewrites a
# unit, so it goes with the unit.
rm -f "$unit_file" "$unit_file.bak" "$unit_dir/default.target.wants/$unit"
systemctl --user daemon-reload 2>/dev/null || true
# A unit that had been failing stays listed as "not-found failed" after
# its file is gone until its failed state is reset.
systemctl --user reset-failed "$unit" 2>/dev/null || true
else
echo "Could not stop $unit; Hermes was not removed." >&2
exit 1
fi
done
# The installer leaves a unit waiting to hand the app the Omarchy theme; it
# runs Hermes itself to do so, so it is stopped before the sweep reaches it.
systemctl --user stop omarchy-hermes-theme.service 2>/dev/null || true
# The mise CLI is the app's predecessor, not the app itself: Hermes Desktop takes
# it over on install and runs its own runtime instead, so a copy still here is one
# the app never superseded -- an interrupted install, or the terminal CLI from
# before the app existed. Remove Hermes clears that too, scoped by the installer
# to what Omarchy owns so a hermes the user set up themselves is left alone.
# 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
hermes_holders stop
omarchy-pkg-drop hermes-desktop
# The app writes this when the runtime it provisions under ~/.hermes has landed,
# and it is the only thing that tells that runtime apart from one the user
# installed themselves -- the paths are the same either way. Without it the app
# never got that far: a machine where it was installed but never launched still
# has whatever was there before, and none of it is ours to delete unasked.
# Upstream's installer writes this when the runtime under ~/.hermes has landed,
# whether Omarchy ran it for the app or the app's own first launch did, and it
# is the only thing that tells that runtime apart from one the user installed
# themselves -- the paths are the same either way. Without it the install never
# got that far: a machine where the package landed but nothing set Hermes up
# still has whatever was there before, and none of it is ours to delete unasked.
if [[ -f $HOME/.hermes/hermes-agent/.hermes-bootstrap-complete ]]; then
# The checkout and venv, its own uv, its own node. None of it is any use once
# the app is gone, so it goes without asking; what the user made with the app
@@ -125,7 +225,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
hermes_holders stop
rm -rf "$HOME/.hermes" "$HOME/.config/Hermes"
data_removed=true
fi
@@ -139,9 +239,3 @@ elif [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]]; then
echo "Your chats, memories, and skills are still in ~/.hermes,"
echo "and your connections and settings in ~/.config/Hermes."
fi
# The messages above still hold -- the app and its runtime are gone -- but a CLI
# teardown that failed already said so on stderr, and that stands.
if [[ $cli_removed == "false" ]]; then
exit 1
fi
-8
View File
@@ -30,14 +30,6 @@ if gum confirm "Are you sure you want to remove all preinstalled web apps, TUI w
rm -f ~/.local/bin/muse
fi
# Only the wrapper omarchy-install-hermes-cli wrote is a preinstall. Hermes
# Desktop's command, an official install, or anything else at that path is
# the user's, so it is the installer that decides whether the wrapper is its
# own, rather than a copy of its marker kept here.
if omarchy-install-hermes-cli --owns; then
rm -f ~/.local/bin/hermes
fi
omarchy-pkg-drop \
aether \
cliamp \
+2 -1
View File
@@ -193,7 +193,8 @@ if (( HERMES_ACTIVATE == 0 )); then
esac
fi
# Omarchy's cold stub installs Hermes when run, so ask the probe before running it.
# The probe vets the command before anything here runs it: a wrapper that is
# not a working Hermes, or the retired mise stub that would build one.
if ! omarchy-install-hermes-cli --check 2>/dev/null; then
note "Hermes is not ready, so the Omarchy skin is published but not active."
note "Once Hermes runs, activate it with: hermes config set display.skin $HERMES_SKIN_NAME"
-6
View File
@@ -21,12 +21,6 @@ omarchy-mise-install github:basecamp/hey-cli hey
omarchy-mise-install github:basecamp/basecamp-cli basecamp
omarchy-mise-install npm:cf cf
omarchy-mise-install github:OpenRouterLabs/ori-releases ori
# Every line above writes a stub and cannot fail. This one can: it exits
# non-zero when Hermes Desktop owns Hermes but has not finished setting it up,
# and this leaf is sourced under `bash -eE`, so that would abort the rest of
# omarchy-provision-user -- the default browser, the mailto handler and the
# finalize-user marker all come after it.
omarchy-install-hermes-cli || true
if omarchy-cmd-missing muse; then
omarchy-mise-install "http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]" muse
fi
+3 -3
View File
@@ -1,6 +1,6 @@
# AI
Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a favorite for you. Instead, every major coding-agent CLI comes pre-wired as a lazy-loaded launcher. The launchers are tiny [mise](https://mise.jdx.dev/)-managed stubs in `~/.local/bin/`, so nothing is downloaded until the first time you actually run one. Invoke any of these and authenticate when prompted:
Omarchy treats AI coding agents as first-class citizens, but it doesn't pick a favorite for you. Instead, every major coding-agent CLI comes pre-wired as a lazy-loaded launcher. The launchers are tiny [mise](https://mise.jdx.dev/)-managed stubs in `~/.local/bin/`, so nothing is downloaded until the first time you actually run one. Hermes is the exception: nothing is stubbed for it, and choosing it installs Hermes Desktop, as _Desktop apps_ below explains. Invoke any of these and authenticate when prompted:
| Command | Agent |
|------------|------------------------------------------------------------------|
@@ -32,7 +32,7 @@ Choosing Claude also attempts to set up its browser extension for Chromium, Chro
Once you've chosen, `Super + Shift + Ctrl + A` launches the default agent in a dedicated terminal window (or brings up the picker if you haven't chosen yet). You can also launch it straight into a task with `omarchy agent prompt "Review this project"`. Agents launched this way run unattended in their respective don't-stop-to-ask modes, so be ready for them to actually do things! And since agents refuse to remember trust for your home directory, launches from `$HOME` start in `~/Work` instead.
There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, OpenCode, and Hermes (once Hermes Desktop is installed) all follow along when you switch the Omarchy theme.
There are terminal shortcuts too: `a` runs the default agent inline in the current terminal, while `c`, `cx`, and `cy` start OpenCode, Claude Code, and Codex directly (again in their auto-approving modes). Theme changes sync to the agents as well: Claude Code, Pi, OpenCode, and Hermes (once Omarchy has installed it) all follow along when you switch the Omarchy theme.
### The agents panel
@@ -52,7 +52,7 @@ Crashes can also be silenced one program at a time, which is what the diagnosis
The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app.
Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too.
Hermes Desktop is the one to know about, because a machine has one Hermes, and it is the app's: Hermes is only ever installed through the app, and the `hermes` command the default agent runs is the app's. Choosing Hermes as the default agent installs the same thing the _Install > AI_ entry does: the package, and a Hermes runtime under `~/.hermes` set up by Hermes' own installer, which takes a few minutes the first time. That runtime is the one Hermes the terminal `hermes` command, the default agent and the app all use, and it updates itself with `hermes update` rather than waiting on an Omarchy release. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ closes Hermes first, gateway included, takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too.
OpenClaw's desktop experience is its Control UI, which opens as a web app backed by its own local gateway. OpenClaw updates arrive through Omarchy's package updates, so skip the Control UI's own "Update Gateway" button: it would try to write into the package-managed install and fail. Removing OpenClaw under _Remove > AI_ takes the gateway service and the app with it and then asks whether `~/.openclaw` should go too, since that holds your chats and credentials alongside the plugin runtimes OpenClaw downloads for itself; the default keeps it.
+1 -23
View File
@@ -1,25 +1,3 @@
echo "Install the Hermes CLI wrapper for existing installs"
# Users who removed the preinstalls opted out of the mise wrappers, and Hermes
# is one of them.
[[ -f $HOME/.local/state/omarchy/preinstalls-removed ]] && exit 0
# Hermes Desktop provides its own Hermes. The installer stands aside for it,
# removing the mise copy and the Omarchy wrapper an earlier install may have
# left beside the app. It also reports when the app has not finished setting
# Hermes up, which is the app's to finish, not this migration's to fail on.
if omarchy-pkg-present hermes-desktop; then
omarchy-install-hermes-cli || true
exit 0
fi
# Anything already answering to hermes that this installer did not write --
# an official install, a hand-rolled wrapper, even a dangling link -- belongs to
# the user and stays exactly as it is. The installer is asked rather than
# matched against here, so there is one answer to who owns that wrapper.
wrapper="$HOME/.local/bin/hermes"
if [[ -e $wrapper || -L $wrapper ]] && ! omarchy-install-hermes-cli --owns; then
exit 0
fi
omarchy-install-hermes-cli
# Nothing to install any more: the mise-backed wrapper this wrote was retired in favour of the self-updating runtime, and the migration that retired it clears any it had already written.
+9
View File
@@ -0,0 +1,9 @@
echo "Retire the Hermes that mise built; Hermes now updates itself"
# Hermes installs the way Hermes Desktop does now: upstream's installer makes a checkout under ~/.hermes that `hermes update` fast-forwards. The wrapper that built Hermes through mise had nothing an update could move, so it goes, with the mise environment it built. The wrapper, the environment and what proves them Omarchy's are known to the installer and nowhere else, so it is asked rather than matched against here; it leaves a hermes the user set up themselves, and a mise environment without the wrapper, as they are, and exits non-zero with the commands to finish by hand when the environment cannot be removed, which keeps this pending rather than done.
omarchy-install-hermes-cli --retire-mise
# Choosing Hermes again is what installs the runtime, and nothing else will.
if [[ $(omarchy-default-agent) == "hermes" ]] && ! omarchy-install-hermes-cli --check; then
echo "Hermes is the default agent but is no longer installed. Choose it again under Setup > Default Agent, or run: omarchy default agent hermes"
fi
-133
View File
@@ -1,133 +0,0 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1787760281.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mock_bin="$test_tmp/bin"
test_home="$test_tmp/home"
hermes="$test_home/.local/bin/hermes"
marker="# Written by omarchy-install-hermes-cli."
mkdir -p "$mock_bin" "$test_home/.local/bin" "$test_home/.local/state/omarchy"
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
#!/bin/bash
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
SH
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
#!/bin/bash
! command -v "$1" >/dev/null 2>&1
SH
mise_log="$test_tmp/mise-log"
cat >"$mock_bin/mise" <<'SH'
#!/bin/bash
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
[[ $1 != "where" ]]
SH
chmod +x "$mock_bin"/*
# The real installer is on PATH so the migration writes today's stub, not a
# copy of it.
run_migration() {
OMARCHY_TEST_DESKTOP_INSTALLED="${1:-0}" \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$test_home" \
PATH="$mock_bin:$ROOT/bin:$PATH" \
bash -euo pipefail "$migration" >/dev/null 2>&1
}
run_migration || fail "the migration installs the wrapper on a plain install"
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" || fail "the migration writes the Omarchy wrapper"
pass "the migration installs the Hermes wrapper"
before=$(cat "$hermes")
run_migration || fail "rerunning the migration succeeds"
[[ $(cat "$hermes") == "$before" ]] || fail "rerunning the migration leaves the same wrapper"
pass "the migration is idempotent"
chmod -x "$hermes"
run_migration || fail "the migration repairs a non-executable Omarchy wrapper"
[[ -x $hermes ]] && grep -qxF "$marker" "$hermes" ||
fail "the migration restores a non-executable Omarchy wrapper"
pass "the migration repairs a non-executable Omarchy wrapper"
rm -f "$hermes"
touch "$test_home/.local/state/omarchy/preinstalls-removed"
run_migration || fail "the migration succeeds for users who removed the preinstalls"
[[ ! -e $hermes ]] || fail "the migration respects the preinstalls opt-out"
pass "the migration skips users who removed the preinstalls"
rm -f "$test_home/.local/state/omarchy/preinstalls-removed"
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes"
[[ ! -e $hermes ]] || fail "the migration writes nothing when Hermes Desktop owns Hermes"
pass "the migration stands aside for Hermes Desktop"
# Standing aside is not the same as leaving a second Hermes behind: the wrapper
# an earlier install wrote and the mise copy it points at both go when the
# desktop app owns Hermes, even though the app has not finished setting up.
printf '%s\n' "#!/bin/bash" "$marker" >"$hermes"
chmod +x "$hermes"
: >"$mise_log"
run_migration 1 || fail "the migration succeeds when Hermes Desktop owns Hermes and the old wrapper is present"
[[ ! -e $hermes ]] || fail "the migration removes the Omarchy wrapper when Hermes Desktop owns Hermes"
mise_calls=$(tr '\0' ' ' <"$mise_log")
[[ $mise_calls == *"rm -g "* ]] || fail "the migration removes the global mise Hermes for Hermes Desktop"
[[ $mise_calls == *"uninstall --all "* ]] || fail "the migration uninstalls the mise Hermes for Hermes Desktop"
pass "the migration clears the old Omarchy Hermes for Hermes Desktop"
# ...while anyone else's hermes stays exactly where it is, and is not run.
foreign_ran="$test_tmp/foreign-ran"
foreign_body="#!/bin/bash
touch $foreign_ran
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$foreign_body" >"$hermes"
chmod +x "$hermes"
run_migration 1 || fail "the migration succeeds over a foreign hermes when Hermes Desktop owns Hermes"
[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] ||
fail "the migration leaves a foreign hermes alone when Hermes Desktop owns Hermes"
[[ ! -e $foreign_ran ]] || fail "the migration does not run a foreign hermes"
pass "the migration preserves a foreign hermes for Hermes Desktop"
rm -f "$hermes"
official_body="#!/bin/bash
unset PYTHONPATH
unset PYTHONHOME
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$official_body" >"$hermes"
chmod +x "$hermes"
run_migration || fail "the migration succeeds over a foreign hermes command"
[[ $(cat "$hermes") == "$official_body" ]] || fail "the migration leaves a foreign hermes command alone"
pass "the migration preserves a foreign hermes command"
chmod -x "$hermes"
run_migration || fail "the migration succeeds over a non-executable foreign hermes"
[[ -f $hermes && ! -x $hermes && $(cat "$hermes") == "$official_body" ]] ||
fail "the migration leaves a non-executable foreign hermes alone"
pass "the migration preserves a non-executable foreign hermes"
rm -f "$hermes"
ln -s "$test_home/nowhere/hermes" "$hermes"
run_migration || fail "the migration succeeds over a dangling hermes link"
[[ -L $hermes && $(readlink "$hermes") == "$test_home/nowhere/hermes" ]] ||
fail "the migration leaves a dangling hermes link alone"
pass "the migration preserves a dangling hermes link"
rm -f "$hermes"
mkdir "$hermes"
run_migration || fail "the migration succeeds over a directory at the hermes path"
[[ -d $hermes ]] || fail "the migration leaves a directory at the hermes path alone"
pass "the migration preserves a directory at the hermes path"
rmdir "$hermes"
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
chmod +x "$hermes"
run_migration || fail "the migration succeeds over a wrapper that mentions the installer"
grep -qxF "$marker" "$hermes" && fail "the migration does not rewrite a wrapper that merely mentions the installer"
pass "the migration preserves a wrapper that merely mentions the installer"
+204 -521
View File
@@ -9,568 +9,251 @@ trap 'rm -rf "$test_tmp"' EXIT
mock_bin="$test_tmp/bin"
test_home="$test_tmp/home"
hermes="$test_home/.local/bin/hermes"
ran="$test_tmp/ran"
mise_log="$test_tmp/mise-log"
pkg_log="$test_tmp/pkg-log"
mkdir -p "$mock_bin" "$test_home/.local/bin"
: >"$mise_log"
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
#!/bin/bash
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
SH
cat >"$mock_bin/omarchy-cmd-missing" <<'SH'
# Installing the package is the first thing the runtime setup does, so a mock
# that fails there proves the installer would have installed without running
# the rest of the setup, which hermes-desktop-install-test.sh covers.
cat >"$mock_bin/omarchy-pkg-add" <<'SH'
#!/bin/bash
! command -v "$1" >/dev/null 2>&1
printf '%s\n' "$*" >>"$OMARCHY_TEST_PKG_LOG"
exit 1
SH
# `mise where` must fail so the installer sees no Hermes behind the stub.
#
# With OMARCHY_TEST_MISE_X_HERMES=1, `mise x -- hermes ...` emulates the Hermes
# the Omarchy stub runs, so the readiness probe can be exercised through a
# mise-installed hermes and not only the foreign and desktop wrappers. Off by
# default, so `mise x` stays silent for every test that does not opt in.
# Hermes never comes from mise any more; the only thing the installer asks
# mise is to remove what the retired wrapper built. `where` finds that
# environment and `ls -g` lists it as requested when a test says it was built,
# until uninstalled and unrequested respectively. Every call is logged.
cat >"$mock_bin/mise" <<'SH'
#!/bin/bash
printf '%s\0' "$@" >>"$OMARCHY_TEST_MISE_LOG"
if [[ $1 == "where" && ${OMARCHY_TEST_MISE_WHERE_OK:-0} == 1 ]]; then
printf '%s\n' "$OMARCHY_TEST_MISE_ROOT"
exit 0
fi
if [[ $1 == "x" && ${OMARCHY_TEST_MISE_X_HERMES:-0} == 1 ]]; then
# Args are `x <tool> -- hermes <hermes-args...>`; skip to what follows hermes.
shift
while (( $# )) && [[ $1 != "--" ]]; do shift; done
shift 2
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
[[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-test"
fi
exit 0
fi
[[ $1 != "where" ]]
printf 'mise %s\n' "$*" >>"$OMARCHY_TEST_MISE_LOG"
case "$1" in
where)
[[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_MISE_LOG.removed ]]
;;
ls)
if [[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_MISE_LOG.unrequested ]]; then
echo '{"pipx:hermes-agent[extras=all]": [{"version": "latest"}]}'
elif [[ ${OMARCHY_TEST_MISE_SIBLING:-0} == 1 ]]; then
echo '{"pipx:hermes-agent-tools": [{"version": "latest"}]}'
else
echo '{}'
fi
;;
rm)
touch "$OMARCHY_TEST_MISE_LOG.unrequested"
;;
uninstall)
touch "$OMARCHY_TEST_MISE_LOG.removed"
rm -f "${OMARCHY_TEST_SHIM:-}"
;;
esac
SH
chmod +x "$mock_bin"/*
# ~/.local/bin is on PATH the way Omarchy puts it there, after the mocks, so
# `hermes` resolves to the command under test unless a test shadows it.
run_installer() {
OMARCHY_TEST_DESKTOP_INSTALLED="$1" \
OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-0}" \
OMARCHY_TEST_MISE_LOG="$mise_log" \
OMARCHY_TEST_PKG_LOG="$pkg_log" \
OMARCHY_TEST_RAN="$ran" \
HOME="$test_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" ${2:+"$2"} >/dev/null 2>&1
PATH="$mock_bin:$test_home/.local/bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" "$@" >"$test_tmp/output" 2>&1
}
stub_marker="# Written by omarchy-install-hermes-cli."
python_pin="3.13"
app_stub_body='#!/bin/bash
exec /home/x/.hermes/hermes-agent/venv/bin/hermes "$@"'
# Writing the stub must not provision anything: user setup calls this on every
# machine, including the ones that never run Hermes.
: >"$mise_log"
rm -f "$test_home/.local/bin/hermes"
run_installer 0 || fail "installer failed with no desktop installed"
[[ -x $test_home/.local/bin/hermes ]] || fail "installer writes a hermes stub when the desktop is absent"
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the stub records which command wrote it"
tr '\0' ' ' <"$mise_log" | grep -q "use -g --quiet uv" &&
fail "writing the stub does not install uv"
pass "writing the Hermes stub provisions nothing"
# The desktop app owns Hermes, so our own stub must go rather than sit there
# answering `hermes` until the app's bootstrap replaces it.
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 1 || true
[[ ! -e $test_home/.local/bin/hermes ]] ||
fail "the desktop taking over removes the stub this command wrote"
pass "installing the desktop app removes the CLI stub"
# ...but the app's own hermes is not ours to delete.
printf '%s\n' "$app_stub_body" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 1 || true
[[ -x $test_home/.local/bin/hermes ]] ||
fail "the desktop app's own hermes command survives"
pass "the app's own hermes command is left alone"
# A copy mise cannot vouch for is still a second Hermes.
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
: >"$mise_log"
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 1 || true
tr '\0' '\n' <"$mise_log" | grep -q "uninstall" ||
fail "takeover removes a mise copy even when it is not healthy"
pass "takeover removes an unhealthy mise copy"
# --check answers about Hermes being usable, not about the venv appearing. The
# venv exists from the python-deps stage, several stages before the command.
rm -rf "$test_home/.hermes"
rm -f "$test_home/.local/bin/hermes"
run_installer 1 --check && fail "--check reports Hermes missing before the app installs it"
# The venv command answers the readiness probes, as the real one does: foreign
# wrappers below exec it, and the installer runs both before trusting them.
mkdir -p "$test_home/.hermes/hermes-agent/venv/bin"
cat >"$test_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
# A hermes that runs, defines the flags omarchy-agent passes unless a test says
# otherwise, and records that it ran. With "ours" it names the runtime the way
# upstream's launcher does; without, it is a command from somewhere else.
write_hermes() {
cat >"$hermes" <<'SH'
#!/bin/bash
touch "$OMARCHY_TEST_RAN"
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
[[ ${OMARCHY_TEST_HERMES_CAPABLE:-1} == 1 ]] && echo "[-q QUERY, --query QUERY] [--tui]"
printf '%s\n' "${OMARCHY_TEST_HERMES_HELP-[-q QUERY, --query QUERY] [--tui]}"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$test_home/.hermes/hermes-agent/venv/bin/hermes"
run_installer 1 --check && fail "--check waits for the install to finish, not just the venv"
touch "$test_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 1 --check || fail "--check reports Hermes present once the app has finished"
pass "--check follows the app's completed install"
# An executable called hermes that belongs to something else is not this
# install being ready.
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 1 --check && fail "--check rejects a hermes command belonging to something else"
pass "--check rejects a foreign hermes command"
# A hermes the user installed themselves -- the official installer, a wrapper of
# their own -- is not ours to replace. --check follows whether it runs, and
# installing steps aside so the default agent uses it.
official_body="#!/bin/bash
unset PYTHONPATH
unset PYTHONHOME
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 0 --check || fail "--check accepts a working foreign hermes command"
run_installer 0 || fail "installing over a foreign hermes command returns success"
run_installer 0 --now || fail "--now over a foreign hermes command returns success"
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
fail "a foreign hermes command is left untouched"
pass "a foreign hermes command is preserved and satisfies --check"
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 --check &&
fail "--check rejects a foreign Hermes without native prompted sessions"
OMARCHY_TEST_HERMES_CAPABLE=0 run_installer 0 &&
fail "installing refuses a foreign Hermes without native prompted sessions"
[[ $(cat "$test_home/.local/bin/hermes") == "$official_body" ]] ||
fail "an older foreign Hermes command is left untouched"
pass "a foreign Hermes must support native prompted sessions"
# Broken foreign paths are still foreign. They cannot be used, so --check says
# so and the installer refuses rather than replacing them.
printf '%s\n' "$official_body" >"$test_home/.local/bin/hermes"
chmod -x "$test_home/.local/bin/hermes"
run_installer 0 --check && fail "--check rejects a non-executable foreign hermes"
run_installer 0 && fail "the installer does not succeed over a non-executable foreign hermes"
[[ -f $test_home/.local/bin/hermes && ! -x $test_home/.local/bin/hermes ]] ||
fail "a non-executable foreign hermes is left untouched"
pass "a non-executable foreign hermes is preserved"
# The executable bit is not enough: a wrapper whose interpreter is gone passes
# -x and still cannot run. The probe has to run it to find out, and finding
# out never touches the file.
broken_interp_body="#!$test_home/nowhere/python3
print('hermes')"
printf '%s\n' "$broken_interp_body" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 0 --check && fail "--check rejects a foreign hermes whose interpreter is missing"
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose interpreter is missing"
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose interpreter is missing"
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_interp_body" ]] ||
fail "a foreign hermes whose interpreter is missing is left untouched"
pass "a foreign hermes with a missing interpreter is preserved and rejected"
# Likewise a wrapper that execs a target that is no longer there.
broken_target_body="#!/bin/bash
exec $test_home/nowhere/hermes \"\$@\""
printf '%s\n' "$broken_target_body" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 0 --check && fail "--check rejects a foreign hermes whose target is missing"
run_installer 0 && fail "the installer does not succeed over a foreign hermes whose target is missing"
run_installer 0 --now && fail "--now does not succeed over a foreign hermes whose target is missing"
[[ -x $test_home/.local/bin/hermes && $(cat "$test_home/.local/bin/hermes") == "$broken_target_body" ]] ||
fail "a foreign hermes whose target is missing is left untouched"
pass "a foreign hermes with a missing target is preserved and rejected"
foreign_target="$test_home/foreign/hermes"
mkdir -p "$(dirname "$foreign_target")"
printf '%s\n' "$official_body" >"$foreign_target"
chmod +x "$foreign_target"
rm -f "$test_home/.local/bin/hermes"
ln -s "$foreign_target" "$test_home/.local/bin/hermes"
run_installer 0 --check || fail "--check accepts a foreign link to a working hermes command"
run_installer 0 || fail "the installer succeeds over a foreign link to a working hermes command"
run_installer 0 --now || fail "--now succeeds over a foreign link to a working hermes command"
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$foreign_target" ]] ||
fail "a foreign link to a working hermes command is left untouched"
pass "a foreign link to a working hermes command is preserved"
rm -f "$test_home/.local/bin/hermes"
ln -s "$test_home/nowhere/hermes" "$test_home/.local/bin/hermes"
run_installer 0 --check && fail "--check rejects a dangling hermes link"
run_installer 0 && fail "the installer does not succeed over a dangling hermes link"
[[ -L $test_home/.local/bin/hermes && $(readlink "$test_home/.local/bin/hermes") == "$test_home/nowhere/hermes" ]] ||
fail "a dangling hermes link is left untouched"
pass "a dangling hermes link is preserved"
# A directory passes -x on search permission alone. It is still not a command.
rm -f "$test_home/.local/bin/hermes"
mkdir "$test_home/.local/bin/hermes"
run_installer 0 --check && fail "--check rejects a directory at the hermes path"
run_installer 0 && fail "the installer does not succeed over a directory at the hermes path"
[[ -d $test_home/.local/bin/hermes ]] || fail "a directory at the hermes path is left untouched"
pass "a directory at the hermes path is preserved and rejected"
# Mentioning the installer is not the same as being written by it.
rmdir "$test_home/.local/bin/hermes"
mentions_body="#!/bin/bash
# Replaces the stub omarchy-install-hermes-cli used to write.
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$mentions_body" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 0 || fail "installing over a wrapper that mentions the installer returns success"
[[ $(cat "$test_home/.local/bin/hermes") == "$mentions_body" ]] ||
fail "a wrapper that merely mentions the installer is left untouched"
pass "ownership needs the exact marker line, not a mention"
# Our own stub is ours to rewrite, so reinstalling refreshes it to the current
# template.
rm -f "$test_home/.local/bin/hermes"
printf '%s\n' "#!/bin/bash" "$stub_marker" "# stale template" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_installer 0 || fail "reinstalling over our own stub succeeds"
grep -qxF "$stub_marker" "$test_home/.local/bin/hermes" || fail "the refreshed stub still carries the marker"
grep -q "stale template" "$test_home/.local/bin/hermes" && fail "reinstalling rewrites our own stub"
grep -q "exec env -u UV_PYTHON mise x" "$test_home/.local/bin/hermes" || fail "the refreshed stub is the current template"
pass "reinstalling refreshes the Omarchy stub"
mkdir -p "$test_tmp/mise/hermes-agent/lib/python$python_pin"
: >"$mise_log"
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 || fail "reinstalling replaces an older owned Hermes environment"
tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "an older owned Hermes environment is removed from mise config"
tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "an older owned Hermes environment is uninstalled"
pass "reinstalling replaces an older owned Hermes environment"
# The mise-installed path is what a machine without the desktop app runs, and
# --check gates the default agent there too. The stub is present and its mise
# environment resolves, so readiness turns on the hermes mise runs -- exercised
# here in both directions, since the desktop and foreign cases cover only their
# own wrappers.
run_mise_check() {
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
OMARCHY_TEST_MISE_WHERE_OK=1 \
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
OMARCHY_TEST_MISE_LOG="$mise_log" \
OMARCHY_TEST_MISE_X_HERMES=1 \
OMARCHY_TEST_HERMES_CAPABLE="$1" \
HOME="$test_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1
}
run_mise_check 1 || fail "--check accepts a mise-installed hermes that runs the seeded session"
run_mise_check 0 && fail "--check rejects a mise-installed hermes without the flags omarchy-agent passes"
pass "--check follows the mise-installed hermes it would actually run"
rm -f "$test_home/.local/bin/hermes"
: >"$mise_log"
OMARCHY_TEST_MISE_WHERE_OK=1 run_installer 0 &&
fail "installing refuses to claim an unmarked Hermes mise environment"
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
fail "an unmarked Hermes mise environment is never removed"
[[ ! -e $test_home/.local/bin/hermes ]] ||
fail "an unmarked Hermes mise environment is not given an Omarchy wrapper"
pass "a Hermes mise environment needs wrapper ownership before replacement"
# install/user/mise.sh is sourced by install/user/all.sh through run_logged,
# which runs it under `bash -eE` and hands its exit code back to
# omarchy-provision-user's `set -euo pipefail`. Everything that finalizes a user
# -- the default browser, the mailto handler, the first-install migration
# markers, the finalize-user marker -- runs after that source, so this leaf
# returning non-zero costs the user all of it. The Hermes installer is the only
# line in it that can fail, and it does exactly that whenever hermes-desktop is
# installed but the app has not been launched yet: the case a second user on a
# shared machine hits on their first login.
mise_sh_home="$test_tmp/mise-sh-home"
mkdir -p "$mise_sh_home/.local/bin"
cat >"$mock_bin/omarchy-mise-install" <<'SH'
#!/bin/bash
exit 0
SH
chmod +x "$mock_bin/omarchy-mise-install"
# Desktop installed, nothing bootstrapped: omarchy-install-hermes-cli exits 1.
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$mise_sh_home" \
PATH="$mock_bin:$ROOT/bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 &&
fail "the Hermes installer exits non-zero when the desktop app has not set Hermes up"
# Sourced exactly as run_logged does it.
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$mise_sh_home" \
PATH="$mock_bin:$ROOT/bin:$PATH" \
bash -eE -c 'source "$1"' bash "$ROOT/install/user/mise.sh" >/dev/null 2>&1 ||
fail "user setup survives a Hermes install that cannot finish"
pass "user setup survives a Hermes install that cannot finish"
# UV_PYTHON pins the interpreter Hermes is built against. Left in the
# environment it reaches Hermes itself and every command the agent shells out
# to, so a `uv` run in the user's own project resolves 3.13 there as well --
# uv only warns that this contradicts the project's requires-python, then
# builds the venv anyway. The stub drops it before handing over.
leak_home="$test_tmp/leak-home"
leak_bin="$test_tmp/leak-bin"
leak_log="$test_tmp/leak-log"
leak_prefix="$test_tmp/leak-prefix"
mkdir -p "$leak_home/.local/bin" "$leak_bin" "$leak_prefix/hermes-agent/lib/python$python_pin"
# A mise whose `where` satisfies the stub's probe, so the stub goes straight to
# handing over, and whose `x` records the UV_PYTHON it was handed.
cat >"$leak_bin/mise" <<SH
#!/bin/bash
case \$1 in
where) echo "$leak_prefix" ;;
x) printf '%s' "\${UV_PYTHON-}" >"$leak_log" ;;
esac
SH
chmod +x "$leak_bin/mise"
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$leak_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" >/dev/null 2>&1 ||
fail "the installer writes a stub for the leak check"
HOME="$leak_home" PATH="$leak_bin:$mock_bin:$PATH" \
"$leak_home/.local/bin/hermes" --version >/dev/null 2>&1
[[ -f $leak_log ]] || fail "the stub reaches the command it wraps"
[[ -z $(cat "$leak_log") ]] ||
fail "the interpreter pin does not follow Hermes into the commands it runs"
pass "the interpreter pin does not follow Hermes into the commands it runs"
# --owns is the one answer to whether the wrapper on PATH is this installer's.
# Remove Preinstalls and the migration both ask it rather than carrying their
# own copy of the marker, so a change to what ownership means reaches them.
owns_home="$test_tmp/owns-home"
mkdir -p "$owns_home/.local/bin"
run_owns() {
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$owns_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" --owns
if [[ ${1:-} == "ours" ]]; then
printf '# stands in for: exec "%s/.hermes/hermes-agent/venv/bin/python" "$@"\n' "$test_home" >>"$hermes"
fi
chmod +x "$hermes"
}
rm -f "$owns_home/.local/bin/hermes"
run_owns && fail "--owns says no when there is no wrapper at all"
legacy_marker="# Written by omarchy-install-hermes-cli."
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$owns_home/.local/bin/hermes"
chmod +x "$owns_home/.local/bin/hermes"
run_owns || fail "--owns recognises the stub this installer wrote"
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." \
>"$owns_home/.local/bin/hermes"
run_owns && fail "--owns needs the exact marker line, not a mention"
# Quoting the marker inside a longer line is not the same as carrying it: the
# match is whole-line, so a wrapper describing what it replaced stays the
# user's.
printf '%s\n' "#!/bin/bash" "# Replaced '$stub_marker' with my own." \
>"$owns_home/.local/bin/hermes"
run_owns && fail "--owns needs the marker to be the whole line, not part of one"
rm -f "$owns_home/.local/bin/hermes"
ln -s "$test_home/.local/bin/hermes" "$owns_home/.local/bin/hermes"
run_owns && fail "--owns disclaims a symlink, whatever it resolves to"
rm -f "$owns_home/.local/bin/hermes"
pass "--owns answers for the wrapper this installer wrote and nothing else"
# The marker lives in exactly one place. Every other caller asks --owns, so a
# second copy is drift waiting to happen.
marker_copies=$(grep -rl "Written by omarchy-install-hermes-cli" \
"$ROOT/bin" "$ROOT/install" "$ROOT/migrations" 2>/dev/null | wc -l)
(( marker_copies == 1 )) ||
fail "only omarchy-install-hermes-cli spells out the ownership marker"
pass "the ownership marker is written down once"
# --remove tears down a Hermes CLI this installer owns, so Remove Hermes can
# clear one the desktop app never superseded. It turns on the same ownership as
# the rest of the file, so its cases mirror that split.
remove_home="$test_tmp/remove-home"
mkdir -p "$remove_home/.local/bin"
run_remove() {
OMARCHY_TEST_DESKTOP_INSTALLED=0 \
OMARCHY_TEST_MISE_WHERE_OK="${OMARCHY_TEST_MISE_WHERE_OK:-0}" \
OMARCHY_TEST_MISE_ROOT="$test_tmp/mise" \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$remove_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" --remove
write_legacy_stub() {
printf '%s\n' "#!/bin/bash" "$legacy_marker" 'touch "$OMARCHY_TEST_RAN"' >"$1"
chmod +x "$1"
}
rm -f "$remove_home/.local/bin/hermes"
: >"$mise_log"
run_remove || fail "--remove succeeds when there is nothing to remove"
# No stub means no proof the mise environment -- if one even exists -- is
# Omarchy's, so nothing may reach mise at all.
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
fail "--remove leaves mise alone when nothing proves ownership"
pass "--remove is idempotent when no Hermes CLI is present"
# The app's launcher used to call this with no arguments; a default of --now
# would turn every launch into an install.
: >"$pkg_log"
run_installer && fail "the installer runs without a mode"
grep -q 'Usage' "$test_tmp/output" || fail "a missing mode prints usage"
[[ ! -s $pkg_log ]] || fail "a missing mode installs nothing"
pass "the installer needs its mode named"
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
chmod +x "$remove_home/.local/bin/hermes"
: >"$mise_log"
run_remove || fail "--remove succeeds tearing down an owned CLI"
tr '\0' '\n' <"$mise_log" | grep -q '^rm$' || fail "--remove drops the mise tool from config"
tr '\0' '\n' <"$mise_log" | grep -q '^uninstall$' || fail "--remove uninstalls the mise tool"
[[ ! -e $remove_home/.local/bin/hermes ]] || fail "--remove takes the stub it owns"
pass "--remove tears down the mise CLI and the stub this installer owns"
rm -f "$hermes"
run_installer --check && fail "--check reports a Hermes with nothing at the command's path"
pass "--check is false with no hermes command"
# When mise still resolves the tool after the teardown, the environment
# survived whatever uninstall claimed, and --remove has to say so.
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
chmod +x "$remove_home/.local/bin/hermes"
OMARCHY_TEST_MISE_WHERE_OK=1 run_remove && fail "--remove claims success while mise still resolves the tool"
pass "--remove fails when the mise environment survives the teardown"
# The retired wrapper built Hermes through mise when run, so it is never run to
# find out whether Hermes is there, whether it is the file or a link to it.
write_legacy_stub "$hermes"
rm -f "$ran"
run_installer --check && fail "--check reports the retired mise wrapper as a Hermes"
[[ ! -e $ran ]] || fail "--check runs the retired mise wrapper"
rm -f "$hermes"
write_legacy_stub "$test_tmp/stub"
ln -s "$test_tmp/stub" "$hermes"
run_installer --check && fail "--check reports a link to the retired wrapper as a Hermes"
[[ ! -e $ran ]] || fail "--check runs the retired wrapper through a link"
rm -f "$hermes"
pass "--check never runs the retired mise wrapper"
foreign_remove_body="#!/bin/bash
exec /usr/local/bin/my-own-hermes \"\$@\""
printf '%s\n' "$foreign_remove_body" >"$remove_home/.local/bin/hermes"
chmod +x "$remove_home/.local/bin/hermes"
: >"$mise_log"
OMARCHY_TEST_MISE_WHERE_OK=1 run_remove || fail "--remove succeeds with a foreign hermes present"
[[ -f $remove_home/.local/bin/hermes && $(cat "$remove_home/.local/bin/hermes") == "$foreign_remove_body" ]] ||
fail "--remove leaves a hermes it does not own untouched"
# The wrapper may front a mise environment the user built against the very same
# spec; without the marker there is no telling, so the environment stays too.
tr '\0' '\n' <"$mise_log" | grep -Eq '^(rm|uninstall)$' &&
fail "--remove never removes a mise environment it cannot prove is Omarchy's"
pass "--remove leaves a Hermes the user installed themselves"
# Hermes is only ever installed through the app: a hermes that works but did
# not come from it is not the app's, so --check says no and --now installs the
# app over it, the command saved aside by the runtime setup further on.
write_hermes
run_installer --check && fail "--check calls a hermes without the app installed"
: >"$pkg_log"
run_installer --now && fail "--now carries on past the mocked package failure"
grep -qx 'hermes-desktop' "$pkg_log" || fail "--now installs the app over a hermes from elsewhere" "$(cat "$test_tmp/output")"
pass "a hermes that did not come from the app is not the app's; choosing Hermes installs the app"
# Judged by what is left, not by what rm claimed: a stub that survives the
# teardown is a CLI still installed, and --remove has to say so.
printf '%s\n' "#!/bin/bash" "$stub_marker" >"$remove_home/.local/bin/hermes"
chmod +x "$remove_home/.local/bin/hermes"
chmod 555 "$remove_home/.local/bin"
run_remove && fail "--remove claims success while the stub survives"
chmod 755 "$remove_home/.local/bin"
rm -f "$remove_home/.local/bin/hermes"
pass "--remove fails when the stub cannot be removed"
rm -f "$hermes"
: >"$pkg_log"
run_installer --now && fail "--now carries on past the mocked package failure"
grep -qx 'hermes-desktop' "$pkg_log" || fail "--now installs the hermes-desktop package" "$(cat "$test_tmp/output")"
pass "--now installs Hermes Desktop when no Hermes answers"
# The app's marker says its install once landed, not that it is still there. A
# wrapper whose runtime has since gone answers for nothing, so readiness runs
# the command, exactly as it does for a hermes the user installed themselves.
ready_home="$test_tmp/ready-home"
mkdir -p "$ready_home/.hermes/hermes-agent/venv/bin" "$ready_home/.local/bin"
touch "$ready_home/.hermes/hermes-agent/.hermes-bootstrap-complete"
printf '%s\n' "#!/bin/bash" "exec $ready_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
>"$ready_home/.local/bin/hermes"
chmod +x "$ready_home/.local/bin/hermes"
# The retired wrapper is not a Hermes for --now either, and still is not run.
write_legacy_stub "$hermes"
rm -f "$ran"
: >"$pkg_log"
run_installer --now && fail "--now carries on past the mocked package failure"
grep -qx 'hermes-desktop' "$pkg_log" || fail "--now installs over the retired wrapper"
[[ ! -e $ran ]] || fail "--now runs the retired wrapper"
rm -f "$hermes"
pass "--now installs over the retired mise wrapper without running it"
run_ready_check() {
OMARCHY_TEST_DESKTOP_INSTALLED=1 \
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$ready_home" \
PATH="$mock_bin:$PATH" \
bash "$ROOT/bin/omarchy-install-hermes-cli" --check >/dev/null 2>&1
# With the app installed, a command that runs is not the whole answer: until
# the runtime carries the marker upstream writes last and the packaged app has
# been seeded, --now still has minutes of work, so --check says no and the menu
# opens a terminal for it rather than running it where nobody can see.
runtime="$test_home/.hermes/hermes-agent"
native="$runtime/apps/desktop/release/linux-unpacked/resources"
write_hermes ours
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check calls an app with no runtime installed"
mkdir -p "$runtime" "$native"
touch "$runtime/.hermes-bootstrap-complete"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check calls an app whose packaged build is not seeded installed"
touch "$native/app.asar" "$native/install-stamp.json"
printf '#!/bin/bash\nexit 0\n' >"$native/../Hermes"
chmod +x "$native/../Hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check || fail "--check follows a finished install"
pass "--check says no while --now still has work to do behind the app"
# With the app installed, the terminal has to be on the app's Hermes: a working
# command from somewhere else beside a finished runtime is not installed, so
# --now gets to put the runtime's own command back.
write_hermes
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check calls the app installed while the command is somebody else's"
write_hermes ours
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check || fail "--check follows the runtime's own command"
pass "--check needs the app's own command once the app is installed"
# The flags have to be defined by the help, not merely mentioned in it, and
# both of them: omarchy-agent passes --query to seed the session and --tui to
# keep it interactive.
OMARCHY_TEST_DESKTOP_INSTALLED=1 OMARCHY_TEST_HERMES_HELP='Run with --tui for a terminal session; see --query in the docs.' run_installer --check &&
fail "--check accepts flags that are only mentioned"
OMARCHY_TEST_DESKTOP_INSTALLED=1 OMARCHY_TEST_HERMES_HELP='[--tui]' run_installer --check && fail "--check accepts a hermes without --query"
OMARCHY_TEST_DESKTOP_INSTALLED=1 OMARCHY_TEST_HERMES_HELP='[-q QUERY, --query QUERY]' run_installer --check && fail "--check accepts a hermes without --tui"
pass "--check needs both flags defined, not mentioned"
chmod -x "$hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check accepts a hermes that is not executable"
rm -f "$hermes"
mkdir "$hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check accepts a directory at the command's path"
rmdir "$hermes"
ln -s "$test_tmp/nowhere" "$hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check accepts a dangling link"
rm -f "$hermes"
write_hermes ours
pass "--check rejects what is not a command that runs"
# Installed and finished: nothing to do, and quickly, because choosing the
# agent from the menu runs this.
: >"$pkg_log"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --now || fail "--now accepts a finished install" "$(cat "$test_tmp/output")"
[[ ! -s $pkg_log && ! -s $test_tmp/output ]] || fail "a finished install is set up again" "$(cat "$test_tmp/output")"
pass "--now has nothing to do once the app and its runtime are in"
[[ ! -s $mise_log ]] || fail "the installer touched mise" "$(cat "$mise_log")"
pass "Hermes never goes through mise"
# A PATH that finds no hermes at all is not in the way: the theme unit asks
# from a service whose PATH has no ~/.local/bin and runs the command by path.
run_installer_bare_path() {
OMARCHY_TEST_DESKTOP_INSTALLED="${OMARCHY_TEST_DESKTOP_INSTALLED:-0}" \
OMARCHY_TEST_MISE_LOG="$mise_log" OMARCHY_TEST_PKG_LOG="$pkg_log" OMARCHY_TEST_RAN="$ran" \
HOME="$test_home" PATH="$mock_bin:/usr/bin" \
bash "$ROOT/bin/omarchy-install-hermes-cli" "$@" >"$test_tmp/output" 2>&1
}
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer_bare_path --check || fail "--check from a PATH without ~/.local/bin still follows a finished install" "$(cat "$test_tmp/output")"
pass "--check does not need ~/.local/bin on the caller's PATH"
run_ready_check && fail "--check rejects the app's wrapper when its runtime is gone"
# What omarchy-agent runs is whichever hermes PATH finds first, and Omarchy
# puts mise's shims ahead of ~/.local/bin. A command ahead of the one the probe
# vets means the agent would run something else, so it is not installed, and
# --now says what is in the way rather than reporting a Hermes that is not the
# one the agent gets.
cp "$hermes" "$mock_bin/hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check && fail "--check calls a shadowed command installed"
rm -f "$mock_bin/hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 run_installer --check || fail "--check follows the command once nothing shadows it"
pass "a hermes ahead of ~/.local/bin on PATH is not the one the agent gets"
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check || fail "--check accepts the app's wrapper once it runs"
pass "readiness runs the app's command rather than trusting its marker"
# The retired wrapper's environment goes with the wrapper's proof, wherever
# that is. --retire-mise is the migration's whole job; --now does the same once
# the runtime installer has saved the wrapper aside, so a user who chose Hermes
# before their migration ran is not left with mise's shim answering `hermes`.
write_legacy_stub "$hermes"
: >"$mise_log"; rm -f "$mise_log.removed" "$mise_log.unrequested"
OMARCHY_TEST_MISE_BUILT=1 run_installer --retire-mise || fail "--retire-mise succeeds over the wrapper" "$(cat "$test_tmp/output")"
[[ ! -e $hermes ]] || fail "--retire-mise removes the wrapper"
grep -qF "mise rm -g pipx:hermes-agent[extras=all]" "$mise_log" || fail "--retire-mise removes the global mise Hermes" "$(cat "$mise_log")"
grep -qF "mise uninstall --all pipx:hermes-agent[extras=all]" "$mise_log" || fail "--retire-mise uninstalls the mise Hermes" "$(cat "$mise_log")"
: >"$mise_log"
run_installer --retire-mise || fail "--retire-mise succeeds with nothing of Omarchy's"
[[ ! -s $mise_log ]] || fail "--retire-mise asks mise about an environment nothing proves Omarchy's" "$(cat "$mise_log")"
pass "--retire-mise removes the wrapper and the environment it built, and only with proof"
# A release whose help lists only the old probe's --oneshot marker cannot run
# the seeded --tui --query session omarchy-agent starts, so it is not ready.
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "--oneshot"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check && fail "--check accepts a release without the flags omarchy-agent passes"
pass "a release listing only --oneshot is not prompt-ready"
# A global tool whose name merely starts the same way is not the retired one;
# read as it, it could never be removed and the migration would stay pending.
write_legacy_stub "$hermes"
: >"$mise_log"
OMARCHY_TEST_MISE_SIBLING=1 run_installer --retire-mise || fail "--retire-mise succeeds beside a tool named like the retired one" "$(cat "$test_tmp/output")"
[[ ! -e $hermes ]] || fail "the wrapper goes when only a similarly named tool is requested"
! grep -q '^mise rm' "$mise_log" || fail "a similarly named tool is taken for the retired one" "$(cat "$mise_log")"
pass "a mise tool named like the retired one does not keep the migration pending"
# A release that lists --tui-theme and --query-log but has dropped the bare
# --tui/--query omarchy-agent passes must not read as ready on the substring
# alone. The probe matches at a flag boundary for exactly this case.
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[--tui-theme THEME] [--query-log FILE]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check && fail "--check accepts a release whose flags only contain --tui/--query as a substring"
pass "a flag that merely contains --tui or --query is not prompt-ready"
# Each flag answers for itself: a release that kept --tui but dropped --query,
# or the reverse, cannot run the seeded session either, so neither grep may
# ride on the other's match.
for kept in '--tui' '-q QUERY, --query QUERY'; do
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<SH
#!/bin/bash
if [[ \${1:-} == "chat" && \${2:-} == "--help" ]]; then
echo "[$kept]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check && fail "--check accepts a release listing only $kept"
done
pass "either flag alone is not prompt-ready"
# An underscore continues a flag name just as a dash does: --tui_mode is not
# --tui.
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[--tui_mode MODE] [--query_log FILE]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check && fail "--check accepts flags that extend --tui/--query with an underscore"
pass "an underscore continuation is not the bare flag"
# A flag mentioned in another option's help text is not that option. Hermes
# already writes "With --tui:" into --dev's description, so prose has to stay
# prose even when both names appear in it.
cat >"$ready_home/.hermes/hermes-agent/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo " --dev With --tui: run sources via tsx"
echo " --log FILE Where --query output lands"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$ready_home/.hermes/hermes-agent/venv/bin/hermes"
run_ready_check && fail "--check accepts flags that appear only in option descriptions"
pass "a flag mentioned in prose is not a defined option"
# --now finishing a handover, mise environment and shim included, needs the
# package fixtures and lives in hermes-desktop-install-test.sh.
+362 -38
View File
@@ -53,20 +53,40 @@ 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"
printf '%s\n' "${npm_config_yes:-unset}" >"$OMARCHY_TEST_ROOT/install-npx-answer"
[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7
commit=$OMARCHY_TEST_RELEASE_COMMIT
force=false
stage=""
while (( $# )); do
case "$1" in
--dir) runtime=$2; shift ;;
--commit) commit=$2; shift ;;
--force-commit) force=true ;;
--stage) stage=$2; shift ;;
--hermes-home) [[ $2 == "$HERMES_HOME" ]] ;;
esac
shift
done
# The commands upstream writes last: shims for the two side commands and, for
# hermes, a launcher into the runtime's venv the way the real one is written.
write_commands() {
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
printf '#!/bin/bash\nexec "%s/venv/bin/hermes" "$@"\n' "$runtime" >"$HOME/.local/bin/hermes"
chmod +x "$HOME/.local/bin/hermes"
}
# The path stage writes the commands alone, without touching the checkout.
if [[ $stage == "path" ]]; then
printf 'path\n' >>"$OMARCHY_TEST_ROOT/events"
write_commands
exit 0
fi
printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events"
mkdir -p -- "${runtime%/*}"
if [[ ! -d $runtime ]]; then
git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime"
@@ -80,33 +100,38 @@ if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$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"
# The venv command answers the readiness probes the way the real one does: the
# installer runs the command it leaves on PATH before calling Hermes ready.
cat >"$runtime/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
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
write_commands
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 ]]
[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] || exit 1
touch "$OMARCHY_TEST_ROOT/package-installed"
MOCK
cat >"$test_tmp/bin/omarchy-pkg-present" <<'MOCK'
#!/bin/bash
[[ -e $OMARCHY_TEST_ROOT/package-installed ]]
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 "$@"
@@ -149,28 +174,48 @@ 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
# The only thing the installer asks mise is to remove what the retired wrapper
# built. `where` finds that environment and `ls -g` lists it while a test says
# it was built and it has not been uninstalled and unrequested; uninstalling
# also takes the shim stand-in named in OMARCHY_TEST_SHIM, as mise's does.
cat >"$test_tmp/bin/mise" <<'MOCK'
#!/bin/bash
printf 'mise %s\n' "$*" >>"$OMARCHY_TEST_ROOT/mise-log"
case "$1" in
where) [[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_ROOT/mise-removed ]] ;;
ls)
if [[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_ROOT/mise-unrequested ]]; then
echo '{"pipx:hermes-agent[extras=all]": [{"version": "latest"}]}'
else
echo '{}'
fi
;;
rm) touch "$OMARCHY_TEST_ROOT/mise-unrequested" ;;
uninstall) touch "$OMARCHY_TEST_ROOT/mise-removed"; rm -f "${OMARCHY_TEST_SHIM:-}" ;;
esac
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'
# Substitute only system package paths in scratch copies of the actual scripts.
# The runtime setup lives in omarchy-install-hermes-cli, which the desktop
# installer finds on PATH under its own name.
python3 - "$ROOT/bin" "$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 {
substitutions = {
'/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)
}
for name, target in (('omarchy-install-ai-hermes', scratch / 'installer'),
('omarchy-install-hermes-cli', scratch / 'bin/omarchy-install-hermes-cli')):
script = (source / name).read_text()
for original, replacement in substitutions.items():
script = script.replace(original, replacement)
target.write_text(script)
target.chmod(0o755)
PY
new_home() {
@@ -179,11 +224,24 @@ new_home() {
runtime="$hermes_home/hermes-agent"
native="$runtime/apps/desktop/release/linux-unpacked"
mkdir -p "$test_home"
rm -f "$test_tmp/package-installed"
: >"$test_tmp/events"
}
# The app opens in the background, so a run that got that far is joined to it
# before anything is asserted; one that stopped earlier started nothing.
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
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$test_home/.local/bin:$PATH" npm_config_yes= \
bash "$test_tmp/installer" >"$test_tmp/output" 2>&1 || return
for (( attempt=0; attempt<200; attempt++ )); do
if grep -q '^launch' "$test_tmp/events"; then return 0; fi
sleep 0.01
done
return 1
}
# ~/.local/bin is on PATH the way Omarchy puts it there, after the mocks.
run_cli() {
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$test_home/.local/bin:$PATH" npm_config_yes= \
bash "$test_tmp/bin/omarchy-install-hermes-cli" "$@" >"$test_tmp/output" 2>&1
}
assert_stopped() {
if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi
@@ -193,9 +251,14 @@ 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"
# npx asks before fetching a package it does not have, Playwright's included,
# and the floating terminal the menu opens has nobody to answer. The runners
# clear the variable, so only the installer can have set it.
[[ $(cat "$test_tmp/install-npx-answer") == "true" ]] || fail "upstream installer runs with npx's question answered" "$(cat "$test_tmp/install-npx-answer")"
[[ $(head -2 "$test_tmp/events") == $'package hermes-desktop\nbootstrap' ]] || fail "the package precedes runtime bootstrap" "$(cat "$test_tmp/events")"
[[ $(sed -n '3p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy" "$(cat "$test_tmp/events")"
[[ $(tail -1 "$test_tmp/events") == launch ]] || fail "the app opens only once setup and the theme hand-over are in place" "$(cat "$test_tmp/events")"
grep -qx theme-start "$test_tmp/events" || fail "setup hands Hermes the theme"
[[ $(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"
@@ -236,6 +299,28 @@ 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"
# The refusal comes before anything is touched: a launcher of the user's own
# beside that newer runtime is neither saved aside nor replaced by a run that
# is going to stop anyway.
own_launcher="#!/bin/bash
exec \"$test_home/tools/hermes\" \"\$@\""
printf '%s\n' "$own_launcher" >"$test_home/.local/bin/hermes"
: >"$test_tmp/events"
run_cli --now && fail "a newer runtime with a launcher of its own still stops"
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "the stop still carries the build-only guidance" "$(cat "$test_tmp/output")"
[[ $(cat "$test_home/.local/bin/hermes") == "$own_launcher" ]] || fail "a run that stops leaves the user's launcher as it was"
[[ -z $(find "$test_home/.local/bin" -maxdepth 1 -name '.hermes-before-desktop.*' -print) ]] || fail "a run that stops saves nothing aside"
[[ ! -s $test_tmp/events ]] || fail "a run that stops writes no commands" "$(cat "$test_tmp/events")"
# A git state that cannot be read is a refusal too, not a clean tree: back at
# the release with the index unreadable, the launcher is still left alone.
git -C "$runtime" checkout -q --detach "$release_commit"
chmod 000 "$runtime/.git/index"
: >"$test_tmp/events"
run_cli --now && { chmod 644 "$runtime/.git/index"; fail "an unreadable git state is not a clean tree"; }
chmod 644 "$runtime/.git/index"
grep -q 'Could not read' "$test_tmp/output" || fail "an unreadable git state is named" "$(cat "$test_tmp/output")"
[[ $(cat "$test_home/.local/bin/hermes") == "$own_launcher" ]] || fail "an unreadable git state leaves the user's launcher as it was"
[[ ! -s $test_tmp/events ]] || fail "an unreadable git state writes no commands" "$(cat "$test_tmp/events")"
pass "updated runtimes are preserved and never seeded with the old packaged app"
new_home dirty-desktop
@@ -284,8 +369,10 @@ run_installer && fail "incomplete existing app requires repair"
assert_stopped "incomplete native app prevents launch"
pass "an incomplete existing native app is preserved"
# A runtime already at the release, edited where the patch lands, before Omarchy
# has prepared it: the conflict is reported and nothing is touched.
new_home patch-conflict
run_installer || fail "patch conflict fixture sets up"
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'local edit\n' >"$runtime/runtime.txt"
: >"$test_tmp/events"
run_installer && fail "unexpected patch conflict stops setup"
@@ -297,6 +384,61 @@ 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"
# Every refusal comes before anything of the user's is touched, whatever state
# the runtime is in: a launcher of their own is neither saved aside nor
# replaced, and no bootstrap runs, by a run that is going to stop anyway.
own_launcher="#!/bin/bash
exec \"$test_home/tools/hermes\" \"\$@\""
assert_untouched() {
[[ $(cat "$test_home/.local/bin/hermes") == "$own_launcher" ]] || fail "$1: the user's launcher is not as it was"
[[ -z $(find "$test_home/.local/bin" -maxdepth 1 -name '.hermes-before-desktop.*' -print) ]] || fail "$1: something was saved aside"
[[ ! -s $test_tmp/events ]] || fail "$1: setup ran" "$(cat "$test_tmp/events")"
}
# A finished runtime at the release whose edit the runtime patch cannot land on.
new_home patch-conflict-own-launcher
touch "$test_tmp/package-installed"
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'local edit\n' >"$runtime/runtime.txt"
printf '%s\n' "$own_launcher" >"$test_home/.local/bin/hermes"
: >"$test_tmp/events"
run_cli --now && fail "a conflicting edit still stops setup"
grep -q 'patch conflicts' "$test_tmp/output" || fail "a conflicting edit is named" "$(cat "$test_tmp/output")"
assert_untouched "a patch conflict"
# An unfinished runtime beside a half-built app.
new_home incomplete-app
touch "$test_tmp/package-installed"
OMARCHY_TEST_NO_MARKER=1 HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
mkdir -p "$native/resources"
printf 'half\n' >"$native/resources/app.asar"
printf '%s\n' "$own_launcher" >"$test_home/.local/bin/hermes"
: >"$test_tmp/events"
run_cli --now && fail "a half-built app beside an unfinished runtime still stops setup"
grep -q 'is incomplete' "$test_tmp/output" || fail "a half-built app is named" "$(cat "$test_tmp/output")"
assert_untouched "a half-built app"
# An unfinished runtime whose git state cannot be read is not a clean one.
new_home unreadable-incomplete
touch "$test_tmp/package-installed"
OMARCHY_TEST_NO_MARKER=1 HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf '%s\n' "$own_launcher" >"$test_home/.local/bin/hermes"
chmod 000 "$runtime/.git/index"
: >"$test_tmp/events"
run_cli --now && { chmod 644 "$runtime/.git/index"; fail "an unreadable git state beside an unfinished runtime is not a clean tree"; }
chmod 644 "$runtime/.git/index"
grep -q 'incomplete at' "$test_tmp/output" || fail "an unreadable git state is refused as incomplete" "$(cat "$test_tmp/output")"
assert_untouched "an unreadable git state"
pass "a refusal leaves the user's launcher and runtime as they were, whatever state the runtime is in"
# Once set up, a runtime is the user's to edit; a finished install is not
# re-patched or re-verified, only opened.
new_home finished-edit
run_installer || fail "finished-edit fixture sets up" "$(cat "$test_tmp/output")"
printf 'local edit\n' >"$runtime/runtime.txt"
: >"$test_tmp/events"
run_installer || fail "a finished install with local edits is accepted" "$(cat "$test_tmp/output")"
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "local edits to a finished runtime are preserved"
[[ $(cat "$test_tmp/events") == launch ]] || fail "a finished install is only opened" "$(cat "$test_tmp/events")"
pass "a finished install is left as the user has it"
new_home full-history-retry
git clone -q "$test_tmp/seed" "$runtime"
git -C "$runtime" checkout -q --detach "$release_commit"
@@ -305,6 +447,10 @@ run_installer || fail "clean incomplete full-history release checkout is repaire
[[ $(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"
# A main that is neither the release nor origin/main is kept under another name
# and main still starts at the release: a user's own commits stay reachable,
# and a clone whose main is off origin/main only because upstream rewrote its
# history is not refused for work it never did.
new_home local-main
git clone -q "$test_tmp/seed" "$runtime"
printf 'local branch work\n' >"$runtime/keep"
@@ -312,10 +458,89 @@ 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"
run_installer || fail "a runtime whose main carries other work still sets up" "$(cat "$test_tmp/output")"
[[ $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release"
kept=$(git -C "$runtime" for-each-ref --format='%(objectname)' 'refs/heads/main-before-omarchy-*')
[[ $kept == "$local_main" ]] || fail "what main pointed at is kept under another name" "$kept"
grep -q 'main-before-omarchy-' "$test_tmp/output" || fail "the kept branch is named in the output"
[[ -f $native/resources/app.asar ]] || fail "setup carries on to seed the app"
pass "work on main is kept under another name rather than refused"
# A shallow.lock nothing has touched for a minute is what a probe that killed
# Hermes mid-fetch leaves behind; it must not stop the history fetch for good.
new_home stale-lock
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'stale\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
run_installer || fail "a stale shallow.lock does not stop setup" "$(cat "$test_tmp/output")"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the stale lock is cleared"
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "the history fetch went ahead after the stale lock"
pass "a stale shallow.lock is cleared rather than left to block every history fetch"
# The same old lock with a git still working in the runtime is somebody's: it
# is waited for, not taken. Each way a live git is found gets its own run: a
# stand-in git that lives until this test releases it, only after setup has
# said it is waiting, and that says if its lock was stolen while it lived; it
# then leaves the lock behind orphaned the way a killed fetch would.
live_lock_case() {
local name=$1 where=$2 output_line='Waiting for Hermes' attempt
shift 2
new_home "$name"
ln -s "$test_home" "$test_tmp/$name-link"
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'live\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
rm -f "$test_tmp/lock-stolen" "$test_tmp/release-git"
(cd "$where" && { if (( $# )); then export "$@"; fi; } && export LOCK="$runtime/.git/shallow.lock" && exec -a "${OMARCHY_TEST_GIT_ARGV0:-git}" bash -c 'for (( i = 0; i < 900; i++ )); do [[ -e "$1" ]] && exit; [[ -e $LOCK ]] || { touch "$2"; exit; }; sleep 0.1; done' _ "$test_tmp/release-git" "$test_tmp/lock-stolen") &
fake_git=$!
: >"$test_tmp/output"
# The runtime is reached through a link, as a symlinked home would, since
# /proc reports canonical paths and the runtime path keeps links.
OMARCHY_TEST_HOME="$test_tmp/$name-link/.hermes" run_installer &
installer=$!
for (( attempt = 0; attempt < 300; attempt++ )); do
grep -q "$output_line" "$test_tmp/output" 2>/dev/null && break
sleep 0.1
done
if ! grep -q "$output_line" "$test_tmp/output"; then
touch "$test_tmp/release-git"; wait "$installer" || true
fail "setup says it is waiting for the live git ($name)" "$(cat "$test_tmp/output")"
fi
[[ ! -e $test_tmp/lock-stolen ]] || fail "the lock was cleared while a git still worked in the runtime ($name)"
touch "$test_tmp/release-git"
wait "$installer" || fail "setup goes ahead once the git is gone ($name)" "$(cat "$test_tmp/output")"
wait "$fake_git" 2>/dev/null || true
[[ ! -e $test_tmp/lock-stolen ]] || fail "the lock was cleared while a git still worked in the runtime ($name)"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the orphaned lock is cleared once nothing holds it ($name)"
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "the history fetch went ahead after the wait ($name)"
}
# Found by its working directory.
live_lock_case live-lock-cwd "$test_tmp/live-lock-cwd/.hermes/hermes-agent"
pass "a lock a live git holds is waited for, not taken, through a linked runtime path"
# Found by GIT_DIR alone, with a trailing slash, from elsewhere, with a large
# environment: read whole and NUL-delimited, or it would go unseen.
big_env=$(head -c 120000 /dev/zero | tr '\0' 'x')
live_lock_case live-lock-env "$test_tmp" BIG_ENV="$big_env" GIT_DIR="$test_tmp/live-lock-env/.hermes/hermes-agent/.git/"
pass "a git working from elsewhere with GIT_DIR naming the runtime is found by its environment"
# Named by its path, as a git run as /usr/bin/git is.
live_lock_case live-lock-path "$test_tmp/live-lock-path/.hermes/hermes-agent" OMARCHY_TEST_GIT_ARGV0=/usr/bin/git
pass "a git that names itself by its path is found too"
# A process that only names git on its command line, an editor opened on
# /usr/bin/git from inside the runtime say, is not a git at work there: the
# stale lock goes and setup carries on without waiting.
new_home bystander
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'stale\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
rm -f "$test_tmp/release-bystander"
(cd "$runtime" && exec -a nvim bash -c 'for (( i = 0; i < 900; i++ )); do [[ -e "$1" ]] && exit; sleep 0.1; done' _ "$test_tmp/release-bystander" /usr/bin/git) &
bystander=$!
run_installer || { touch "$test_tmp/release-bystander"; fail "setup goes ahead beside a process that only names git" "$(cat "$test_tmp/output")"; }
touch "$test_tmp/release-bystander"
wait "$bystander" 2>/dev/null || true
! grep -q 'Waiting for Hermes' "$test_tmp/output" || fail "a process that only names git is waited for"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the stale lock is cleared beside a process that only names git"
pass "a process that only names git on its command line is not waited for"
new_home deepen-retry
OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup"
@@ -345,10 +570,109 @@ 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"
pass "old package fails with upgrade guidance before changing the runtime"
# Choosing Hermes as the default agent runs the same setup, short of opening
# the app: the package, the runtime, the seeded app and the theme hand-over.
new_home terminal-agent
run_cli --now || fail "the default agent path sets Hermes up" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == $'package hermes-desktop\nbootstrap\nbuild-stamp\ntheme-stop\ntheme-start' ]] ||
fail "the default agent path installs the app's runtime without opening the app" "$(cat "$test_tmp/events")"
[[ -x $test_home/.local/bin/hermes && -f $native/resources/app.asar ]] || fail "the default agent path leaves the command and the seeded app in place"
: >"$test_tmp/events"
run_cli --now || fail "a finished install is accepted by the default agent path" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/events ]] || fail "a finished install is set up again" "$(cat "$test_tmp/events")"
run_cli --check || fail "--check follows the installed runtime"
pass "choosing Hermes as the default agent installs the app's runtime without opening the app"
# A Hermes from elsewhere is not the app's: choosing Hermes installs the app
# over it, with the previous command saved aside.
new_home own-hermes
mkdir -p "$test_home/.local/bin"
cat >"$test_home/.local/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-user"
fi
SH
chmod +x "$test_home/.local/bin/hermes"
own_hermes=$(cat "$test_home/.local/bin/hermes")
run_cli --check && fail "--check calls a Hermes from elsewhere the app's"
run_cli --now || fail "choosing Hermes installs the app over a Hermes from elsewhere" "$(cat "$test_tmp/output")"
[[ $(head -2 "$test_tmp/events") == $'package hermes-desktop\nbootstrap' ]] || fail "the app and its runtime are installed over a Hermes from elsewhere" "$(cat "$test_tmp/events")"
backups=("$test_home/.local/bin/".hermes-before-desktop.*)
[[ ${#backups[@]} == 1 && $(cat "${backups[0]}/hermes") == "$own_hermes" ]] || fail "the previous hermes command is saved aside"
grep -qF "$hermes_home/" "$test_home/.local/bin/hermes" || fail "the command is now the app's"
run_cli --check || fail "--check follows the app's Hermes"
pass "Hermes is only installed through the app: a Hermes from elsewhere is superseded, its command saved aside"
# A finished runtime whose command is gone, or not its own, gets its command
# back from upstream's path stage alone: no bootstrap, the runtime untouched,
# and what held the name saved aside. Until then --check says no, so the menu
# opens a terminal for the repair rather than running the agent on the wrong
# Hermes.
new_home command-repair
run_cli --now || fail "command-repair fixture sets up" "$(cat "$test_tmp/output")"
rm "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls a runtime installed without its command"
: >"$test_tmp/events"
run_cli --now || fail "a missing command is restored" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path ]] || fail "a missing command is restored without bootstrapping again" "$(cat "$test_tmp/events")"
run_cli --check || fail "--check follows the restored command"
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls the app installed while the terminal is on another Hermes"
: >"$test_tmp/events"
run_cli --now || fail "a foreign command beside the app's runtime is replaced" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path ]] || fail "a foreign command is replaced without bootstrapping again" "$(cat "$test_tmp/events")"
grep -lF somebody-elses-hermes "$test_home/.local/bin/".hermes-before-desktop.*/hermes >/dev/null 2>&1 || fail "the foreign command is saved aside"
grep -qF "$hermes_home/" "$test_home/.local/bin/hermes" || fail "the runtime's own command is back"
run_cli --check || fail "--check follows the runtime's own command"
chmod -x "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls the runtime's own command installed when it cannot run"
: >"$test_tmp/events"
run_cli --now || fail "the runtime's own command is rewritten when it cannot run" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path && -x $test_home/.local/bin/hermes ]] || fail "a command that cannot run is rewritten by the path stage alone" "$(cat "$test_tmp/events")"
pass "a finished runtime gets its own command back without bootstrapping again"
# A hermes ahead of ~/.local/bin on PATH is what the default agent would run,
# so a finished install behind it is not installed, and --now names it rather
# than setting anything up again.
cp "$test_home/.local/bin/hermes" "$test_tmp/bin/hermes"
run_cli --check && fail "--check calls a shadowed install installed"
: >"$test_tmp/events"
run_cli --now && fail "--now reports a shadowed install as ready"
grep -qF "$test_tmp/bin/hermes" "$test_tmp/output" || fail "--now names the command in the way" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/events ]] || fail "a shadowed install is set up again" "$(cat "$test_tmp/events")"
rm -f "$test_tmp/bin/hermes"
run_cli --check || fail "--check follows the install once nothing shadows it"
pass "a hermes ahead of ~/.local/bin on PATH is reported, not set up over"
# A machine that chose Hermes before its migration ran: the wrapper's copy the
# runtime setup saved aside proves the mise environment Omarchy's, and mise's
# shim for it sits ahead of ~/.local/bin. Choosing Hermes again finishes the
# handover: the environment goes, the shim and the saved copy with it, and
# only then is the command the one PATH finds.
new_home handover
run_cli --now || fail "handover fixture sets up" "$(cat "$test_tmp/output")"
saved="$test_home/.local/bin/.hermes-before-desktop.mise01"
mkdir -p "$saved"
printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$saved/hermes"
cp "$test_home/.local/bin/hermes" "$test_tmp/bin/hermes"
: >"$test_tmp/mise-log"; rm -f "$test_tmp/mise-removed" "$test_tmp/mise-unrequested"
OMARCHY_TEST_MISE_BUILT=1 run_cli --check && fail "--check calls a handover with the mise environment still there finished"
: >"$test_tmp/events"
OMARCHY_TEST_MISE_BUILT=1 OMARCHY_TEST_SHIM="$test_tmp/bin/hermes" run_cli --now || fail "--now finishes the handover" "$(cat "$test_tmp/output")"
grep -q 'mise uninstall --all' "$test_tmp/mise-log" || fail "the environment the saved wrapper proves Omarchy's is removed" "$(cat "$test_tmp/mise-log")"
[[ ! -e $test_tmp/bin/hermes ]] || fail "the shim is gone with the environment"
[[ ! -e $saved/hermes && ! -d $saved ]] || fail "the saved wrapper and its directory go once the environment is gone"
[[ ! -s $test_tmp/events ]] || fail "the handover sets nothing up again" "$(cat "$test_tmp/events")"
run_cli --check || fail "--check follows the finished handover"
pass "choosing Hermes again before the migration finishes the handover: the mise Hermes and its shim go"
new_home custom-profile
hermes_home="$test_home/custom home"
+252
View File
@@ -0,0 +1,252 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
migration="$ROOT/migrations/1790017600.sh"
test_tmp=$(mktemp -d)
trap 'rm -rf "$test_tmp"' EXIT
mock_bin="$test_tmp/bin"
test_home="$test_tmp/home"
hermes="$test_home/.local/bin/hermes"
marker="# Written by omarchy-install-hermes-cli."
tool='pipx:hermes-agent[extras=all]'
mise_log="$test_tmp/mise-log"
mkdir -p "$mock_bin" "$test_home/.local/bin"
cat >"$mock_bin/omarchy-pkg-present" <<'SH'
#!/bin/bash
[[ ${OMARCHY_TEST_DESKTOP_INSTALLED:-0} == 1 ]]
SH
cat >"$mock_bin/omarchy-default-agent" <<'SH'
#!/bin/bash
printf '%s\n' "${OMARCHY_TEST_DEFAULT_AGENT:-}"
SH
# `where` finds the environment mise built when a test says it did, and `ls -g`
# lists it as requested, until uninstalled and unrequested respectively; a test
# can make either removal stick. Every call is logged, so a test can tell
# being asked from being told to remove.
cat >"$mock_bin/mise" <<'SH'
#!/bin/bash
printf '%s\n' "$*" >>"$OMARCHY_TEST_MISE_LOG"
case "$1" in
where)
[[ ${OMARCHY_TEST_MISE_STUCK:-0} == 1 ]] && exit 0
[[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_MISE_LOG.removed ]]
;;
ls)
[[ ${OMARCHY_TEST_MISE_LS_FAIL:-0} == 1 ]] && exit 1
if [[ ${OMARCHY_TEST_MISE_BUILT:-0} == 1 && ! -e $OMARCHY_TEST_MISE_LOG.unrequested ]]; then
echo '{"pipx:hermes-agent[extras=all]": [{"version": "latest"}]}'
else
echo '{}'
fi
;;
rm)
[[ ${OMARCHY_TEST_MISE_RM_STUCK:-0} == 1 ]] || touch "$OMARCHY_TEST_MISE_LOG.unrequested"
;;
uninstall)
touch "$OMARCHY_TEST_MISE_LOG.removed"
;;
esac
SH
chmod +x "$mock_bin"/*
# A PATH with no mise on it at all, for a machine that has none: the mocks
# minus theirs, and links to every system command but the real one, so the
# ambient PATH cannot supply what the test says is missing.
no_mise_path="$test_tmp/bin-without-mise:$test_home/.local/bin:$ROOT/bin:$test_tmp/usr-bin-without-mise"
mkdir -p "$test_tmp/bin-without-mise"
for mock in "$mock_bin"/*; do
[[ $(basename "$mock") == mise ]] || ln -s "$mock" "$test_tmp/bin-without-mise/"
done
# A directory of links made here, never a copy of /usr/bin that could itself be
# a link to it: the rm below must only ever remove a link of this test's own.
mkdir "$test_tmp/usr-bin-without-mise"
ln -s "$(realpath /usr/bin)"/* "$test_tmp/usr-bin-without-mise/"
rm -f "$test_tmp/usr-bin-without-mise/mise"
! PATH="$no_mise_path" command -v mise >/dev/null 2>&1 || fail "the PATH built for a machine without mise still finds one"
# The real installer is on PATH: the migration asks it what to retire and
# whether a Hermes still answers before telling the user how to get one back.
# ~/.local/bin is on PATH the way Omarchy puts it there, after the mocks.
run_migration() {
: >"$mise_log"
rm -f "$mise_log.removed" "$mise_log.unrequested"
OMARCHY_TEST_MISE_LOG="$mise_log" \
HOME="$test_home" \
PATH="${OMARCHY_TEST_PATH:-$mock_bin:$test_home/.local/bin:$ROOT/bin:$PATH}" \
bash -euo pipefail "$migration" >"$test_tmp/output" 2>&1
}
write_stub() {
printf '%s\n' "#!/bin/bash" "$marker" "exec mise x '$tool' -- hermes \"\$@\"" >"$hermes"
chmod +x "$hermes"
}
write_stub
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds over the Omarchy wrapper" "$(cat "$test_tmp/output")"
[[ ! -e $hermes ]] || fail "the migration removes the wrapper Omarchy wrote"
grep -qxF "rm -g $tool" "$mise_log" || fail "the migration removes the global mise Hermes" "$(cat "$mise_log")"
grep -qxF "uninstall --all $tool" "$mise_log" || fail "the migration uninstalls the mise Hermes" "$(cat "$mise_log")"
pass "the migration retires the wrapper and the Hermes mise built"
write_stub
run_migration || fail "the migration succeeds over a wrapper nobody ran"
[[ ! -e $hermes ]] || fail "the migration removes a wrapper nobody ran"
! grep -q '^rm -g' "$mise_log" || fail "nothing built means nothing to uninstall"
pass "a wrapper nobody ran goes without an uninstall"
run_migration || fail "the migration succeeds with nothing to do"
[[ ! -s $mise_log ]] || fail "with nothing of Omarchy's left, mise is not asked" "$(cat "$mise_log")"
pass "the migration is a no-op once the wrapper is gone"
# Anyone else's hermes stays exactly where it is, is not run, and does not vouch
# for a mise environment being Omarchy's.
foreign_ran="$test_tmp/foreign-ran"
foreign_body="#!/bin/bash
touch $foreign_ran
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$foreign_body" >"$hermes"
chmod +x "$hermes"
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds over a foreign hermes"
[[ -x $hermes && $(cat "$hermes") == "$foreign_body" ]] || fail "a hermes the user set up stays as it is"
[[ ! -e $foreign_ran ]] || fail "the migration runs a foreign hermes"
! grep -q '^rm -g' "$mise_log" || fail "a user's mise environment is removed on the strength of their wrapper"
pass "the migration leaves a hermes the user set up alone"
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
chmod +x "$hermes"
run_migration || fail "the migration succeeds over a wrapper that mentions the installer"
[[ -x $hermes ]] || fail "a wrapper that merely mentions the installer is removed"
pass "a wrapper that merely mentions the installer stays"
# The wrapper was written as a regular file, so a link is someone else's
# arrangement even when it lands on the marked file.
rm -f "$hermes"
printf '%s\n' "#!/bin/bash" "$marker" >"$test_tmp/stub"
ln -s "$test_tmp/stub" "$hermes"
run_migration || fail "the migration succeeds over a link to the wrapper"
[[ -L $hermes ]] || fail "a link to the wrapper is removed"
rm -f "$hermes"
ln -s "$test_home/nowhere" "$hermes"
run_migration || fail "the migration succeeds over a dangling link"
[[ -L $hermes ]] || fail "a dangling link is removed"
rm -f "$hermes"
mkdir "$hermes"
run_migration || fail "the migration succeeds over a directory at the command's path"
[[ -d $hermes ]] || fail "a directory at the command's path is removed"
rmdir "$hermes"
pass "the migration leaves links and directories at the command's path alone"
# Without the wrapper nothing proves a mise environment is Omarchy's, the app
# being installed included: a user who built the same spec keeps it.
rm -f "$hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds with the app installed and no wrapper"
! grep -q '^rm -g' "$mise_log" || fail "a mise environment without the wrapper is removed" "$(cat "$mise_log")"
pass "a mise environment without the wrapper is nobody's to remove"
# The environment goes before the wrapper does, because the wrapper is the
# only proof a rerun would have. A removal that leaves the environment behind
# keeps the wrapper, says how to finish by hand, and leaves the migration
# pending; once it can finish, it does.
write_stub
OMARCHY_TEST_MISE_BUILT=1 OMARCHY_TEST_MISE_STUCK=1 run_migration && fail "a removal that left the environment behind counts as done"
[[ -x $hermes ]] || fail "a failed removal takes the wrapper anyway"
grep -qF "mise uninstall --all '$tool'" "$test_tmp/output" || fail "a failed removal says how to finish by hand" "$(cat "$test_tmp/output")"
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration finishes once the environment can go" "$(cat "$test_tmp/output")"
[[ ! -e $hermes ]] || fail "the retry takes the wrapper once the environment is gone"
pass "a removal that cannot finish leaves the wrapper and the migration pending"
# `mise rm -g` exits 0 whether or not it removed anything, so an environment
# uninstalled but still requested in the global config -- where `mise up` would
# build it again -- is judged by the listing, not the exit code.
write_stub
OMARCHY_TEST_MISE_BUILT=1 OMARCHY_TEST_MISE_RM_STUCK=1 run_migration && fail "an environment still requested counts as removed"
[[ -x $hermes ]] || fail "a still-requested environment takes the wrapper anyway"
grep -qF "mise rm -g '$tool'" "$test_tmp/output" || fail "a still-requested environment says how to finish by hand" "$(cat "$test_tmp/output")"
pass "an environment mise still requests is not counted as gone"
# A listing that cannot be read is not an answer: the wrapper stays and the
# migration stays pending rather than deleting the proof on a guess.
write_stub
OMARCHY_TEST_MISE_BUILT=1 OMARCHY_TEST_MISE_LS_FAIL=1 run_migration && fail "an unreadable listing counts as retired"
[[ -x $hermes ]] || fail "an unreadable listing takes the wrapper anyway"
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration finishes once the listing can be read" "$(cat "$test_tmp/output")"
[[ ! -e $hermes ]] || fail "the retry takes the wrapper once the listing answers"
pass "a mise listing that cannot be read leaves the migration pending"
# Without mise the listing cannot be read either, and a request it may still
# hold would build Hermes again the day mise is back; the wrapper stays, the
# migration stays pending, and the way out names mise first.
write_stub
OMARCHY_TEST_PATH="$no_mise_path" run_migration && fail "a machine without mise counts the environment as retired"
[[ -x $hermes ]] || fail "without mise the wrapper is taken anyway"
grep -qF "omarchy pkg add mise" "$test_tmp/output" || fail "without mise the way out names mise first" "$(cat "$test_tmp/output")"
pass "without mise the migration stays pending rather than guessing"
# The runtime installer saves whatever held the command aside before upstream's
# installer takes the name, so a user who chose Hermes before this ran has the
# wrapper in that backup and the environment still requested. The copy is proof
# enough, and goes once it has served: left behind it would keep --check
# saying no for a finished install.
saved_dir="$test_home/.local/bin/.hermes-before-desktop.abc123"
mkdir -p "$saved_dir"
write_stub
mv "$hermes" "$saved_dir/hermes"
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/python $test_home/.hermes/hermes-agent/hermes \"\$@\"" >"$hermes"
chmod +x "$hermes"
runtime_command=$(cat "$hermes")
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds over a saved wrapper" "$(cat "$test_tmp/output")"
grep -qxF "rm -g $tool" "$mise_log" || fail "a saved wrapper proves the environment Omarchy's" "$(cat "$mise_log")"
[[ ! -e $saved_dir/hermes && ! -d $saved_dir ]] || fail "the saved copy and its empty directory go once the environment is gone"
[[ $(cat "$hermes") == "$runtime_command" ]] || fail "the runtime's command is left alone"
pass "a wrapper saved aside by the runtime installer still retires the environment"
rm -rf "$saved_dir" "$hermes"
# The installer makes real backup directories, so one that is a link is
# somebody else's arrangement: what it points at is neither proof nor ours to
# remove, marker or no marker.
mkdir -p "$test_home/archive"
write_stub
mv "$hermes" "$test_home/archive/hermes"
ln -s "$test_home/archive" "$test_home/.local/bin/.hermes-before-desktop.linked"
OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds over a linked backup directory"
! grep -q '^rm -g' "$mise_log" || fail "a marked file behind a linked backup directory counts as proof"
[[ -f $test_home/archive/hermes ]] || fail "a file behind a linked backup directory is removed"
rm -f "$test_home/.local/bin/.hermes-before-desktop.linked"; rm -rf "$test_home/archive"
pass "a linked backup directory is neither proof nor touched"
# Choosing Hermes again is what installs the runtime, so a default agent whose
# command just went is told so; one that still answers, or another agent, is not.
write_stub
OMARCHY_TEST_DEFAULT_AGENT=hermes OMARCHY_TEST_MISE_BUILT=1 run_migration || fail "the migration succeeds for a Hermes default agent"
grep -q 'omarchy default agent hermes' "$test_tmp/output" || fail "a default agent that just went is told how to come back" "$(cat "$test_tmp/output")"
# A working Hermes is the app's: the package, its runtime finished and seeded,
# and the command upstream's installer wrote for it.
app_runtime="$test_home/.hermes/hermes-agent"
mkdir -p "$app_runtime/apps/desktop/release/linux-unpacked/resources"
touch "$app_runtime/.hermes-bootstrap-complete" "$app_runtime/apps/desktop/release/linux-unpacked/resources/app.asar" "$app_runtime/apps/desktop/release/linux-unpacked/resources/install-stamp.json"
printf '#!/bin/bash\nexit 0\n' >"$app_runtime/apps/desktop/release/linux-unpacked/Hermes"
chmod +x "$app_runtime/apps/desktop/release/linux-unpacked/Hermes"
cat >"$hermes" <<SH
#!/bin/bash
# stands in for: exec "$app_runtime/venv/bin/python" "$app_runtime/hermes" "\$@"
if [[ \${1:-} == "chat" && \${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$hermes"
OMARCHY_TEST_DESKTOP_INSTALLED=1 OMARCHY_TEST_DEFAULT_AGENT=hermes run_migration || fail "the migration succeeds with the app's Hermes in place"
! grep -q 'default agent' "$test_tmp/output" || fail "the app's own Hermes gets reinstall guidance" "$(cat "$test_tmp/output")"
rm -rf "$hermes" "$test_home/.hermes"
OMARCHY_TEST_DEFAULT_AGENT=codex run_migration || fail "the migration succeeds for another default agent"
! grep -q 'default agent' "$test_tmp/output" || fail "another default agent gets Hermes guidance"
pass "the migration says how to reinstall a Hermes that was the default agent"
+169 -43
View File
@@ -24,15 +24,6 @@ cat >"$mock_bin/omarchy-pkg-drop" <<'SH'
printf '%s\0' "$@" >>"$OMARCHY_TEST_DROP_LOG"
SH
# The CLI teardown is the installer's own, exercised in hermes-cli-test.sh; here
# it is mocked to a logger so this test stays about what Remove Hermes does with
# ~/.hermes, and to keep real mise out of a run with HOME pointed at a fixture.
cat >"$mock_bin/omarchy-install-hermes-cli" <<'SH'
#!/bin/bash
printf '%s\0' "$@" >>"$OMARCHY_TEST_INSTALLER_LOG"
exit "${OMARCHY_TEST_INSTALLER_STATUS:-0}"
SH
# The remover asks through gum whether the user's data should go too. The stub
# answers "no" unless a test says otherwise, and logs every call: a real gum
# would hang a test run, and one that answered "yes" on its own would be the
@@ -50,9 +41,21 @@ if [[ -n ${OMARCHY_TEST_PROMPT_GATE:-} ]]; then
fi
exit "${OMARCHY_TEST_GUM_STATUS:-1}"
SH
# A unit that will not stop, when a test says so: disable fails and is-active
# keeps answering active.
cat >"$mock_bin/systemctl" <<'SH'
#!/bin/bash
echo "systemctl $*" >>"$OMARCHY_TEST_SYSTEMCTL_LOG"
if [[ ${OMARCHY_TEST_UNIT_STUCK:-0} == 1 ]]; then
[[ $2 == "disable" ]] && exit 1
[[ $2 == "is-active" ]] && echo active
fi
# A user manager that refuses the disable of a unit that is already down.
if [[ ${OMARCHY_TEST_UNIT_DOWN:-0} == 1 ]]; then
[[ $2 == "disable" ]] && exit 1
[[ $2 == "is-active" ]] && echo inactive
fi
exit 0
SH
chmod +x "$mock_bin"/*
@@ -77,30 +80,25 @@ seed_install() {
# </dev/null pins stdin off a terminal, so these runs exercise the
# non-interactive path no matter where the suite itself is running.
remove() {
: >"$test_tmp/installer-log"
: >"$test_tmp/gum-log"
: >"$test_tmp/systemctl-log"
OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \
OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \
OMARCHY_TEST_INSTALLER_STATUS="${OMARCHY_TEST_INSTALLER_STATUS:-0}" \
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
HOME="$test_home" PATH="$mock_bin:$PATH" \
HOME="$test_home" XDG_CONFIG_HOME= PATH="$mock_bin:$PATH" \
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
# without a person at a real one; the stubbed gum then supplies the answer.
remove_tty() {
: >"$test_tmp/installer-log"
: >"$test_tmp/gum-log"
: >"$test_tmp/systemctl-log"
OMARCHY_TEST_DROP_LOG="$test_tmp/drop-log" \
OMARCHY_TEST_INSTALLER_LOG="$test_tmp/installer-log" \
OMARCHY_TEST_SYSTEMCTL_LOG="$test_tmp/systemctl-log" \
OMARCHY_TEST_GUM_LOG="$test_tmp/gum-log" \
OMARCHY_TEST_GUM_STATUS="${OMARCHY_TEST_GUM_STATUS:-1}" \
HOME="$test_home" PATH="$mock_bin:$PATH" \
HOME="$test_home" XDG_CONFIG_HOME= PATH="$mock_bin:$PATH" \
script -qec "bash '$test_tmp/remover'" /dev/null >"$test_tmp/output" 2>&1
}
@@ -142,12 +140,6 @@ pass "removal keeps the user's data unasked when there is no terminal"
[[ ! -e $test_home/.local/bin/hermes ]] || fail "the app's own hermes command is removed"
pass "removal takes the command the app installed"
# Removal also asks the installer to tear down a mise CLI the app superseded, so
# a copy left from before the app took over does not linger once Hermes is gone.
tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' ||
fail "removal asks the installer to tear down its own CLI"
pass "removal tears down the mise CLI through the installer"
# A hermes command the app did not write survives even when the app did install
# a runtime of its own.
seed_install
@@ -168,10 +160,6 @@ printf 'my local edit\n' >"$test_home/.hermes/hermes-agent/PATCH"
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
>"$test_home/.local/bin/hermes"
remove || fail "remove succeeds when the app never finished installing Hermes"
# The stranded pre-desktop CLI is exactly the interrupted-install case, so the
# teardown must be asked for here too, not only when the app's runtime landed.
tr '\0' '\n' <"$test_tmp/installer-log" | grep -qx -- '--remove' ||
fail "removal tears down the CLI even when the app never finished installing"
[[ -d $test_home/.hermes/hermes-agent ]] ||
fail "a Hermes runtime the app never installed survives removal"
[[ -f $test_home/.hermes/hermes-agent/PATCH ]] ||
@@ -235,16 +223,49 @@ OMARCHY_TEST_GUM_STATUS=0 remove_tty ||
fail "a yes takes ~/.hermes whole when the marker never appeared"
pass "removal honors a yes on the named paths without the marker"
# A CLI teardown that fails must not stop the runtime handling, and must not be
# papered over either: the data work still happens, and the failure reaches the
# caller's exit code.
# The gateway unit upstream's `hermes gateway install` writes runs the runtime
# being removed, so it is stopped first and goes with it: the unit file, the
# .bak a forced reinstall leaves, and its enablement link.
seed_install
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" \
>"$test_home/.local/bin/hermes"
OMARCHY_TEST_INSTALLER_STATUS=1 remove && fail "a failed CLI teardown surfaces in the exit code"
[[ ! -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"
unit_dir="$test_home/.config/systemd/user"
mkdir -p "$unit_dir/default.target.wants"
printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\nEnvironment="HERMES_HOME=%s/.hermes"\n' "$test_home" "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service"
cp "$unit_dir/hermes-gateway.service" "$unit_dir/hermes-gateway.service.bak"
ln -s "$unit_dir/hermes-gateway.service" "$unit_dir/default.target.wants/hermes-gateway.service"
# A unit that runs a Hermes kept somewhere else is not this runtime's, even
# when the home it serves sits under ~/.hermes.
printf '[Service]\nExecStart=/srv/hermes/venv/bin/python /srv/hermes/hermes gateway run\nEnvironment="HERMES_HOME=%s/.hermes/profiles/work"\n' "$test_home" >"$unit_dir/hermes-gateway-other.service"
remove || fail "remove succeeds with a gateway unit installed" "$(cat "$test_tmp/output")"
grep -Fxq 'systemctl --user disable --now hermes-gateway.service' "$test_tmp/systemctl-log" ||
fail "the gateway unit is stopped and disabled" "$(cat "$test_tmp/systemctl-log")"
grep -Fxq 'systemctl --user daemon-reload' "$test_tmp/systemctl-log" || fail "systemd is told the unit is gone"
grep -Fxq 'systemctl --user reset-failed hermes-gateway.service' "$test_tmp/systemctl-log" || fail "a failed state is reset"
[[ ! -e $unit_dir/hermes-gateway.service && ! -e $unit_dir/hermes-gateway.service.bak && ! -L $unit_dir/default.target.wants/hermes-gateway.service ]] ||
fail "the gateway unit, its backup and its enablement link are removed"
[[ -f $unit_dir/hermes-gateway-other.service ]] || fail "a gateway unit for a Hermes kept elsewhere survives"
! grep -q 'hermes-gateway-other' "$test_tmp/systemctl-log" || fail "a gateway unit for a Hermes kept elsewhere is not touched" "$(cat "$test_tmp/systemctl-log")"
[[ ! -d $test_home/.hermes/hermes-agent ]] || fail "the runtime goes once its gateway is stopped"
pass "removal stops the gateway unit and takes it with the runtime, leaving units for other homes alone"
# A user manager that will not disable a unit already down is not in the way.
seed_install
mkdir -p "$unit_dir"
printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\n' "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service"
OMARCHY_TEST_UNIT_DOWN=1 remove || fail "remove succeeds when disable fails on a unit that is already inactive" "$(cat "$test_tmp/output")"
[[ ! -e $unit_dir/hermes-gateway.service && ! -d $test_home/.hermes/hermes-agent ]] || fail "an inactive unit whose disable failed still goes with the runtime"
pass "a unit already down goes even when systemd refuses the disable"
# A gateway that will not stop keeps the removal from dropping the package
# under a live process.
seed_install
mkdir -p "$unit_dir"
printf '[Service]\nExecStart=%s/.hermes/hermes-agent/venv/bin/python %s/.hermes/hermes-agent/hermes gateway run\n' "$test_home" "$test_home" >"$unit_dir/hermes-gateway.service"
: >"$test_tmp/drop-log"
OMARCHY_TEST_UNIT_STUCK=1 remove && fail "a gateway that will not stop aborts the removal"
grep -q 'Could not stop hermes-gateway.service' "$test_tmp/output" || fail "a gateway that will not stop is named" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/drop-log ]] || fail "the package is not dropped under a gateway that will not stop"
[[ -d $test_home/.hermes/hermes-agent && -f $unit_dir/hermes-gateway.service ]] || fail "nothing is removed under a gateway that will not stop"
pass "a gateway unit that will not stop aborts the removal before anything goes"
# Real SQLite writers exercise the kernel's live/deleted file descriptors.
# Package, service and confirmation commands remain confined to the mocks.
@@ -274,9 +295,9 @@ def setup(name):
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",
env = {**os.environ, 'HOME': str(home), 'XDG_CONFIG_HOME': '', 'PATH': f"{scratch / 'bin'}:/usr/bin:/bin",
'OMARCHY_TEST_GUM_STATUS': '0'}
for key in ('DROP', 'INSTALLER', 'SYSTEMCTL', 'GUM'):
for key in ('DROP', 'SYSTEMCTL', 'GUM'):
log = home / (key + '.log')
log.touch()
env['OMARCHY_TEST_' + key + '_LOG'] = str(log)
@@ -306,10 +327,10 @@ def remove(env):
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 'other than 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'))
for name in ('DROP', 'SYSTEMCTL', 'GUM'))
assert child.poll() is None, 'remover must not kill sessions'
for deleted in (False, True):
@@ -330,6 +351,9 @@ for deleted in (False, True):
assert not (home / '.hermes').exists()
print('ok - live and deleted SQLite holders block removal before any side effects; closing them allows retry')
# Hermes's own processes are stopped by the removal rather than reported: the
# agent in its terminal, run by the runtime's command, and the packaged app.
# One of the user's that merely sits in the runtime is still theirs to close.
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')
@@ -337,11 +361,113 @@ for kind in ('terminal', 'desktop', 'working-directory'):
child = subprocess.Popen(args, executable='/usr/bin/sleep',
cwd=runtime if kind == 'working-directory' else scratch)
try:
blocked(remove(env), home, runtime, child)
result = remove(env)
if kind == 'working-directory':
blocked(result, home, runtime, child)
else:
assert result.returncode == 0, result
assert 'Stopping Hermes (PIDs: ' + str(child.pid) in result.stdout, result.stdout
assert child.wait(timeout=5) != 0, 'the removal ends a Hermes process of its own'
assert not (home / '.hermes').exists()
finally:
child.terminate()
child.wait(timeout=5)
print('ok - terminal, packaged desktop and runtime working-directory processes are detected without a database')
if child.poll() is None:
child.terminate()
child.wait(timeout=5)
print('ok - the agent and the packaged app are stopped by the removal; a process merely sitting in the runtime still blocks it')
# With a stranger holding Hermes's files, the refusal comes before Hermes's
# own processes are touched: the agent is still running afterwards.
home, runtime, env = setup('stranger-and-agent')
unit_dir = home / '.config/systemd/user'
unit_dir.mkdir(parents=True)
unit = unit_dir / 'hermes-gateway.service'
unit.write_text(f'[Service]\nExecStart={runtime}/venv/bin/python {runtime}/hermes gateway run\n')
stranger = writer(home / '.hermes/state.db')
agent = subprocess.Popen([str(runtime / 'hermes'), '30'], executable='/usr/bin/sleep', cwd=scratch)
try:
result = remove(env)
blocked(result, home, runtime, stranger)
assert str(agent.pid) not in result.stderr, result.stderr
assert agent.poll() is None, 'a refusal over a stranger leaves Hermes running'
assert 'Stopping Hermes' not in result.stdout, result.stdout
assert unit.exists(), 'a refusal over a stranger leaves the gateway unit in place'
finally:
stop(stranger)
if agent.poll() is None:
agent.terminate()
agent.wait(timeout=5)
print('ok - a stranger holding Hermes files stops the removal before Hermes itself is touched')
# A process that merely names a runtime file on its command line, an editor
# opened on one say, is not Hermes: neither stopped nor, holding nothing, in
# the way.
home, runtime, env = setup('argument-only')
editor = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)', str(runtime / 'README.md')], cwd=scratch)
try:
result = remove(env)
assert result.returncode == 0, result
assert editor.poll() is None, 'a process naming a runtime file as an argument is killed'
assert 'Stopping Hermes' not in result.stdout, result.stdout
finally:
editor.terminate()
editor.wait(timeout=5)
print('ok - a runtime path in an argument does not make a process Hermes')
# A gateway started by hand with the venv active is an interpreter running the
# runtime's script: the script is the program, and it is stopped.
home, runtime, env = setup('interpreter-script')
script = runtime / 'hermes'
script.write_text('import time\ntime.sleep(30)\n')
gateway = subprocess.Popen([sys.executable, str(script), 'gateway', 'run'], cwd=scratch)
try:
result = remove(env)
assert result.returncode == 0, result
assert 'Stopping Hermes (PIDs: ' + str(gateway.pid) in result.stdout, result.stdout
assert gateway.wait(timeout=5) != 0, 'an interpreter running the runtime script is stopped'
finally:
if gateway.poll() is None:
gateway.terminate()
gateway.wait(timeout=5)
print('ok - an interpreter running a runtime script is Hermes and is stopped')
# Run as a module from an activated venv, the interpreter is bare and the
# script is nowhere on the command line; Hermes's own package name is.
home, runtime, env = setup('module')
(runtime / 'hermes_cli').mkdir(parents=True)
(runtime / 'hermes_cli/__init__.py').write_text('')
(runtime / 'hermes_cli/main.py').write_text('import time\ntime.sleep(30)\n')
module = subprocess.Popen([sys.executable, '-m', 'hermes_cli.main', 'gateway', 'run'], cwd=scratch,
env={**os.environ, 'PYTHONPATH': str(runtime)})
try:
result = remove(env)
assert result.returncode == 0, result
assert 'Stopping Hermes (PIDs: ' + str(module.pid) in result.stdout, result.stdout
assert module.wait(timeout=5) != 0, 'hermes_cli run as a module is stopped'
finally:
if module.poll() is None:
module.terminate()
module.wait(timeout=5)
print('ok - hermes_cli run as a module from a bare interpreter is Hermes and is stopped')
# A program whose executable lives in the runtime is Hermes whatever it was
# started as: the Electron helpers name themselves by path, but a copy of the
# binary started by a bare name is found through /proc/<pid>/exe.
home, runtime, env = setup('executable')
import shutil
(runtime / 'venv/bin').mkdir(parents=True)
binary = runtime / 'venv/bin/sleeper'
shutil.copy('/usr/bin/sleep', binary)
helper = subprocess.Popen(['sleeper', '30'], executable=str(binary), cwd=scratch)
try:
result = remove(env)
assert result.returncode == 0, result
assert 'Stopping Hermes (PIDs: ' + str(helper.pid) in result.stdout, result.stdout
assert helper.wait(timeout=5) != 0, 'a program whose executable is in the runtime is stopped'
finally:
if helper.poll() is None:
helper.terminate()
helper.wait(timeout=5)
print('ok - a program whose executable is in the runtime is Hermes and is stopped')
home, runtime, env = setup('unrelated-writer')
sibling = home / '.hermes-other'
+2 -43
View File
@@ -36,9 +36,8 @@ SH
chmod +x "$mock_bin"/*
# $ROOT/bin after the mocks: Remove Preinstalls asks omarchy-install-hermes-cli
# whether the wrapper is Omarchy's rather than matching the marker itself, and
# that is the real command at runtime. The mocks still shadow what they name.
# $ROOT/bin after the mocks, so the real helpers answer wherever a mock does not
# shadow them.
export PATH="$mock_bin:$ROOT/bin:$PATH"
export HOME="$test_home"
export OMARCHY_TEST_PKG_LOG="$pkg_log"
@@ -92,43 +91,3 @@ pass "declining Remove Preinstalls changes nothing"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ -f $marker ]] || fail "Remove Preinstalls records the opt-out"
pass "Remove Preinstalls records the opt-out"
# Hermes' wrapper is only a preinstall when omarchy-install-hermes-cli wrote it.
# The desktop app's command and an official install live at the same path and
# are the user's, whether or not any package says so.
hermes="$test_home/.local/bin/hermes"
mkdir -p "$(dirname "$hermes")"
printf '%s\n' "#!/bin/bash" "# Written by omarchy-install-hermes-cli." >"$hermes"
chmod +x "$hermes"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ ! -e $hermes ]] || fail "Remove Preinstalls deletes the Omarchy Hermes wrapper"
pass "Remove Preinstalls deletes the Omarchy Hermes wrapper"
printf '%s\n' "#!/bin/bash" "exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\"" >"$hermes"
chmod +x "$hermes"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ -x $hermes ]] || fail "Remove Preinstalls keeps the desktop app's Hermes command"
pass "Remove Preinstalls keeps the desktop app's Hermes command"
official_body="#!/bin/bash
unset PYTHONPATH
unset PYTHONHOME
exec $test_home/.hermes/hermes-agent/venv/bin/hermes \"\$@\""
printf '%s\n' "$official_body" >"$hermes"
chmod +x "$hermes"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ -x $hermes && $(cat "$hermes") == "$official_body" ]] || fail "Remove Preinstalls keeps an official Hermes install"
pass "Remove Preinstalls keeps an official Hermes install"
printf '%s\n' "#!/bin/bash" "# Replaces the stub omarchy-install-hermes-cli used to write." >"$hermes"
chmod +x "$hermes"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ -x $hermes ]] || fail "Remove Preinstalls keeps a wrapper that merely mentions the installer"
pass "Remove Preinstalls keeps a wrapper that merely mentions the installer"
rm -f "$hermes"
ln -s "$test_home/nowhere/hermes" "$hermes"
"$ROOT/bin/omarchy-remove-preinstalls" >/dev/null
[[ -L $hermes ]] || fail "Remove Preinstalls keeps a foreign hermes link"
pass "Remove Preinstalls keeps a foreign hermes link"