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; 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 { 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; 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 }; }