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
+109
View File
@@ -0,0 +1,109 @@
import { join } from 'node:path';
import { getOwnerUser, saveServiceConnection } from 'officerdb';
import type { InstallEffects, StepContext } from './installer';
import { preflight } from './preflight';
import { runSetupScript } from './run-script';
import { startProcess } from './pm2';
import { serviceDir } from './paths';
// The installer's steps, wired to the actual world.
//
// Kept apart from `installer.ts` on purpose: that file is the machine — ordering, resume, blocking —
// and is tested with these replaced by spies. This file is the thin part that genuinely touches PM2,
// Postgres and the filesystem, and is deliberately boring enough to read in one sitting.
/** Where the platform lives, for `pm2 start ecosystem.config.cjs`. */
const PLATFORM_DIR = process.cwd();
const TEMPLATES_DIR = join(import.meta.dir, 'templates');
export function createEffects(): InstallEffects {
return {
preflight: (entry, mode) => preflight(entry, mode),
async provision(ctx: StepContext) {
const template = ctx.entry.composeTemplate;
// planSteps only emits `provision` for a provisioned install, and the catalogue test asserts
// every such entry has a template — so reaching here without one is a broken catalogue, not a
// user error, and it should say so rather than fail obscurely inside the runner.
if (!template) throw new Error(`${ctx.entry.id} has no compose template but was asked to provision`);
const result = await runSetupScript({
sidecarId: ctx.entry.id,
templateDir: join(TEMPLATES_DIR, template),
env: ctx.values,
log: ctx.log,
});
if (!result.ok) throw new Error(result.error);
return { results: result.results, composeDir: result.serviceDir };
},
async connect(ctx: StepContext) {
const url = ctx.values.url;
// A provisioned install knows its own URL because the setup script printed it. An `existing` one
// was given it in the form. Neither having produced one means we cannot reach the service, and
// the honest answer is to stop rather than write a row that points nowhere.
if (!url) {
return {
status: 'blocked',
reason: `${ctx.entry.label} is running, but no connection URL was provided.`,
};
}
// Shape 3 from templates/README.md: Immich, Jellyfin and Memos mint their API key in their own
// UI, so a provisioned install is up and healthy but not yet connectable. That is a pause, not a
// failure — see markBlocked.
const needsSecret = ctx.entry.existingFields?.some((f) => f.key === 'secret' && f.required);
if (needsSecret && !ctx.values.secret) {
return {
status: 'blocked',
reason: `${ctx.entry.label} needs an API key, which only its own interface can create.`,
completeAt: url,
};
}
const owner = await getOwnerUser();
if (!owner) throw new Error('no owner account');
// The OWNER's row: it carries the URL and IS the instance. Members get their own rows later, with
// a null url that inherits this one — see members.ts and the service_connections schema.
await saveServiceConnection({
userId: owner.id,
service: ctx.entry.id as never,
url,
username: ctx.values.username || null,
secret: ctx.values.secret || null,
path: ctx.values.path || null,
});
ctx.log(`connection saved for ${ctx.entry.label}`);
return { status: 'done' };
},
async applySchema(ctx: StepContext) {
// A no-op today, and honestly so. Every sidecar's tables still ship in the platform's single
// Drizzle schema and arrive together via `bun db:push`, so by the time an install runs they
// already exist. The step is in the plan because per-sidecar schema is the direction — a
// marketplace plugin cannot ship a table into a schema it does not own — and adding the step
// later would mean revisiting every install that had already recorded its progress without it.
ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)');
},
async startProcess(ctx: StepContext) {
const result = await startProcess(ctx.entry.process, PLATFORM_DIR);
if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`);
ctx.log(`${ctx.entry.process} started`);
},
async provisionMembers(ctx: StepContext) {
// Nothing implements MemberProvisioner yet — each sidecar will, beside its own code, rather than
// in a switch here. Logged rather than silently skipped so that a member who cannot see a service
// has a trail explaining why.
ctx.log(`members: no provisioner registered for ${ctx.entry.id} yet — members will need manual access`);
},
};
}
/** Where a provisioned service's compose file lives, for the caller to record. */
export const composeDirFor = (sidecarId: string): string => serviceDir(sidecarId);