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,109 @@
|
||||
import { join } from 'node:path';
|
||||
import { getOwnerUser, saveServiceConnection } from 'officerdb';
|
||||
import type { InstallEffects, StepContext } from './installer';
|
||||
import { preflight } from './preflight';
|
||||
import { runSetupScript } from './run-script';
|
||||
import { startProcess } from './pm2';
|
||||
import { serviceDir } from './paths';
|
||||
|
||||
// The installer's steps, wired to the actual world.
|
||||
//
|
||||
// Kept apart from `installer.ts` on purpose: that file is the machine — ordering, resume, blocking —
|
||||
// and is tested with these replaced by spies. This file is the thin part that genuinely touches PM2,
|
||||
// Postgres and the filesystem, and is deliberately boring enough to read in one sitting.
|
||||
|
||||
/** Where the platform lives, for `pm2 start ecosystem.config.cjs`. */
|
||||
const PLATFORM_DIR = process.cwd();
|
||||
|
||||
const TEMPLATES_DIR = join(import.meta.dir, 'templates');
|
||||
|
||||
export function createEffects(): InstallEffects {
|
||||
return {
|
||||
preflight: (entry, mode) => preflight(entry, mode),
|
||||
|
||||
async provision(ctx: StepContext) {
|
||||
const template = ctx.entry.composeTemplate;
|
||||
// planSteps only emits `provision` for a provisioned install, and the catalogue test asserts
|
||||
// every such entry has a template — so reaching here without one is a broken catalogue, not a
|
||||
// user error, and it should say so rather than fail obscurely inside the runner.
|
||||
if (!template) throw new Error(`${ctx.entry.id} has no compose template but was asked to provision`);
|
||||
|
||||
const result = await runSetupScript({
|
||||
sidecarId: ctx.entry.id,
|
||||
templateDir: join(TEMPLATES_DIR, template),
|
||||
env: ctx.values,
|
||||
log: ctx.log,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
|
||||
return { results: result.results, composeDir: result.serviceDir };
|
||||
},
|
||||
|
||||
async connect(ctx: StepContext) {
|
||||
const url = ctx.values.url;
|
||||
// A provisioned install knows its own URL because the setup script printed it. An `existing` one
|
||||
// was given it in the form. Neither having produced one means we cannot reach the service, and
|
||||
// the honest answer is to stop rather than write a row that points nowhere.
|
||||
if (!url) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reason: `${ctx.entry.label} is running, but no connection URL was provided.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Shape 3 from templates/README.md: Immich, Jellyfin and Memos mint their API key in their own
|
||||
// UI, so a provisioned install is up and healthy but not yet connectable. That is a pause, not a
|
||||
// failure — see markBlocked.
|
||||
const needsSecret = ctx.entry.existingFields?.some((f) => f.key === 'secret' && f.required);
|
||||
if (needsSecret && !ctx.values.secret) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reason: `${ctx.entry.label} needs an API key, which only its own interface can create.`,
|
||||
completeAt: url,
|
||||
};
|
||||
}
|
||||
|
||||
const owner = await getOwnerUser();
|
||||
if (!owner) throw new Error('no owner account');
|
||||
|
||||
// The OWNER's row: it carries the URL and IS the instance. Members get their own rows later, with
|
||||
// a null url that inherits this one — see members.ts and the service_connections schema.
|
||||
await saveServiceConnection({
|
||||
userId: owner.id,
|
||||
service: ctx.entry.id as never,
|
||||
url,
|
||||
username: ctx.values.username || null,
|
||||
secret: ctx.values.secret || null,
|
||||
path: ctx.values.path || null,
|
||||
});
|
||||
|
||||
ctx.log(`connection saved for ${ctx.entry.label}`);
|
||||
return { status: 'done' };
|
||||
},
|
||||
|
||||
async applySchema(ctx: StepContext) {
|
||||
// A no-op today, and honestly so. Every sidecar's tables still ship in the platform's single
|
||||
// Drizzle schema and arrive together via `bun db:push`, so by the time an install runs they
|
||||
// already exist. The step is in the plan because per-sidecar schema is the direction — a
|
||||
// marketplace plugin cannot ship a table into a schema it does not own — and adding the step
|
||||
// later would mean revisiting every install that had already recorded its progress without it.
|
||||
ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)');
|
||||
},
|
||||
|
||||
async startProcess(ctx: StepContext) {
|
||||
const result = await startProcess(ctx.entry.process, PLATFORM_DIR);
|
||||
if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`);
|
||||
ctx.log(`${ctx.entry.process} started`);
|
||||
},
|
||||
|
||||
async provisionMembers(ctx: StepContext) {
|
||||
// Nothing implements MemberProvisioner yet — each sidecar will, beside its own code, rather than
|
||||
// in a switch here. Logged rather than silently skipped so that a member who cannot see a service
|
||||
// has a trail explaining why.
|
||||
ctx.log(`members: no provisioner registered for ${ctx.entry.id} yet — members will need manual access`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Where a provisioned service's compose file lives, for the caller to record. */
|
||||
export const composeDirFor = (sidecarId: string): string => serviceDir(sidecarId);
|
||||
Reference in New Issue
Block a user