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
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import { needsDocker, preflight } from './preflight';
import { CATALOGUE, byId } from './catalogue';
describe('needsDocker is derived, not declared', () => {
it('is true for exactly the entries that can provision', () => {
for (const e of CATALOGUE) expect(needsDocker(e)).toBe(e.modes.includes('provisioned'));
});
});
describe('preflight is per mode, not per entry', () => {
it('allows pointing at an existing instance without Docker', async () => {
// The whole point: a host with no Docker can still use Photos against an Immich elsewhere.
// Refusing the entry outright is the over-strict check that makes people bypass the installer.
const photos = byId('photos')!;
expect(await preflight(photos, 'existing')).toEqual({ ok: true });
});
it('allows a config-only sidecar regardless', async () => {
expect(await preflight(byId('email')!, 'config')).toEqual({ ok: true });
});
});