the install runner: ecosystem entry, sidecar, row, mounts

closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 21:38:21 +00:00
co-authored by Claude Opus 5
parent ed195e0904
commit 2634df7a04
7 changed files with 375 additions and 50 deletions
+24
View File
@@ -0,0 +1,24 @@
// The reference sidecar: a long-lived process PM2 supervises.
//
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
// it, and it is the property that makes install-without-restart possible on the platform side too.
//
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
// it by capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
// installer, and adding a socket here would test Bun rather than that.
const name = 'officer-example';
console.log(`[${name}] started (pid ${process.pid})`);
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
const shutdown = (signal: string) => {
console.log(`[${name}] ${signal} — exiting`);
clearInterval(beat);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+19 -46
View File
@@ -1,9 +1,9 @@
import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
import { mountPrefix } from '../../plugins/manifest';
import { refreshPluginMounts, snapshotPlugins } from '../../plugins/mount';
import { snapshotPlugins } from '../../plugins/mount';
import { installPlugin, pluginProcessStatus, setPluginRunning, uninstallPlugin } from '../../plugins/install';
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
//
@@ -33,8 +33,12 @@ pluginsRouter.use(async (ctx, next) => {
*/
pluginsRouter.get('/', async (ctx) => {
const { states, broken } = await snapshotPlugins();
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
// rendering differently — it is the difference between "off" and "broken".
const statuses = await Promise.all(states.map(({ plugin }) => pluginProcessStatus(plugin)));
return ctx.json({
plugins: states.map(({ plugin, install, outdated }) => ({
plugins: states.map(({ plugin, install, outdated }, i) => ({
appName: plugin.appName,
prefix: mountPrefix(plugin),
label: plugin.manifest.label,
@@ -56,64 +60,33 @@ pluginsRouter.get('/', async (ctx) => {
enabled: install?.enabled ?? false,
installedVersion: install?.version ?? null,
outdated,
processStatus: statuses[i] ?? null,
})),
broken,
});
});
/** The plugin by name, or a 404 naming it. Shared by every verb below. */
async function findPlugin(appName: string) {
const { states } = await snapshotPlugins();
const state = states.find((s) => s.plugin.appName === appName);
if (!state) throw errors.NOT_FOUND(`No plugin directory named "${appName}"`);
return state;
}
/**
* POST /api/plugins/:appName/install
*
* Idempotent, and re-installing is how a plugin is upgraded: the row follows the version on disk. It does
* not touch `enabled`, so re-installing something the owner had switched off does not switch it back on.
*
* `[open]` The schema push and the sidecar's PM2 entry are not wired yet — this records the install and
* mounts the routes. A plugin with `db/schema.ts` or `sidecar/` will need both before it works end to end.
* The four verbs. Each returns the ordered list of what actually happened, rather than a bare `ok` —
* "installed" and "installed but the sidecar would not start" are different outcomes and the second is
* the one worth reading. See `plugins/install.ts` for why the order inside each is what it is.
*/
pluginsRouter.post('/:appName/install', async (ctx) => {
const { plugin } = await findPlugin(ctx.req.param('appName'));
await recordPluginInstall(plugin.appName, plugin.manifest.version);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName: plugin.appName, ...mounts });
const result = await installPlugin(ctx.req.param('appName'));
return ctx.json(result, result.ok ? 200 : 400);
});
/**
* POST /api/plugins/:appName/uninstall
*
* Drops the row and unmounts. Deletes nothing the plugin owns — its tables and every row in them survive,
* so reinstalling is a restore rather than a fresh start. Dropping a plugin's data is a separate and
* deliberate act, not a side effect of an unrelated one.
*/
pluginsRouter.post('/:appName/uninstall', async (ctx) => {
const appName = ctx.req.param('appName');
const removed = await removePluginInstall(appName);
if (!removed) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, ...mounts });
const result = await uninstallPlugin(ctx.req.param('appName'));
return ctx.json(result, result.ok ? 200 : 400);
});
/** POST /api/plugins/:appName/enable — mount its routes again. Nothing else changes. */
pluginsRouter.post('/:appName/enable', async (ctx) => {
const appName = ctx.req.param('appName');
const row = await setPluginEnabled(appName, true);
if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, enabled: true, ...mounts });
const result = await setPluginRunning(ctx.req.param('appName'), true);
return ctx.json(result, result.ok ? 200 : 400);
});
/** POST /api/plugins/:appName/disable — unmount, keep everything. The reversible middle ground. */
pluginsRouter.post('/:appName/disable', async (ctx) => {
const appName = ctx.req.param('appName');
const row = await setPluginEnabled(appName, false);
if (!row) throw errors.NOT_FOUND(`"${appName}" is not installed`);
const mounts = await refreshPluginMounts();
return ctx.json({ ok: true, appName, enabled: false, ...mounts });
const result = await setPluginRunning(ctx.req.param('appName'), false);
return ctx.json(result, result.ok ? 200 : 400);
});
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'bun:test';
import { pluginProcessName } from './ecosystem';
// The read/write pair is exercised live in `install.test.ts` against a real generated file, because the
// thing worth testing is that a round trip preserves the CORE entries officer-setup wrote — and that is a
// property of the real file's shape, not of a fixture I would author to match my own parser.
describe('pluginProcessName', () => {
it('is one rule, so nothing has to look it up', () => {
expect(pluginProcessName('offscale')).toBe('officer-offscale');
expect(pluginProcessName('example')).toBe('officer-example');
});
});
+126
View File
@@ -0,0 +1,126 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import type { DiscoveredPlugin } from './manifest';
// Giving a plugin's sidecar an entry in the PM2 ecosystem file, and taking it away again.
//
// ── The hole this closes ──
//
// `app-store/pm2.ts` has carried this since 2026-08-13:
//
// "[open] As of 2026-08-13 it contains the SIX core processes and nothing else, so
// `pm2 start ecosystem.config.cjs --only officer-jellyfin` finds no such app and does nothing.
// Installing a plugin has to append its entry here before starting it — that is the plugin
// system's job and it is not built."
//
// This is that job. It is why nothing in the app-store catalogue installs end to end today: the
// container comes up, the connection is written, the icon publishes, and the sidecar never starts.
//
// ── Why the file is read back rather than regenerated ──
//
// The CORE entries are written by `officer-setup` from a shell array (`services.sh`), so the platform
// cannot regenerate the whole file — it does not know what the core list is, and duplicating it here
// would be a second copy to drift. Instead the file is `require`d (it is CommonJS, deliberately, because
// package.json says `"type": "module"` and PM2 `require`s the config), its `apps` array is edited, and it
// is written back. Whatever officer-setup put there survives untouched.
//
// The header above `module.exports` is preserved verbatim — officer-setup's is the better one, and losing
// it to a plugin install would be a poor trade.
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
/**
* The header to write when there is no file to take one from.
*
* Normally the EXISTING header is preserved verbatim — officer-setup's explains why `cwd` is pinned on
* every app and what happens when it is wrong, and that is worth more than anything restated here. This
* is only the fallback for a file that does not exist yet.
*/
const FALLBACK_HEADER = `// Generated. Not in git, and not meant to be — it describes THIS install.
// Core processes come from officer-setup (scripts/setup/officer-setup/lib/services.sh); plugin processes
// are added and removed by the plugin installer (servers/plugins/ecosystem.ts).
`;
/** The PM2 process name for a plugin's sidecar. One rule, so nothing has to look it up. */
export const pluginProcessName = (appName: string): string => `officer-${appName}`;
/** Read the current apps array. An unreadable or missing file is an empty one — the caller decides. */
export function readEcosystemApps(): Pm2App[] {
if (!existsSync(ECOSYSTEM_PATH)) return [];
try {
// A plain parse rather than `require`: the file is generated and its shape is known, and requiring it
// would cache the module so a second read in the same process returned a stale array.
const source = readFileSync(ECOSYSTEM_PATH, 'utf-8');
const start = source.indexOf('[');
const end = source.lastIndexOf(']');
if (start < 0 || end < 0) return [];
// The generated file is JS object literals, not JSON — keys are bare and strings are single-quoted.
// Evaluating it is safe in the way that matters: it is a file this process wrote, sitting inside the
// install root, and anything able to edit it can already edit the server's own source.
const apps = new Function(`return ${source.slice(start, end + 1)}`)() as Pm2App[];
return Array.isArray(apps) ? apps : [];
} catch {
return [];
}
}
/**
* Rewrite the apps array, keeping everything above `module.exports` exactly as it was.
*
* The header is preserved rather than regenerated because officer-setup's is the better one — it explains
* that Bun auto-loads .env from the working directory, and that data-path derives the install root from
* its PARENT, so a wrong `cwd` silently relocates the whole install rather than failing. Losing that to a
* plugin install would be a poor trade.
*/
function writeEcosystemApps(apps: Pm2App[]): void {
const existing = existsSync(ECOSYSTEM_PATH) ? readFileSync(ECOSYSTEM_PATH, 'utf-8') : '';
const marker = existing.indexOf('module.exports');
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
const lines = apps.map(
(app) =>
` { name: '${app.name}', script: '${app.script}'` +
(app.args ? `, args: '${app.args}'` : '') +
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
);
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
}
/**
* Ensure the plugin's sidecar has an entry, replacing one that is already there.
*
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
*/
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
if (!plugin.sidecar) return false;
const name = pluginProcessName(plugin.appName);
// Relative to the platform, because `cwd` is pinned to it and an absolute path would break the moment
// the install root moved — which is exactly what happened to this machine two days ago.
const script = plugin.sidecar.script.startsWith(PLATFORM_DIR)
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
: plugin.sidecar.script;
const entry: Pm2App =
plugin.sidecar.runtime === 'node'
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
const apps = readEcosystemApps().filter((a) => a.name !== name);
apps.push(entry);
writeEcosystemApps(apps);
return true;
}
/** Drop the plugin's entry. Safe to call when it was never there. */
export function removePluginFromEcosystem(appName: string): void {
const name = pluginProcessName(appName);
const apps = readEcosystemApps();
const remaining = apps.filter((a) => a.name !== name);
if (remaining.length !== apps.length) writeEcosystemApps(remaining);
}
+147
View File
@@ -0,0 +1,147 @@
import { recordPluginInstall, removePluginInstall, setPluginEnabled } from 'officerdb';
import { PLATFORM_DIR } from '../data-path';
import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2';
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
import { refreshPluginMounts, snapshotPlugins } from './mount';
import type { DiscoveredPlugin } from './manifest';
// The install runner: the four verbs, each as a short ordered list of effects.
//
// ── Order is the whole design ──
//
// Every verb does its work in the order that leaves the system coherent if it stops halfway, because it
// can. Bringing something UP goes outside-in (make it possible, then start it, then record it, then
// expose it); taking something DOWN goes inside-out (stop exposing it, stop it, then forget it). The
// worst intermediate state is then "recorded but not running", which the UI can show and a retry fixes —
// never "running but forgotten", which nothing can see and nothing will clean up.
//
// ── What each verb touches ──
//
// ecosystem PM2 row mounts tables
// install add start upsert rebuild (see below)
// uninstall remove delete delete rebuild untouched
// enable — start enabled=t rebuild untouched
// disable — stop enabled=f rebuild untouched
//
// Nothing here drops a table, ever. Uninstall means "stop running this", and for a plugin holding a
// user's data the two are unrecoverably different — see `plugin_installs` schema.
//
// `[open]` The schema push. A plugin with `db/schema.ts` still needs its tables created, which means
// regenerating the drizzle barrel and running `db:push`. Deliberately not done in the same pass as this:
// push DROPS tables absent from the schema it is given, so an uninstall that regenerated the barrel would
// delete a plugin's data as a side effect of stopping it — exactly the thing this file refuses to do.
// Offscale does not need it yet (`headscale_servers` already ships in the platform schema).
export type PluginActionResult = {
ok: boolean;
appName: string;
/** What actually happened, in order. Returned so the UI can show a real account rather than a spinner. */
steps: string[];
/** Present when a step failed. The plugin is left in the last coherent state above. */
error?: string;
};
/** PM2 is only involved when the plugin actually has a sidecar. Most won't. */
const hasSidecar = (plugin: DiscoveredPlugin) => !!plugin.sidecar;
async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
const { states } = await snapshotPlugins();
return states.find((s) => s.plugin.appName === appName)?.plugin ?? null;
}
/**
* Install, or upgrade one already installed.
*
* Idempotent by construction: the ecosystem entry is replaced rather than appended, the row is an upsert,
* and the mount is a rebuild. Re-running after a failure resumes rather than duplicating.
*
* `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had
* switched off must not switch it back on.
*/
export async function installPlugin(appName: string): Promise<PluginActionResult> {
const plugin = await findPlugin(appName);
if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` };
const steps: string[] = [];
try {
if (hasSidecar(plugin)) {
addPluginToEcosystem(plugin);
steps.push(`ecosystem: ${pluginProcessName(appName)} added`);
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
if (!started.ok) {
// The entry stays. A sidecar that will not start is a plugin to retry or debug, and removing the
// entry would take away the thing that makes `pm2 logs officer-<name>` work.
return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` };
}
steps.push('sidecar: started');
}
if (plugin.schema) steps.push('schema: skipped — not wired yet (see install.ts)');
await recordPluginInstall(appName, plugin.manifest.version);
steps.push(`recorded at ${plugin.manifest.version}`);
const { mounted } = await refreshPluginMounts();
steps.push(mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)');
return { ok: true, appName, steps };
} catch (err) {
return { ok: false, appName, steps, error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Uninstall: stop answering, stop running, forget.
*
* Keeps every table and row the plugin owns, and keeps the directory. Reinstalling is therefore a restore
* rather than a fresh start, which is the whole reason not to drop anything here.
*/
export async function uninstallPlugin(appName: string): Promise<PluginActionResult> {
const steps: string[] = [];
const plugin = await findPlugin(appName);
// Row first: the mount rebuild below reads it, and a failure after this point leaves the plugin
// unmounted and stopped rather than half-visible.
const removed = await removePluginInstall(appName);
if (!removed) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
steps.push('install record removed');
const { mounted } = await refreshPluginMounts();
steps.push(`unmounted (now: ${mounted.join(', ') || 'no plugin routes'})`);
if (plugin && hasSidecar(plugin)) {
await stopProcess(pluginProcessName(appName), PLATFORM_DIR);
await deleteProcess(pluginProcessName(appName), PLATFORM_DIR);
removePluginFromEcosystem(appName);
steps.push('sidecar: stopped, deleted, ecosystem entry removed');
}
steps.push('tables and data: untouched');
return { ok: true, appName, steps };
}
/** Enable: mount and run again. Disable: the reversible middle — unmount and stop, keep everything. */
export async function setPluginRunning(appName: string, enabled: boolean): Promise<PluginActionResult> {
const steps: string[] = [];
const row = await setPluginEnabled(appName, enabled);
if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` };
steps.push(enabled ? 'enabled' : 'disabled');
const plugin = await findPlugin(appName);
if (plugin && hasSidecar(plugin)) {
const name = pluginProcessName(appName);
const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR);
steps.push(result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`);
}
const { mounted } = await refreshPluginMounts();
steps.push(`mounts: ${mounted.join(', ') || 'no plugin routes'}`);
return { ok: true, appName, steps };
}
/** Whether a plugin's sidecar is actually up, for the UI. `null` when it has none or PM2 has not seen it. */
export async function pluginProcessStatus(plugin: DiscoveredPlugin): Promise<string | null> {
if (!hasSidecar(plugin)) return null;
return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR);
}
@@ -62,6 +62,10 @@ export const PluginDetail = () => {
}
const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending;
// Whichever verb ran last. They are mutually exclusive in practice — the buttons are disabled while any
// is pending — so the first with data is the one that just happened.
const last = [install, uninstall, enable, disable].find((m) => m.data || m.error);
const result = last?.data;
return (
<div className="h-full overflow-auto p-6">
@@ -83,6 +87,21 @@ export const PluginDetail = () => {
<Row label="Ships">
<Parts has={plugin.has} />
</Row>
{plugin.has.sidecar ? (
<Row label="Sidecar">
{/* Enabled but not online is the state worth naming: the plugin is switched on and its
process is not running, which is broken rather than off. */}
<span
className={
plugin.enabled && plugin.processStatus !== 'online' && plugin.installed
? 'text-amber-600'
: 'text-duck-dark'
}
>
{plugin.processStatus ?? 'not started'}
</span>
</Row>
) : null}
<Row label="Permissions">
{plugin.permissions.length
? plugin.permissions.map((p) => `${p.key}${p.ownerOnly ? ' (owner only)' : ''}`).join(', ')
@@ -118,6 +137,20 @@ export const PluginDetail = () => {
)}
</div>
{/* What the last verb actually did. An install that mounted routes but could not start a sidecar is
a different outcome from one that worked, and a spinner that stops cannot say which happened. */}
{result ? (
<div className="mt-5 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3">
<div className="mb-1 text-xs font-medium text-duck-dark/60">{result.ok ? 'Done' : 'Failed'}</div>
<ul className="space-y-0.5 text-xs text-duck-dark/70">
{result.steps.map((step, i) => (
<li key={i}>· {step}</li>
))}
</ul>
{result.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
</div>
) : null}
{/* Uninstall keeps every table and row the plugin owns, so this is worth saying rather than
leaving someone to guess whether the button destroys their data. */}
{plugin.installed ? (
@@ -27,8 +27,17 @@ export type PluginItem = {
installedVersion: string | null;
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
outdated: boolean;
/**
* PM2's word for the sidecar, or null when the plugin has none. `online` while enabled is healthy;
* anything else while enabled is the state worth rendering differently — the difference between a
* plugin that is off and one that is broken.
*/
processStatus: string | null;
};
/** What a verb actually did, in order. Shown rather than collapsed to a spinner. */
export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string };
export type BrokenPlugin = { appName: string; error: string };
const PLUGINS_KEY = ['plugins'];
@@ -52,19 +61,19 @@ export function usePlugins() {
// Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a
// helper is a rules-of-hooks violation even when the call order happens to be stable.
const install = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}),
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/install`, {}),
onSuccess: invalidate,
});
const uninstall = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}),
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/uninstall`, {}),
onSuccess: invalidate,
});
const enable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}),
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/enable`, {}),
onSuccess: invalidate,
});
const disable = useMutation({
mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}),
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/disable`, {}),
onSuccess: invalidate,
});