headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Compose templates and their setup scripts
One directory per provisionable service. Each holds a docker-compose.yaml and a setup.sh, and
together they are everything needed to bring that service up.
This is deliberately the shape a sidecar will need when it lives in its own repository: metadata (the catalogue entry), a compose template, a setup script, and a schema. Nothing here may assume it is being read out of this repo.
The setup.sh contract
Answers come from the environment. It never prompts when they are already there.
The app store collects them in a web form and passes them as environment variables. A person running it by hand on a VPS gets prompted for anything missing, but only when stdin is a TTY — so the same script serves both, and neither is a second code path.
OFFICER_SERVICE_DIR=~/officerdev/dockers/transmission \
OFFICER_UID=1000 OFFICER_GID=1000 \
TRANSMISSION_PORT=9091 \
bash setup.sh
Every script must:
| Rule | Why |
|---|---|
| Be idempotent. Running twice must be safe and must not create a second anything. | Install is resumable; a retry after a half-failure re-runs steps that already succeeded. |
Never prompt when OFFICER_NONINTERACTIVE=1. Fail with a clear message instead. |
A prompt behind a web form is a hang with no output, which is the worst failure to diagnose. |
Write only inside OFFICER_SERVICE_DIR. |
The app store owns that directory and nothing else. The user's own estate is never touched. |
| Emit progress on stdout. | The installer streams it to a terminal panel in the UI, so the user watches it happen rather than staring at a spinner. |
Print OFFICER_RESULT_<KEY>=value for anything the platform must store. |
How a generated secret or a resolved port gets back to service_connections without the installer parsing free text. |
Exit non-zero on failure, with the reason on stderr. The installer records it in last_error and the
row stays failed rather than pretending to be installed.
Volumes are always relative bind mounts
./data, ./config, ./database — never named volumes. Configuration and data sit beside the compose
file so both the platform and a human can see exactly what a service keeps and where. A named volume
hides it behind docker volume inspect, which is the opposite of the point.
Containers run as the owner
user: "${OFFICER_UID}:${OFFICER_GID}", so files a container writes are owned by the person who
installed it and not by root. This is the convention already in use across the owner's own services.
Ports bind to loopback unless the service genuinely needs to be reachable
127.0.0.1:9091:9091, not 9091:9091. Officer reaches these over loopback; anything published on all
interfaces is a service exposed to the network by an installer the user trusted to be careful. The
exception is a service whose whole function is inbound connections — slskd's P2P listener, for example —
and those say so in a comment.
No external networks
The owner's own composes attach to an external nginx network that exists on his machine. Templates
must not require one: a fresh VPS has no such network and docker compose up would fail before it
started. Default network only.
Three provisioning shapes, not one
Worth knowing before writing a template, because the design doc originally assumed only the first:
- We set the credentials. Passed as environment to the container, so the connection is fully known
the moment it is up. Transmission (
USER/PASS), Vaultwarden (ADMIN_TOKEN). - We generate a secret into a config file. The bind mount lets us write it before first boot, so it
is still known without asking. slskd's API key lives in its
slskd.yml. - A human must mint a token in the service's own UI after it boots. Immich, Jellyfin and Memos all work this way — there is no environment variable that pre-seeds an API key.
Shape 3 means an install can be provisioned and running, but not yet connected. That is a real state, not an error: the container is up, the compose file is written, and the platform is waiting for a token. The installer stops there with the step recorded, and the UI asks for the key with a link to the page that mints it. Resuming finishes the job.