Files
platform/src/servers/app-store/service.ts
T
pastilhasandClaude Opus 5 e83585dd42 app store: containers follow the sidecar through enable, disable and uninstall
Tier two works end to end. Transmission installed through the API with a real container, verified on
this machine and then removed:

  install    preflight, provision, connect, schema, assets, process — container up, health-checked,
             connection written from what the setup script printed
  disable    process stopped, then container Exited(0), data intact
  enable     container back up, then the process
  uninstall  container gone, install row gone, DATA UNTOUCHED — config, compose file, downloads and
             watch directories all still present

Order matters in both directions and it is opposite each way. Enable brings the container up first: a
sidecar that starts before its upstream exists spends its first seconds failing health checks and
logging about a service that is merely not up yet. Disable stops the process first, for the same reason
in reverse.

`down`, never `down -v`, and no `--rmi`: the volumes are the user's data and the images are shared and
expensive to re-pull. Both are deliberate omissions, stated so nobody adds them later as a tidy-up.

Uninstall only brings down containers for `mode: 'provisioned'`. An `existing` install points at a
service the user runs themselves, and `down` there would stop a container Officer never started.

Every compose call tolerates a missing directory rather than failing. Three call sites can legitimately
arrive with nothing there — an `existing` install, a failed install that died before writing the file,
and a resumed uninstall re-running a completed step — and erroring would make a row impossible to
uninstall, which is the one state a user cannot escape.

Adds an `assets` step, before `process`: the dock reads manifests as soon as the install is recorded, so
an icon arriving a moment later shows as broken on the first render. And composeDir is recorded from the
install rather than derived later, because the directory is the user's and they may move it — uninstall
must not guess at a path it is about to run `down` in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:02:40 +00:00

199 lines
7.7 KiB
TypeScript

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<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') {
// 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 };
}