import type { CatalogueEntry } from './catalogue'; // Can this machine install this sidecar at all — asked BEFORE anything is written. // // The point is the ordering. An install that discovers a missing dependency halfway through has already // created a directory, possibly started a container, and written a row; it then has to unwind, and the // user is left with something that neither works nor uninstalls. A check that costs 30ms up front is // worth a great deal of that. // // ── What this deliberately does NOT do ── // // It does not install anything. Today nothing in `scripts/` installs Docker either — the host installer // `scripts/setup/setup.sh` runs `scripts/setup/setup-dockers.sh`, which invokes `docker compose` without // ever checking it exists, so a fresh host without Docker fails partway through setup with a bare // "command not found". That is a real gap, and // the intended fix is a per-sidecar `setup.sh` that ensures its own dependencies — which is also the // shape a sidecar needs once it lives in its own repository and ships independently. // // Until that exists, the honest thing is to detect and report rather than to guess or to half-install. export type Preflight = | { ok: true } | { ok: false; reason: string; /** What the user has to do about it. */ remedy: string }; /** * Docker is needed to PROVISION, never to point at something already running. Derived from `modes` * rather than declared per entry, so the two cannot drift: an entry that can provision needs Docker, by * definition, and nobody has to remember to tick a second box. */ export const needsDocker = (entry: CatalogueEntry): boolean => entry.modes.includes('provisioned'); /** `docker` on PATH, the daemon reachable, and the compose plugin present. All three, or it is not usable. */ export async function checkDocker(): Promise { try { // `docker compose version` exercises the binary, the daemon connection and the plugin in one call. // `docker --version` would pass with a dead daemon, which is the failure people actually hit. const proc = Bun.spawn(['docker', 'compose', 'version'], { stdout: 'pipe', stderr: 'pipe' }); const code = await proc.exited; if (code === 0) return { ok: true }; const err = (await new Response(proc.stderr).text()).trim(); // The daemon being down and the plugin being absent need different remedies, and the message is the // only way to tell them apart — the exit code is 1 for both. if (/permission denied|daemon|cannot connect/i.test(err)) { return { ok: false, reason: 'The Docker daemon is not reachable.', remedy: 'Start Docker (`sudo systemctl start docker`), or add your user to the `docker` group and log in again.', }; } return { ok: false, reason: 'Docker Compose is not available.', remedy: 'Install the Docker Compose plugin (`docker-compose-plugin`).', }; } catch { return { ok: false, reason: 'Docker is not installed.', remedy: 'Install Docker Engine, then try again. https://docs.docker.com/engine/install/', }; } } /** * Everything that must be true before `mode` can be attempted for `entry`. * * Split by mode on purpose: a host with no Docker can still install Photos by pointing at an Immich * somewhere else. Refusing the whole entry would be wrong, and is the sort of over-strict check that * makes people work around the installer instead of using it. */ export async function preflight(entry: CatalogueEntry, mode: string): Promise { if (entry.requires === 'linux-display' && process.platform !== 'linux') { return { ok: false, reason: `${entry.label} mirrors an Xorg display, which this platform does not have.`, remedy: 'This sidecar only runs on a Linux host with a display.', }; } if (mode === 'provisioned' && needsDocker(entry)) return checkDocker(); return { ok: true }; }