An instance may be on another host, behind a reverse proxy on 443 under a path prefix, on a tailnet address, or on an arbitrary port because the usual one was taken. All ordinary self-hosted setups, and each one a case where assuming otherwise produces a connection that fails later with no clue why. Two places were sloppy about it. Jellyfin's placeholder read `http://localhost:8096` and Transmission's `http://localhost:9091`, which quietly teach that a service must be local and on its project's default port; both now show remote examples, and the field type says why. And nothing validated what was typed, so a bare hostname or a URL with a token in the query string was stored as-is. The rule: reject only what cannot work, normalise what is merely untidy, have no opinion about the rest. No check that the host is local, that the port matches a default, or that the scheme is https — a tailnet HTTP service is completely normal. Trailing slashes go, because `${url}/api/x` otherwise doubles the separator: accepted by some servers and 404 by others, which is the kind of difference that reproduces on one machine and not another. Query strings go, because that is where a token hides, and it would sit in a column meant for a location. Credentials in the URL are refused for the same reason — outside the encrypted secret, and in every log line that ever prints it. A missing scheme is named rather than called invalid: it is the commonest mistake, because it is what people type into a browser. Verified through the API: `memos.example.com` is blocked with the fix quoted back, and `https://memos.example.com:8443/memos/` installs and stores normalised — remote host, non-standard port, path prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
152 lines
7.2 KiB
TypeScript
152 lines
7.2 KiB
TypeScript
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);
|