mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
* feat: uninstall the Chat GUI without removing the agent (CLI + desktop UI) Adds a GUI-only uninstall path so people can remove the desktop Chat GUI while keeping the Hermes agent + their config/sessions/.env, and surfaces the three CLI uninstall modes inside the desktop app's Settings → About. CLI: - New hermes_cli/gui_uninstall.py: cross-platform discovery + removal of the desktop GUI's artifacts (source-built dist/release/node_modules + build stamp, the packaged app bundle, and the Electron userData dir) on Linux, macOS, and Windows. Never touches the agent source, venv, or user data. - `hermes uninstall --gui` removes only the Chat GUI; `--gui-summary` prints a JSON install snapshot (used by the desktop UI to gate options + detect a missing agent for a future lite client). - `hermes uninstall --yes` / `--full --yes` now run non-interactively, sharing the destructive sequence via a new _perform_uninstall() helper. The keep-data and full flows also sweep the GUI artifacts. Desktop: - electron/desktop-uninstall.cjs: pure helpers mapping each mode (gui/lite/full) to CLI flags, resolving the running app bundle per OS, and building the detached cleanup script that waits for the app to exit, runs the Python uninstall, and removes the bundle. - IPC hermes:uninstall:summary / :run, preload bridge, and types. - Settings → About "Danger zone" with the three options; agent-removing options hide when no local agent is detected. Tests: tests/hermes_cli/test_gui_uninstall.py (22 pass with the existing uninstall tests), electron/desktop-uninstall.test.cjs (17 pass, wired into test:desktop:platforms). Docs: desktop.md "Uninstalling" + cli-commands.md. * fix(desktop): tear down backend process tree before GUI uninstall (Windows lock safety) The desktop uninstall cleanup script waited only on the desktop app's own PID, but a backend grandchild (gateway / pty terminal / hermes REPL) can outlive it and keep hermes.exe + venv files mandatory-locked on Windows — making the script's rmdir half-fail and leaving a partial install, the same failure class as the self-update path's #37532. - main.cjs: runDesktopUninstall now awaits releaseBackendLock() before spawning the cleanup script — tree-kills every backend PID the desktop owns (primary + pool) via taskkill /T /F and polls the venv shim until unlocked. Extracted the shared core out of releaseBackendLockForUpdate so both the update hand-off and the uninstaller use the identical, incident-hardened teardown. No-op on macOS/Linux (no mandatory locks). - desktop-uninstall.cjs: Windows cleanup script removes the bundle via a bounded rmdir retry loop (10x, 1s) instead of a single rmdir, since Windows releases directory handles lazily even after the holding process exits. - Dropped a fragile tasklist|findstr reap-by-path attempt; the Electron-side tree-kill-by-PID is the reliable mechanism. Tests: desktop-uninstall.test.cjs updated for the retry-loop output (17 pass). * fix(desktop): address review on GUI uninstall (venv self-delete, gates, wait-loop) Resolves @OutThisLife's review on #40355: 1. full mode now gated on agent presence (needsAgent: true). It removes the agent + user data, so on a lite client with no local agent it's hidden like lite — no more offering to remove an agent that isn't there. 2. (Finding 3, the real bug) lite/full no longer rmtree the venv from the venv's OWN python. On Windows a running python.exe is mandatory-locked, so that half-fails. New lightweight 'python -m hermes_cli.uninstall --mode X' entrypoint (stdlib-only imports) lets the desktop run agent-removing modes under the SYSTEM python (findSystemPython) with PYTHONPATH=<agentRoot>, so import hermes_cli resolves from source while the venv is torn down. Falls back to venv python + logs when no system python (gui-only unaffected). 3. Windows wait-loop is now bounded (60 tries, matching POSIX) and matches the PID as a whole space-delimited token via findstr (no substring 99->990 trap, no redundant bare find). set HERMES_HOME/PID/PYTHONPATH now quoted. 4. Renamed the misleading 'returns null for dev run' test — the dev-run safety is shouldRemoveAppBundle(isPackaged=false), which the test now asserts. Docs: note that --gui on a source checkout also sweeps node_modules/build output. Tests: 18 python + 19 desktop pass.
246 lines
9.4 KiB
JavaScript
246 lines
9.4 KiB
JavaScript
/**
|
|
* Tests for electron/desktop-uninstall.cjs.
|
|
*
|
|
* Run with: node --test electron/desktop-uninstall.test.cjs
|
|
* (Wired into npm test:desktop:platforms in package.json.)
|
|
*
|
|
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
|
|
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
|
|
* cleanup-script builders (POSIX + Windows).
|
|
*/
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const {
|
|
UNINSTALL_MODES,
|
|
buildPosixCleanupScript,
|
|
buildWindowsCleanupScript,
|
|
modeRemovesAgent,
|
|
modeRemovesUserData,
|
|
resolveRemovableAppPath,
|
|
shouldRemoveAppBundle,
|
|
uninstallArgsForMode
|
|
} = require('./desktop-uninstall.cjs')
|
|
|
|
// --- uninstallArgsForMode ---
|
|
|
|
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
|
|
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
|
|
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
|
|
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
|
|
})
|
|
|
|
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
|
|
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
|
|
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
|
|
})
|
|
|
|
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
|
|
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
|
|
})
|
|
|
|
// --- modeRemovesAgent / modeRemovesUserData ---
|
|
|
|
test('mode predicates classify what each mode removes', () => {
|
|
assert.equal(modeRemovesAgent('gui'), false)
|
|
assert.equal(modeRemovesAgent('lite'), true)
|
|
assert.equal(modeRemovesAgent('full'), true)
|
|
|
|
assert.equal(modeRemovesUserData('gui'), false)
|
|
assert.equal(modeRemovesUserData('lite'), false)
|
|
assert.equal(modeRemovesUserData('full'), true)
|
|
})
|
|
|
|
// --- resolveRemovableAppPath ---
|
|
|
|
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
|
|
assert.equal(
|
|
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
|
'/Applications/Hermes.app'
|
|
)
|
|
assert.equal(
|
|
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
|
'/Users/x/Applications/Hermes.app'
|
|
)
|
|
})
|
|
|
|
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
|
|
// A dev run from node_modules' Electron DOES resolve to a .app — the real
|
|
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
|
|
// null return here. This test documents that contract.
|
|
assert.equal(
|
|
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
|
|
'/repo/node_modules/electron/dist/Electron.app'
|
|
)
|
|
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
|
|
// A bare path with no .app ancestor → null.
|
|
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
|
|
})
|
|
|
|
test('resolveRemovableAppPath finds the install dir on Windows', () => {
|
|
assert.equal(
|
|
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
|
|
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
|
|
)
|
|
assert.equal(
|
|
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
|
|
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
|
|
)
|
|
})
|
|
|
|
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
|
|
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
|
|
})
|
|
|
|
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
|
|
assert.equal(
|
|
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
|
|
'/home/x/Apps/Hermes.AppImage'
|
|
)
|
|
})
|
|
|
|
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
|
|
assert.equal(
|
|
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
|
|
'/opt/hermes/linux-unpacked'
|
|
)
|
|
// A system-package install (/usr/bin) → null, left to apt/dnf.
|
|
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
|
|
})
|
|
|
|
test('resolveRemovableAppPath returns null for an empty exe path', () => {
|
|
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
|
|
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
|
|
})
|
|
|
|
// --- shouldRemoveAppBundle ---
|
|
|
|
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
|
|
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
|
|
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
|
|
assert.equal(shouldRemoveAppBundle(true, null), false)
|
|
assert.equal(shouldRemoveAppBundle(false, null), false)
|
|
})
|
|
|
|
// --- buildPosixCleanupScript ---
|
|
|
|
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
|
|
const script = buildPosixCleanupScript({
|
|
desktopPid: 4321,
|
|
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
|
|
pythonPath: null,
|
|
agentRoot: '/home/x/.hermes/hermes-agent',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
|
appPath: '/opt/hermes/linux-unpacked',
|
|
hermesHome: '/home/x/.hermes'
|
|
})
|
|
assert.match(script, /^#!\/bin\/bash/)
|
|
assert.match(script, /pid=4321/)
|
|
assert.match(script, /kill -0 "\$pid"/)
|
|
// bounded wait (~30s), not unbounded
|
|
assert.match(script, /seq 1 60/)
|
|
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
|
|
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
|
|
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
|
|
})
|
|
|
|
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
|
|
const script = buildPosixCleanupScript({
|
|
desktopPid: 1,
|
|
pythonExe: '/usr/bin/python3',
|
|
pythonPath: '/home/x/.hermes/hermes-agent',
|
|
agentRoot: '/home/x/.hermes/hermes-agent',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
|
appPath: null,
|
|
hermesHome: '/home/x/.hermes'
|
|
})
|
|
// System python + source on PYTHONPATH so import hermes_cli works while the
|
|
// venv is torn down.
|
|
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
|
|
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
|
|
})
|
|
|
|
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
|
|
const script = buildPosixCleanupScript({
|
|
desktopPid: 1,
|
|
pythonExe: '/p/python',
|
|
pythonPath: null,
|
|
agentRoot: '/a',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
|
appPath: null,
|
|
hermesHome: '/h'
|
|
})
|
|
assert.doesNotMatch(script, /export PYTHONPATH/)
|
|
})
|
|
|
|
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
|
|
const script = buildPosixCleanupScript({
|
|
desktopPid: 1,
|
|
pythonExe: '/p/python',
|
|
pythonPath: null,
|
|
agentRoot: '/a',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
|
|
appPath: null,
|
|
hermesHome: '/h'
|
|
})
|
|
assert.doesNotMatch(script, /rm -rf '\//)
|
|
// Still runs the uninstall.
|
|
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
|
|
})
|
|
|
|
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
|
|
const script = buildPosixCleanupScript({
|
|
desktopPid: 1,
|
|
pythonExe: "/home/o'brien/python",
|
|
pythonPath: null,
|
|
agentRoot: '/a',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
|
appPath: null,
|
|
hermesHome: '/h'
|
|
})
|
|
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
|
|
assert.match(script, /'\/home\/o'\\''brien\/python'/)
|
|
})
|
|
|
|
// --- buildWindowsCleanupScript ---
|
|
|
|
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
|
|
const script = buildWindowsCleanupScript({
|
|
desktopPid: 9988,
|
|
pythonExe: 'C:\\Python313\\python.exe',
|
|
pythonPath: 'C:\\hermes',
|
|
agentRoot: 'C:\\hermes',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
|
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
|
|
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
|
|
})
|
|
assert.match(script, /@echo off/)
|
|
assert.match(script, /set "PID=9988"/)
|
|
// PYTHONPATH set so a system python can import hermes_cli from source.
|
|
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
|
|
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
|
|
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
|
|
assert.match(script, /if %waited% geq 60 goto waited_done/)
|
|
assert.match(script, /findstr \/r \/c:" %PID% "/)
|
|
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
|
|
// Removal is a retry loop (Windows releases dir handles lazily).
|
|
assert.match(script, /:rmloop/)
|
|
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
|
|
assert.match(script, /if %tries% geq 10 goto rmdone/)
|
|
assert.match(script, /del "%~f0"/)
|
|
})
|
|
|
|
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
|
|
const script = buildWindowsCleanupScript({
|
|
desktopPid: 2,
|
|
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
|
|
pythonPath: null,
|
|
agentRoot: 'C:\\h',
|
|
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
|
appPath: null,
|
|
hermesHome: 'C:\\h'
|
|
})
|
|
assert.doesNotMatch(script, /rmdir/)
|
|
assert.doesNotMatch(script, /set "PYTHONPATH=/)
|
|
})
|