import { listSidecarInstalls, getSidecarInstall, beginInstall, markInstalled, markFailed, markBlocked, recordSteps, 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'; import { composeUp, composeStop, composeDown } from './compose'; import { unpublishAssets } from './assets'; import { serviceDir } from './paths'; // 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') { // Recorded from the install rather than derived later: the directory is the user's and they may move // it, and uninstall must not guess at a path it is about to run `docker compose down` in. if (req.mode === 'provisioned') await recordSteps(entry.id, outcome.completed, serviceDir(entry.id)); 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. * * Order matters in both directions, and it is the opposite each way: * * enable container first, then the process — a sidecar that starts before its upstream exists * spends its first seconds failing health checks and logging errors about a service that * is merely not up yet. * disable process first, then the container — stopping the container underneath a running sidecar * produces the same noise for the same reason, in reverse. * * `mode: 'existing'` has no container of ours, and `composeUp`/`composeStop` return `ran: false` * rather than failing: there is no compose file, which is the correct state, not an error. */ 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 dir = row.composeDir ?? serviceDir(sidecarId); if (enabled) { const container = await composeUp(dir); if (!container.ok) throw new Error(`could not start ${entry.label}'s container: ${container.error}`); const process = await startProcess(entry.process, PLATFORM_DIR); if (!process.ok) throw new Error(process.error); } else { const process = await stopProcess(entry.process, PLATFORM_DIR); if (!process.ok) throw new Error(process.error); const container = await composeStop(dir); if (!container.ok) throw new Error(`could not stop ${entry.label}'s container: ${container.error}`); } await setEnabledRow(sidecarId, enabled); return { ok: true as const }; } /** * Stop running this. Never "delete my data". * * What goes: the process (stopped, then removed from PM2), the containers (`docker compose down`, no * `-v`), the published assets, and the install row. * * What stays: the service directory and everything under it — configuration, databases, media — and the * sidecar's tables. Reinstalling later is therefore a restore rather than a fresh start. * * The assets are the only thing here that is deleted outright, and only because they are COPIES; the * originals live with the sidecar. Nothing a user made is in that directory. */ export async function uninstall(sidecarId: string) { const entry = byId(sidecarId); if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`); const row = await getSidecarInstall(sidecarId); const stopped = await stopProcess(entry.process, PLATFORM_DIR); if (!stopped.ok) throw new Error(stopped.error); await deleteProcess(entry.process, PLATFORM_DIR); // Only for something we provisioned. An `existing` install points at a service the user runs // themselves, and bringing it down would stop a container Officer did not start. if (row?.mode === 'provisioned') { const dir = row.composeDir ?? serviceDir(sidecarId); const container = await composeDown(dir); if (!container.ok) throw new Error(`could not remove ${entry.label}'s containers: ${container.error}`); } await unpublishAssets(sidecarId); await removeInstall(sidecarId); return { ok: true as const }; }