import { stat } from 'node:fs/promises'; import { join } from 'node:path'; // Starting, stopping and removing the containers behind a provisioned sidecar. // // ── The one rule ── // // `down`, never `down -v`. Uninstall removes containers; it does not remove data, and there is no // option here that does. A user uninstalling Photos is saying "stop running this", not "delete my // library", and for Immich or Jellyfin getting that wrong once is unrecoverable. // // The bind-mount convention makes that structural rather than a rule to remember: the data lives on the // host inside the service directory, so `-v` — which only removes NAMED volumes — could not delete it // even if someone added the flag. This module simply never gives them the chance. // // ── Why every function tolerates a missing directory ── // // Three of the four call sites can legitimately arrive with nothing there: an `existing` install never // provisioned anything, a failed install may have died before writing the compose file, and a resumed // uninstall may be re-running a step that already succeeded. Treating those as errors would make a row // impossible to uninstall, which is the one state a user cannot get themselves out of. export type ComposeResult = { ok: true; ran: boolean } | { ok: false; error: string }; async function hasCompose(serviceDir: string): Promise { try { await stat(join(serviceDir, 'docker-compose.yaml')); return true; } catch { return false; } } async function compose(serviceDir: string, args: string[]): Promise { if (!(await hasCompose(serviceDir))) return { ok: true, ran: false }; const proc = Bun.spawn(['docker', 'compose', '--project-directory', serviceDir, ...args], { 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; if (code !== 0) return { ok: false, error: `${err || out}`.trim() || `docker compose ${args[0]} exited ${code}` }; return { ok: true, ran: true }; } /** * Bring the containers up. Used by enable, and by a resumed install whose containers were stopped. * * `up -d` rather than `start`: `start` only works on containers that already exist, and after a * `down` they do not. `up -d` covers both, and is a no-op against an unchanged compose file. */ export const composeUp = (serviceDir: string): Promise => compose(serviceDir, ['up', '-d']); /** * Stop them, leaving them defined. * * Disable stops the container as well as the sidecar — there is no reason to leave Immich holding * memory while Photos is switched off — and `stop` rather than `down` is what makes re-enabling * instant rather than a fresh `up`. */ export const composeStop = (serviceDir: string): Promise => compose(serviceDir, ['stop']); /** * Remove containers and networks. Uninstall only. * * No `-v`, and no `--rmi`: the images are shared and expensive to re-pull, and the volumes are the * user's data. Both are deliberate omissions rather than oversights. */ export const composeDown = (serviceDir: string): Promise => compose(serviceDir, ['down']);