Commit Graph
12 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 82e9fdacc2 dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin,
Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar
meant editing the shell. Neither survives sidecars shipping from their own repositories.

Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the
app's own screens, and Gitea, which is in the light profile because it fronts a remote instance.
Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities.

Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests
means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing
for something installed. Filtered by capability server-side too: a member is not handed the manifest of
a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until
someone opens the network tab.

Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent
from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled`
makes its tile leave and return with no process touched.

Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as
Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And
DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default
dock came up a tile short on any machine where Music was never installed — a default that references an
optional feature is how an app looks subtly wrong on a fresh install for no stated reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:32:16 +00:00
pastilhasandClaude Opus 5 209e916343 app store: publish a sidecar's assets to public/plugins/<id>/
Real PNG icons are coming, so this is the path they arrive by: a sidecar ships its assets beside its own
code, and install copies them to public/plugins/<id>/ where one static route serves them.

Copied rather than served in place because a sidecar shipping from its own repository has its assets
wherever that repository was unpacked, which is not a path the web server can be taught at build time.
One predictable destination means the serving rule never has to know how many plugins exist or where any
came from. It also makes assets a property of the INSTALL: uninstall removes them, and a plugin nobody
installed serves nothing.

Needed a new route, and the reason is a trap worth recording. `publicRoutes` in server.tsx is built by
globbing ./public at BOOT, so anything copied there afterwards is invisible to it — the first install of
a plugin would show a broken image until the server was restarted, and "install it, then restart to see
the icon" is not an install. `/plugins/*` resolves per request, like /novnc/* and /vendor/* already do.

Unlike those two it answers 404 rather than 500 for a missing file: an unpublished icon is an ordinary
state on a fresh machine, and a 500 would put a red line in the log for every dock render.

Proven end to end with a real asset: slskd's icon moved from public/slskd.png into the sidecar's own
assets/, published against an ALREADY RUNNING server, and fetched at 200 with the right bytes and
content-type — 404 before publishing, no restart between.

public/plugins/ is gitignored: it holds copies, and the originals live with each sidecar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:20:46 +00:00
pastilhasandClaude Opus 5 9084fabbf6 app store: note where a real PNG icon's bytes will have to live
Real icons are coming and the manifest field already exists — slskd uses it. What is not decided is
where the bytes come from for a sidecar that ships from its own repository: /slskd.png works only
because it sits in the platform's public/, which a marketplace plugin cannot write to.

Records the three options and their trade — marketplace URL (loses icons offline), served by us from the
sidecar's directory (works offline, needs a route and caching), or a data URI (no fetch, but bloats
every manifest) — so the next person meets the question instead of assuming the current path generalises.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:17:05 +00:00
pastilhasandClaude Opus 5 27c1331b3c app store: a sidecar carries its own dock tile and routes, and an uninstalled one has neither
Two gaps, both from the same root: the app knew what an account MAY use and not what this server
actually HAS.

Availability is now subtracted server-side in the /capabilities answer. "Installed" is orthogonal to
"permitted" and the owner is subject to it — the owner bypasses every permission check, but a capability
they hold unconditionally still means nothing if its sidecar was never installed. Without this the dock
on a fresh machine lists Photos, Jellyfin, Transmission and the rest, each leading to a screen that
reports itself unavailable.

Computed on the server rather than intersected in the client, so the rule lives in one place: the dock
already reads `/capabilities`, and making it read a second list and combine them is how a member's dock
and an owner's dock drift apart. `unavailable` is returned alongside `deniedRoutes` because the two mean
different things to a UI — "not yours" versus "not here yet, install it".

A disabled sidecar counts as unavailable: disable stops the process and its container, so the feature
genuinely does not work, and leaving its icon would make disable look broken rather than effective.
Reading install state failing subtracts NOTHING, matching useCapabilities' deliberate fail-open.

Each entry now also carries a UI manifest — name, icon, colour, rootRoute, routes — because a sidecar
shipping from its own repository has to be able to say what it looks like. The icon is a NAME rather
than an imported component: a manifest has to survive being JSON from marketplace.officer.dev, which a
lucide import cannot make. Tests pin the manifests against the capability registry, so a tile cannot
appear for a route the server guards differently, and against each other, so two sidecars cannot claim
one root route.

No backfill, by decision: this is proven on a blank machine first and applied to alpha from scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:15:40 +00:00
pastilhasandClaude Opus 5 ba71dc1957 app store: make it work — pm2, install state, and the routes
Email installs end to end now, which was the point of picking it as tier one: no container, no external
wiring, so the machinery is exercised without the provisioning half.

Verified against the running system, not asserted:

  POST /api/app-store/email/install  -> {"status":"installed","completed":["preflight","schema","process"]}
  row                                -> email mode=config status=installed enabled=true
  pm2                                -> officer-email online
  second install                     -> all three steps skipped, process not restarted
  disable                            -> stopped

The server boots with the new router, which is the real test of the capability entry: totality.ts throws
before serve() if a mounted router has none, so booting IS the check passing.

pm2.ts shells out rather than importing pm2 as a library. PM2 is already the supervisor and the
ecosystem file is already the definition of how each process runs; a second thing in charge of that
means two supervisors disagreeing. It also means an owner can undo anything the app store did with a
command they already know. The one fact that matters: `pm2 start <name>` fails for a process PM2 has
never seen, so a first install starts from the ecosystem file with --only, and everything after goes by
name. Callers cannot know which case they are in, so startProcess decides.

Disable stops rather than deletes: a stopped process still shows in `pm2 list`, which is the honest
picture. Deleting would make a disabled sidecar indistinguishable from one never installed.

beginInstall returns the existing row instead of replacing it — that is what makes a retry a resume
rather than a re-provision — and clears lastError on the way in, so a UI never shows a stale failure
beside a working service.

The container half of enable/disable/uninstall is deliberately absent rather than stubbed silently: a
disable that leaves Immich running is a different thing from one that stops it, and the difference is
memory on the user's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:55:11 +00:00
pastilhasandClaude Opus 5 4b9b98efda app store: run a sidecar's setup.sh, streaming it and collecting what it returns
Implements the half of the template contract that faces the platform: answers go in as environment and
never as prompts, and results come back as OFFICER_RESULT_<KEY>= lines on stdout.

A line protocol rather than JSON because the same stream is the user's live log — it goes to a terminal
panel while the install runs. A script that must emit clean JSON cannot also narrate, and one that emits
both needs a framing convention anyway. This mirrors the @@officer:progress@@ sentinel the job runner
already uses, with the same rule: marker lines are plucked out, everything else passes through.

parseResults is pure and tested against the realistic near-misses: a line that MENTIONS the prefix
without starting with it, an empty value (Transmission with no RPC auth returns exactly that, and blank
is a real answer), a value containing `=` (splitting on every one would truncate a credential), and a
prefix with no assignment (a script bug — skipped rather than stored as a blank key).

Verified end to end against a real script: environment reaches it, stderr is forwarded (docker compose
writes its progress there, so dropping it would hide most of what a user watches), OFFICER_NONINTERACTIVE
is set so a script that would block fails loudly instead of hanging behind a web form, and a non-zero
exit is reported with the tail.

Notes an artifact rather than hiding it: the two streams are pumped concurrently, so the error tail can
interleave differently from real time. The live log is correctly ordered; only the summary can read out
of order. Serialising the pumps would make a script that writes heavily to one stream block on the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 4a03b9f84e app store: the install step machine, resumable and testable without docker
Install spans a container start, a health wait, an upstream API call and a process start. Any can fail,
and one of them — a token only a human can mint — is EXPECTED to stop the run. A straight-line function
has two bad options there: unwind everything, or leave a half-installed service that neither works nor
uninstalls, which is the state users cannot get out of.

So each step is named, completion is persisted, and running install again resumes. planSteps is a pure
function of (entry, mode) and the effects are injected, which makes ordering, resume, blocking and
failure testable with no Docker, Postgres, PM2 or Immich in sight. 15 tests cover exactly the behaviour
that only appears when something goes wrong.

Two rules are enforced by the plan rather than remembered at call sites: 'existing' never provisions, so
pointing at an instance the user already runs cannot start a container; and the members step is omitted
entirely for a service with no user concept, so a Transmission install does not report a step that did
nothing — which reads as a silent failure to anyone debugging a member's access.

`blocked` is a first-class outcome, not an error. For Immich the container is up and healthy and only
its own UI can mint a key; calling that a failure would make a normal install look broken and invite the
user to tear down a working container. The blocking step is deliberately NOT recorded as complete, so a
resume re-runs the step the human just answered.

Results feed forward — provision discovers the URL that connect writes down two steps later — over a
copy of the caller's values, so a failure halfway cannot rewrite what an earlier attempt achieved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 ee38257856 app store: make member provisioning a mechanism, not an install-time loop
The owner installs, but a server may already have members, and a member added next month needs the same
work. So the unit is (service × member) reachable from two triggers — install a service, provision
existing members; add a member, provision installed services — rather than a loop inside the installer.
Only handling the first works on day one and rots.

No new table. A member is provisioned exactly when they hold a service_connections row: their own
credential, url NULL, inheriting the instance from the owner's. That schema anticipated this before this
existed, and a second record of the same fact would only be able to disagree with the first.

Three outcomes, declared per catalogue entry so the installer never special-cases a service. `accounts`
is fully transparent. `none` is a single-tenant daemon with nothing to do — filtered before the
provisioning loop so callers can tell "nothing to do" from "did nothing", which look identical at a call
site and matter when someone is asking why a member cannot see a feature.

`invite` is not a weaker `accounts`, it is the correct outcome: Vaultwarden derives its encryption key
from the master password, so a credential we could mint would mean a vault we could read. Transparent
right up to where being transparent would be a defect.

The per-service work is an interface implemented beside each sidecar rather than a switch in core — a
central function growing a case per service is what would stop any of this shipping from its own
repository. Implementations must be idempotent, since both triggers can fire for the same pair and a
duplicate account upstream is not ours to undo. Deprovision is optional and defaults to leaving the
upstream account alone: deleting an Immich user deletes their photos.

Written assuming the vault's multi-user adaptation has landed. Today /api/vault is owner-only by an
explicit ownerGate, so a member is refused before Vaultwarden is reached — verified, and out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 890f57a7a6 app store: compose templates and their setup scripts, with two proven end to end
Each provisionable service gets a directory holding a compose template and a setup.sh. Deliberately the
shape a sidecar needs once it lives in its own repository: metadata, compose, setup script, schema.

The contract (templates/README.md): answers come from the ENVIRONMENT, so the web form fills them in and
a person on a VPS is prompted only for what is missing, and only on a TTY — one script for both, not two
code paths. Idempotent, writes only inside its own directory, streams progress on stdout (the installer
pipes it to a terminal panel), and returns results as OFFICER_RESULT_<KEY>= lines so nothing has to
scrape a log.

House conventions throughout: relative bind mounts so data sits beside the compose file rather than
hiding behind `docker volume inspect`, containers running as the installing user so downloads are not
root-owned, loopback-only ports unless the service's whole job is inbound connections, and no external
networks — the owner's own composes attach to an `nginx` network that a fresh VPS does not have.

Transmission verified end to end on this machine, on non-conflicting ports, then torn down: renders,
starts, waits, reports. Its health check accepts 409 because Transmission rejects the first request by
design — only-200 would have waited out the full timeout against a working daemon. Re-run produced
exactly one container, and files landed owned by the user rather than root.

Vaultwarden covers the case where we GENERATE the credential rather than asking for one. An existing
token is reused, never rotated, because rotating during a resumed install would lock the owner out of
the admin page. The Argon2 hash has its `$` doubled or compose interpolation mangles it. The token is
not returned to the platform at all — the vault sidecar proxies the Bitwarden protocol and never needs
it, and a secret we do not hold is one we cannot leak.

Corrects the design doc, which assumed provisioning always knows the connection. Three shapes: we set
the credential, we generate it, or a human must mint it in the service's UI afterwards (Immich, Jellyfin,
Memos). The third makes "provisioned and running but not yet connected" a real state rather than a
failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 7359867f7f 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>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 654cb10711 app store: put provisioned containers under the officer root, not the user's
The install layout a machine should have, seasoned owner or not:

  ~/officerdev/
    platform/      the app
    data/          DATA_PATH
    dockers/       services the app store provisioned
    capabilities/  the file-based item store

One root, everything under it. OFFICER_ROOT derives from DATA_PATH rather than being a second variable
that has to agree with the first.

Deliberately not `~/dockers`, where a seasoned user already keeps their own estate — 47 services on this
machine. That separation buys two things. Containers the app store created are distinguishable from the
user's own structurally, rather than by a naming convention we would have to enforce and they could
break. And we never reason about someone else's compose files: the store does not scan, adopt or modify
anything outside its own directory.

That also simplifies "I already have one of these" — it is answered by the user giving a URL, never by
us finding a directory and guessing whose it is. An earlier draft had the installer adopting existing
directories, which meant reading, and potentially writing over, services Officer did not create.

This development machine predates the convention and derives an ugly-but-correct path, since the project
sits inside ~/dockers/officer.dev. Still isolated, still one root. New installs get the clean shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:44:30 +00:00
pastilhasandClaude Opus 5 fc48a572d2 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>
2026-08-10 13:44:30 +00:00