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';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '../../custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listStore, install, setEnabled, uninstall } from '../../app-store/service';
|
||||
import { byId, type InstallMode } from '../../app-store/catalogue';
|
||||
|
||||
// /api/app-store — what can be installed, what is installed, and the four verbs that change it.
|
||||
//
|
||||
// ── Owner only, explicitly ──
|
||||
//
|
||||
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
|
||||
// an administrative act however many members share the server, so this router gates on the owner in its
|
||||
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and
|
||||
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
|
||||
// mistake here is not a leak of data, it is arbitrary process control.
|
||||
//
|
||||
// The per-user half lives elsewhere: `service_connections` is where a member's own credential goes, and
|
||||
// members.ts is how they get one.
|
||||
|
||||
export const appStoreRouter = createRouter();
|
||||
|
||||
appStoreRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The app store is owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/** GET /api/app-store — the catalogue joined to what has happened to each entry. */
|
||||
appStoreRouter.get('/', async (ctx) => {
|
||||
return ctx.json({ items: await listStore() });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/app-store/:id/install — install, or resume one that stopped.
|
||||
*
|
||||
* Resume is the same call deliberately: pressing the button after a failure and pressing it after
|
||||
* supplying the API key it was waiting for are one action to the user. What re-runs is decided by the
|
||||
* steps already recorded, not by which endpoint was hit.
|
||||
*
|
||||
* Answers with the outcome rather than a bare 200 — `blocked` is a normal result that the UI has to
|
||||
* render differently from success, and flattening it to "ok" would lose the reason and the link.
|
||||
*/
|
||||
appStoreRouter.post('/:id/install', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const entry = byId(id);
|
||||
if (!entry) throw errors.NOT_FOUND(`Unknown sidecar: ${id}`);
|
||||
|
||||
const body = (ctx.get('body') ?? {}) as { mode?: string; values?: Record<string, string> };
|
||||
const mode = body.mode as InstallMode | undefined;
|
||||
if (!mode) throw errors.BAD_REQUEST('mode is required');
|
||||
if (!entry.modes.includes(mode)) {
|
||||
throw errors.BAD_REQUEST(`${entry.label} cannot be installed as '${mode}'`);
|
||||
}
|
||||
|
||||
// The install log is collected rather than streamed for now. Streaming it into a terminal panel is
|
||||
// the intended shape — the lines are already produced one at a time — and needs a channel this route
|
||||
// does not have yet.
|
||||
const lines: string[] = [];
|
||||
const outcome = await install({ sidecarId: id, mode, values: body.values ?? {}, log: (l) => lines.push(l) });
|
||||
|
||||
return ctx.json({ outcome, log: lines });
|
||||
});
|
||||
|
||||
/** POST /api/app-store/:id/enable — start the sidecar (and, later, its container). */
|
||||
appStoreRouter.post('/:id/enable', async (ctx) => {
|
||||
await setEnabled(ctx.req.param('id'), true);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/** POST /api/app-store/:id/disable — stop it, keeping everything installed. */
|
||||
appStoreRouter.post('/:id/disable', async (ctx) => {
|
||||
await setEnabled(ctx.req.param('id'), false);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/app-store/:id/uninstall — stop running this.
|
||||
*
|
||||
* A POST rather than a DELETE, because it is not a deletion: the sidecar's tables and the service
|
||||
* directory survive it. Calling it DELETE would suggest otherwise to the next person reading the route
|
||||
* table.
|
||||
*/
|
||||
appStoreRouter.post('/:id/uninstall', async (ctx) => {
|
||||
await uninstall(ctx.req.param('id'));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -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);
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,12 @@ import { parseResults } from './run-script';
|
||||
describe('parseResults', () => {
|
||||
it('reads the values a script hands back', () => {
|
||||
const out = parseResults(
|
||||
['==> Starting', 'OFFICER_RESULT_URL=http://127.0.0.1:18091', 'OFFICER_RESULT_PATH=/transmission/rpc', '==> Done'].join('\n'),
|
||||
[
|
||||
'==> Starting',
|
||||
'OFFICER_RESULT_URL=http://127.0.0.1:18091',
|
||||
'OFFICER_RESULT_PATH=/transmission/rpc',
|
||||
'==> Done',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
listSidecarInstalls,
|
||||
getSidecarInstall,
|
||||
beginInstall,
|
||||
markInstalled,
|
||||
markFailed,
|
||||
markBlocked,
|
||||
setEnabled as setEnabledRow,
|
||||
removeInstall,
|
||||
type SidecarInstall,
|
||||
} from 'officerdb';
|
||||
import { CATALOGUE, byId, type CatalogueEntry, type InstallMode } from './catalogue';
|
||||
import { runInstall, type StepName } from './installer';
|
||||
import { createEffects } from './effects';
|
||||
import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2';
|
||||
import { plannedOutcome } from './members';
|
||||
|
||||
// The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this
|
||||
// stays about what installing, enabling and uninstalling actually mean.
|
||||
|
||||
const PLATFORM_DIR = process.cwd();
|
||||
|
||||
export type StoreItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
modes: InstallMode[];
|
||||
members: CatalogueEntry['members'];
|
||||
/** What adding a member would do for this service, so the UI can say so before anyone commits. */
|
||||
memberOutcome: ReturnType<typeof plannedOutcome>;
|
||||
existingFields: CatalogueEntry['existingFields'];
|
||||
configFields: CatalogueEntry['configFields'];
|
||||
install: {
|
||||
status: 'not-installed' | SidecarInstall['status'];
|
||||
enabled: boolean;
|
||||
mode: string | null;
|
||||
lastError: string | null;
|
||||
completedSteps: string[];
|
||||
};
|
||||
/** PM2's own view. Included because the row saying `enabled` and the process being dead is the
|
||||
* interesting case, and hiding it would make the store lie about what is running. */
|
||||
processStatus: string | null;
|
||||
};
|
||||
|
||||
/** The catalogue joined to what has actually happened to each entry. */
|
||||
export async function listStore(): Promise<StoreItem[]> {
|
||||
const installs = new Map((await listSidecarInstalls()).map((row) => [row.sidecarId, row]));
|
||||
|
||||
return Promise.all(
|
||||
CATALOGUE.map(async (entry) => {
|
||||
const row = installs.get(entry.id);
|
||||
return {
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
summary: entry.summary,
|
||||
modes: entry.modes,
|
||||
members: entry.members,
|
||||
memberOutcome: plannedOutcome(entry),
|
||||
existingFields: entry.existingFields,
|
||||
configFields: entry.configFields,
|
||||
install: {
|
||||
status: row?.status ?? 'not-installed',
|
||||
enabled: row?.enabled ?? false,
|
||||
mode: row?.mode ?? null,
|
||||
lastError: row?.lastError ?? null,
|
||||
completedSteps: (row?.completedSteps as string[]) ?? [],
|
||||
},
|
||||
// Only asked for things that claim to be installed: `pm2 jlist` per catalogue entry would be
|
||||
// fourteen subprocesses to render a page.
|
||||
processStatus: row ? await processStatus(entry.process, PLATFORM_DIR) : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export type InstallRequest = {
|
||||
sidecarId: string;
|
||||
mode: InstallMode;
|
||||
/** Answers from the form. Passed to the setup script as environment and to `connect` as values. */
|
||||
values: Record<string, string>;
|
||||
log?: (line: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Install, or resume an install that stopped.
|
||||
*
|
||||
* Resume is not a separate entry point on purpose: pressing the button again after a failure, and
|
||||
* pressing it again after supplying the API key it was waiting for, are the same action from the user's
|
||||
* side. The recorded steps decide what actually re-runs.
|
||||
*/
|
||||
export async function install(req: InstallRequest) {
|
||||
const entry = byId(req.sidecarId);
|
||||
if (!entry) throw new Error(`unknown sidecar: ${req.sidecarId}`);
|
||||
if (!entry.modes.includes(req.mode)) {
|
||||
throw new Error(`${entry.label} cannot be installed as '${req.mode}'`);
|
||||
}
|
||||
|
||||
const row = await beginInstall(entry.id, req.mode);
|
||||
const completed = (row.completedSteps as StepName[]) ?? [];
|
||||
|
||||
const outcome = await runInstall({
|
||||
entry,
|
||||
mode: req.mode,
|
||||
values: req.values,
|
||||
completed,
|
||||
effects: createEffects(),
|
||||
log: req.log,
|
||||
});
|
||||
|
||||
if (outcome.status === 'installed') {
|
||||
await markInstalled(entry.id, outcome.completed);
|
||||
} else if (outcome.status === 'blocked') {
|
||||
await markBlocked(entry.id, outcome.completed, outcome.reason);
|
||||
} else {
|
||||
await markFailed(entry.id, outcome.completed, outcome.error);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or stop a sidecar and its container, without changing what is installed.
|
||||
*
|
||||
* The container half is not implemented yet — `docker compose stop` against the recorded compose
|
||||
* directory — and is deliberately absent rather than silently skipped: a disable that leaves Immich
|
||||
* running is a different thing from one that stops it, and the difference is memory on the user's box.
|
||||
*/
|
||||
export async function setEnabled(sidecarId: string, enabled: boolean) {
|
||||
const entry = byId(sidecarId);
|
||||
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
|
||||
const row = await getSidecarInstall(sidecarId);
|
||||
if (!row) throw new Error(`${entry.label} is not installed`);
|
||||
|
||||
const result = enabled
|
||||
? await startProcess(entry.process, PLATFORM_DIR)
|
||||
: await stopProcess(entry.process, PLATFORM_DIR);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
|
||||
await setEnabledRow(sidecarId, enabled);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop running this. Never "delete my data".
|
||||
*
|
||||
* The process is stopped and removed from PM2. The sidecar's tables, the service directory and
|
||||
* everything under it survive — see the schema comment and the design doc. Removing the containers is
|
||||
* the missing half, for the same reason as above.
|
||||
*/
|
||||
export async function uninstall(sidecarId: string) {
|
||||
const entry = byId(sidecarId);
|
||||
if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`);
|
||||
|
||||
const stopped = await stopProcess(entry.process, PLATFORM_DIR);
|
||||
if (!stopped.ok) throw new Error(stopped.error);
|
||||
await deleteProcess(entry.process, PLATFORM_DIR);
|
||||
|
||||
await removeInstall(sidecarId);
|
||||
return { ok: true as const };
|
||||
}
|
||||
@@ -298,6 +298,18 @@ export const CAPABILITIES: Capability[] = [
|
||||
},
|
||||
|
||||
// ── admin: the platform administering itself ────────────────────────────────────────────────────
|
||||
{
|
||||
key: 'app-store',
|
||||
label: 'App store',
|
||||
description: 'Install, enable and remove the sidecars this server runs',
|
||||
// Admin, not app. Installing a sidecar starts a process on the machine and provisioning one starts
|
||||
// containers — that is process control, not a feature a member can be granted a read of. The router
|
||||
// gates on the owner in its own right as well; this entry is what makes the boot check pass and what
|
||||
// keeps the surface visible in one enumeration.
|
||||
kind: 'admin',
|
||||
api: ['/app-store'],
|
||||
routes: ['/app-store'],
|
||||
},
|
||||
{
|
||||
key: 'server-admin',
|
||||
label: 'Server settings',
|
||||
|
||||
@@ -36,6 +36,7 @@ import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
import { memosRouter } from './api/memos/router';
|
||||
import { giteaRouter } from './api/gitea/router';
|
||||
import { appStoreRouter } from './api/app-store/router';
|
||||
import { davSyncRouter } from './api/dav/sync-router';
|
||||
import { davRouter } from './api/dav/router';
|
||||
import { claimIosProfile } from './api/dav/ios-profile';
|
||||
@@ -186,6 +187,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/terminal', terminalRouter],
|
||||
['/memos', memosRouter],
|
||||
['/gitea', giteaRouter],
|
||||
['/app-store', appStoreRouter],
|
||||
['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI
|
||||
['/dav', davRouter], // app-password management (the sync door is /dav, top-level)
|
||||
['/notify', notifyRouter],
|
||||
|
||||
Reference in New Issue
Block a user