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>
161 lines
5.6 KiB
TypeScript
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 };
|
|
}
|