Installing Music left the dock unchanged. The tile existed server-side the moment the plugin was enabled, and the only way to see it was Profile → Dock, by hand, after a page refresh. Installing something is already the decision to use it; saying so twice is the bug. Three separate causes, which is why it looked like one stubborn one: 1. `dock_configs.paths` is a COMPLETE ordered list, not a diff — useDock renders the saved list rather than everything available, so anything not named in it is invisible. Nothing in the plugin system had ever written to that table. installPlugin now appends the new route, for the caller (threaded through the route) rather than a guessed owner id. Only for a user who HAS a row. A user without one is left alone deliberately: there is nothing to append to, and writing one would freeze today's defaults into their account permanently. That case is (2). 2. The fallback for a user who never customised was DEFAULT_DOCK_PATHS, which is core-only — so a plugin tile was hidden there too. `defaultDockPaths(plugins)` is now that list plus a tile per installed plugin, and the dock and its settings screen both call it so they cannot disagree about what "default" means. 3. useDock holds ['DOCK'] at staleTime: Infinity and the plugin verbs invalidated only ['plugins'] and ['self-permissions']. So even once the server wrote the path, nothing asked again — that is the part that made a reload necessary. Also fixes a guard I broke reaching for this: typing VERBS as Record<string, VerbFn> widens keyof to `string`, which makes `isVerb` prove nothing and lets an unknown verb index the map. `satisfies` instead, and there is now a check that Verb is still the four-member union. Uninstall deliberately does not remove the path. A stale path with no item behind it is dropped by useDock, and keeping it means disable/enable and reinstall put the tile back where the user had it rather than at the end. tsgo clean, frontend builds. 787 pass / 7 fail — 6 identical to before; the 7th is plugins/music/cliamp, which install pulled into test discovery and which the manifest documents as parked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
17 KiB
TypeScript
350 lines
17 KiB
TypeScript
import { getDockPaths, recordPluginInstall, removePluginInstall, setDockPaths, 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 { discoverPlugins } from './discover';
|
|
import { fetchPlugin } from './fetch';
|
|
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
|
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
|
import { generatePluginSchemas, pushSchema } from './schema';
|
|
import { installDependencies, manualInstallHint, reportDependencies, stillMissing } from './os-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<void>;
|
|
|
|
/**
|
|
* 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<void> {
|
|
steps.push(text);
|
|
await onStep?.(text);
|
|
if (onStep) await Bun.sleep(STEP_BEAT_MS);
|
|
}
|
|
|
|
async function findPlugin(appName: string): Promise<DiscoveredPlugin | null> {
|
|
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<boolean> {
|
|
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): Promise<PluginActionResult> {
|
|
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);
|
|
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` };
|
|
}
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
|
const installed = await installDependencies(deps.manager!, deps.packagesToInstall);
|
|
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`);
|
|
}
|
|
|
|
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);
|
|
|
|
const pushed = await pushSchema();
|
|
if (!pushed.ok) {
|
|
return { ok: false, appName, steps, error: `schema push failed: ${pushed.output}` };
|
|
}
|
|
await step(steps, onStep, `schema: applied in ${pushed.ms}ms`);
|
|
}
|
|
|
|
await recordPluginInstall(appName, plugin.manifest.version);
|
|
await step(steps, onStep, `recorded at ${plugin.manifest.version}`);
|
|
|
|
// The dock tile's artwork, copied where the browser can fetch it. Reuses the app store's publisher —
|
|
// `<dir>/assets/` → `public/plugins/<appName>/` — 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.
|
|
if (plugin.icon) {
|
|
await publishAssets(appName, plugin.dir);
|
|
await step(steps, onStep, `assets: published to /plugins/${appName}/`);
|
|
}
|
|
|
|
// 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 (`<name>: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-<name>` 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): 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` };
|
|
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.
|
|
await unpublishAssets(appName);
|
|
await step(steps, onStep, 'tables and data: untouched (published assets removed)');
|
|
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<PluginActionResult> {
|
|
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<string | null> {
|
|
if (!hasSidecar(plugin)) return null;
|
|
return processStatus(pluginProcessName(plugin.appName), PLATFORM_DIR);
|
|
}
|