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>
99 lines
4.5 KiB
TypeScript
99 lines
4.5 KiB
TypeScript
// Starting and stopping a sidecar's PM2 process.
|
|
//
|
|
// ── Why shell out rather than use the pm2 API ──
|
|
//
|
|
// PM2 is already the supervisor for every process here and the ecosystem file is already the definition
|
|
// of how each one runs. Importing pm2 as a library would put a second thing in charge of that, and the
|
|
// failure mode is two supervisors disagreeing about what should be running. The CLI is the same
|
|
// interface a human uses, which also means an owner can undo anything the app store did with a command
|
|
// they already know.
|
|
//
|
|
// ── The one PM2 fact that matters here ──
|
|
//
|
|
// `pm2 start <name>` only works for a process PM2 has already seen. A sidecar that has never run is not
|
|
// in PM2's list, and starting it by name fails with "process or namespace not found". So a first
|
|
// install has to start it FROM THE ECOSYSTEM FILE, and every start after that can go by name.
|
|
//
|
|
// `startProcess` handles both without the caller having to know which case it is in, because the caller
|
|
// genuinely cannot know: a resumed install, a re-enable after a restart, and a first install all arrive
|
|
// at the same line.
|
|
|
|
export type Pm2Result = { ok: true } | { ok: false; error: string };
|
|
|
|
const ECOSYSTEM = 'ecosystem.config.cjs';
|
|
|
|
async function pm2(args: string[], cwd: string): Promise<{ code: number; out: string }> {
|
|
const proc = Bun.spawn(['pm2', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
|
const code = await proc.exited;
|
|
return { code, out: `${out}${err}`.trim() };
|
|
}
|
|
|
|
/** Is this process known to PM2 at all — running, stopped or errored? */
|
|
export async function isKnownToPm2(name: string, cwd: string): Promise<boolean> {
|
|
const { code, out } = await pm2(['jlist'], cwd);
|
|
if (code !== 0) return false;
|
|
try {
|
|
const list = JSON.parse(out) as Array<{ name?: string }>;
|
|
return list.some((p) => p.name === name);
|
|
} catch {
|
|
// jlist printing something unparseable means PM2 is in a state we should not guess about. Saying
|
|
// "not known" makes the caller start from the ecosystem file, which is the safe direction — it
|
|
// works whether or not the process exists.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start a sidecar, whether or not PM2 has seen it before.
|
|
*
|
|
* `--only` is what keeps this surgical: starting the ecosystem file without it would bring up every
|
|
* process in the estate, which on a light install is precisely the thing the user opted out of.
|
|
*/
|
|
export async function startProcess(name: string, cwd: string): Promise<Pm2Result> {
|
|
const known = await isKnownToPm2(name, cwd);
|
|
const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name];
|
|
|
|
const { code, out } = await pm2(args, cwd);
|
|
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Stop it, leaving it in PM2's list.
|
|
*
|
|
* Stop rather than delete, deliberately. A stopped process still appears in `pm2 list` as stopped,
|
|
* which is the honest picture for a disabled sidecar — deleting it would make a disabled service
|
|
* indistinguishable from one that was never installed, both to PM2 and to anyone looking.
|
|
*/
|
|
export async function stopProcess(name: string, cwd: string): Promise<Pm2Result> {
|
|
const { code, out } = await pm2(['stop', name], cwd);
|
|
// Stopping something that is not there is the desired end state, not an error. This happens on a
|
|
// resumed uninstall, and treating it as a failure would leave the row un-uninstallable.
|
|
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
|
|
return { ok: false, error: out || `pm2 stop ${name} exited ${code}` };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** Remove it from PM2 entirely. Uninstall only — see `stopProcess` for why disable does not do this. */
|
|
export async function deleteProcess(name: string, cwd: string): Promise<Pm2Result> {
|
|
const { code, out } = await pm2(['delete', name], cwd);
|
|
if (code !== 0 && !/not found|doesn't exist/i.test(out)) {
|
|
return { ok: false, error: out || `pm2 delete ${name} exited ${code}` };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */
|
|
export async function processStatus(name: string, cwd: string): Promise<string | null> {
|
|
const { code, out } = await pm2(['jlist'], cwd);
|
|
if (code !== 0) return null;
|
|
try {
|
|
const list = JSON.parse(out) as Array<{ name?: string; pm2_env?: { status?: string } }>;
|
|
return list.find((p) => p.name === name)?.pm2_env?.status ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|