diff --git a/apps/desktop/electron/desktop-uninstall.cjs b/apps/desktop/electron/desktop-uninstall.cjs new file mode 100644 index 00000000000..41360df2612 --- /dev/null +++ b/apps/desktop/electron/desktop-uninstall.cjs @@ -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 +} diff --git a/apps/desktop/electron/desktop-uninstall.test.cjs b/apps/desktop/electron/desktop-uninstall.test.cjs new file mode 100644 index 00000000000..b6e5a386ff8 --- /dev/null +++ b/apps/desktop/electron/desktop-uninstall.test.cjs @@ -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=/) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 054b4e22454..873712ae787 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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= 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()) diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index e1479b1ba99..27bc1b20b53 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -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), diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f4bf696f8a1..33aaf057ec8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/src/app/settings/about-settings.tsx b/apps/desktop/src/app/settings/about-settings.tsx index 18a56128c89..cef90450ef2 100644 --- a/apps/desktop/src/app/settings/about-settings.tsx +++ b/apps/desktop/src/app/settings/about-settings.tsx @@ -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} /> + + ) diff --git a/apps/desktop/src/app/settings/uninstall-section.tsx b/apps/desktop/src/app/settings/uninstall-section.tsx new file mode 100644 index 00000000000..b9bf98133d9 --- /dev/null +++ b/apps/desktop/src/app/settings/uninstall-section.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [pending, setPending] = useState(null) + const [running, setRunning] = useState(false) + const [error, setError] = useState(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 ( +
+ + +
+ {loading ? ( +
+ + Checking what's installed… +
+ ) : pendingOption ? ( +
+

Confirm uninstall

+

+ This removes {pendingOption.consequence}. This can't be undone. +

+ {summary?.running_app_path && ( +

+ App: {summary.running_app_path} +

+ )} + {error &&

{error}

} +
+ + +
+
+ ) : ( +
+

Uninstall Hermes

+

+ Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back. +

+
+ {visibleOptions.map(opt => ( + + ))} +
+
+ )} +
+
+ ) +} diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 43718037a1d..aff578ac502 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -81,6 +81,10 @@ declare global { setBranch: (name: string) => Promise<{ branch: string }> onProgress: (callback: (payload: DesktopUpdateProgress) => void) => () => void } + uninstall: { + summary: () => Promise + run: (mode: DesktopUninstallMode) => Promise + } } } } @@ -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 diff --git a/hermes_cli/gui_uninstall.py b/hermes_cli/gui_uninstall.py new file mode 100644 index 00000000000..941604cfc46 --- /dev/null +++ b/hermes_cli/gui_uninstall.py @@ -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 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index eebeac4af82..4945a375cf4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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" ) diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index d6b809fe090..9b53500734b 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -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 ``. + + 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=`` + 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()) diff --git a/tests/hermes_cli/test_gui_uninstall.py b/tests/hermes_cli/test_gui_uninstall.py new file mode 100644 index 00000000000..951f3ae8b93 --- /dev/null +++ b/tests/hermes_cli/test_gui_uninstall.py @@ -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("") + (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 + diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 12e3c6a84d5..c80a2fde933 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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 diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index f4508c7e43a..f0fb2dddab3 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -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.