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:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Vendored
+28
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user