app store: make it work — pm2, install state, and the routes

Email installs end to end now, which was the point of picking it as tier one: no container, no external
wiring, so the machinery is exercised without the provisioning half.

Verified against the running system, not asserted:

  POST /api/app-store/email/install  -> {"status":"installed","completed":["preflight","schema","process"]}
  row                                -> email mode=config status=installed enabled=true
  pm2                                -> officer-email online
  second install                     -> all three steps skipped, process not restarted
  disable                            -> stopped

The server boots with the new router, which is the real test of the capability entry: totality.ts throws
before serve() if a mounted router has none, so booting IS the check passing.

pm2.ts shells out rather than importing pm2 as a library. PM2 is already the supervisor and the
ecosystem file is already the definition of how each process runs; a second thing in charge of that
means two supervisors disagreeing. It also means an owner can undo anything the app store did with a
command they already know. The one fact that matters: `pm2 start <name>` fails for a process PM2 has
never seen, so a first install starts from the ecosystem file with --only, and everything after goes by
name. Callers cannot know which case they are in, so startProcess decides.

Disable stops rather than deletes: a stopped process still shows in `pm2 list`, which is the honest
picture. Deleting would make a disabled sidecar indistinguishable from one never installed.

beginInstall returns the existing row instead of replacing it — that is what makes a retry a resume
rather than a re-provision — and clears lastError on the way in, so a UI never shows a stale failure
beside a working service.

The container half of enable/disable/uninstall is deliberately absent rather than stubbed silently: a
disable that leaves Immich running is a different thing from one that stops it, and the difference is
memory on the user's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:55:11 +00:00
co-authored by Claude Opus 5
parent 4b9b98efda
commit ba71dc1957
10 changed files with 596 additions and 1 deletions
+85
View File
@@ -0,0 +1,85 @@
import { createRouter } from '../../create-router';
import * as errors from '../../custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { listStore, install, setEnabled, uninstall } from '../../app-store/service';
import { byId, type InstallMode } from '../../app-store/catalogue';
// /api/app-store — what can be installed, what is installed, and the four verbs that change it.
//
// ── Owner only, explicitly ──
//
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
// an administrative act however many members share the server, so this router gates on the owner in its
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
// mistake here is not a leak of data, it is arbitrary process control.
//
// The per-user half lives elsewhere: `service_connections` is where a member's own credential goes, and
// members.ts is how they get one.
export const appStoreRouter = createRouter();
appStoreRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The app store is owner-only');
return next();
});
/** GET /api/app-store — the catalogue joined to what has happened to each entry. */
appStoreRouter.get('/', async (ctx) => {
return ctx.json({ items: await listStore() });
});
/**
* POST /api/app-store/:id/install — install, or resume one that stopped.
*
* Resume is the same call deliberately: pressing the button after a failure and pressing it after
* supplying the API key it was waiting for are one action to the user. What re-runs is decided by the
* steps already recorded, not by which endpoint was hit.
*
* Answers with the outcome rather than a bare 200 — `blocked` is a normal result that the UI has to
* render differently from success, and flattening it to "ok" would lose the reason and the link.
*/
appStoreRouter.post('/:id/install', async (ctx) => {
const id = ctx.req.param('id');
const entry = byId(id);
if (!entry) throw errors.NOT_FOUND(`Unknown sidecar: ${id}`);
const body = (ctx.get('body') ?? {}) as { mode?: string; values?: Record<string, string> };
const mode = body.mode as InstallMode | undefined;
if (!mode) throw errors.BAD_REQUEST('mode is required');
if (!entry.modes.includes(mode)) {
throw errors.BAD_REQUEST(`${entry.label} cannot be installed as '${mode}'`);
}
// The install log is collected rather than streamed for now. Streaming it into a terminal panel is
// the intended shape — the lines are already produced one at a time — and needs a channel this route
// does not have yet.
const lines: string[] = [];
const outcome = await install({ sidecarId: id, mode, values: body.values ?? {}, log: (l) => lines.push(l) });
return ctx.json({ outcome, log: lines });
});
/** POST /api/app-store/:id/enable — start the sidecar (and, later, its container). */
appStoreRouter.post('/:id/enable', async (ctx) => {
await setEnabled(ctx.req.param('id'), true);
return ctx.json({ ok: true });
});
/** POST /api/app-store/:id/disable — stop it, keeping everything installed. */
appStoreRouter.post('/:id/disable', async (ctx) => {
await setEnabled(ctx.req.param('id'), false);
return ctx.json({ ok: true });
});
/**
* POST /api/app-store/:id/uninstall — stop running this.
*
* A POST rather than a DELETE, because it is not a deletion: the sidecar's tables and the service
* directory survive it. Calling it DELETE would suggest otherwise to the next person reading the route
* table.
*/
appStoreRouter.post('/:id/uninstall', async (ctx) => {
await uninstall(ctx.req.param('id'));
return ctx.json({ ok: true });
});