feat(update): posix hand-off orchestrator (mac/linux quit-first updates)
scripts/desktop-update/posix.sh is the mac/linux twin of windows.ps1: the Desktop spawns it detached and QUITS; it waits the app out, runs plain hermes update (retry-once across the update boundary, truthful desktop-rebuild completion), swaps/relaunches the .app bundle (mac) or the release/*-unpacked binary when its sandbox helper is launchable (linux), writes .hermes-update-result.json, and drives the same shim. Repo-owned, so every update refreshes the code that drives the next one. resolvePosixScriptHandoff mirrors the Windows resolver (with the flat-path fallback covering the scripts/ reorg skew).
This commit is contained in:
parent
503e61b3b1
commit
c991e3f62f
|
|
@ -170,7 +170,7 @@ test('resolveStagedUpdaterBinary returns null on Windows when nothing is staged'
|
|||
|
||||
test('resolveUpdateScriptHandoff prefers the repo script on Windows when present', () => {
|
||||
const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`
|
||||
const expected = path.join(root, 'scripts', 'desktop-update.ps1')
|
||||
const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1')
|
||||
|
||||
const handoff = resolveUpdateScriptHandoff(root, {
|
||||
isWindows: true,
|
||||
|
|
@ -183,6 +183,19 @@ test('resolveUpdateScriptHandoff prefers the repo script on Windows when present
|
|||
assert.deepEqual(handoff.args, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', expected])
|
||||
})
|
||||
|
||||
test('resolveUpdateScriptHandoff falls back to the pre-reorg flat path', () => {
|
||||
const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`
|
||||
const legacy = path.join(root, 'scripts', 'desktop-update.ps1')
|
||||
|
||||
const handoff = resolveUpdateScriptHandoff(root, {
|
||||
isWindows: true,
|
||||
fileExists: candidate => candidate === legacy
|
||||
})
|
||||
|
||||
assert.ok(handoff)
|
||||
assert.equal(handoff.scriptPath, legacy)
|
||||
})
|
||||
|
||||
test('resolveUpdateScriptHandoff returns null when the checkout predates the script', () => {
|
||||
const handoff = resolveUpdateScriptHandoff(String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`, {
|
||||
isWindows: true,
|
||||
|
|
@ -203,7 +216,7 @@ test('resolveUpdateScriptHandoff is Windows-only (POSIX updates in place)', () =
|
|||
|
||||
test('wrapHandoffForDetachedConsole routes through cmd start with own console', () => {
|
||||
const root = String.raw`C:\Users\hermes\AppData\Local\hermes\hermes-agent`
|
||||
const expected = path.join(root, 'scripts', 'desktop-update.ps1')
|
||||
const expected = path.join(root, 'scripts', 'desktop-update', 'windows.ps1')
|
||||
|
||||
const handoff = resolveUpdateScriptHandoff(root, {
|
||||
isWindows: true,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export interface UpdateScriptHandoff {
|
|||
* updater-side fix only reaches users when a new binary is built, signed and
|
||||
* published — which historically lags main by months and strands users on
|
||||
* long-fixed bugs (cache resolver #67369, marker self-adopt #74782; the
|
||||
* 2026-08-09 incident chain). `scripts/desktop-update.ps1` lives in the repo
|
||||
* 2026-08-09 incident chain). `scripts/desktop-update/windows.ps1` lives in the repo
|
||||
* checkout instead: every `hermes update` refreshes the code that drives the
|
||||
* NEXT update, and only PowerShell itself is frozen.
|
||||
*
|
||||
|
|
@ -47,7 +47,50 @@ export function resolveUpdateScriptHandoff(
|
|||
return null
|
||||
}
|
||||
|
||||
const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update.ps1')
|
||||
const exists = deps.fileExists ?? stagedFileExists
|
||||
|
||||
// Current layout first, then the pre-reorg flat path — an updated asar can
|
||||
// meet a checkout from either side of the move (the checkout also ships a
|
||||
// forwarder at the legacy path for the inverse skew).
|
||||
for (const candidate of [
|
||||
path.join(updateRoot, 'scripts', 'desktop-update', 'windows.ps1'),
|
||||
path.join(updateRoot, 'scripts', 'desktop-update.ps1')
|
||||
]) {
|
||||
if (exists(candidate)) {
|
||||
return {
|
||||
command: 'powershell',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', candidate],
|
||||
scriptPath: candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo-owned POSIX update hand-off (the mac/linux twin of the above).
|
||||
*
|
||||
* Replaces the in-app posix updater: the Desktop spawns the script detached
|
||||
* and QUITS, the script waits it out, runs `hermes update`, swaps/relaunches
|
||||
* the app, and writes .hermes-update-result.json. With the app gone before
|
||||
* the update starts, the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance is
|
||||
* unnecessary — there are no live desktop backends to spare.
|
||||
*
|
||||
* Null when the checkout predates the script (caller surfaces the manual
|
||||
* `hermes update` card — old checkouts pull the script on their next update).
|
||||
*/
|
||||
export function resolvePosixScriptHandoff(
|
||||
updateRoot: string,
|
||||
deps: ResolveUpdateScriptHandoffDeps = {}
|
||||
): UpdateScriptHandoff | null {
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
|
||||
if (isWindows) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptPath = path.join(updateRoot, 'scripts', 'desktop-update', 'posix.sh')
|
||||
const exists = deps.fileExists ?? stagedFileExists
|
||||
|
||||
if (!exists(scriptPath)) {
|
||||
|
|
@ -55,8 +98,8 @@ export function resolveUpdateScriptHandoff(
|
|||
}
|
||||
|
||||
return {
|
||||
command: 'powershell',
|
||||
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath],
|
||||
command: '/bin/bash',
|
||||
args: [scriptPath],
|
||||
scriptPath
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
#!/bin/bash
|
||||
# posix.sh -- repo-owned macOS/Linux Desktop update hand-off.
|
||||
#
|
||||
# The whole job: wait for the Desktop to exit, run `hermes update`, tell the
|
||||
# shim how it went, reopen the app. The Desktop spawns this detached and
|
||||
# quits; because it lives in the checkout, every update refreshes the code
|
||||
# that drives the next one. Replaces the in-app updater
|
||||
# (applyUpdatesPosixInApp) -- with the app gone before the update starts,
|
||||
# the HERMES_DESKTOP_CHILD_PID reaper-exclusion dance dies with it.
|
||||
#
|
||||
# CONTRACT (keep in sync with apps/desktop/electron/main.ts):
|
||||
# bash scripts/desktop-update/posix.sh
|
||||
# --install-root <path> repo checkout (HERMES_HOME/hermes-agent)
|
||||
# --branch <ref> branch to update against
|
||||
# --desktop-pid <pid> the Electron main process to wait out
|
||||
# [--relaunch-target <p>] mac: running .app to swap+reopen;
|
||||
# linux: running binary (omit = no relaunch)
|
||||
# [--no-ui] [--no-marker-cleanup] [--self-test-ui]
|
||||
#
|
||||
# The shim (ui.html in a chromeless browser app window) is decoration: it
|
||||
# polls /progress for `done` or `error` and reacts. It owns nothing --
|
||||
# relaunch, result file, marker hygiene all happen here, identically, when
|
||||
# no renderer exists. No chromium-family browser found = no UI, fine.
|
||||
|
||||
set -u
|
||||
|
||||
INSTALL_ROOT="" BRANCH="main" DESKTOP_PID=0 RELAUNCH_TARGET=""
|
||||
NO_UI=0 NO_MARKER_CLEANUP=0 SELF_TEST_UI=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--install-root) INSTALL_ROOT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
--desktop-pid) DESKTOP_PID="$2"; shift 2 ;;
|
||||
--relaunch-target) RELAUNCH_TARGET="$2"; shift 2 ;;
|
||||
--no-ui) NO_UI=1; shift ;;
|
||||
--no-marker-cleanup) NO_MARKER_CLEANUP=1; shift ;;
|
||||
--self-test-ui) SELF_TEST_UI=1; shift ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
[ "$SELF_TEST_UI" -eq 1 ] || [ -n "$INSTALL_ROOT" ] || { echo "--install-root is required" >&2; exit 64; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HERMES_HOME="${INSTALL_ROOT:+$(dirname "$INSTALL_ROOT")}"
|
||||
HERMES_HOME="${HERMES_HOME:-${TMPDIR:-/tmp}}"
|
||||
MARKER="$HERMES_HOME/.hermes-update-in-progress"
|
||||
LOG_DIR="$HERMES_HOME/logs"; mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
LOG="$LOG_DIR/desktop-update-handoff.log"
|
||||
RESULT="$HERMES_HOME/.hermes-update-result.json"
|
||||
STATUS="${TMPDIR:-/tmp}/hermes-update-status.$$"
|
||||
|
||||
UI_SERVER_PID="" UI_BROWSER_PID="" FINAL_CODE=1
|
||||
FINAL_MSG="update did not complete"
|
||||
|
||||
log() { echo "$(date +%Y-%m-%dT%H:%M:%S%z) $1" | tee -a "$LOG" 2>/dev/null; }
|
||||
|
||||
# ── shim ────────────────────────────────────────────────────────────────────
|
||||
publish() { # status message -- atomic replace; the server reads per poll
|
||||
printf '{"status":"%s","message":"%s"}' "$1" "$2" > "$STATUS.tmp" && mv -f "$STATUS.tmp" "$STATUS" 2>/dev/null || true
|
||||
[ -n "$UI_SERVER_PID" ] && sleep 1 # one poll beat to render the state
|
||||
}
|
||||
|
||||
find_browser() {
|
||||
local c
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" \
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium" \
|
||||
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"; do
|
||||
[ -x "$c" ] && { echo "$c"; return; }
|
||||
done
|
||||
else
|
||||
for c in google-chrome google-chrome-stable chromium chromium-browser microsoft-edge brave-browser; do
|
||||
command -v "$c" 2>/dev/null && return
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
start_ui() {
|
||||
[ "$NO_UI" -eq 1 ] && return
|
||||
local html="$SCRIPT_DIR/ui.html" py browser port="" i
|
||||
py="${INSTALL_ROOT:+$INSTALL_ROOT/venv/bin/python3}"
|
||||
[ -x "${py:-/nonexistent}" ] || py="$(command -v python3 2>/dev/null)"
|
||||
browser="$(find_browser)"
|
||||
{ [ -f "$html" ] && [ -n "$py" ] && [ -n "$browser" ]; } || { log "shim: no renderer; skipping UI"; return; }
|
||||
|
||||
publish "running" ""
|
||||
"$py" "$SCRIPT_DIR/serve-ui.py" "$html" "$STATUS" > "$LOG_DIR/desktop-update-ui-port" 2>>"$LOG" &
|
||||
UI_SERVER_PID=$!
|
||||
for i in $(seq 1 10); do
|
||||
port="$(tr -cd '0-9' < "$LOG_DIR/desktop-update-ui-port" 2>/dev/null)"
|
||||
[ -n "$port" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
[ -n "$port" ] || { kill "$UI_SERVER_PID" 2>/dev/null; UI_SERVER_PID=""; return; }
|
||||
|
||||
# Throwaway profile: new window/process we own; user's browser untouched.
|
||||
"$browser" --app="http://127.0.0.1:$port/" --user-data-dir="${TMPDIR:-/tmp}/hermes-update-ui-$$" \
|
||||
--no-first-run --no-default-browser-check --window-size=280,320 >/dev/null 2>&1 &
|
||||
UI_BROWSER_PID=$!
|
||||
log "shim: app window on 127.0.0.1:$port"
|
||||
}
|
||||
|
||||
stop_ui() { # error state leaves the window up for the user to read
|
||||
if [ -n "$UI_SERVER_PID" ]; then
|
||||
{ kill "$UI_SERVER_PID" && wait "$UI_SERVER_PID"; } 2>/dev/null
|
||||
fi
|
||||
if [ "${1:-}" != "leave-window" ] && [ -n "$UI_BROWSER_PID" ]; then
|
||||
{ kill "$UI_BROWSER_PID" && wait "$UI_BROWSER_PID"; } 2>/dev/null
|
||||
fi
|
||||
UI_SERVER_PID="" UI_BROWSER_PID=""
|
||||
}
|
||||
|
||||
# ── relaunch ────────────────────────────────────────────────────────────────
|
||||
relaunch() {
|
||||
[ -n "$RELAUNCH_TARGET" ] || return 0
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
# Swap the rebuilt bundle over the running one when both resolve, then
|
||||
# `open` (fully detached). POSIX doesn't lock running executables.
|
||||
local rebuilt="" c
|
||||
for c in "$INSTALL_ROOT/apps/desktop/release/mac-arm64/Hermes.app" \
|
||||
"$INSTALL_ROOT/apps/desktop/release/mac/Hermes.app"; do
|
||||
[ -d "$c" ] && { rebuilt="$c"; break; }
|
||||
done
|
||||
if [ -n "$rebuilt" ] && [ -d "$RELAUNCH_TARGET" ] && [ "$rebuilt" != "$RELAUNCH_TARGET" ]; then
|
||||
if /usr/bin/ditto "$rebuilt" "$RELAUNCH_TARGET.new"; then
|
||||
mv "$RELAUNCH_TARGET" "$RELAUNCH_TARGET.old" 2>/dev/null || rm -rf "$RELAUNCH_TARGET"
|
||||
mv "$RELAUNCH_TARGET.new" "$RELAUNCH_TARGET"
|
||||
rm -rf "$RELAUNCH_TARGET.old" 2>/dev/null || true
|
||||
log "swapped app bundle"
|
||||
else
|
||||
rm -rf "$RELAUNCH_TARGET.new" 2>/dev/null || true
|
||||
log "WARNING: bundle copy failed; relaunching existing app"
|
||||
fi
|
||||
fi
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$RELAUNCH_TARGET" 2>/dev/null || true
|
||||
/usr/bin/open "$RELAUNCH_TARGET" || log "WARNING: relaunch failed"
|
||||
else
|
||||
# Linux: only relaunch a binary the rebuild actually replaced, with a
|
||||
# launchable sandbox helper -- otherwise say so instead of lying (#37541).
|
||||
case "$RELAUNCH_TARGET" in
|
||||
*/release/*-unpacked/*)
|
||||
if [ -u "$(dirname "$RELAUNCH_TARGET")/chrome-sandbox" ] || [ -n "${HERMES_DESKTOP_NO_SANDBOX:-}" ]; then
|
||||
(setsid "$RELAUNCH_TARGET" >/dev/null 2>&1 &) || log "WARNING: relaunch failed"
|
||||
else
|
||||
FINAL_MSG="Update complete. Reopen Hermes to finish (the app could not restart itself)."
|
||||
fi ;;
|
||||
*)
|
||||
FINAL_MSG="Backend updated, but the desktop app package (AppImage/deb/rpm) was not changed. Update it to match." ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
finish() {
|
||||
printf '{"ok":%s,"exit_code":%s,"message":"%s","branch":"%s","finished_at":%s}' \
|
||||
"$([ "$FINAL_CODE" -eq 0 ] && echo true || echo false)" "$FINAL_CODE" "$FINAL_MSG" "$BRANCH" "$(date +%s)" \
|
||||
> "$RESULT" 2>/dev/null || true
|
||||
if [ "$NO_MARKER_CLEANUP" -eq 0 ] && [ "$(head -1 "$MARKER" 2>/dev/null | tr -d '[:space:]')" = "$$" ]; then
|
||||
rm -f "$MARKER" 2>/dev/null || true
|
||||
fi
|
||||
if [ "$FINAL_CODE" -eq 0 ]; then publish "done" ""; stop_ui
|
||||
else publish "error" "$FINAL_MSG"; stop_ui leave-window; fi
|
||||
relaunch
|
||||
rm -f "$STATUS" "$STATUS.tmp" "$LOG_DIR/desktop-update-ui-port" 2>/dev/null || true
|
||||
}
|
||||
trap finish EXIT
|
||||
|
||||
# ── self-test: shim only, no update, touches nothing ───────────────────────
|
||||
if [ "$SELF_TEST_UI" -eq 1 ]; then
|
||||
start_ui
|
||||
log "SELF-TEST: shim simulation (no update will run)"
|
||||
sleep "${HERMES_SELFTEST_HOLD_SECONDS:-6}"
|
||||
RELAUNCH_TARGET=""
|
||||
if [ -n "${HERMES_SELFTEST_FAIL:-}" ]; then FINAL_MSG="self-test error state"
|
||||
else FINAL_CODE=0 FINAL_MSG="self-test complete"; fi
|
||||
exit "$FINAL_CODE"
|
||||
fi
|
||||
|
||||
# ── the actual job ──────────────────────────────────────────────────────────
|
||||
log "hand-off start: root=$INSTALL_ROOT branch=$BRANCH desktopPid=$DESKTOP_PID pid=$$"
|
||||
rm -f "$RESULT" 2>/dev/null || true
|
||||
start_ui
|
||||
|
||||
# Marker claim: same cross-process lock contract as windows.ps1 /
|
||||
# update_lock.py (the `hermes update` child adopts it via process ancestry).
|
||||
printf '%s\n%s\n' "$$" "$(date +%s)" > "$MARKER" 2>/dev/null || log "WARNING: could not write update marker"
|
||||
|
||||
# Wait out the Desktop (FAIL CLOSED: updating under live backends bricks).
|
||||
if [ "$DESKTOP_PID" -gt 0 ] 2>/dev/null; then
|
||||
for _ in $(seq 1 100); do kill -0 "$DESKTOP_PID" 2>/dev/null || break; sleep 0.3; done
|
||||
if kill -0 "$DESKTOP_PID" 2>/dev/null; then
|
||||
FINAL_CODE=4 FINAL_MSG="Update aborted: the Hermes window (pid $DESKTOP_PID) did not exit within 30s. Nothing was changed. Close Hermes fully and try again."
|
||||
log "$FINAL_MSG"; exit "$FINAL_CODE"
|
||||
fi
|
||||
fi
|
||||
|
||||
HERMES_BIN="$INSTALL_ROOT/venv/bin/hermes"
|
||||
[ -x "$HERMES_BIN" ] || { FINAL_CODE=3 FINAL_MSG="Update aborted: $HERMES_BIN is missing. The install needs repair (run the Hermes installer or hermes doctor)."; log "$FINAL_MSG"; exit 3; }
|
||||
|
||||
export PYTHONUNBUFFERED=1
|
||||
log "running: hermes update --yes --gateway --branch $BRANCH"
|
||||
OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$?
|
||||
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
|
||||
log "hermes update exit code: $CODE"
|
||||
|
||||
if [ "$CODE" -ne 0 ] && [ "$CODE" -ne 2 ]; then
|
||||
# Retry once: update-boundary class (fresh code on disk, stale in memory).
|
||||
# Exit 2 ("close all Hermes windows") is not retryable.
|
||||
log "retrying once (freshly pulled fix loads on the second run)"
|
||||
OUT="$("$HERMES_BIN" update --yes --gateway --branch "$BRANCH" 2>&1)"; CODE=$?
|
||||
printf '%s\n' "$OUT" >> "$LOG" 2>/dev/null
|
||||
log "retry exit code: $CODE"
|
||||
fi
|
||||
|
||||
# Truthful completion: `hermes update` calls a GUI build failure non-fatal
|
||||
# (exit 0). For a Desktop-driven update that would relaunch the OLD build
|
||||
# and call it success -- retry the build once, propagate honestly.
|
||||
if [ "$CODE" -eq 0 ] && printf '%s' "$OUT" | grep -q "Desktop build failed"; then
|
||||
log "desktop build failed inside hermes update; retrying build"
|
||||
"$HERMES_BIN" desktop --force-build --build-only >> "$LOG" 2>&1 || {
|
||||
FINAL_CODE=6 FINAL_MSG="Code and dependencies updated, but the Desktop app rebuild failed - you are running the previous build. Run hermes desktop --force-build from a terminal to retry."
|
||||
exit 6
|
||||
}
|
||||
fi
|
||||
|
||||
if [ "$CODE" -eq 0 ]; then FINAL_CODE=0 FINAL_MSG="Update complete."
|
||||
else FINAL_CODE="$CODE" FINAL_MSG="Update failed (exit $CODE). Run hermes debug share in a terminal to send a report."; fi
|
||||
exit "$FINAL_CODE"
|
||||
Loading…
Reference in New Issue