Files
hermes-agent/apps/desktop/electron/desktop-plugin-remove.ts
teknium1 dec236b214 feat(desktop): Uninstall for standalone desktop plugins via Electron IPC
Rows whose only half is a folder in <HERMES_HOME>/desktop-plugins (no agent
package behind it) get the same trash button + destructive confirm as the
agent rows. The renderer names the FOLDER, never a path; Electron
(`hermes:plugin:removeDesktop` -> desktop-plugin-remove.ts) resolves it under
the app-level root, refuses anything that is not a single contained segment,
refuses unified-package halves (the reconcile would re-copy them; the agent
uninstall prunes them), removes a symlinked folder as the link, and deletes
the tree. The loader then retires the entry (unload, drop rows, stop watch)
so the pane/commands vanish without waiting for the next scan.

Why an IPC: these plugins have no agent half, so `plugins.manage remove` on
the gateway cannot reach them — they live on this computer, in every
connection mode.
2026-09-21 00:09:50 -07:00

61 lines
2.1 KiB
TypeScript

// Uninstall of a STANDALONE desktop plugin: delete one folder directly under
// the app-level `<HERMES_HOME>/desktop-plugins` root. The renderer names the
// folder, never a path — containment is by construction (single segment) and
// re-checked after resolution so nothing outside the root can be addressed.
//
// A unified package's desktop half (folder carrying `.hermes-package.json`) is
// refused here: the reconcile re-copies it from `plugins/<name>/desktop` on
// the next pass while the agent package exists, so deleting the copy would
// only make it flicker. That half goes away with `plugins.manage remove`.
import fs from 'node:fs'
import path from 'node:path'
import { PACKAGE_MARKER } from './desktop-plugins-root'
export interface RemoveDesktopPluginResult {
ok: boolean
error?: string
/** The folder that was deleted (on success). */
path?: string
}
export async function removeDesktopPlugin(appRoot: string, rawName: unknown): Promise<RemoveDesktopPluginResult> {
const name = String(rawName ?? '').trim()
if (!name || name === '.' || name === '..' || /[\\/]/.test(name)) {
return { ok: false, error: 'invalid plugin folder name' }
}
const root = path.resolve(appRoot)
const target = path.resolve(root, name)
if (path.relative(root, target) !== name) {
return { ok: false, error: `${name} is not inside the desktop-plugins folder` }
}
let stat: fs.Stats
try {
// lstat: a symlinked folder is removed as the LINK, never by following it.
stat = await fs.promises.lstat(target)
} catch {
return { ok: false, error: `${name} is not installed` }
}
if (!stat.isDirectory() && !stat.isSymbolicLink()) {
return { ok: false, error: `${name} is not a plugin folder` }
}
if (stat.isDirectory() && fs.existsSync(path.join(target, PACKAGE_MARKER))) {
return { ok: false, error: `${name} is the desktop half of an agent plugin — uninstall that plugin instead` }
}
try {
await fs.promises.rm(target, { force: true, recursive: true })
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
return { ok: true, path: target }
}