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:
2026-08-10 13:55:11 +00:00
co-authored by Claude Opus 5
parent 4b9b98efda
commit ba71dc1957
10 changed files with 596 additions and 1 deletions
+14
View File
@@ -280,3 +280,17 @@ export {
markPushDeviceSeen,
} from './queries/notify';
export type { PushDeviceSelect, PushDeviceInsert } from './types';
// App store — what the owner has installed, and whether it should be running.
export {
listSidecarInstalls,
getSidecarInstall,
beginInstall,
recordSteps,
markInstalled,
markFailed,
markBlocked,
setEnabled,
removeInstall,
type SidecarInstall,
} from './queries/sidecar-installs';
@@ -0,0 +1,109 @@
import { eq } from 'drizzle-orm';
import { db } from '../db';
import { sidecarInstalls } from '../schema';
// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no
// userId and why `installed` and `enabled` are separate.
export type SidecarInstall = typeof sidecarInstalls.$inferSelect;
export async function listSidecarInstalls(): Promise<SidecarInstall[]> {
return db.select().from(sidecarInstalls);
}
export async function getSidecarInstall(sidecarId: string): Promise<SidecarInstall | null> {
const [row] = await db.select().from(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
return row ?? null;
}
/**
* Create the row for an install that is about to start, or pick up the one a previous attempt left.
*
* Returning the existing row rather than replacing it is what makes a retry a RESUME: `completedSteps`
* is how the installer knows not to provision a second container, and starting fresh would throw that
* away every time someone pressed the button again after a failure.
*/
export async function beginInstall(sidecarId: string, mode: string): Promise<SidecarInstall> {
const existing = await getSidecarInstall(sidecarId);
if (existing) {
const [row] = await db
.update(sidecarInstalls)
// `lastError` cleared on the way in: it describes the PREVIOUS attempt, and leaving it visible
// while a new one runs is how a UI ends up showing a stale failure next to a working service.
.set({ status: 'installing', mode, lastError: null, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId))
.returning();
return row!;
}
const [row] = await db.insert(sidecarInstalls).values({ sidecarId, mode, status: 'installing' }).returning();
return row!;
}
/** Record progress mid-install, so an interrupted run can be resumed rather than restarted. */
export async function recordSteps(sidecarId: string, completedSteps: string[], composeDir?: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ completedSteps, ...(composeDir ? { composeDir } : {}), updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/** Install finished. `enabled` goes true here because installing a thing is asking for it to run. */
export async function markInstalled(sidecarId: string, completedSteps: string[]): Promise<void> {
await db
.update(sidecarInstalls)
.set({
status: 'installed',
enabled: true,
completedSteps,
lastError: null,
installedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Install stopped and cannot continue on its own.
*
* `completedSteps` is still written: what worked stays recorded, so resuming picks up rather than
* repeating. A failure that forgot its progress would re-provision on every retry.
*/
export async function markFailed(sidecarId: string, completedSteps: string[], error: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ status: 'failed', completedSteps, lastError: error, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Waiting for a human — a token only the service's own UI can mint.
*
* Deliberately NOT `failed`. The container is up and healthy and everything so far worked; calling it a
* failure would make a normal install look broken and invite the user to tear down a working service.
* `lastError` carries the instruction instead of an error.
*/
export async function markBlocked(sidecarId: string, completedSteps: string[], reason: string): Promise<void> {
await db
.update(sidecarInstalls)
.set({ status: 'blocked', completedSteps, lastError: reason, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/** Enable or disable — the process and its container, without touching anything installed. */
export async function setEnabled(sidecarId: string, enabled: boolean): Promise<void> {
await db
.update(sidecarInstalls)
.set({ enabled, updatedAt: new Date() })
.where(eq(sidecarInstalls.sidecarId, sidecarId));
}
/**
* Forget this install.
*
* Only this row. The sidecar's tables, the service directory and every byte of data under it survive —
* see the schema comment. Uninstalling is "stop running this", not "delete my library".
*/
export async function removeInstall(sidecarId: string): Promise<void> {
await db.delete(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
}
@@ -1,3 +1,4 @@
export * from './app-store';
export * from './agent-panels';
export * from './api-keys';
export * from './auth';