import { join } from 'node:path'; import { getOwnerUser, saveServiceConnection, getServiceConnection } 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'; import { publishAssets } from './assets'; import { checkServiceUrl } from './url'; // 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) { // Validated and normalised before anything is stored. What the user typed may be a bare hostname, // may carry a trailing slash that doubles a separator later, or may hide a token in a query // string — and none of those are visible again once written. const raw = ctx.values.url; if (raw) { const checked = checkServiceUrl(raw); if (!checked.ok) { return { status: 'blocked', reason: `${ctx.entry.label}: ${checked.error}` }; } ctx.values.url = checked.url; } 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'); // Never overwrite a connection the owner already has, unless they said so. // // Found the hard way: installing a sidecar that was already configured replaced its connection // row with the new install's, silently repointing a working service at something else. On a fresh // machine that is harmless; on one with an existing setup it breaks a feature that was fine, and // the only symptom is that it stops working. // // Blocking rather than failing, because there is a sensible answer and the user is the only one // who has it: keep what is there, or replace it deliberately. const current = await getServiceConnection(owner.id, ctx.entry.id as never); if (current && current.url && current.url !== url && ctx.values.replaceConnection !== 'true') { return { status: 'blocked', reason: `${ctx.entry.label} is already connected to ${current.url}. Installing would repoint it to ` + `${url}. Keep the existing connection, or reinstall with "replace connection" to change it.`, completeAt: current.url, }; } // 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 publishAssets(ctx: StepContext) { // Every install, not just provisioned ones: an `existing` Photos still needs its icon in the dock. // The template directory is where a sidecar ships its assets, and a sidecar with none is the // common case rather than an error. const template = ctx.entry.composeTemplate ?? ctx.entry.id; const published = await publishAssets(ctx.entry.id, join(TEMPLATES_DIR, template)); ctx.log(published ? `assets published to /plugins/${ctx.entry.id}/` : 'assets: none shipped'); }, 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);