Files
platform/src/servers/app-store/compose.ts
T
pastilhasandClaude Opus 5 e83585dd42 app store: containers follow the sidecar through enable, disable and uninstall
Tier two works end to end. Transmission installed through the API with a real container, verified on
this machine and then removed:

  install    preflight, provision, connect, schema, assets, process — container up, health-checked,
             connection written from what the setup script printed
  disable    process stopped, then container Exited(0), data intact
  enable     container back up, then the process
  uninstall  container gone, install row gone, DATA UNTOUCHED — config, compose file, downloads and
             watch directories all still present

Order matters in both directions and it is opposite each way. Enable brings the container up first: a
sidecar that starts before its upstream exists spends its first seconds failing health checks and
logging about a service that is merely not up yet. Disable stops the process first, for the same reason
in reverse.

`down`, never `down -v`, and no `--rmi`: the volumes are the user's data and the images are shared and
expensive to re-pull. Both are deliberate omissions, stated so nobody adds them later as a tidy-up.

Uninstall only brings down containers for `mode: 'provisioned'`. An `existing` install points at a
service the user runs themselves, and `down` there would stop a container Officer never started.

Every compose call tolerates a missing directory rather than failing. Three call sites can legitimately
arrive with nothing there — an `existing` install, a failed install that died before writing the file,
and a resumed uninstall re-running a completed step — and erroring would make a row impossible to
uninstall, which is the one state a user cannot escape.

Adds an `assets` step, before `process`: the dock reads manifests as soon as the install is recorded, so
an icon arriving a moment later shows as broken on the first render. And composeDir is recorded from the
install rather than derived later, because the directory is the user's and they may move it — uninstall
must not guess at a path it is about to run `down` in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:02:40 +00:00

72 lines
3.2 KiB
TypeScript

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<boolean> {
try {
await stat(join(serviceDir, 'docker-compose.yaml'));
return true;
} catch {
return false;
}
}
async function compose(serviceDir: string, args: string[]): Promise<ComposeResult> {
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<ComposeResult> => 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<ComposeResult> => 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<ComposeResult> => compose(serviceDir, ['down']);