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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user