import { deleteScreenForAllUsers, getDockPaths, getPluginInstall, recordPluginInstall, removePluginInstall, setDockPaths, setPluginEnabled, } from 'officerdb'; import { corePermissionCollisions } from '../permissions/registry'; import { rm } from 'node:fs/promises'; import { PLATFORM_DIR } from '../data-path'; import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-store/pm2'; import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem'; import { discoverPlugins } from './discover'; import { fetchPlugin } from './fetch'; import { revokeGrantsForPermissions } from 'officerdb'; import { publishAssets, unpublishAssets } from '../app-store/assets'; import { publishAffordances, unpublishAffordances } from '../affordances/publish'; import { publishNotifications, unpublishNotifications } from '../notifications/publish'; import { publishSkills, unpublishSkills } from '../skills/publish'; import { refreshPluginMounts, snapshotPlugins } from './mount'; import { generatePluginSchemas, pushPluginSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; import type { OnLog } from './run-logged'; import { installDependencies, installRelease, manualInstallHint, reportDependencies, stillMissing } from './os-deps'; import { anyPluginWantsPackages, installNpmDependencies, unresolvableDependencies } from './npm-deps'; import { mountPrefix, 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 ── // // host deps ecosystem PM2 row mounts tables // install install add start upsert rebuild (see below) // uninstall untouched remove delete delete rebuild untouched // enable untouched — start enabled=t rebuild untouched // disable untouched — stop enabled=f rebuild untouched // // Host dependencies are installed and never removed. A package is a machine-wide resource that other // things may have started depending on the moment it appeared, so uninstalling a plugin has no business // deciding that `ffmpeg` should go — the same reasoning as tables, one level down. // // 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. // // The schema push happens on install and NEVER on uninstall. `drizzle-kit push` drops what it cannot // see, so the generated barrel follows the plugin DIRECTORIES rather than the install table — uninstall // leaves both the barrel entry and every row alone, and only deleting a plugin's source can lose data. // See plugins/schema.ts. /** * Reported as each step completes, for the streaming endpoint. * * The runner does not know or care whether anyone is listening — it calls this and carries on, so the * non-streaming path is the same code with no callback rather than a second implementation. */ export type OnStep = (step: string) => void | Promise; /** * A beat between steps. Deliberate, and not decoration — do not remove it as a pointless sleep. * * Some of this work is genuinely slow (`pm2 start` measures ~770ms) and some is not: writing a row and * rebuilding the router are effectively instant. Without a pause the fast steps arrive in a single frame, * so the log jumps from empty to finished and the reader cannot tell "it worked" from "nothing happened". * The interval IS the feedback — it is what makes each step something you saw occur rather than something * you found already done. * * Small enough that a script does not care, long enough that a person can follow it. Only applied when * something is listening: `onStep` absent means no beat, so the JSON path runs at full speed. */ const STEP_BEAT_MS = 220; 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; /** Record a step, tell whoever is listening, and pause so the next one does not land in the same frame. */ async function step(steps: string[], onStep: OnStep | undefined, text: string): Promise { steps.push(text); await onStep?.(text); if (onStep) await Bun.sleep(STEP_BEAT_MS); } async function findPlugin(appName: string): Promise { const { states } = await snapshotPlugins(); return states.find((s) => s.plugin.appName === appName)?.plugin ?? null; } /** * Put the new plugin's tile on the installing user's dock. * * Only for a user who has SAVED a dock. That list is a complete, ordered replacement — `useDock` renders * it rather than everything available — so a plugin installed after they arranged it is invisible until * they go to Profile → Dock and add it by hand. Installing something is already the decision to use it; * making them say so twice is the bug. * * A user with no row is deliberately left alone. There is nothing to append to, and writing one here * would freeze today's defaults into their account forever — the frontend covers that case instead, by * including plugin tiles in the default itself (`defaultDockPaths` in Dock.tsx). * * Appended, never inserted, so it lands at the end where a new thing belongs and their ordering survives. * * Best-effort by design: the plugin is installed and working at this point, and failing the whole install * over a dock preference would be the tail wagging the dog. */ async function addToDock(userId: number, route: string): Promise { try { const saved = await getDockPaths(userId); if (!saved || saved.includes(route)) return false; await setDockPaths(userId, [...saved, route]); return true; } catch { return false; } } /** * 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, onStep?: OnStep, forUserId?: number, onLog?: OnLog, ): Promise { const steps: string[] = []; // FETCH FIRST, when the plugin is not here yet. // // Until 2026-08-15 every plugin shipped inside the platform's repository, so "installed" and "present" // were nearly the same thing and install could assume a directory. Music moving to its own repository // ended that: it is real, installable, and absent. So the first question is no longer "is it enabled" // but "is it even on this machine". // // Cloning before anything else keeps the same property every other step here has — it changes nothing // that needs undoing if it fails. A failed clone removes its own directory and the machine is exactly // as it was. let plugin = await findPlugin(appName); if (!plugin) { const fetched = await fetchPlugin(appName, onLog); if (!fetched.ok) { return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` }; } await step(steps, onStep, `fetched: cloned into plugins/${appName}`); // Re-discovered rather than assumed. The catalogue DESCRIBED this plugin; the manifest on disk is // what the platform actually obeys, and a catalogue that disagreed with it is a catalogue bug rather // than a second source of truth. plugin = await findPlugin(appName); if (!plugin) { return { ok: false, appName, steps, error: `cloned "${appName}" but it does not read as a plugin` }; } } // PERMISSION KEYS BEFORE ANYTHING IS WRITTEN. // // A key the platform still claims is refused by `setPluginPermissions` at mount time — correctly, but // too late and too quietly: the install completes, the dock tile appears, the frontend rebuilds with // the route in it, and every request 404s because the plugin never mounted. The only trace is one line // on stderr. // // It is not a hypothetical. photos, soulseek and jellyfin each hit it within a day of being extracted, // always the same way: the plugin's key was a CORE permission until the extraction commit, so any // server whose platform predates that commit still claims it. The fix is always `git pull`, and the // symptom never says so. const collisions = corePermissionCollisions(plugin.manifest.permissions.map((p) => p.key)); if (collisions.length) { return { ok: false, appName, steps, error: `the platform still defines ${collisions.map((c: string) => `"${c}"`).join(', ')} as a core permission, ` + `so "${appName}" cannot claim it. This server's platform predates the commit that extracted ` + `${appName} — pull the platform and restart officer, then install again.`, }; } try { // HOST BINARIES FIRST, before a table is created or a row is written. // // Music is why. Without `ffprobe` its indexer does not fail — it writes a complete library where // every track is a bare filename with no cover, and reports success. So a plugin whose dependencies // cannot be satisfied must not install at all: the alternative is a plugin that runs, answers 200, // and quietly produces nothing, which is far harder to diagnose than a refusal. // // First because it is the only step that can refuse for a reason the owner can act on, and refusing // before any effect means there is nothing to undo. Everything below this line changes the machine. const deps = reportDependencies(plugin); if (deps.missing.length) { if (!deps.canInstall) { const hint = manualInstallHint(deps.manager, deps.packagesToInstall); return { ok: false, appName, steps, error: `${appName} needs ${deps.missing.map((d) => d.binary).join(', ')}, which ${deps.missing.length === 1 ? 'is' : 'are'} not installed. ` + `${deps.blockedReason ?? ''}${hint ? ` Run: ${hint}` : ''}`.trim(), }; } // Release binaries first, and separately: they need no package manager, which is the reason they // exist. `tea` is the motivating case — Gitea publishes it from dl.gitea.com, and the Debian // package of that name is an unrelated text editor. for (const dep of deps.missing.filter((d) => d.releaseUrl)) { await step(steps, onStep, `dependencies: fetching ${dep.binary} from ${dep.releaseUrl}`); const got = await installRelease(dep); if (!got.ok) { return { ok: false, appName, steps, error: `could not fetch ${dep.binary}: ${got.error}` }; } await step(steps, onStep, `dependencies: ${dep.binary} installed to ${got.path}`); } if (deps.packagesToInstall.length) { await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`); } const installed = deps.packagesToInstall.length ? await installDependencies(deps.manager!, deps.packagesToInstall) : { ok: true as const, installed: [], output: '' }; if (!installed.ok) { return { ok: false, appName, steps, error: `dependency install failed: ${installed.error ?? installed.output}`, }; } // Observed, not inferred. A package manager exiting 0 having installed something that does not // provide the binary the plugin spawns is exactly the failure this whole check exists to catch, // and trusting the exit code would reproduce it one layer up. const absent = stillMissing(plugin); if (absent.length) { return { ok: false, appName, steps, error: `installed ${deps.packagesToInstall.join(', ')}, but ${absent.join(', ')} still ${absent.length === 1 ? 'is' : 'are'} not on PATH`, }; } await step(steps, onStep, `dependencies: ${deps.missing.map((d) => d.binary).join(', ')} now on PATH`); } else if (deps.dependencies.length) { // Said out loud rather than skipped silently: "it was already there" and "we never looked" are // different facts, and only one of them is reassuring. await step(steps, onStep, `dependencies: ${deps.dependencies.map((d) => d.binary).join(', ')} already present`); } // ── npm packages, shared across every plugin ── // // Beside the OS dependencies above and verified the same way, because the same failure is available: // an install that exits 0 and still leaves nothing the sidecar can import. Atelier hit exactly that on // 2026-08-20 — `sharp` in the PLATFORM's package.json, alpha never told to `bun install`, and the // sidecar crash-looped on a plugin that had reported a clean install. // // Generated from every plugin ON DISK rather than from this one, so a shared `sharp` is installed once // and two plugins wanting it converge instead of carrying a copy each. { const { plugins: onDisk } = await discoverPlugins(); if (anyPluginWantsPackages(onDisk)) { await step(steps, onStep, 'packages: resolving npm dependencies declared by plugins'); const npm = await installNpmDependencies(onDisk); for (const [name, askers] of Object.entries(npm.conflicts)) { // Named, not silently intersected. Two plugins disagreeing about a version is a thing their // authors need to know, and the install either resolves it or fails on the next line. await step( steps, onStep, `packages: ${name} requested as ${askers.map((a) => `${a.range} (${a.appName})`).join(', ')} — intersected`, ); } if (!npm.ok) { return { ok: false, appName, steps, error: `package install failed: ${npm.output || 'no output'}` }; } // Resolved, not assumed. `sharp` resolves to a platform-specific binary package, so an install // that succeeds on one machine can still leave nothing importable on another — which is the shape // of the failure this whole block exists to stop shipping. const unresolvable = unresolvableDependencies(plugin); if (unresolvable.length) { return { ok: false, appName, steps, error: `packages installed, but ${unresolvable.join(', ')} cannot be resolved from ${plugin.dir}`, }; } await step(steps, onStep, 'packages: installed and resolvable'); } } if (plugin.schema) { // The barrel first, then the push. It is generated from the DIRECTORIES on disk rather than from // what is installed — see schema.ts for why that difference is the whole safety property. const { plugins } = await discoverPlugins(); generatePluginSchemas(plugins); // The table list first: it is both what the push is scoped to and what is verified afterwards. const { tables, refused } = await declaredTables(plugin); const pushed = await pushPluginSchema(plugin, tables, onLog); if (!pushed.ok) { return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` }; } // Exit code 0 is not proof the tables exist. // // An install reported success on a machine where `offscale_servers` was never created: the plugin // mounted, its sidecar started, registered, and failed its first query with 42P01. Every step said // OK and the plugin was unusable, which is the worst shape a failure can take. The scoped push // above removes the cause; this stays because "the command exited 0" and "the tables are there" // are different claims, and only one of them is the one that matters. // // So the install asks Postgres. A missing table fails the install HERE, where the message can say // what happened, instead of appearing later as a stack trace in a sidecar log. if (refused.length) { // Not fatal: the plugin still works, and the tables are still created by push. But they are // outside its prefix, so `purge` will not remove them and nobody will notice until a reinstall // finds them already there. await step(steps, onStep, `schema: WARNING — ${refused.join(', ')} are not prefixed \`${appName}_\``); } const present = await existingTables(tables); const missing = tables.filter((t) => !present.includes(t)); if (missing.length) { return { ok: false, appName, steps, error: `schema push reported success but did not create ${missing.join(', ')}. Run \`bun db:push\` by hand and read its output. Push said: ${pushed.output}`, }; } await step( steps, onStep, `schema: applied in ${pushed.ms}ms${tables.length ? ` (${tables.length} table(s) verified)` : ''}`, ); } await recordPluginInstall(appName, plugin.manifest.version); await step(steps, onStep, `recorded at ${plugin.manifest.version}`); /* Throw away every saved layout for this plugin's screen, for every user. A layout holds `appType` STRINGS. Rename a panel key between two versions and every saved layout still names the old one; `normalizeLayout` then swaps each unknown appType for `allowed[0]` — whichever panel the plugin lists first — and `WorkspaceView` persists the repair. Renaming `headscale-*` to `offscale-*` therefore rewrote the owner's layout into three copies of the picker and saved it, which survived a server restart, a sidecar restart and a reinstall, because nothing on disk was wrong. Install is the only place that knows a plugin's panels may have just changed shape, so it is where the stale layout dies. Nothing is lost: a plugin screen is `locked`, so the layout is the author's design rather than an arrangement someone made, and `useDashboardState` re-seeds from `defaultLayout` on the next load. */ const clearedLayouts = await deleteScreenForAllUsers(`plugin/${appName}`); if (clearedLayouts) await step(steps, onStep, `layout: reset for ${clearedLayouts} user(s)`); // The dock tile's artwork, copied where the browser can fetch it. Reuses the app store's publisher — // `/assets/` → `public/plugins//` — because it already solves the parts that are not // obvious: the `/plugins/*` route is dynamic precisely so a first install does not show a broken // image until the next restart, and uninstall is allowed to delete these because they are copies of // files that still exist in the plugin's source. // Either asset is reason enough to publish — the copy takes the whole directory, and gating on // the icon alone left a plugin that ships only a splash serving nothing. if (plugin.icon || plugin.splash) { await publishAssets(appName, plugin.dir); await step(steps, onStep, `assets: published to /plugins/${appName}/`); } // What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at // runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is // authenticated unless the plugin says so. Same shape as the assets above — a directory in the // repository, copied to a platform-managed place. // // Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install // over it would cost the owner the plugin. try { const published = await publishAffordances(plugin); if (published) { await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`); } } catch (err) { await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`); } // Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs, // and its pushes render the unknown-code fallback on the device rather than nothing at all. try { const published = await publishNotifications(plugin); if (published) { await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`); } } catch (err) { await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`); } // Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does // not learn the reference exists, which is the state every plugin was in before this. try { const published = await publishSkills(plugin); if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`); } catch (err) { await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`); } // The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only // offers a tile for a plugin that has a frontend — a backend-only plugin has no screen to pin. if (plugin.web && forUserId !== undefined) { const route = mountPrefix(plugin); if (await addToDock(forUserId, route)) await step(steps, onStep, `dock: ${route} pinned`); } // MOUNT BEFORE STARTING THE SIDECAR, and the order is not cosmetic. // // `createSidecarProxy` learns its sidecar's port from a one-shot event (`:server`), and it // subscribes when the plugin's router module is first imported — which happens here, at mount. Start // the process first and it announces its port to nobody: the sidecar is online, the routes are // mounted, and every request answers `503 sidecar not available` until something makes it reconnect. // // Found installing offscale, whose sidecar binds its own HTTP server. `example` never caught it // because it has no listener to announce. const { mounted } = await refreshPluginMounts(); await step( steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)', ); if (hasSidecar(plugin)) { addPluginToEcosystem(plugin); await step(steps, onStep, `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-` work. return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` }; } await step(steps, onStep, 'sidecar: started'); } 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, onStep?: OnStep, onLog?: OnLog): Promise { 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` }; await step(steps, onStep, 'install record removed'); const { mounted } = await refreshPluginMounts(); await step(steps, onStep, `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); await step(steps, onStep, 'sidecar: stopped, deleted, ecosystem entry removed'); } // The one thing uninstall IS allowed to delete: published assets are copies, and the originals are // still in the plugin's own directory. Nothing a user made is in there. // Its grants go too. The keys stop existing when the plugin is unmounted, and a grant naming one is a // row nobody can see, edit or remove through the UI — which made the whole permissions screen // unsaveable, because the page submits every grant it loaded and the write refused a key it no longer // recognised. Reported on a real server after uninstalling music. // // This is a deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's record of // who may reach the plugin, not anything the plugin stored. Reinstalling means granting again, which // is the honest behaviour anyway — the alternative is access silently returning. if (plugin) { const keys = plugin.manifest.permissions.map((p) => p.key); const revoked = await revokeGrantsForPermissions(keys).catch(() => 0); if (revoked) await step(steps, onStep, `grants: ${revoked} removed for ${keys.join(', ')}`); } await unpublishAssets(appName); // Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops // being told about a surface that is no longer there — an affordance for an uninstalled plugin is // worse than none, because it reads as true. if (plugin) await unpublishAffordances(plugin).catch(() => {}); // And its notification codes, which stop resolving rather than resolving to something stale. A device // still holding a queued push renders the unknown-code fallback, which is a defined state. if (plugin) await unpublishNotifications(plugin).catch(() => {}); if (plugin) await unpublishSkills(plugin).catch(() => {}); await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)'); return { ok: true, appName, steps }; } /** * Delete a plugin completely: its rows, its tables, and its source. * * ── Why this exists next to an uninstall that deliberately keeps everything ── * * `uninstallPlugin` is careful on purpose. It stops the plugin and forgets it is installed, and every * table and row survives, because "stop running this" must never mean "delete my data" — a reinstall * puts you back where you were, which is the property that makes uninstalling safe to try. * * That leaves no way to get back to a CLEAN machine. There was no way to test an install twice: the * second one finds the directory already there, so the catalogue hides it, and finds the tables already * there, so `db:push` has nothing to do and a broken schema stays broken. This is the verb for that, and * it is deliberately a different word with a different button and a confirmation. * * ── Order, and why this one ── * * Tables are read from the schema BEFORE the directory goes, because the list comes from the file that is * about to be deleted. Then: uninstall (stops the sidecar and unmounts — nothing may be running against * tables that are about to vanish), drop, delete, regenerate the barrel. * * The barrel last and unconditionally: it names plugin directories, and one naming a directory that no * longer exists is exactly the failure that stopped a server booting on 2026-08-16. Leaving it stale here * would break the next `db:push` rather than this command. */ export async function purgePlugin(appName: string, onStep?: OnStep, onLog?: OnLog): Promise { const steps: string[] = []; const plugin = await findPlugin(appName); if (!plugin) return { ok: false, appName, steps, error: `"${appName}" is not on this machine` }; // Read the table list while the source still exists. const { tables, refused } = await declaredTables(plugin).catch(() => ({ tables: [], refused: [] })); if (refused.length) { // Said out loud rather than silently skipped: these are the plugin's tables by any reasonable reading // and they are being LEFT BEHIND, which the next install will trip over. await step(steps, onStep, `left alone (not prefixed \`${appName}_\`): ${refused.join(', ')}`); } // Stop it first. Dropping a table under a running sidecar gives it a 42P01 instead of a shutdown. if (await getPluginInstall(appName)) { const uninstalled = await uninstallPlugin(appName, onStep, onLog); if (!uninstalled.ok) return { ...uninstalled, steps: [...steps, ...uninstalled.steps] }; steps.push(...uninstalled.steps); } else { await step(steps, onStep, 'not installed — nothing to stop'); } if (tables.length) { const present = await existingTables(tables); await dropTables(present); await step(steps, onStep, present.length ? `dropped: ${present.join(', ')}` : 'no tables to drop'); } await rm(plugin.dir, { recursive: true, force: true }); await step(steps, onStep, `deleted ${plugin.dir}`); // From the directories that are left, so the barrel stops naming one that is gone. const { plugins } = await discoverPlugins(); generatePluginSchemas(plugins); await step(steps, onStep, 'schema barrel regenerated'); await step(steps, onStep, `"${appName}" can now be installed fresh from the marketplace`); 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, onStep?: OnStep, ): Promise { const steps: string[] = []; const row = await setPluginEnabled(appName, enabled); if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` }; await step(steps, onStep, enabled ? 'enabled' : 'disabled'); const plugin = await findPlugin(appName); // Enabling mounts BEFORE starting, for the same reason install does: the proxy has to be listening // before the sidecar announces its port. Disabling is the mirror — stop answering, then stop the // process — so neither direction leaves a mounted route in front of a sidecar that cannot be reached. if (enabled) { const { mounted } = await refreshPluginMounts(); await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`); } if (plugin && hasSidecar(plugin)) { const name = pluginProcessName(appName); const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR); await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`); } if (!enabled) { const { mounted } = await refreshPluginMounts(); await step(steps, onStep, `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 { if (!hasSidecar(plugin)) return null; return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR); }