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) (#40355)
* 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.
This commit is contained in:
parent
f2e8234307
commit
5b43bf7d02
14 changed files with 1784 additions and 10 deletions
232
apps/desktop/electron/desktop-uninstall.cjs
Normal file
232
apps/desktop/electron/desktop-uninstall.cjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* desktop-uninstall.cjs
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
|
||||
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
|
||||
* resolve the running app bundle/exe so a detached cleanup script can remove
|
||||
* it after the app quits, and build that cleanup script for each OS.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* The three modes mirror the CLI's options exactly:
|
||||
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
|
||||
* `hermes uninstall --gui --yes`
|
||||
* - 'lite' → remove the GUI + agent code, KEEP user data (config / sessions
|
||||
* / .env) for a future reinstall. `hermes uninstall --yes`
|
||||
* - 'full' → remove everything: GUI + agent + all user data.
|
||||
* `hermes uninstall --full --yes`
|
||||
*
|
||||
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
|
||||
* `hermes` command runs from, and every mode may need to delete the running
|
||||
* app bundle (locked on macOS/Windows while the process is alive). So we hand
|
||||
* the work to a detached child that waits for this app's PID to exit, runs the
|
||||
* Python uninstall, then removes the app bundle — then the app quits. Same
|
||||
* shape as the self-update swap-and-relaunch flow already in main.cjs.
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
|
||||
/**
|
||||
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
|
||||
* python executable). Uses the dedicated lightweight module entrypoint (not
|
||||
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
|
||||
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
|
||||
* Throws on an unknown mode so a typo can't silently become a full wipe.
|
||||
*/
|
||||
function uninstallArgsForMode(mode) {
|
||||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
/** True when `mode` removes the agent (lite/full), false for gui-only. */
|
||||
function modeRemovesAgent(mode) {
|
||||
return mode === 'lite' || mode === 'full'
|
||||
}
|
||||
|
||||
/** True when `mode` removes user data (full only). */
|
||||
function modeRemovesUserData(mode) {
|
||||
return mode === 'full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
|
||||
* given the path to the running executable (`process.execPath`) and platform.
|
||||
*
|
||||
* macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Hermes.app
|
||||
* Windows: …\Hermes\Hermes.exe → …\Hermes (install dir)
|
||||
* Linux: AppImage → the APPIMAGE env path; unpacked → the *-unpacked dir
|
||||
*
|
||||
* Returns null when we can't confidently identify a removable bundle (e.g.
|
||||
* running from a dev checkout, or a system-package install we must not rmtree).
|
||||
*/
|
||||
function resolveRemovableAppPath(execPath, platform, env = {}) {
|
||||
const exe = String(execPath || '')
|
||||
if (!exe) return null
|
||||
|
||||
// Use the path flavor that matches the TARGET platform, not the host running
|
||||
// this code — so the Windows branch parses backslash paths correctly even
|
||||
// when these pure helpers are unit-tested on Linux/macOS CI.
|
||||
const p = platform === 'win32' ? path.win32 : path.posix
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
|
||||
const macOsDir = p.dirname(exe) // …/Contents/MacOS
|
||||
const contents = p.dirname(macOsDir) // …/Contents
|
||||
const appBundle = p.dirname(contents) // …/Hermes.app
|
||||
if (appBundle.endsWith('.app')) return appBundle
|
||||
return null
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NSIS per-user installs Hermes.exe directly in the install dir.
|
||||
const dir = p.dirname(exe)
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
|
||||
return null
|
||||
}
|
||||
|
||||
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
|
||||
if (env.APPIMAGE) return env.APPIMAGE
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
if (/-unpacked$/.test(dir)) return dir
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we even try to remove the running app bundle from a cleanup script?
|
||||
* Only when packaged AND we resolved a concrete removable path. Dev runs
|
||||
* (electron from node_modules) and system-package installs return null above
|
||||
* and are left to the OS package manager.
|
||||
*/
|
||||
function shouldRemoveAppBundle(isPackaged, appPath) {
|
||||
return Boolean(isPackaged) && Boolean(appPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a POSIX cleanup shell script (macOS / Linux). It:
|
||||
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
|
||||
* 2. runs the Python uninstall module with the mode,
|
||||
* 3. removes the app bundle if one was resolved.
|
||||
*
|
||||
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
|
||||
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
|
||||
* resolves from the agent source. `q()` single-quote-escapes for the shell
|
||||
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
|
||||
*/
|
||||
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'set -u',
|
||||
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
|
||||
'# and the app bundle are no longer in use.',
|
||||
`pid=${Number(desktopPid) || 0}`,
|
||||
'if [ "$pid" -gt 0 ]; then',
|
||||
' for _ in $(seq 1 60); do',
|
||||
' kill -0 "$pid" 2>/dev/null || break',
|
||||
' sleep 0.5',
|
||||
' done',
|
||||
'fi',
|
||||
`export HERMES_HOME=${q(hermesHome)}`
|
||||
]
|
||||
if (pythonPath) {
|
||||
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
|
||||
}
|
||||
lines.push(
|
||||
`cd ${q(agentRoot)} 2>/dev/null || true`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
|
||||
)
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
// Self-delete the script.
|
||||
lines.push('rm -f "$0" 2>/dev/null || true')
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
|
||||
*
|
||||
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
|
||||
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
|
||||
* Windows, so running the uninstall from the venv's OWN python half-fails. The
|
||||
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
|
||||
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
|
||||
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
|
||||
* either interpreter.
|
||||
*
|
||||
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
|
||||
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
|
||||
* match, so no redundant `| find` (which would substring-match 99→990).
|
||||
*
|
||||
* Removal: even after the desktop PID is gone, Windows releases directory
|
||||
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
|
||||
*/
|
||||
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const pid = Number(desktopPid) || 0
|
||||
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
|
||||
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
|
||||
// a problem, but Hermes install paths don't use them.
|
||||
const q = s => `"${String(s).replace(/"/g, '')}"`
|
||||
const lines = [
|
||||
'@echo off',
|
||||
'setlocal enableextensions',
|
||||
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
|
||||
`set "PID=${pid}"`
|
||||
]
|
||||
if (pythonPath) {
|
||||
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
|
||||
}
|
||||
lines.push(
|
||||
'set /a waited=0',
|
||||
':waitloop',
|
||||
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
|
||||
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
|
||||
'rem header; findstr matches the PID as a whole space-delimited token so',
|
||||
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
|
||||
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
|
||||
'if %ERRORLEVEL% neq 0 goto waited_done',
|
||||
'set /a waited+=1',
|
||||
'if %waited% geq 60 goto waited_done',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto waitloop',
|
||||
':waited_done',
|
||||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
':rmloop',
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
'set /a tries+=1',
|
||||
'if %tries% geq 10 goto rmdone',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto rmloop',
|
||||
':rmdone'
|
||||
)
|
||||
}
|
||||
lines.push('del "%~f0"')
|
||||
lines.push('')
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UNINSTALL_MODES,
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
uninstallArgsForMode
|
||||
}
|
||||
246
apps/desktop/electron/desktop-uninstall.test.cjs
Normal file
246
apps/desktop/electron/desktop-uninstall.test.cjs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* 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=/)
|
||||
})
|
||||
|
|
@ -29,6 +29,15 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
|
|||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
uninstallArgsForMode
|
||||
} = require('./desktop-uninstall.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
|
|
@ -1488,6 +1497,20 @@ function forceKillProcessTree(pid) {
|
|||
// aggressively SIGKILL-ing the backend here would be an untested behavior change
|
||||
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
|
||||
async function releaseBackendLockForUpdate(updateRoot) {
|
||||
return releaseBackendLock(updateRoot, 'updates')
|
||||
}
|
||||
|
||||
// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update
|
||||
// hand-off and the desktop uninstaller — they have the identical Windows
|
||||
// problem: the desktop's backend (and the grandchildren IT spawned — a hermes
|
||||
// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the
|
||||
// venv mandatory-locked, so any in-place replace/delete of the install tree
|
||||
// races a live handle and half-fails (#37532). We tree-kill every backend PID
|
||||
// the desktop owns, then poll the shim until it's genuinely writable.
|
||||
//
|
||||
// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory
|
||||
// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice).
|
||||
async function releaseBackendLock(updateRoot, tag) {
|
||||
if (!IS_WINDOWS) return { unlocked: true }
|
||||
|
||||
// Collect every backend PID the desktop owns: primary window backend + pool.
|
||||
|
|
@ -1512,14 +1535,12 @@ async function releaseBackendLockForUpdate(updateRoot) {
|
|||
const deadlineMs = Date.now() + 15000
|
||||
while (Date.now() < deadlineMs) {
|
||||
if (!isShimLocked(shim)) {
|
||||
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
|
||||
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
|
||||
return { unlocked: true }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
}
|
||||
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
|
||||
// line of defense, and we pass --force so the guard won't dead-end. Log it.
|
||||
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
|
||||
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
|
||||
return { unlocked: false }
|
||||
}
|
||||
|
||||
|
|
@ -5473,6 +5494,199 @@ ipcMain.handle('hermes:version', async () => ({
|
|||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
|
||||
// ===========================================================================
|
||||
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
|
||||
// ===========================================================================
|
||||
//
|
||||
// The renderer's About → Danger Zone surfaces three options that mirror the
|
||||
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
|
||||
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
|
||||
// registry / service / node-symlink cleanup all lives in one place
|
||||
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
|
||||
//
|
||||
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
|
||||
// JSON probe) so the UI can gate options on what's actually installed — and
|
||||
// detect a missing agent (a future "lite client" that ships without the
|
||||
// bundled agent), hiding the agent/full options when there's nothing to remove.
|
||||
|
||||
function uninstallVenvPython() {
|
||||
return getVenvPython(VENV_ROOT)
|
||||
}
|
||||
|
||||
async function getUninstallSummary() {
|
||||
const py = uninstallVenvPython()
|
||||
const agentRoot = ACTIVE_HERMES_ROOT
|
||||
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
|
||||
// probe fails — the renderer still needs *something* to render options from.
|
||||
const fallback = () => ({
|
||||
hermes_home: HERMES_HOME,
|
||||
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
|
||||
gui_installed: true,
|
||||
source_built_artifacts: [],
|
||||
packaged_app_paths: [],
|
||||
userdata_dir: app.getPath('userData'),
|
||||
userdata_exists: true,
|
||||
platform: process.platform,
|
||||
probe: 'fallback'
|
||||
})
|
||||
|
||||
if (!fileExists(py)) {
|
||||
return fallback()
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let stdout = ''
|
||||
let settled = false
|
||||
const done = value => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
child.on('error', () => done(fallback()))
|
||||
child.on('exit', code => {
|
||||
if (code !== 0) return done(fallback())
|
||||
try {
|
||||
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
|
||||
const parsed = JSON.parse(line)
|
||||
// The app bundle the renderer would be removing on *this* machine,
|
||||
// resolved from the running exe (the Python probe only knows the
|
||||
// standard locations, not where THIS build actually runs from).
|
||||
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
||||
done(parsed)
|
||||
} catch {
|
||||
done(fallback())
|
||||
}
|
||||
})
|
||||
setTimeout(() => done(fallback()), 8000)
|
||||
} catch {
|
||||
done(fallback())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function runDesktopUninstall(mode) {
|
||||
let uninstallArgs
|
||||
try {
|
||||
uninstallArgs = uninstallArgsForMode(mode)
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'invalid-mode', message: error.message }
|
||||
}
|
||||
|
||||
const venvPy = uninstallVenvPython()
|
||||
if (!fileExists(venvPy)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'agent-missing',
|
||||
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
|
||||
}
|
||||
}
|
||||
|
||||
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
|
||||
// running python.exe. On Windows a running .exe is mandatory-locked, so the
|
||||
// rmtree must NOT be driven by the venv's own interpreter — use a system
|
||||
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
|
||||
// source while the venv is torn down. gui-only doesn't touch the venv, so the
|
||||
// venv python is fine there. If no system Python exists (the Windows edge
|
||||
// case), fall back to the venv python — gui-only is unaffected; lite/full may
|
||||
// leave venv remnants the user can delete, which we log.
|
||||
let py = venvPy
|
||||
let pythonPath = null
|
||||
if (modeRemovesAgent(mode)) {
|
||||
const sysPy = findSystemPython()
|
||||
if (sysPy) {
|
||||
py = sysPy
|
||||
pythonPath = ACTIVE_HERMES_ROOT
|
||||
} else if (IS_WINDOWS) {
|
||||
rememberLog(
|
||||
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
|
||||
'to the venv python — venv files locked by the running interpreter may ' +
|
||||
'remain and need manual deletion.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
|
||||
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
|
||||
|
||||
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
|
||||
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
|
||||
// the venv, and even gui-only removes the install tree's GUI artifacts — a
|
||||
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
|
||||
// lock would make the script's rmdir half-fail (#37532 for the update path).
|
||||
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
|
||||
try {
|
||||
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
|
||||
} catch (error) {
|
||||
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
|
||||
}
|
||||
|
||||
const scriptArgs = {
|
||||
desktopPid: process.pid,
|
||||
pythonExe: py,
|
||||
pythonPath,
|
||||
agentRoot: ACTIVE_HERMES_ROOT,
|
||||
uninstallArgs,
|
||||
appPath: removeBundle,
|
||||
hermesHome: HERMES_HOME
|
||||
}
|
||||
|
||||
let scriptPath
|
||||
let runner
|
||||
let runnerArgs
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
|
||||
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
|
||||
runner = process.env.ComSpec || 'cmd.exe'
|
||||
runnerArgs = ['/c', scriptPath]
|
||||
} else {
|
||||
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
|
||||
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
|
||||
runner = '/bin/bash'
|
||||
runnerArgs = [scriptPath]
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'script-write-failed', message: error.message }
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(runner, runnerArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
child.unref()
|
||||
} catch (error) {
|
||||
return { ok: false, error: 'spawn-failed', message: error.message }
|
||||
}
|
||||
|
||||
rememberLog(
|
||||
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
|
||||
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
|
||||
)
|
||||
|
||||
// Give the renderer a beat to show its "uninstalling…" state, then quit so
|
||||
// the venv python shim + app bundle unlock and the cleanup script can run.
|
||||
setTimeout(() => app.quit(), 800)
|
||||
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
|
||||
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
|
||||
const mode = payload && typeof payload === 'object' ? payload.mode : payload
|
||||
return runDesktopUninstall(String(mode || ''))
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
||||
},
|
||||
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
||||
uninstall: {
|
||||
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
|
||||
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
|
||||
},
|
||||
updates: {
|
||||
check: () => ipcRenderer.invoke('hermes:updates:check'),
|
||||
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { UninstallSection } from './uninstall-section'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
|
||||
|
||||
|
|
@ -167,6 +168,8 @@ export function AboutSettings() {
|
|||
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
|
||||
title={a.automaticUpdates}
|
||||
/>
|
||||
|
||||
<UninstallSection />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
|
|
|
|||
185
apps/desktop/src/app/settings/uninstall-section.tsx
Normal file
185
apps/desktop/src/app/settings/uninstall-section.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
|
||||
|
||||
import { SectionHeading } from './primitives'
|
||||
|
||||
interface ModeOption {
|
||||
mode: DesktopUninstallMode
|
||||
title: string
|
||||
description: string
|
||||
/** Shown in the confirm step so people know exactly what disappears. */
|
||||
consequence: string
|
||||
/** True when the option removes the Python agent (hidden if no agent). */
|
||||
needsAgent: boolean
|
||||
}
|
||||
|
||||
const OPTIONS: ModeOption[] = [
|
||||
{
|
||||
mode: 'gui',
|
||||
title: 'Uninstall Chat GUI only',
|
||||
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
|
||||
consequence: 'the desktop Chat GUI (this app and its data)',
|
||||
needsAgent: false
|
||||
},
|
||||
{
|
||||
mode: 'lite',
|
||||
title: 'Uninstall GUI + agent, keep my data',
|
||||
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
|
||||
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
|
||||
needsAgent: true
|
||||
},
|
||||
{
|
||||
mode: 'full',
|
||||
title: 'Uninstall everything',
|
||||
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
|
||||
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
|
||||
// full removes the agent (and user data), so it's an agent-removing option:
|
||||
// hide it on a lite client with no local agent, same as lite. A lite client
|
||||
// connecting to a remote backend has no local agent OR local user data the
|
||||
// GUI installed, so gui-only is the correct (and only) option there.
|
||||
needsAgent: true
|
||||
}
|
||||
]
|
||||
|
||||
export function UninstallSection() {
|
||||
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
const bridge = window.hermesDesktop?.uninstall
|
||||
if (!bridge) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void bridge
|
||||
.summary()
|
||||
.then(result => {
|
||||
if (alive) {
|
||||
setSummary(result)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-fatal — we degrade to offering the GUI-only option.
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) {
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const bridge = window.hermesDesktop?.uninstall
|
||||
if (!bridge) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Gate the agent-removing options on whether an agent is actually present.
|
||||
// A future lite client that ships without the bundled agent shows GUI-only.
|
||||
const agentInstalled = summary?.agent_installed ?? false
|
||||
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
setRunning(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await bridge.run(pending)
|
||||
if (!result.ok) {
|
||||
setError(result.message || result.error || 'Uninstall could not start.')
|
||||
setRunning(false)
|
||||
setPending(null)
|
||||
}
|
||||
// On success the app quits shortly; keep the spinner up until it does.
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRunning(false)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<SectionHeading icon={AlertTriangle} title="Danger zone" />
|
||||
|
||||
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking what's installed…
|
||||
</div>
|
||||
) : pendingOption ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This removes {pendingOption.consequence}. This can't be undone.
|
||||
</p>
|
||||
{summary?.running_app_path && (
|
||||
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
|
||||
App: {summary.running_app_path}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
disabled={running}
|
||||
onClick={() => void handleConfirm()}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{running && <Loader2 className="size-3 animate-spin" />}
|
||||
{running ? 'Uninstalling…' : 'Yes, uninstall'}
|
||||
</Button>
|
||||
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Uninstall Hermes</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-col gap-2">
|
||||
{visibleOptions.map(opt => (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
|
||||
'hover:border-destructive/40 hover:bg-destructive/5'
|
||||
)}
|
||||
key={opt.mode}
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setPending(opt.mode)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
apps/desktop/src/global.d.ts
vendored
28
apps/desktop/src/global.d.ts
vendored
|
|
@ -81,6 +81,10 @@ declare global {
|
|||
setBranch: (name: string) => Promise<{ branch: string }>
|
||||
onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void
|
||||
}
|
||||
uninstall: {
|
||||
summary: () => Promise<DesktopUninstallSummary>
|
||||
run: (mode: DesktopUninstallMode) => Promise<DesktopUninstallResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,6 +108,30 @@ export interface DesktopVersionInfo {
|
|||
hermesRoot: string
|
||||
}
|
||||
|
||||
export type DesktopUninstallMode = 'full' | 'gui' | 'lite'
|
||||
|
||||
export interface DesktopUninstallSummary {
|
||||
hermes_home: string
|
||||
agent_installed: boolean
|
||||
gui_installed: boolean
|
||||
source_built_artifacts: string[]
|
||||
packaged_app_paths: string[]
|
||||
userdata_dir: string
|
||||
userdata_exists: boolean
|
||||
platform: string
|
||||
running_app_path?: null | string
|
||||
probe?: string
|
||||
}
|
||||
|
||||
export interface DesktopUninstallResult {
|
||||
ok: boolean
|
||||
mode?: DesktopUninstallMode
|
||||
willRemoveAppBundle?: boolean
|
||||
scriptPath?: string
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DesktopUpdateCommit {
|
||||
sha: string
|
||||
summary: string
|
||||
|
|
|
|||
285
hermes_cli/gui_uninstall.py
Normal file
285
hermes_cli/gui_uninstall.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""
|
||||
Hermes Desktop (Chat GUI) uninstaller.
|
||||
|
||||
The desktop GUI ships in two shapes and this module knows how to find and
|
||||
remove the artifacts of both, on Linux, macOS, and Windows, WITHOUT touching
|
||||
the Python agent or the user's config/data:
|
||||
|
||||
1. Source-built GUI (``hermes desktop`` / ``hermes gui``)
|
||||
Built inside the agent checkout under ``$HERMES_HOME/hermes-agent/``:
|
||||
- ``apps/desktop/dist`` (compiled renderer)
|
||||
- ``apps/desktop/release`` (electron-builder unpacked app + installers)
|
||||
- ``apps/desktop/node_modules`` and the workspace-root ``node_modules``
|
||||
(Electron itself, ~200MB) — only removed on a GUI uninstall because
|
||||
the agent does not need them.
|
||||
- ``$HERMES_HOME/desktop-build-stamp.json`` (the build freshness stamp)
|
||||
|
||||
2. Packaged distributable (DMG / NSIS / AppImage / deb / rpm)
|
||||
Installed by the OS to a standard application location and carrying its
|
||||
own bundled Electron + a per-user Electron ``userData`` directory:
|
||||
- macOS: ``/Applications/Hermes.app`` or ``~/Applications/Hermes.app``
|
||||
- Windows: ``%LOCALAPPDATA%\\Programs\\Hermes`` (NSIS per-user)
|
||||
- Linux: ``~/.local/share/applications`` .desktop entry + AppImage
|
||||
|
||||
In both shapes the Electron runtime keeps a ``userData`` directory keyed on
|
||||
the app name ("Hermes"), separate from ``$HERMES_HOME``:
|
||||
- macOS: ``~/Library/Application Support/Hermes``
|
||||
- Windows: ``%APPDATA%\\Hermes``
|
||||
- Linux: ``$XDG_CONFIG_HOME/Hermes`` (default ``~/.config/Hermes``)
|
||||
|
||||
This holds the desktop's own ``connection.json`` / ``updates.json`` and
|
||||
Chromium cache — pure GUI state, safe to remove on a GUI uninstall.
|
||||
|
||||
The functions here are deliberately import-light and side-effect-free at
|
||||
import time so the Electron main process can shell out to
|
||||
``hermes uninstall --gui`` (and friends) without paying for the full CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
|
||||
def log_info(msg: str):
|
||||
print(f"{color('→', Colors.CYAN)} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str):
|
||||
print(f"{color('✓', Colors.GREEN)} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str):
|
||||
print(f"{color('⚠', Colors.YELLOW)} {msg}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _agent_root(hermes_home: Path) -> Path:
|
||||
"""The agent checkout root — same layout install.sh / install.ps1 use."""
|
||||
return hermes_home / "hermes-agent"
|
||||
|
||||
|
||||
def desktop_userdata_dir() -> Path:
|
||||
"""Return the Electron ``userData`` directory for the desktop app.
|
||||
|
||||
Mirrors Electron's ``app.getPath('userData')`` for an app named "Hermes"
|
||||
on each platform. This is GUI-only state (connection.json, updates.json,
|
||||
Chromium cache) and never holds agent config or sessions.
|
||||
"""
|
||||
home = Path.home()
|
||||
if sys.platform == "darwin":
|
||||
return home / "Library" / "Application Support" / "Hermes"
|
||||
if sys.platform == "win32":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
|
||||
return base / "Hermes"
|
||||
# Linux / other POSIX — XDG config home.
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else (home / ".config")
|
||||
return base / "Hermes"
|
||||
|
||||
|
||||
def source_built_gui_artifacts(hermes_home: Path) -> "list[Path]":
|
||||
"""GUI build artifacts produced by ``hermes desktop`` inside the checkout.
|
||||
|
||||
These are removable on a GUI uninstall without harming the agent: the
|
||||
Python agent runs from ``hermes-agent/`` source + ``venv/`` and never
|
||||
needs the Electron build output or node_modules.
|
||||
"""
|
||||
agent_root = _agent_root(hermes_home)
|
||||
desktop_dir = agent_root / "apps" / "desktop"
|
||||
return [
|
||||
desktop_dir / "dist",
|
||||
desktop_dir / "release",
|
||||
desktop_dir / "node_modules",
|
||||
# Workspace-root node_modules carries Electron (devDependency of the
|
||||
# desktop workspace, ~200MB). The agent does not use any npm package,
|
||||
# so this is GUI tooling — safe to drop on a GUI uninstall.
|
||||
agent_root / "node_modules",
|
||||
hermes_home / "desktop-build-stamp.json",
|
||||
]
|
||||
|
||||
|
||||
def packaged_gui_app_paths() -> "list[Path]":
|
||||
"""Standard install locations of the packaged desktop distributable.
|
||||
|
||||
Returns every candidate for the current OS; the caller filters to those
|
||||
that actually exist. We never glob system-wide — only the well-known
|
||||
electron-builder output locations for the "Hermes" product.
|
||||
"""
|
||||
home = Path.home()
|
||||
paths: list[Path] = []
|
||||
if sys.platform == "darwin":
|
||||
paths += [
|
||||
Path("/Applications/Hermes.app"),
|
||||
home / "Applications" / "Hermes.app",
|
||||
]
|
||||
elif sys.platform == "win32":
|
||||
local = os.environ.get("LOCALAPPDATA")
|
||||
local_base = Path(local) if local else (home / "AppData" / "Local")
|
||||
paths += [
|
||||
# NSIS per-user install (perMachine=false → Programs\Hermes).
|
||||
local_base / "Programs" / "Hermes",
|
||||
# Older / alternate layout some builds used.
|
||||
local_base / "hermes-desktop",
|
||||
]
|
||||
program_files = os.environ.get("ProgramFiles")
|
||||
if program_files:
|
||||
# NSIS per-machine fallback (needs admin to remove).
|
||||
paths.append(Path(program_files) / "Hermes")
|
||||
else:
|
||||
# Linux: AppImage is a single file the user placed somewhere; we can
|
||||
# only reliably clean the desktop entry + icon we know the name of.
|
||||
# The AppImage itself lives wherever the user put it, so we surface a
|
||||
# hint rather than guessing. deb/rpm installs are owned by the system
|
||||
# package manager and must be removed via apt/dnf — see the message in
|
||||
# ``uninstall_gui``.
|
||||
data = os.environ.get("XDG_DATA_HOME")
|
||||
data_base = Path(data) if data else (home / ".local" / "share")
|
||||
paths += [
|
||||
data_base / "applications" / "hermes.desktop",
|
||||
data_base / "applications" / "Hermes.desktop",
|
||||
]
|
||||
return paths
|
||||
|
||||
|
||||
def agent_is_installed(hermes_home: Path) -> bool:
|
||||
"""Return True when a usable Python agent install exists under HERMES_HOME.
|
||||
|
||||
Used by the desktop UI to decide which uninstall options to offer: if the
|
||||
agent isn't present (a future "lite" GUI-only client), the "remove agent"
|
||||
options are hidden.
|
||||
"""
|
||||
agent_root = _agent_root(hermes_home)
|
||||
# A real install has the package source + a venv. Either signal alone is
|
||||
# enough — a source checkout without a venv is still "the agent is here".
|
||||
if (agent_root / "hermes_cli").is_dir():
|
||||
return True
|
||||
if (agent_root / "venv").is_dir() or (agent_root / ".venv").is_dir():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gui_is_installed(hermes_home: Path) -> bool:
|
||||
"""Return True when any desktop GUI artifact exists (built or packaged)."""
|
||||
for p in source_built_gui_artifacts(hermes_home):
|
||||
if p.exists():
|
||||
return True
|
||||
for p in packaged_gui_app_paths():
|
||||
if p.exists():
|
||||
return True
|
||||
if desktop_userdata_dir().exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
|
||||
"""Structured snapshot of what's installed, for the desktop UI to render.
|
||||
|
||||
Returns JSON-serializable primitives so the Electron main process can
|
||||
forward it to the renderer via IPC (paths as strings, booleans for the
|
||||
high-level questions the UI gates options on).
|
||||
"""
|
||||
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
|
||||
source_artifacts = [p for p in source_built_gui_artifacts(home) if p.exists()]
|
||||
packaged = [p for p in packaged_gui_app_paths() if p.exists()]
|
||||
userdata = desktop_userdata_dir()
|
||||
|
||||
return {
|
||||
"hermes_home": str(home),
|
||||
"agent_installed": agent_is_installed(home),
|
||||
"gui_installed": gui_is_installed(home),
|
||||
"source_built_artifacts": [str(p) for p in source_artifacts],
|
||||
"packaged_app_paths": [str(p) for p in packaged],
|
||||
"userdata_dir": str(userdata),
|
||||
"userdata_exists": userdata.exists(),
|
||||
"platform": sys.platform,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Removal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _remove_path(path: Path) -> bool:
|
||||
"""Remove a file or directory tree. Returns True when something was removed."""
|
||||
try:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
return True
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
return True
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def uninstall_gui(hermes_home: "Path | None" = None, *, remove_userdata: bool = True) -> "list[Path]":
|
||||
"""Remove the desktop GUI's artifacts, leaving the agent + user data intact.
|
||||
|
||||
Removes:
|
||||
- source-built GUI artifacts (dist/release/node_modules/build-stamp)
|
||||
- the packaged app bundle / install dir (best-effort; deb/rpm need the
|
||||
system package manager and are reported, not force-removed)
|
||||
- the Electron ``userData`` directory (unless ``remove_userdata=False``)
|
||||
|
||||
Never touches ``hermes-agent/hermes_cli`` (agent source), ``venv/``, or any
|
||||
config / sessions / .env under ``$HERMES_HOME``.
|
||||
|
||||
Returns the list of paths actually removed.
|
||||
"""
|
||||
home: Path = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
|
||||
removed: list[Path] = []
|
||||
|
||||
log_info("Removing built GUI artifacts (renderer, release, node_modules)...")
|
||||
for path in source_built_gui_artifacts(home):
|
||||
if path.exists() and _remove_path(path):
|
||||
log_success(f"Removed {path}")
|
||||
removed.append(path)
|
||||
|
||||
log_info("Removing installed desktop app...")
|
||||
found_packaged = False
|
||||
for path in packaged_gui_app_paths():
|
||||
if path.exists():
|
||||
found_packaged = True
|
||||
if _remove_path(path):
|
||||
log_success(f"Removed {path}")
|
||||
removed.append(path)
|
||||
if not found_packaged:
|
||||
log_info("No packaged desktop app found in standard locations")
|
||||
|
||||
if remove_userdata:
|
||||
userdata = desktop_userdata_dir()
|
||||
if userdata.exists():
|
||||
log_info("Removing desktop app data (Electron userData)...")
|
||||
if _remove_path(userdata):
|
||||
log_success(f"Removed {userdata}")
|
||||
removed.append(userdata)
|
||||
|
||||
if not removed:
|
||||
log_info("No desktop GUI artifacts found to remove")
|
||||
|
||||
# Linux deb/rpm installs are owned by the package manager; we can't (and
|
||||
# shouldn't) rmtree files under /usr. Surface the hint so the user can
|
||||
# finish the job. AppImages live wherever the user dropped them.
|
||||
if sys.platform.startswith("linux"):
|
||||
log_info(
|
||||
"If you installed the desktop via a .deb / .rpm package, remove it "
|
||||
"with your package manager (e.g. 'sudo apt remove hermes' or "
|
||||
"'sudo dnf remove hermes'). AppImage builds are a single file you "
|
||||
"can delete from wherever you saved it."
|
||||
)
|
||||
|
||||
return removed
|
||||
|
|
@ -6697,8 +6697,30 @@ def cmd_version(args):
|
|||
|
||||
|
||||
def cmd_uninstall(args):
|
||||
"""Uninstall Hermes Agent."""
|
||||
_require_tty("uninstall")
|
||||
"""Uninstall Hermes Agent (or just the Chat GUI with --gui)."""
|
||||
# Machine-readable install snapshot for the desktop app's uninstall UI.
|
||||
# Must run before any TTY gate — it's called from a non-interactive child.
|
||||
if getattr(args, "gui_summary", False):
|
||||
from hermes_cli.gui_uninstall import gui_install_summary
|
||||
|
||||
print(json.dumps(gui_install_summary()))
|
||||
return
|
||||
|
||||
# GUI-only uninstall. The desktop app shells out to this non-interactively
|
||||
# with --yes, so only gate on a TTY when we actually need to prompt.
|
||||
if getattr(args, "gui", False):
|
||||
if not getattr(args, "yes", False):
|
||||
_require_tty("uninstall --gui")
|
||||
from hermes_cli.uninstall import run_gui_uninstall
|
||||
|
||||
run_gui_uninstall(args)
|
||||
return
|
||||
|
||||
# Full/keep-data uninstall. ``--yes`` runs non-interactively (the desktop
|
||||
# app's lite/full modes drive this from a detached cleanup script), so only
|
||||
# gate on a TTY when we actually need to prompt for the option + confirm.
|
||||
if not getattr(args, "yes", False):
|
||||
_require_tty("uninstall")
|
||||
from hermes_cli.uninstall import run_uninstall
|
||||
|
||||
run_uninstall(args)
|
||||
|
|
@ -15454,6 +15476,17 @@ Examples:
|
|||
action="store_true",
|
||||
help="Full uninstall - remove everything including configs and data",
|
||||
)
|
||||
uninstall_parser.add_argument(
|
||||
"--gui",
|
||||
action="store_true",
|
||||
help="Uninstall only the desktop Chat GUI, leaving the agent intact",
|
||||
)
|
||||
uninstall_parser.add_argument(
|
||||
"--gui-summary",
|
||||
action="store_true",
|
||||
help="Print a JSON summary of installed GUI/agent artifacts and exit "
|
||||
"(used by the desktop app to gate uninstall options)",
|
||||
)
|
||||
uninstall_parser.add_argument(
|
||||
"--yes", "-y", action="store_true", help="Skip confirmation prompts"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -492,6 +492,78 @@ def _uninstall_profile(profile) -> None:
|
|||
log_warn(f" Could not remove {profile_home}: {e}")
|
||||
|
||||
|
||||
def run_gui_uninstall(args):
|
||||
"""GUI-only uninstall: remove the Chat GUI, leave the agent + data intact.
|
||||
|
||||
Mirrors ``hermes uninstall --gui``. Removes the desktop app's built
|
||||
artifacts, the packaged app bundle (best-effort), and the Electron
|
||||
userData dir — nothing under ``$HERMES_HOME`` config/sessions/.env, and
|
||||
never the Python agent or its venv.
|
||||
"""
|
||||
from hermes_cli.gui_uninstall import (
|
||||
agent_is_installed,
|
||||
gui_install_summary,
|
||||
uninstall_gui,
|
||||
)
|
||||
|
||||
hermes_home = get_hermes_home()
|
||||
summary = gui_install_summary(hermes_home)
|
||||
skip_confirm = bool(getattr(args, "yes", False))
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
|
||||
print(color("│ ⚕ Hermes Chat GUI Uninstaller │", Colors.MAGENTA, Colors.BOLD))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.MAGENTA, Colors.BOLD))
|
||||
print()
|
||||
|
||||
if not summary["gui_installed"]:
|
||||
print("No Hermes Chat GUI installation was found.")
|
||||
print(f" Checked: {hermes_home}, and the standard app locations for this OS.")
|
||||
return
|
||||
|
||||
print(color("This removes the Chat GUI only. The Hermes agent stays installed.", Colors.CYAN))
|
||||
print()
|
||||
print(color("Will remove:", Colors.YELLOW, Colors.BOLD))
|
||||
for p in summary["source_built_artifacts"]:
|
||||
print(f" • {p}")
|
||||
for p in summary["packaged_app_paths"]:
|
||||
print(f" • {p}")
|
||||
if summary["userdata_exists"]:
|
||||
print(f" • {summary['userdata_dir']} (desktop app data)")
|
||||
print()
|
||||
if agent_is_installed(hermes_home):
|
||||
print(color("Kept intact:", Colors.GREEN, Colors.BOLD))
|
||||
print(f" • The Hermes agent at {hermes_home / 'hermes-agent'}")
|
||||
print(f" • Your config, sessions, and secrets under {hermes_home}")
|
||||
print()
|
||||
|
||||
if not skip_confirm:
|
||||
try:
|
||||
confirm = input(f"Type '{color('yes', Colors.YELLOW)}' to remove the Chat GUI: ").strip().lower()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print("Cancelled.")
|
||||
return
|
||||
if confirm != "yes":
|
||||
print()
|
||||
print("Uninstall cancelled.")
|
||||
return
|
||||
|
||||
print()
|
||||
print(color("Uninstalling Chat GUI...", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
uninstall_gui(hermes_home)
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.GREEN, Colors.BOLD))
|
||||
print(color("│ ✓ Chat GUI Uninstalled! │", Colors.GREEN, Colors.BOLD))
|
||||
print(color("└─────────────────────────────────────────────────────────┘", Colors.GREEN, Colors.BOLD))
|
||||
print()
|
||||
print("The Hermes agent is still installed. Run 'hermes' to use the CLI,")
|
||||
print("or 'hermes uninstall' to remove the agent too.")
|
||||
print()
|
||||
|
||||
|
||||
def run_uninstall(args):
|
||||
"""
|
||||
Run the uninstall process.
|
||||
|
|
@ -509,6 +581,24 @@ def run_uninstall(args):
|
|||
is_default_profile = _is_default_hermes_home(hermes_home)
|
||||
named_profiles = _discover_named_profiles() if is_default_profile else []
|
||||
|
||||
# Non-interactive fast path (``--yes``): no prompts. ``--full`` selects a
|
||||
# full wipe (code + ~/.hermes data); otherwise keep-data. Named profiles
|
||||
# are NOT auto-removed here — that's a destructive, surprising default for
|
||||
# an unattended run, so it stays opt-in to the interactive flow. This is
|
||||
# the path the desktop app's detached cleanup script uses for its
|
||||
# lite/full modes.
|
||||
skip_confirm = bool(getattr(args, "yes", False))
|
||||
if skip_confirm:
|
||||
full_uninstall = bool(getattr(args, "full", False))
|
||||
_perform_uninstall(
|
||||
project_root=project_root,
|
||||
hermes_home=hermes_home,
|
||||
full_uninstall=full_uninstall,
|
||||
remove_profiles=False,
|
||||
named_profiles=named_profiles,
|
||||
)
|
||||
return
|
||||
|
||||
print()
|
||||
print(color("┌─────────────────────────────────────────────────────────┐", Colors.MAGENTA, Colors.BOLD))
|
||||
print(color("│ ⚕ Hermes Agent Uninstaller │", Colors.MAGENTA, Colors.BOLD))
|
||||
|
|
@ -604,7 +694,32 @@ def run_uninstall(args):
|
|||
print()
|
||||
print("Uninstall cancelled.")
|
||||
return
|
||||
|
||||
|
||||
_perform_uninstall(
|
||||
project_root=project_root,
|
||||
hermes_home=hermes_home,
|
||||
full_uninstall=full_uninstall,
|
||||
remove_profiles=remove_profiles,
|
||||
named_profiles=named_profiles,
|
||||
)
|
||||
|
||||
|
||||
def _perform_uninstall(
|
||||
*,
|
||||
project_root: Path,
|
||||
hermes_home: Path,
|
||||
full_uninstall: bool,
|
||||
remove_profiles: bool,
|
||||
named_profiles: list,
|
||||
) -> None:
|
||||
"""Execute the uninstall steps. Shared by the interactive and ``--yes``
|
||||
paths so the destructive sequence lives in exactly one place.
|
||||
|
||||
Steps: stop gateway → strip PATH (rc files + Windows registry) → remove the
|
||||
``hermes`` wrapper + node symlinks → remove the desktop Chat GUI artifacts →
|
||||
delete the code checkout → (Windows) remove PortableGit/Node → optionally
|
||||
wipe ``$HERMES_HOME`` data and named profiles on full uninstall.
|
||||
"""
|
||||
print()
|
||||
print(color("Uninstalling...", Colors.CYAN, Colors.BOLD))
|
||||
print()
|
||||
|
|
@ -664,7 +779,25 @@ def run_uninstall(args):
|
|||
log_success(f"Removed {link}")
|
||||
else:
|
||||
log_info("No Hermes-managed node/npm/npx symlinks found")
|
||||
|
||||
|
||||
# 3c. Remove the desktop Chat GUI's artifacts too (built renderer/release,
|
||||
# node_modules, the packaged app bundle, and the Electron userData
|
||||
# dir). Both the "keep data" and "full" CLI flows remove the agent
|
||||
# code, so the GUI — which is just another consumer of the same
|
||||
# checkout — should go with it. uninstall_gui() never touches config /
|
||||
# sessions / .env, so it's safe in keep-data mode; on full uninstall the
|
||||
# step-5 rmtree(hermes_home) would sweep the in-tree artifacts anyway,
|
||||
# but the packaged app + Electron userData live OUTSIDE HERMES_HOME and
|
||||
# must be cleaned explicitly here.
|
||||
log_info("Removing desktop Chat GUI artifacts...")
|
||||
try:
|
||||
from hermes_cli.gui_uninstall import uninstall_gui
|
||||
gui_removed = uninstall_gui(hermes_home)
|
||||
if not gui_removed:
|
||||
log_info("No desktop GUI artifacts found")
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove desktop GUI artifacts: {e}")
|
||||
|
||||
# 4. Remove installation directory (code)
|
||||
log_info("Removing installation directory...")
|
||||
|
||||
|
|
@ -748,3 +881,50 @@ def run_uninstall(args):
|
|||
print()
|
||||
print("Thank you for using Hermes Agent! ⚕")
|
||||
print()
|
||||
|
||||
|
||||
class _UninstallArgs:
|
||||
"""Lightweight args namespace for the module entrypoint below."""
|
||||
|
||||
def __init__(self, *, mode: str):
|
||||
self.gui = mode == "gui"
|
||||
self.gui_summary = False
|
||||
self.full = mode == "full"
|
||||
self.yes = True # the module entrypoint is always non-interactive
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
"""Module entrypoint: ``python -m hermes_cli.uninstall --mode <gui|lite|full>``.
|
||||
|
||||
Exists so the desktop app can run the uninstall under a Python interpreter
|
||||
OUTSIDE the venv being deleted. On Windows, ``lite``/``full`` rmtree the
|
||||
venv that contains the running ``python.exe`` — and a running .exe is
|
||||
mandatory-locked, so doing that from the venv's own interpreter half-fails.
|
||||
The desktop launches this with the system Python + ``PYTHONPATH=<agentRoot>``
|
||||
so ``import hermes_cli`` resolves from source while the venv is torn down.
|
||||
|
||||
This module imports only stdlib + ``hermes_constants`` + ``hermes_cli.colors``
|
||||
(and lazily ``hermes_cli.gui_uninstall``), so it runs fine under a bare
|
||||
system Python with no site-packages from the venv.
|
||||
"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(prog="python -m hermes_cli.uninstall")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["gui", "lite", "full"],
|
||||
required=True,
|
||||
help="gui = Chat GUI only; lite = GUI + agent, keep data; full = everything",
|
||||
)
|
||||
ns = parser.parse_args(argv)
|
||||
args = _UninstallArgs(mode=ns.mode)
|
||||
|
||||
if args.gui:
|
||||
run_gui_uninstall(args)
|
||||
else:
|
||||
run_uninstall(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
|
|||
348
tests/hermes_cli/test_gui_uninstall.py
Normal file
348
tests/hermes_cli/test_gui_uninstall.py
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
"""Tests for hermes_cli.gui_uninstall — GUI-only uninstall + install discovery.
|
||||
|
||||
Covers the cross-platform artifact discovery, the agent/GUI detection the
|
||||
desktop UI gates options on, and that ``uninstall_gui`` removes only GUI
|
||||
artifacts (built renderer/release/node_modules, packaged bundle, Electron
|
||||
userData) while leaving the Python agent + config/sessions/.env intact.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.gui_uninstall as gu
|
||||
|
||||
|
||||
def _make_agent(hermes_home: Path) -> Path:
|
||||
"""Create a fake agent install: source package + venv."""
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
(agent_root / "hermes_cli" / "__init__.py").write_text("")
|
||||
(agent_root / "venv" / "bin").mkdir(parents=True)
|
||||
return agent_root
|
||||
|
||||
|
||||
def _make_gui_build(hermes_home: Path) -> None:
|
||||
"""Create the source-built GUI artifacts a `hermes desktop` run produces."""
|
||||
desktop = hermes_home / "hermes-agent" / "apps" / "desktop"
|
||||
(desktop / "dist").mkdir(parents=True)
|
||||
(desktop / "dist" / "index.html").write_text("<html>")
|
||||
(desktop / "release" / "linux-unpacked").mkdir(parents=True)
|
||||
(desktop / "node_modules").mkdir(parents=True)
|
||||
(hermes_home / "hermes-agent" / "node_modules").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
|
||||
|
||||
def _make_user_data(hermes_home: Path) -> None:
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
(hermes_home / ".env").write_text("KEY=secret\n")
|
||||
(hermes_home / "sessions").mkdir()
|
||||
|
||||
|
||||
def test_agent_is_installed_detects_source_and_venv(tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
assert gu.agent_is_installed(hermes_home) is False
|
||||
_make_agent(hermes_home)
|
||||
assert gu.agent_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_agent_is_installed_venv_only(tmp_path):
|
||||
"""A checkout with only a venv (no package dir yet) still counts."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "hermes-agent" / "venv").mkdir(parents=True)
|
||||
assert gu.agent_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_source_built_artifacts_lists_known_paths(tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_gui_build(hermes_home)
|
||||
artifacts = gu.source_built_gui_artifacts(hermes_home)
|
||||
names = {p.name for p in artifacts}
|
||||
assert "dist" in names
|
||||
assert "release" in names
|
||||
assert "node_modules" in names
|
||||
assert "desktop-build-stamp.json" in names
|
||||
|
||||
|
||||
def test_gui_is_installed_true_when_built(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_gui_build(hermes_home)
|
||||
# Make sure packaged-app + userdata probes don't false-positive on the box
|
||||
# running the test.
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
|
||||
assert gu.gui_is_installed(hermes_home) is True
|
||||
|
||||
|
||||
def test_gui_is_installed_false_when_nothing(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "nope")
|
||||
assert gu.gui_is_installed(hermes_home) is False
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_only_gui_artifacts(tmp_path, monkeypatch):
|
||||
"""The core invariant: GUI gone, agent + user data untouched."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = _make_agent(hermes_home)
|
||||
_make_gui_build(hermes_home)
|
||||
_make_user_data(hermes_home)
|
||||
|
||||
# Isolate the packaged-app + userdata probes from the test machine.
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "userdata-none")
|
||||
|
||||
removed = gu.uninstall_gui(hermes_home)
|
||||
removed_names = {p.name for p in removed}
|
||||
|
||||
# GUI artifacts removed.
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
assert not (desktop / "dist").exists()
|
||||
assert not (desktop / "release").exists()
|
||||
assert not (desktop / "node_modules").exists()
|
||||
assert not (agent_root / "node_modules").exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert "dist" in removed_names
|
||||
|
||||
# Agent + user data preserved.
|
||||
assert (agent_root / "hermes_cli" / "__init__.py").exists()
|
||||
assert (agent_root / "venv").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert (hermes_home / ".env").exists()
|
||||
assert (hermes_home / "sessions").exists()
|
||||
# The desktop source dir itself survives (only its build output is gone).
|
||||
assert desktop.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_userdata(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
userdata = tmp_path / "Hermes-userdata"
|
||||
userdata.mkdir()
|
||||
(userdata / "connection.json").write_text("{}")
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
|
||||
|
||||
gu.uninstall_gui(hermes_home)
|
||||
assert not userdata.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_keeps_userdata_when_requested(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
userdata = tmp_path / "Hermes-userdata"
|
||||
userdata.mkdir()
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: userdata)
|
||||
|
||||
gu.uninstall_gui(hermes_home, remove_userdata=False)
|
||||
assert userdata.exists()
|
||||
|
||||
|
||||
def test_uninstall_gui_removes_packaged_bundle(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
bundle = tmp_path / "Hermes.app"
|
||||
(bundle / "Contents").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [bundle])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
removed = gu.uninstall_gui(hermes_home)
|
||||
assert not bundle.exists()
|
||||
assert bundle in removed
|
||||
|
||||
|
||||
def test_gui_install_summary_shape(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
_make_agent(hermes_home)
|
||||
_make_gui_build(hermes_home)
|
||||
monkeypatch.setattr(gu, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
summary = gu.gui_install_summary(hermes_home)
|
||||
# JSON-serializable primitives the desktop UI gates on.
|
||||
assert summary["agent_installed"] is True
|
||||
assert summary["gui_installed"] is True
|
||||
assert isinstance(summary["source_built_artifacts"], list)
|
||||
assert all(isinstance(p, str) for p in summary["source_built_artifacts"])
|
||||
assert summary["hermes_home"] == str(hermes_home)
|
||||
assert summary["platform"] == sys.platform
|
||||
|
||||
|
||||
def test_userdata_dir_per_platform(monkeypatch):
|
||||
"""userData path matches Electron's app.getPath('userData') for "Hermes"."""
|
||||
home = Path("/home/tester")
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
|
||||
monkeypatch.setattr(gu.sys, "platform", "darwin")
|
||||
assert gu.desktop_userdata_dir() == home / "Library" / "Application Support" / "Hermes"
|
||||
|
||||
monkeypatch.setattr(gu.sys, "platform", "linux")
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
assert gu.desktop_userdata_dir() == home / ".config" / "Hermes"
|
||||
|
||||
|
||||
def test_userdata_dir_windows(monkeypatch):
|
||||
home = Path("/home/tester")
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
||||
monkeypatch.setattr(gu.sys, "platform", "win32")
|
||||
monkeypatch.setenv("APPDATA", r"C:\Users\tester\AppData\Roaming")
|
||||
assert gu.desktop_userdata_dir() == Path(r"C:\Users\tester\AppData\Roaming") / "Hermes"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX symlink semantics")
|
||||
def test_remove_path_handles_symlink(tmp_path):
|
||||
target = tmp_path / "real"
|
||||
target.mkdir()
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(target)
|
||||
assert gu._remove_path(link) is True
|
||||
assert not link.exists()
|
||||
# The symlink is gone but its target is untouched.
|
||||
assert target.exists()
|
||||
|
||||
|
||||
class _Args:
|
||||
"""Minimal argparse-Namespace stand-in for run_uninstall."""
|
||||
|
||||
def __init__(self, *, yes=False, full=False, gui=False, gui_summary=False):
|
||||
self.yes = yes
|
||||
self.full = full
|
||||
self.gui = gui
|
||||
self.gui_summary = gui_summary
|
||||
|
||||
|
||||
def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch):
|
||||
"""``--yes`` (no ``--full``) runs with no prompt, sweeps the GUI, keeps data.
|
||||
|
||||
We DO NOT spawn the real CLI here (its project_root removal would delete the
|
||||
test checkout) — we call run_uninstall in-process against a throwaway
|
||||
HERMES_HOME with all the destructive externals stubbed out.
|
||||
"""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
(desktop / "release").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
fake_code = tmp_path / "checkout"
|
||||
fake_code.mkdir()
|
||||
|
||||
# Stub every destructive external so the test only exercises the control
|
||||
# flow + the real GUI sweep (which is safe inside tmp_path).
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
|
||||
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
|
||||
# Make input() blow up so a regression that reaches a prompt fails loudly.
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
|
||||
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
uninstall.run_uninstall(_Args(yes=True, full=False))
|
||||
|
||||
# Code checkout removed, GUI artifacts swept, but user data preserved.
|
||||
assert not fake_code.exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert not (desktop / "release").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
assert hermes_home.exists()
|
||||
|
||||
|
||||
def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch):
|
||||
"""``--yes --full`` removes the whole HERMES_HOME non-interactively."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
(hermes_home / "hermes-agent" / "hermes_cli").mkdir(parents=True)
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
fake_code = tmp_path / "checkout"
|
||||
fake_code.mkdir()
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_node_symlinks", lambda h: [])
|
||||
monkeypatch.setattr(uninstall, "_discover_named_profiles", lambda: [])
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in --yes mode"))
|
||||
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
|
||||
uninstall.run_uninstall(_Args(yes=True, full=True))
|
||||
|
||||
assert not hermes_home.exists()
|
||||
|
||||
|
||||
def test_uninstall_module_main_gui_mode(tmp_path, monkeypatch):
|
||||
"""`python -m hermes_cli.uninstall --mode gui` runs the GUI-only path.
|
||||
|
||||
This is the lightweight, venv-independent entrypoint the desktop launches
|
||||
with a system Python (so lite/full don't rmtree their own running venv on
|
||||
Windows). Verify it dispatches by mode without prompting.
|
||||
"""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
agent_root = hermes_home / "hermes-agent"
|
||||
(agent_root / "hermes_cli").mkdir(parents=True)
|
||||
desktop = agent_root / "apps" / "desktop"
|
||||
(desktop / "release").mkdir(parents=True)
|
||||
(hermes_home / "desktop-build-stamp.json").write_text("{}")
|
||||
(hermes_home / "config.yaml").write_text("x: 1\n")
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
from hermes_cli import gui_uninstall as gu_mod
|
||||
monkeypatch.setattr(gu_mod, "packaged_gui_app_paths", lambda: [])
|
||||
monkeypatch.setattr(gu_mod, "desktop_userdata_dir", lambda: tmp_path / "none")
|
||||
monkeypatch.setattr(gu_mod, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr("builtins.input", lambda *a, **k: pytest.fail("prompted in module main"))
|
||||
|
||||
rc = uninstall.main(["--mode", "gui"])
|
||||
assert rc == 0
|
||||
# GUI swept, agent + config kept (gui-only contract).
|
||||
assert not (desktop / "release").exists()
|
||||
assert not (hermes_home / "desktop-build-stamp.json").exists()
|
||||
assert (agent_root / "hermes_cli").exists()
|
||||
assert (hermes_home / "config.yaml").exists()
|
||||
|
||||
|
||||
def test_uninstall_module_main_rejects_bad_mode():
|
||||
"""An invalid --mode exits non-zero (argparse), never silently full-wipes."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
uninstall.main(["--mode", "nuke"])
|
||||
assert exc.value.code != 0
|
||||
|
||||
|
||||
def test_uninstall_args_namespace_mode_mapping():
|
||||
"""_UninstallArgs maps mode → the gui/full flags run_uninstall reads."""
|
||||
import hermes_cli.uninstall as uninstall
|
||||
|
||||
gui = uninstall._UninstallArgs(mode="gui")
|
||||
assert gui.gui is True and gui.full is False and gui.yes is True
|
||||
|
||||
lite = uninstall._UninstallArgs(mode="lite")
|
||||
assert lite.gui is False and lite.full is False and lite.yes is True
|
||||
|
||||
full = uninstall._UninstallArgs(mode="full")
|
||||
assert full.gui is False and full.full is True and full.yes is True
|
||||
|
||||
|
|
@ -1467,7 +1467,7 @@ Additional behavior:
|
|||
| `hermes version` | Print version information. |
|
||||
| `hermes update` | Pull latest changes and reinstall dependencies. |
|
||||
| `hermes postinstall` | Internal bootstrap. Runs once after `pip install hermes-agent` (or `hermes update` on pip installs) to install non-Python dependencies that pip cannot provide — Node.js runtime, headless browser, ripgrep, ffmpeg — and then trigger `hermes setup` if the profile has not been configured yet. Safe to re-run idempotently. |
|
||||
| `hermes uninstall [--full] [--yes]` | Remove Hermes, optionally deleting all config/data. |
|
||||
| `hermes uninstall [--full] [--gui] [--yes]` | Remove Hermes, optionally deleting all config/data. `--gui` removes only the desktop Chat GUI, leaving the agent intact; `--full` also deletes config/data; `--yes` skips prompts. |
|
||||
|
||||
## See also
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,22 @@ The app checks for updates in the background and offers a one-click update when
|
|||
|
||||
The [manual update process](https://hermes-agent.nousresearch.com/docs/getting-started/updating) also works with the GUI.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Open **Settings → About → Danger zone** and pick how much to remove:
|
||||
|
||||
- **Uninstall Chat GUI only** — removes the desktop app and its data; the Hermes agent, your config, and your chats stay. (Same as `hermes uninstall --gui`.)
|
||||
- **Uninstall GUI + agent, keep my data** — removes the app and the agent but keeps config, chats, and secrets for a future reinstall. (Same as `hermes uninstall`.)
|
||||
- **Uninstall everything** — removes the app, the agent, and all user data. (Same as `hermes uninstall --full`.)
|
||||
|
||||
The app closes to finish the job (the cleanup runs after it exits so it can remove the running app bundle and its own venv). The agent-removing options are hidden automatically when no local agent is installed (for example, a GUI-only "lite" client connected to a remote backend).
|
||||
|
||||
You can do the same from the terminal — `hermes uninstall --gui` for the GUI alone, or `hermes uninstall` / `hermes uninstall --full` for the agent too.
|
||||
|
||||
:::note
|
||||
Running `hermes uninstall --gui` from a **source checkout** (a `hermes desktop` dev build) also removes the workspace `node_modules` and `apps/desktop/{dist,release}` build output, since those are GUI build artifacts. They're recoverable with `hermes desktop` (or `npm install` + a rebuild) — but if you're actively hacking on the desktop app, expect to reinstall dependencies afterward.
|
||||
:::
|
||||
|
||||
## CLI reference: `hermes desktop`
|
||||
|
||||
To launch via the CLI, simply run `hermes desktop`. By default it installs workspace Node dependencies, builds the current OS's unpacked Electron app, then launches that packaged artifact.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue