app store: the catalogue, the install-state table, and what phase 0 must not foreclose

First slice, on a worktree branch so none of it touches the tree the live server runs from.

`sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine.
That is the line that keeps the model coherent for several users: installed is server-level and
owner-only, configured is per user in service_connections. A member can use Gitea without being able to
install it or point it somewhere else.

`installed` and `enabled` are separate because they answer different questions, which is what gives the
reversible middle ground: disable stops the process and keeps container, config, schema and data.
`completedSteps` makes install resumable rather than merely retryable — the failure mode being designed
against is a half-installed service that neither works nor uninstalls.

The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to
arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers
exactly the processes the light profile excludes, names processes that exist, and claims capabilities
that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is
EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which
does have one, where I had written null).

Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per
service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner.
An existing directory is evidence of an existing install and must be adopted, never overwritten.

Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own
repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's
invariant rather than the design's, since that relationship inverts once sidecars leave this repo.

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 977d14922f
commit fc48a572d2
4 changed files with 459 additions and 4 deletions
+39 -4
View File
@@ -60,8 +60,12 @@ for someone who does not. The prompt is the fork.
**Officer is the installer, never the owner.** Concretely:
- A real `docker-compose.yml` per service, written into a **user-owned directory**
(`~/officer-services/<service>/`), from our template.
- A real compose file per service, written into a **user-owned directory**, from our template —
following the convention the owner already uses for 47 services in `~/dockers/`:
one directory per service, `docker-compose.yaml` inside, and **relative bind mounts**
(`./data`, `./database`, `./storage`) so configuration and data sit beside the compose file where
both we and the user can find them. Named volumes are used by 3 of those 47 and are the exception;
templates use bind mounts, always.
- Started with `docker compose up -d` **as the owner**, not as officer's own identity.
- Found again by **label** (`officer.sidecar=<id>`), not by holding a handle.
@@ -75,8 +79,16 @@ Consequences, which are the point:
The template is what makes this non-technical-user-friendly: sensible defaults, ports, volumes and
health checks already correct, so "install Gitea" does not become a tutorial.
`[open]` Rootful Docker runs container processes as root unless `user:` is set. Do we set it? And do we
support Podman for people who want genuinely rootless?
**`USER_UID` / `USER_GID` are set to the owner**, as the existing services already do. That answers the
"do containers run as root" question: no, and this is not a new convention — it is the one in use.
**An existing directory is evidence, not an obstacle.** `~/dockers/` already holds `gitea`, `memos`,
`immich`, `jellyfin` and `invoice_shelf`. The installer must never write into a directory that exists;
finding one is the strongest possible signal that this is the "you already have one" case, and the store
should offer to ADOPT it — read its ports out of the compose file and write the connection — rather than
provision a second copy or overwrite a running service's data.
`[open]` Podman, for anyone wanting genuinely rootless.
---
@@ -174,6 +186,29 @@ What a plugin author is promised, and bound by. To be written properly; the shap
---
## What Phase 0 must not foreclose
Three things are coming, and each one constrains a decision that looks free today.
**1. `marketplace.officer.dev`.** Phase 1 keeps the catalogue inside this repo; later the app lists what
is on a remote marketplace instead. So catalogue entries must stay **serialisable data** — no functions,
no imports, nothing that only means something at compile time. They are plain objects today and must
remain so, because the same shape has to arrive as JSON over HTTP. Compose templates travel with them.
**2. Every sidecar becomes its own repository.** Today `catalogue.test.ts` asserts the catalogue equals
"everything in ecosystem.config.cjs that light excludes". That is the right check _now_, and it inverts
later: once sidecars live elsewhere, the catalogue entry becomes the source of truth for how to run one
(command, args, env) and the ecosystem file is generated from what is installed, not the other way
round. **Do not treat that test as a permanent law** — it pins Phase 0's invariant, not the design's.
**3. Third-party plugins.** Already the reason per-sidecar schema is in scope. It is also why the
`service_connections` ID needs namespacing before the marketplace opens, not after.
The through-line: **nothing in Phase 0 may assume the catalogue is compiled in, or that a sidecar's code
is in this repository.**
---
## Open questions
1. `user:` in compose, and Podman support for rootless.