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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user