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:
@@ -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