app store: check the host before writing anything

An install that discovers a missing dependency halfway through has already made a directory, possibly
started a container and written a row, and then has to unwind — leaving the user with something that
neither works nor uninstalls. A 30ms check first is worth most of that.

Verified while writing this: nothing in scripts/ installs Docker, and nothing checks for it.
setup-dockers.sh invokes `docker compose` with no preflight, so a fresh host without Docker fails
partway through setup with a bare "command not found". Recorded in the design doc rather than fixed
here — the intended fix is a setup.sh per sidecar, which is also what a sidecar needs once it ships from
its own repository.

`docker compose version` is the probe, not `docker --version`: the latter passes with a dead daemon,
which is the failure people actually hit. "Not installed" and "daemon unreachable" are reported
separately because the remedies differ.

Checked per MODE, not per entry. A host without Docker can still install Photos by pointing at an Immich
somewhere else; refusing the whole entry is the over-strict check that makes people work around the
installer instead of using it.

Dropped `requires: 'docker'` from the catalogue type. Needing Docker is exactly "this entry can
provision", which `modes` already says, so declaring it twice invites the two to disagree. Derived by
needsDocker instead, and a test asserts the derivation matches every entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent 654cb10711
commit 7359867f7f
4 changed files with 161 additions and 9 deletions
+84
View File
@@ -0,0 +1,84 @@
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 — `setup.sh` runs
// `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<Preflight> {
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<Preflight> {
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 };
}