Skip to content

Add force-uninstall escape hatch and run deactivate before uninstall#9

Merged
DavidBabinec merged 1 commit into
mainfrom
feat/force-uninstall
Jun 10, 2026
Merged

Add force-uninstall escape hatch and run deactivate before uninstall#9
DavidBabinec merged 1 commit into
mainfrom
feat/force-uninstall

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Problem

A throwing (or unloadable) uninstall hook permanently blocked plugin removal: server/handlers/cms/plugins/state.ts returned 400 before deleting the DB row, assets, or worker, with no recovery path short of DB surgery. A missing/corrupt entry file failed the same way. Related hygiene gaps: clearPluginCrashes claimed to run "on uninstall" but never did, plugin_crash_events / plugin_schedule_runs rows outlived uninstall (no FK), and interrupted upgrades left stale version dirs under uploads/plugins/<id>/ forever.

What changed

Server (server/handlers/cms/plugins/state.ts, shared.ts, index.ts)

  • DELETE /admin/api/cms/plugins/:id?force=true skips lifecycle hooks and proceeds with full teardown. Capability + step-up gating identical to normal uninstall (plugins.install + step-up); audit event carries forced: true.
  • Normal uninstall now runs deactivate (when active) before uninstall — code now matches the documented "(if active) deactivate → uninstall" contract.
  • Hook failure returns a 400 naming the failed hook and pointing at the force-remove escape hatch; the plugin row survives, parked in error.
  • All removal variants — normal, forced, corrupt-manifest — converge on one teardown (removePluginCompletely): worker unload, canvas module pack deactivation, DB row delete (records/schedules cascade), crash counter + crash events + schedule run history sweep, and removal of the whole uploads/plugins/<id>/ tree including stale version dirs (removeAllPluginAssets, assertPathWithin guard retained). The old duplicate corrupt-manifest delete path is gone.

Admin UI (src/admin/pages/plugins/)

  • Failed uninstall surfaces as a role="alert" with the server message and a "Remove anyway" action; forcing is gated by its own confirmation dialog (shared <Dialog> primitive, no native dialogs) warning that the plugin's cleanup code is skipped and external resources (webhooks, third-party registrations) may remain.
  • PluginRemoveDialog gains a force variant; removeCmsPlugin(pluginId, force) in src/core/persistence/cmsPlugins.ts.

Docsdocs/features/plugin-system.md gains a "Force-uninstall" section; the lifecycle contract line is now true.

Tests

  • src/__tests__/server/cmsPlugins.test.ts: deactivate runs before uninstall (ordered markers); hook failure → 400 with escape-hatch message and plugin still installed; ?force=true removes row + crash events + entire asset tree (stale 0.9.0 dir included) despite a throwing hook; force removal works when the entry file is missing from disk.
  • src/__tests__/plugins/pluginsAdmin.test.tsx: failed uninstall → "Remove anyway" → confirm dialog → DELETE ?force=true.
  • src/__tests__/architecture/plugin-boot-resilience.test.ts: gate updated for the unified teardown (drifted rule fixed in the same change per CLAUDE.md).

Verification

  • bun test — 4912 pass / 0 fail
  • bun run build — clean (tsc -b && vite build)
  • bun run lint — clean

🤖 Generated with Claude Code

A throwing (or unloadable) uninstall hook could permanently block plugin
removal: the DELETE handler returned 400 before deleting anything, with no
recovery path short of DB surgery.

Server:
- DELETE /admin/api/cms/plugins/:id?force=true skips lifecycle hooks and
  proceeds with full teardown. Capability + step-up gating unchanged
  (plugins.install + step-up); the audit event records forced: true.
- Normal uninstall now runs deactivate (when active) before uninstall,
  matching the documented "(if active) deactivate -> uninstall" contract.
- Hook failures return a 400 that names the failed hook and points at the
  force-remove escape hatch.
- All removal variants (normal, forced, corrupt manifest) converge on one
  teardown (removePluginCompletely): worker unload, canvas module pack
  deactivation, DB row delete, crash counter + crash events + schedule run
  history sweep (the latter two have no FK and used to outlive the row),
  and removal of the whole uploads/plugins/<id>/ tree - including stale
  version dirs left behind by interrupted upgrades (removeAllPluginAssets
  replaces the per-version removePluginAssets, assertPathWithin retained).

Admin UI:
- A hook-failed uninstall surfaces as a role="alert" with a "Remove anyway"
  action; forcing is gated by its own confirmation dialog warning that the
  plugin's cleanup code is skipped and external resources may remain.
- PluginRemoveDialog gains a force variant; pendingRemove carries the mode.

Docs/tests:
- plugin-system.md documents force-uninstall; the lifecycle contract line
  now matches the code.
- Server tests: deactivate-before-uninstall ordering, hook-failure 400 with
  escape-hatch message, force teardown (row + crash events + stale version
  dirs), force removal with a missing entry file.
- UI test: failed uninstall -> Remove anyway -> confirm -> DELETE ?force=true.
- plugin-boot-resilience architecture gate updated for the unified teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@DavidBabinec DavidBabinec force-pushed the feat/force-uninstall branch from 9bbf632 to 0092b6c Compare June 10, 2026 11:52
@DavidBabinec DavidBabinec merged commit 047cd01 into main Jun 10, 2026
5 checks passed
@DavidBabinec DavidBabinec deleted the feat/force-uninstall branch June 10, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant