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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user