Files
platform/src/servers/app-store/service.ts
T
pastilhasandClaude Opus 5 ba71dc1957 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>
2026-08-10 13:55:11 +00:00

161 lines
5.6 KiB
TypeScript

import {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
markInstalled,
markFailed,
markBlocked,
setEnabled as setEnabledRow,
removeInstall,
type SidecarInstall,
} from 'officerdb';
import { CATALOGUE, byId, type CatalogueEntry, type InstallMode } from './catalogue';
import { runInstall, type StepName } from './installer';
import { createEffects } from './effects';
import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2';
import { plannedOutcome } from './members';
// The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this
// stays about what installing, enabling and uninstalling actually mean.
const PLATFORM_DIR = process.cwd();
export type StoreItem = {
id: string;
label: string;
summary: string;
modes: InstallMode[];
members: CatalogueEntry['members'];
/** What adding a member would do for this service, so the UI can say so before anyone commits. */
memberOutcome: ReturnType<typeof plannedOutcome>;
existingFields: CatalogueEntry['existingFields'];
configFields: CatalogueEntry['configFields'];
install: {
status: 'not-installed' | SidecarInstall['status'];
enabled: boolean;
mode: string | null;
lastError: string | null;
completedSteps: string[];
};
/** PM2's own view. Included because the row saying `enabled` and the process being dead is the
* interesting case, and hiding it would make the store lie about what is running. */
processStatus: string | null;
};
/** The catalogue joined to what has actually happened to each entry. */
export async function listStore(): Promise<StoreItem[]> {
const installs = new Map((await listSidecarInstalls()).map((row) => [row.sidecarId, row]));
return Promise.all(
CATALOGUE.map(async (entry) => {
const row = installs.get(entry.id);
return {
id: entry.id,
label: entry.label,
summary: entry.summary,
modes: entry.modes,
members: entry.members,
memberOutcome: plannedOutcome(entry),
existingFields: entry.existingFields,
configFields: entry.configFields,
install: {
status: row?.status ?? 'not-installed',
enabled: row?.enabled ?? false,
mode: row?.mode ?? null,
lastError: row?.lastError ?? null,
completedSteps: (row?.completedSteps as string[]) ?? [],
},
// Only asked for things that claim to be installed: `pm2 jlist` per catalogue entry would be
// fourteen subprocesses to render a page.
processStatus: row ? await processStatus(entry.process, PLATFORM_DIR) : null,
};
}),
);
}
export type InstallRequest = {
sidecarId: string;
mode: InstallMode;
/** Answers from the form. Passed to the setup script as environment and to `connect` as values. */
values: Record<string, string>;
log?: (line: string) => void;
};
/**
* Install, or resume an install that stopped.
*
* Resume is not a separate entry point on purpose: pressing the button again after a failure, and
* pressing it again after supplying the API key it was waiting for, are the same action from the user's
* side. The recorded steps decide what actually re-runs.
*/
export async function install(req: InstallRequest) {
const entry = byId(req.sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${req.sidecarId}`);
if (!entry.modes.includes(req.mode)) {
throw new Error(`${entry.label} cannot be installed as '${req.mode}'`);
}
const row = await beginInstall(entry.id, req.mode);
const completed = (row.completedSteps as StepName[]) ?? [];
const outcome = await runInstall({
entry,
mode: req.mode,
values: req.values,
completed,
effects: createEffects(),
log: req.log,
});
if (outcome.status === 'installed') {
await markInstalled(entry.id, outcome.completed);
} else if (outcome.status === 'blocked') {
await markBlocked(entry.id, outcome.completed, outcome.reason);
} else {
await markFailed(entry.id, outcome.completed, outcome.error);
}
return outcome;
}
/**
* Start or stop a sidecar and its container, without changing what is installed.
*
* The container half is not implemented yet — `docker compose stop` against the recorded compose
* directory — and is deliberately absent rather than silently skipped: a disable that leaves Immich
* running is a different thing from one that stops it, and the difference is memory on the user's box.
*/
export async function setEnabled(sidecarId: string, enabled: boolean) {
const entry = byId(sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
const row = await getSidecarInstall(sidecarId);
if (!row) throw new Error(`${entry.label} is not installed`);
const result = enabled
? await startProcess(entry.process, PLATFORM_DIR)
: await stopProcess(entry.process, PLATFORM_DIR);
if (!result.ok) throw new Error(result.error);
await setEnabledRow(sidecarId, enabled);
return { ok: true as const };
}
/**
* Stop running this. Never "delete my data".
*
* The process is stopped and removed from PM2. The sidecar's tables, the service directory and
* everything under it survive — see the schema comment and the design doc. Removing the containers is
* the missing half, for the same reason as above.
*/
export async function uninstall(sidecarId: string) {
const entry = byId(sidecarId);
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
const stopped = await stopProcess(entry.process, PLATFORM_DIR);
if (!stopped.ok) throw new Error(stopped.error);
await deleteProcess(entry.process, PLATFORM_DIR);
await removeInstall(sidecarId);
return { ok: true as const };
}