feat(docker): restore in-app self-update in the Compose deployment

Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.

Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:

- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
  update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
  container on the new dist/. This is the one supervisor whose updater does NOT
  outlive the restart, which is safe only because the terminal "restarting"
  marker is written first.
- node_modules and dist are named volumes over the bind mount, so
  container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
  `npm run build` is tsc + esbuild and node-pty has no Linux prebuild.

An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.

The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.

Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.

Documented in docs/docker-self-update.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
This commit is contained in:
Devvyn
2026-09-02 19:33:32 +08:00
co-authored by Claude Opus 5
parent 1e24817b51
commit 66eb01ba8f
16 changed files with 1054 additions and 34 deletions
+37
View File
@@ -0,0 +1,37 @@
---
'aicodeman': minor
---
Restore in-app self-update for the Docker Compose deployment.
App Settings → Updates now works in the container, using the same updater,
status file and progress UI as a bare-host install. The Compose file mounts the
checkout it builds from at `/opt/codeman`, so an update's `git checkout` and
rebuild land on the host and survive container recreation, and the restart is
the server exiting — `restart: unless-stopped` relaunches it on the new build.
An in-place container update applies application code only, since a restarted
container reuses its existing image and configuration. The updater therefore
refuses a release that changes `docker/server.Dockerfile` or
`docker/docker-compose.yaml`, or that adds keys to `docker/.env.example` the
user's `.env` has no value for, naming what changed and pointing at
`docker/Start-Codeman.sh` on the host. It also refuses when the container's
restart policy would not bring it back. The missing-key check matters most:
Compose resolves an unset `${VAR}` to the empty string and starts anyway, so a
new required setting would otherwise arrive as a silently blank variable.
Supporting changes:
- The runtime image keeps devDependencies and gains `python3`/`make`/`g++`, so
`npm install` and `npm run build` can run inside the container. This makes the
image larger; that is the cost of updating in place.
- Build artefacts live in `codeman-node-modules` and `codeman-dist` named
volumes so container-compiled native modules never land in the host checkout.
- The four global agent CLIs are pinned, so a release needing newer CLI
behaviour becomes a Dockerfile change the environment gate can detect.
- `docker/Start-Codeman.sh` records the Dockerfile and compose fingerprints the
container was created from, which is the baseline the gate compares against.
- New CI guard: `test/docker-compose-env-parity.test.ts` fails when a compose
variable has no `.env.example` entry, or the reverse.
Documented in `docs/docker-self-update.md`.
+3 -3
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -20,6 +20,13 @@ CODEMAN_RUNTIME_USER=opencode
# directory in the container. # directory in the container.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
# Optional. Absolute host path of this Codeman checkout, mounted at
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
# start script detects it from the compose file's own location, so it only needs
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
# at a directory that is not a git checkout and in-app updates are unavailable.
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
# Required for Docker cases. This must be an absolute path on the Docker host. # Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a # Codeman and each isolated case use this same path, so it cannot be a
# container-only path such as /home/opencode/codeman-cases. # container-only path such as /home/opencode/codeman-cases.
+12
View File
@@ -26,6 +26,18 @@ Codeman, Claude, OpenCode, and other local sessions run as the unprivileged acco
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically. To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
## Updating
Use **App Settings → Updates** in the web UI. The checkout Compose builds from is
also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist
on the host, and the server exiting is what restarts the container onto the new
build.
Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
`.env.example` cannot be applied that way — the updater detects them, names what
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
## Application data storage ## Application data storage
The default configuration uses a host-folder bind mount: The default configuration uses a host-folder bind mount:
+50
View File
@@ -70,4 +70,54 @@ fi
export DOCKER_SOCKET_GID=${socket_ids##*:} export DOCKER_SOCKET_GID=${socket_ids##*:}
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
if [[ ! -d "$repo_path" ]]; then
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
exit 1
fi
export CODEMAN_REPO_PATH="$repo_path"
# The in-app updater runs `git checkout` and `npm install` against this checkout
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
# ("detected dubious ownership") and the update fails at the first step — so warn
# here, where the fix is obvious, rather than in a failed update hours later.
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
if [[ "$repo_owner" != "$PUID" ]]; then
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
fi
fi
if [[ ! -d "$repo_path/.git" ]]; then
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
fi
# Record what the container is about to be built and created FROM. The in-app
# updater compares these against the release it wants to apply: a release that
# changes either file cannot be applied by the container restarting itself (a
# restart reuses the existing image and config), so it is refused and the user
# is sent back here. Written on every start, so the baseline always describes
# the container that is actually running. See docs/docker-self-update.md.
if command -v sha256sum >/dev/null 2>&1; then
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
else
sha256_of() { printf ''; }
fi
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
compose_sha=$(sha256_of "$compose_file")
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
# dataPath('docker-env-applied.json') as the server inside the container sees it.
state_dir="$appdata_path/.codeman"
mkdir -p -- "$state_dir"
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
+40
View File
@@ -15,6 +15,11 @@ services:
ports: ports:
- "${CODEMAN_PORT}:${CODEMAN_PORT}" - "${CODEMAN_PORT}:${CODEMAN_PORT}"
environment: environment:
# Tells the self-updater to restart by exiting (the restart policy below
# relaunches it) rather than by looking for an init system that is not
# here. Also set in the image; repeated so a container started without the
# image default still self-identifies.
CODEMAN_IN_CONTAINER: "1"
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS} CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
# Host-side equivalent of the runtime user's HOME. Docker case seed, # Host-side equivalent of the runtime user's HOME. Docker case seed,
# credential and hook mounts are translated into the daemon namespace. # credential and hook mounts are translated into the daemon namespace.
@@ -48,6 +53,32 @@ services:
- type: bind - type: bind
source: ${DOCKER_SOCKET} source: ${DOCKER_SOCKET}
target: /var/run/docker.sock target: /var/run/docker.sock
# The application source, so App Settings -> Updates can update in place.
# This is the SAME checkout used as the build context above, mounted over
# the image's baked copy: a `git checkout` performed inside the container
# then lands on the host and survives the container being recreated.
# Without it the pull would go to the container's writable layer and be
# silently discarded by the next `up`. See docs/docker-self-update.md.
# Defaults to `..` — the build context above — which Compose resolves
# against the project directory, so plain `docker compose up` works with
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
- type: bind
source: ${CODEMAN_REPO_PATH:-..}
target: /opt/codeman
# Build artefacts live in named volumes layered OVER the repo bind mount,
# so `npm install` and `npm run build` inside the container never write
# into the host checkout. That keeps container-compiled native modules
# (node-pty is built from source here) out of a checkout that may also be
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
# an EMPTY named volume from the image, so the first start inherits the
# image's already-built node_modules and dist rather than paying for a
# bootstrap build.
- type: volume
source: codeman-node-modules
target: /opt/codeman/node_modules
- type: volume
source: codeman-dist
target: /opt/codeman/dist
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
security_opt: security_opt:
@@ -63,3 +94,12 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
volumes:
# Container-owned build artefacts. They persist across container recreation,
# so an in-app update's `npm install` output is not thrown away by the next
# `up`, and they are seeded from the image on first use. Removing them (or
# `docker compose down -v`) is the supported reset: the next start rebuilds
# from the image.
codeman-node-modules:
codeman-dist:
+34 -6
View File
@@ -12,9 +12,12 @@ WORKDIR /opt/codeman
COPY . . COPY . .
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
# updater rebuilds from inside this container, and `npm run build` is tsc +
# esbuild — both devDependencies. Pruning them saves image size and takes the
# self-updater with it. See docs/docker-self-update.md.
RUN npm ci \ RUN npm ci \
&& npm run build \ && npm run build \
&& npm prune --omit=dev --ignore-scripts \
&& npm cache clean --force && npm cache clean --force
# The Docker CLI talks to the host daemon through the socket mounted by # The Docker CLI talks to the host daemon through the socket mounted by
@@ -25,13 +28,21 @@ ARG CODEMAN_RUNTIME_USER=opencode
ARG PUID=1000 ARG PUID=1000
ARG PGID=1000 ARG PGID=1000
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
# runs `npm install` inside the running container, and node-pty ships no Linux
# prebuild, so a release that bumps it compiles from source right here. Without
# a toolchain that install fails and the update rolls back — every time, on the
# releases that need it most. Same reason install.sh installs one on bare hosts.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
curl \ curl \
g++ \
git \ git \
make \
openssh-client \ openssh-client \
procps \ procps \
python3 \
ripgrep \ ripgrep \
tmux \ tmux \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -59,11 +70,23 @@ COPY --from=docker:29-cli \
# Keep credentials out of the image. Users authenticate these CLIs at runtime # Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state. # through Codeman sessions, and the configured host bind mount retains state.
#
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
# a function of WHEN their image was built, not of any commit — so a Codeman
# release that depends on newer CLI behaviour (the trust-dialog handling is
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
# on an older image with no diff anywhere to explain why. In-app updates make
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
# a newer CLI" into a Dockerfile change, which the updater's environment gate
# already detects and refuses (docs/docker-self-update.md).
#
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
# this layer when only the pins change upstream.
RUN npm install --global \ RUN npm install --global \
@anthropic-ai/claude-code \ @anthropic-ai/claude-code@2.1.258 \
@google/gemini-cli \ @google/gemini-cli@0.58.0 \
@openai/codex \ @openai/codex@0.152.1 \
opencode-ai \ opencode-ai@1.18.26 \
&& npm cache clean --force && npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and # Keep the web server and every local Codeman session unprivileged. PUID and
@@ -103,7 +126,12 @@ WORKDIR /opt/codeman
COPY --from=build /opt/codeman /opt/codeman COPY --from=build /opt/codeman /opt/codeman
ENV CODEMAN_PORT=3000 \ # CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
# than by asking an init system that is not here (src/web/self-update.ts).
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
# explicitly, since that value would otherwise omit the build toolchain.
ENV CODEMAN_IN_CONTAINER=1 \
CODEMAN_PORT=3000 \
HOME=/home/${CODEMAN_RUNTIME_USER} \ HOME=/home/${CODEMAN_RUNTIME_USER} \
NODE_ENV=production NODE_ENV=production
+10
View File
@@ -61,6 +61,16 @@ If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIM
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them. If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
## Updating
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md).
## Docker cases ## Docker cases
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path. The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
+205
View File
@@ -0,0 +1,205 @@
# Self-update in the Docker Compose deployment
Codeman running as a container updates itself from **App Settings → Updates**, the
same place and the same button as a bare-host install. This document explains how
that works, what it deliberately refuses to do, and how to recover when it stops.
The bare-host updater is documented in
[`architecture-invariants.md#self-update`](architecture-invariants.md#self-update);
this file covers only what the container changes.
## The short version
| Change in the release | Applied by |
| -------------------------------- | ------------------------------------------------ |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
The in-app updater detects all three of the bottom rows itself and refuses with a
message naming what changed, so you never have to work out which case you are in.
## Why the container needs its own path
The bare-host updater does `git checkout <tag> && npm install && npm run build`,
then asks systemd or launchd to restart the service. Two of those assumptions are
false in a container:
1. **There is no init system.** A container's supervisor is the Docker daemon,
which acts on the container, not on processes inside it.
2. **The image is immutable.** A `git pull` into the image's baked `/opt/codeman`
would land in the container's writable layer, survive `docker restart`, and be
silently discarded by the next `docker compose up`.
Both are solved by configuration rather than by a second updater:
- **The checkout is a host bind mount.** `docker-compose.yaml` mounts the repo
(the same directory used as the build context) over `/opt/codeman`, so the
updater's `git checkout` writes to the host filesystem and survives the
container being recreated.
- **The restart is the server exiting.** `restart: unless-stopped` relaunches the
container whenever its main process ends, including on a clean exit — so the
updater's final step is to signal the server, and Docker starts it again on the
freshly built `dist/`.
Everything else — the release-tag channel, the auto-stash, the atomic
`update-status.json` the browser polls across the connection drop, the boot-time
reconcile that flips `restarting` to `completed` — is the existing machinery,
unchanged. The container path is a new `SupervisorKind`, not a new updater.
## What the pieces are
| Piece | Role |
| ---------------------------------------------- | ------------------------------------------------------------------- |
| Repo bind mount at `/opt/codeman` | Makes the pull persistent. Without it, self-update is unavailable. |
| `codeman-node-modules`, `codeman-dist` volumes | Container-owned build artefacts, layered over the bind mount. |
| `CODEMAN_IN_CONTAINER=1` | Tells `detectSupervisor()` to restart by exiting. |
| `restart: unless-stopped` | Turns that exit into a restart. Verified before every update. |
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
### Why build artefacts are in named volumes
`node_modules` and `dist` are mounted as named volumes **on top of** the repo bind
mount. Without that, an update's `npm install` would write into the host checkout,
leaving container-compiled native modules (node-pty builds from source here) in a
directory that may also be used to run Codeman natively, and leaving `git status`
permanently noisy.
Docker seeds an empty named volume from the image, so the first start inherits the
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
`docker compose down -v` is the supported reset: the next start re-seeds them.
### Why the runtime image carries a build toolchain
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
longer runs `npm prune --omit=dev`. And `npm install` may rebuild node-pty, which
ships no Linux prebuild, so `python3`, `make` and `g++` are installed as well.
This is the real cost of in-place updates: a noticeably larger image than a
runtime-only one. It buys an update that takes about a minute instead of a full
image rebuild, and it is why `NODE_ENV=production` is paired with an explicit
`npm install --include=dev` in the updater.
## The environment gate
An in-place update applies **code only**. A restarted container reuses its existing
image and configuration, so a release that changes the environment cannot take
effect that way — and would half-apply: new code against an old environment. The
updater therefore checks the **target release's own files**, read straight out of
git with `git show <tag>:<path>` before anything is checked out.
### 1. `server.Dockerfile` changed, so the image must be rebuilt
Compared by sha256 against the fingerprint `Start-Codeman.sh` recorded when the
running container was built.
### 2. `docker-compose.yaml` changed, so the container must be recreated
Same mechanism. A restart cannot pick up a new mount, port or environment
variable; only recreating the container can.
### 3. `.env.example` gained keys your `.env` has no value for
The check that matters most, because **Compose will not tell you**. An unset
`${VAR}` interpolates to the empty string; Compose prints a warning to a terminal
nobody is watching and starts anyway. A new required setting therefore arrives as
a silently blank environment variable and misbehaves later, far from the cause.
The updater names the missing keys instead.
Commented-out lines in `.env.example` are deliberately *not* keys — that is how
the file marks optional overrides such as `# PUID=1000`, and counting them would
block updates on settings you are meant to leave alone.
### 4. A restart policy that would not bring the container back
Before signalling the server, the updater asks the Docker daemon for its own
container's restart policy. If it is `no`, the update is refused: applying it
would take Codeman down and leave no UI to recover from.
### What the gate deliberately does not do
Every unknown fails **open**:
- A missing fingerprint baseline (a container started before this feature existed)
is not treated as a change, or those installs could never update at all.
- An unreadable `.env`, an unreachable Docker socket, or a target tag whose files
cannot be read all yield "no blocker" rather than a refusal.
The gate catches a specific, detectable class of mistake; it is not a last line of
defence. It is also re-evaluated server-side on `POST /api/system/update`, so
hiding the button in the UI is a courtesy rather than the control.
## The one residual risk
The gate is derived from the diff, so it cannot see a release that needs a newer
environment **without changing any of those files** — for example, code that
depends on newer agent-CLI behaviour.
That is why the four global CLIs in `server.Dockerfile` are **pinned**. Unpinned,
the versions a user ends up with are a function of when their image was built
rather than of any commit, and in-app updates make rebuilds rarer, which makes
that drift worse over time. Pinned, "this release needs a newer CLI" becomes a
Dockerfile change, which check 1 already detects. Bump them deliberately, as part
of a release.
The complementary merge-side guard is `test/docker-compose-env-parity.test.ts`,
which fails CI when a variable is added to `docker-compose.yaml` without an entry
in `.env.example`, or the reverse.
## Sequence of an in-place update
1. **Check** — `GET /api/system/update/check` finds the latest release tag, fetches
that one ref so the gate can read the target's files, and returns any blockers.
2. **Start** — `POST /api/system/update` re-evaluates the gate, writes `queued` to
`update-status.json`, stages `self-update.sh` outside the repo and runs it.
3. **Apply** — stash if dirty, fetch the tag, check it out, `npm install
--include=dev`, `npm run build`. A failure at any step rolls back to the
previous commit, rebuilds it and reports `failed`; the server is never
restarted into a broken build.
4. **Restart** — write the terminal `restarting` marker, then signal the server.
The container exits and Docker restarts it.
5. **Reconcile** — the rebooted server compares its own version against the target
and flips the status to `completed` or `failed`. The browser, still polling,
picks that up.
Step 4 kills the updater script along with the container — unlike the systemd
path, it does not outlive the restart. That is safe only because the terminal
marker is written first, which is why nothing may be appended after the kill.
## Troubleshooting
**"This install can't update itself (unknown)"** — the repo bind mount is missing,
so the container is running the baked image copy. Check `CODEMAN_REPO_PATH` and
confirm the mounted directory really contains `.git`.
**The update fails immediately with a git ownership or permission error** — the
mounted checkout belongs to a different user than the one Codeman runs as
(`PUID`), so git refuses it as "dubious ownership". `Start-Codeman.sh` warns
about this at start; fix it by chowning the checkout to the same account that
owns `CODEMAN_APPDATA_PATH`.
**A rebuild is reported as required every time** — the fingerprint baseline does
not match the checkout. `Start-Codeman.sh` writes it on every start, so start
through that script rather than a bare `docker compose up` after either file
changes.
**Codeman does not come back after an update** — the build succeeded, since the
updater gates the restart on it, so read the container logs with `docker compose
logs codeman`. To roll back, check out the previous tag in the host checkout and
run `docker/Start-Codeman.sh`.
**The update failed during `npm install`** — most likely a native rebuild with no
toolchain, meaning the image predates the toolchain being added. Rebuild once from
the host and the in-app path works from then on.
**Resetting the build artefacts** — `docker compose down -v`, then
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
image.
## Disabling it
Set `CODEMAN_DISABLE_SELF_UPDATE=1` in `docker/.env` and pass it through in the
compose file's `environment:` block. The Updates panel then reports that in-app
updates are disabled, and the host-side script is the only way to update.
+39 -6
View File
@@ -7,16 +7,22 @@
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git # the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
# checkout` rewrites the in-repo copy and bash reads scripts lazily. # checkout` rewrites the in-repo copy and bash reads scripts lazily.
# #
# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the
# restart IS the container exiting, which kills this script too. That is safe
# because the terminal "restarting" marker is written before the kill and the
# rebooted server reconciles it — but nothing may be added after that kill.
#
# Reports progress by writing ~/.codeman/update-status.json atomically; the # Reports progress by writing ~/.codeman/update-status.json atomically; the
# browser polls GET /api/system/update/status across the restart drop. The # browser polls GET /api/system/update/status across the restart drop. The
# freshly-booted server reconciles the final "restarting" → "completed"/"failed". # freshly-booted server reconciles the final "restarting" → "completed"/"failed".
# #
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a # Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit
# manual command (foreground installs). Linux launches inside a transient # under Docker Compose (the restart policy relaunches it), or prints a manual
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build. # command (foreground installs). Linux launches inside a transient systemd scope
# so `systemctl restart codeman-web` can't kill it mid-build.
# #
# Args (all from the server, never user input — tag is validated server-side): # Args (all from the server, never user input — tag is validated server-side):
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none> # --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|docker-compose|none>
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path> # --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
# --log <path> [--prev-sha <sha>] [--stash] # --log <path> [--prev-sha <sha>] [--stash]
# #
@@ -144,7 +150,7 @@ rollback_and_fail() {
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}" echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
if [[ -n "$PREV_SHA" ]]; then if [[ -n "$PREV_SHA" ]]; then
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
npm install --no-fund --no-audit >/dev/null 2>&1 || true npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true
npm run build >/dev/null 2>&1 || true npm run build >/dev/null 2>&1 || true
fi fi
fail "$msg — rolled back to the previous version" "$msg" fail "$msg — rolled back to the previous version" "$msg"
@@ -176,7 +182,9 @@ write_status "checkout" "Checking out $TAG…"
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG" git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
# 4) Install dependencies (heartbeat keeps the UI live during this slow step). # 4) Install dependencies (heartbeat keeps the UI live during this slow step).
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \ # --include=dev: tsc and esbuild are devDependencies, and the Compose image sets
# NODE_ENV=production, which would otherwise omit them and fail the build below.
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \
|| rollback_and_fail "Dependency install failed" || rollback_and_fail "Dependency install failed"
# 5) Build (gate the restart on success — never restart into a torn dist/). # 5) Build (gate the restart on success — never restart into a torn dist/).
@@ -200,6 +208,31 @@ case "$SUPERVISOR" in
|| fail "Build succeeded but launchd restart failed" "launchctl" || fail "Build succeeded but launchd restart failed" "launchctl"
} }
;; ;;
docker-compose)
# In the Compose deployment there is no init system to ask: the "restart" is
# the server EXITING, so the container's `restart: unless-stopped` policy
# relaunches it on the dist/ we just built. The repo and dist/ live on host
# mounts, so the new build survives the container being replaced.
#
# ⚠️ This script dies WITH the container it is restarting — it is a child of
# the server process, not a survivor like the systemd-scope path. That is
# fine, and load-bearing: the terminal "restarting" marker is already written
# above, and the freshly-booted server reconciles it. Nothing may be appended
# after the kill that the update depends on.
#
# ⚠️ The server is signalled by PID rather than `docker restart`: this
# container's own Docker CLI talks to the HOST daemon, and a self-directed
# restart there races the client's own death. Exiting is the one path that
# needs no cooperation from anything outside the container.
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
: # container exit + restart policy take it from here
else
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION."
echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required"
exit 0
fi
;;
launchd-daemon) launchd-daemon)
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system # System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
# domain needs root, but we don't need it — kill the server and launchd # domain needs root, but we don't need it — kill the server and launchd
+54 -3
View File
@@ -7,6 +7,10 @@
* (see `dataPath('update-status.json')`) that the browser polls across the * (see `dataPath('update-status.json')`) that the browser polls across the
* restart boundary. * restart boundary.
* *
* The Docker Compose deployment updates in place too (same script, same status
* file) — see `docs/docker-self-update.md` for how the container restarts itself
* and what the environment gate refuses.
*
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts` * Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`). * (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
* *
@@ -17,11 +21,56 @@
* Which init system supervises the running server (decides how we restart it). * Which init system supervises the running server (decides how we restart it).
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI * `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
* login): restart works by killing the server and letting launchd respawn it. * login): restart works by killing the server and letting launchd respawn it.
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
* "restart" is the server exiting so the container's `restart: unless-stopped`
* policy relaunches it on the freshly built `dist/`.
*/ */
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none'; export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
/** How Codeman was installed — only `git` installs can self-update in place. */ /**
export type InstallKind = 'git' | 'npm' | 'unknown'; * How Codeman was installed. `git` and `docker-compose` can self-update in
* place; `docker-compose` is a git checkout bind-mounted into the container, so
* the pull/build happen on the host filesystem and survive container recreation.
*/
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
/**
* Why an in-place container update is refused. Each is derived mechanically from
* the target release's own files — nothing here depends on a human remembering
* to declare something at release time.
*
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
* which a self-restart cannot apply (a restart reuses the existing container's
* image and config). Needs a rebuild + recreate from the host.
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
* EMPTY STRING and starts anyway, so without this check a new required setting
* arrives as a silently blank env var.
* - `no-auto-restart`: the container's restart policy would not bring it back
* after the server exits, so applying the update would take Codeman down.
*/
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
/** One reason an in-place container update is refused, with UI-ready text. */
export interface EnvironmentBlocker {
kind: EnvironmentBlockerKind;
/** One-line explanation shown in App Settings → Updates. */
message: string;
/** Optional specifics (e.g. the names of the missing env keys). */
details?: string[];
}
/**
* Result of the environment gate for a candidate release. `checked: false` means
* the gate did not run (not a container install, or the target tag's files could
* not be read) — callers must not treat that as "no blockers".
*/
export interface EnvironmentGate {
checked: boolean;
blockers: EnvironmentBlocker[];
/** The host command that resolves every blocker. */
hostCommand: string;
}
/** /**
* Lifecycle of a single update run. `idle`/`completed`/`failed`/ * Lifecycle of a single update run. `idle`/`completed`/`failed`/
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
/** epoch ms of the check. */ /** epoch ms of the check. */
checkedAt: number; checkedAt: number;
source: 'github-api' | 'git-ls-remote' | 'none'; source: 'github-api' | 'git-ls-remote' | 'none';
/** Environment gate for THIS candidate release (container installs only). */
environment?: EnvironmentGate;
error?: string; error?: string;
} }
+32 -4
View File
@@ -1079,10 +1079,14 @@ Object.assign(CodemanApp.prototype, {
const verEl = this.$('updateCurrentVersion'); const verEl = this.$('updateCurrentVersion');
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`; if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
if (data.installKind && data.installKind !== 'git') { // `docker-compose` self-updates in place like `git` does — the container
this._setUpdateResult( // restarts itself. Anything else cannot.
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.` if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
); const hint =
data.supervisor === 'docker-compose'
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
return; return;
} }
if (data.selfUpdateEnabled === false) { if (data.selfUpdateEnabled === false) {
@@ -1093,6 +1097,30 @@ Object.assign(CodemanApp.prototype, {
this._setUpdateResult(escapeHtml(data.error)); this._setUpdateResult(escapeHtml(data.error));
return; return;
} }
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
// or new .env keys) cannot be applied by the container restarting itself, so
// the update button is never offered — the host command is, instead. The
// server re-checks this on POST, so hiding the button is UX, not the gate.
const blockers = data.environment?.blockers || [];
if (data.updateAvailable && blockers.length > 0) {
const reasons = blockers
.map((b) => {
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
return `<li>${escapeHtml(b.message)}${details}</li>`;
})
.join('');
this._setUpdateResult(
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
);
if (notes && data.notes) {
notes.style.display = 'block';
notes.textContent = data.notes;
}
return;
}
if (data.updateAvailable && data.latestVersion) { if (data.updateAvailable && data.latestVersion) {
this._setUpdateResult( this._setUpdateResult(
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> &nbsp;(current v${escapeHtml(data.currentVersion || '')})` `Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> &nbsp;(current v${escapeHtml(data.currentVersion || '')})`
+3
View File
@@ -389,6 +389,9 @@ export function registerSystemRoutes(
'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, 'in-flight': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS }, 'up-to-date': { http: 409, api: ApiErrorCode.ALREADY_EXISTS },
'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT }, 'not-git': { http: 400, api: ApiErrorCode.INVALID_INPUT },
// A container release that changes the ENVIRONMENT: not a client error to
// retry, it needs a host-side rebuild (docs/docker-self-update.md).
'env-blocked': { http: 409, api: ApiErrorCode.INVALID_INPUT },
disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT }, disabled: { http: 403, api: ApiErrorCode.INVALID_INPUT },
'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT }, 'bad-tag': { http: 400, api: ApiErrorCode.INVALID_INPUT },
error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR }, error: { http: 500, api: ApiErrorCode.INTERNAL_ERROR },
+299 -12
View File
@@ -16,6 +16,15 @@
* tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`, * tested, and IO wrappers (`getInstallInfo`, `checkForUpdate`, `startUpdate`,
* `reconcileUpdateOnBoot`) that touch git/network/fs. * `reconcileUpdateOnBoot`) that touch git/network/fs.
* *
* DOCKER COMPOSE installs update in place too, through the same script and the
* same status file. The repo is a host bind mount, so the pull/build land on the
* host filesystem and survive container recreation; the "restart" is the server
* EXITING so the container's restart policy relaunches it on the new `dist/`.
* That applies CODE only — a restart reuses the existing container's image and
* config — so `evaluateEnvironmentGate()` refuses a release that changes
* `server.Dockerfile`, `docker-compose.yaml` or `.env.example`, pointing at the
* host command instead. See `docs/docker-self-update.md`.
*
* Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in * Related: `src/types/update.ts`, `scripts/self-update.sh`, routes in
* `src/web/routes/system-routes.ts`. * `src/web/routes/system-routes.ts`.
* *
@@ -26,13 +35,15 @@ import { spawn, execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { homedir, tmpdir } from 'node:os'; import { homedir, hostname, tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto'; import { randomUUID, createHash } from 'node:crypto';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { dataPath } from '../config/instance.js'; import { dataPath } from '../config/instance.js';
import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js'; import { LAUNCHD_LABEL, SYSTEMD_UNIT } from '../config/service-names.js';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js'; import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import type { import type {
EnvironmentBlocker,
EnvironmentGate,
InstallInfo, InstallInfo,
InstallKind, InstallKind,
SupervisorKind, SupervisorKind,
@@ -215,6 +226,120 @@ export function reconcileStatusDecision(
return null; return null;
} }
// ─────────────────────────────────────────────────────────────────────────────
// PURE helpers — the container environment gate
// ─────────────────────────────────────────────────────────────────────────────
/** Host command that resolves every environment blocker. */
export const DOCKER_HOST_UPDATE_COMMAND = 'docker/Start-Codeman.sh';
/**
* Parse the SET keys out of a dotenv file. Commented-out lines are deliberately
* NOT keys: `docker/.env.example` uses `# PUID=1000` to document an OPTIONAL
* override, so treating those as required would block every update on settings
* the user is meant to leave alone.
*/
export function parseEnvKeys(text: string): string[] {
const keys: string[] = [];
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const m = line.replace(/^export\s+/, '').match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
if (m && !keys.includes(m[1])) keys.push(m[1]);
}
return keys;
}
/**
* Keys the TARGET release's `.env.example` sets that the user's `.env` does not.
*
* This is the check that makes a new required setting visible: Compose resolves
* an unset `${VAR}` to the empty string and starts anyway, so a missing key is
* otherwise silent until something misbehaves at runtime.
*/
export function diffRequiredEnvKeys(targetExample: string, userEnv: string): string[] {
const have = new Set(parseEnvKeys(userEnv));
return parseEnvKeys(targetExample).filter((k) => !have.has(k));
}
/**
* True when the container's restart policy relaunches it after the server exits.
* `no` and an empty policy mean an in-place update would take Codeman DOWN
* rather than restart it, so the update is refused instead.
*/
export function isAutoRestartPolicy(name: string | null | undefined): boolean {
return name === 'always' || name === 'unless-stopped' || name === 'on-failure';
}
export interface EnvironmentGateInput {
/** sha256 of `docker/server.Dockerfile` the running container was built from. */
appliedDockerfileHash: string | null;
/** sha256 of `docker/server.Dockerfile` at the target release tag. */
targetDockerfileHash: string | null;
/** sha256 of `docker/docker-compose.yaml` the running container was created from. */
appliedComposeHash: string | null;
/** sha256 of `docker/docker-compose.yaml` at the target release tag. */
targetComposeHash: string | null;
/** Keys from `diffRequiredEnvKeys()`. */
missingEnvKeys: string[];
/** Docker restart policy name of the running container, or null if unknown. */
restartPolicy: string | null;
}
/**
* PURE gate decision. An in-place container update applies CODE only: the server
* exits and the container's restart policy relaunches it on the new `dist/`. A
* restart reuses the existing container's image and config, so anything that
* changes the ENVIRONMENT cannot take effect that way and is refused here with
* the host command that can apply it.
*
* ⚠️ An unknown hash (null) is NOT treated as "changed": a first update from a
* container created before the fingerprint file existed has no baseline, and
* failing closed there would block every such install from ever updating. The
* baseline is written by `Start-Codeman.sh`, so it exists from the first
* host-side start onward. An unknown restart policy is likewise not a blocker —
* the shipped Compose file sets `unless-stopped`, and the probe needs the Docker
* socket, which a user may not have mounted.
*/
export function computeEnvironmentBlockers(input: EnvironmentGateInput): EnvironmentBlocker[] {
const blockers: EnvironmentBlocker[] = [];
if (
input.appliedDockerfileHash &&
input.targetDockerfileHash &&
input.appliedDockerfileHash !== input.targetDockerfileHash
) {
blockers.push({
kind: 'dockerfile-changed',
message: 'This release changes docker/server.Dockerfile, so the image must be rebuilt.',
});
}
if (input.appliedComposeHash && input.targetComposeHash && input.appliedComposeHash !== input.targetComposeHash) {
blockers.push({
kind: 'compose-changed',
message: 'This release changes docker/docker-compose.yaml, so the container must be recreated.',
});
}
if (input.missingEnvKeys.length > 0) {
blockers.push({
kind: 'env-keys-missing',
message: `This release adds ${input.missingEnvKeys.length} setting(s) your docker/.env has no value for.`,
details: input.missingEnvKeys,
});
}
if (input.restartPolicy !== null && !isAutoRestartPolicy(input.restartPolicy)) {
blockers.push({
kind: 'no-auto-restart',
message: `This container's restart policy is "${input.restartPolicy}", so it would not come back after the update.`,
});
}
return blockers;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Status file IO // Status file IO
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -273,19 +398,149 @@ export function resolveInstallDir(): string {
return process.cwd(); return process.cwd();
} }
/**
* True when this process runs inside a container. `/.dockerenv` is created by the
* Docker daemon itself; the env var is set by our own Compose file so the check
* also holds under runtimes that omit that file.
*/
export function isRunningInContainer(): boolean {
return process.env.CODEMAN_IN_CONTAINER === '1' || existsSync('/.dockerenv');
}
function detectInstallKind(dir: string): InstallKind { function detectInstallKind(dir: string): InstallKind {
if (existsSync(join(dir, '.git'))) return 'git'; // A container whose code is a bind-mounted checkout updates in place (the pull
// and build land on the host filesystem and survive container recreation). A
// container WITHOUT that mount runs a baked image copy — a pull there would go
// to the writable layer and vanish on the next `up`, so it is not updatable.
if (existsSync(join(dir, '.git'))) return isRunningInContainer() ? 'docker-compose' : 'git';
// Global npm install ships only dist/ (no src/, no .git). // Global npm install ships only dist/ (no src/, no .git).
if (!existsSync(join(dir, 'src'))) return 'npm'; if (!existsSync(join(dir, 'src'))) return 'npm';
return 'unknown'; return 'unknown';
} }
/** Install kinds whose update is applied in place by `scripts/self-update.sh`. */
export function canSelfUpdateInPlace(kind: InstallKind): boolean {
return kind === 'git' || kind === 'docker-compose';
}
/** Path of the fingerprint baseline written by `docker/Start-Codeman.sh`. */
const DOCKER_ENV_APPLIED_FILE = dataPath('docker-env-applied.json');
/** Files whose content defines the container ENVIRONMENT (vs. the app's code). */
const DOCKERFILE_REL = 'docker/server.Dockerfile';
const COMPOSE_REL = 'docker/docker-compose.yaml';
const ENV_EXAMPLE_REL = 'docker/.env.example';
const ENV_REL = 'docker/.env';
function sha256(text: string): string {
return createHash('sha256').update(text, 'utf-8').digest('hex');
}
/** Read a file at a git TAG without checking it out (`git show tag:path`). */
function gitShowAtTag(repo: string, tag: string, relPath: string): string | null {
return tryExec('git', ['show', `${tag}:${relPath}`], repo);
}
function readFileOrNull(path: string): string | null {
try {
return readFileSync(path, 'utf-8');
} catch {
return null;
}
}
/**
* The fingerprints the RUNNING container was created from, recorded on the host
* by `Start-Codeman.sh` at each build/recreate. Returns nulls when absent (a
* container started before this file existed) — `computeEnvironmentBlockers()`
* deliberately treats an unknown baseline as "not a blocker".
*/
function readAppliedEnvironmentFingerprints(): { dockerfile: string | null; compose: string | null } {
const raw = readFileOrNull(DOCKER_ENV_APPLIED_FILE);
if (!raw) return { dockerfile: null, compose: null };
try {
const parsed = JSON.parse(raw) as { dockerfileSha256?: string; composeSha256?: string };
return { dockerfile: parsed.dockerfileSha256 ?? null, compose: parsed.composeSha256 ?? null };
} catch {
return { dockerfile: null, compose: null };
}
}
/**
* Restart policy of the container we're running in, via the mounted Docker
* socket. Returns null when the socket or CLI is unavailable — an unknown policy
* is not a blocker (see `computeEnvironmentBlockers`).
*/
function detectOwnRestartPolicy(): string | null {
// Docker sets HOSTNAME to the short container id; os.hostname() is the same
// value when the env var is absent. A custom `hostname:` in the compose file
// makes both unresolvable to the daemon, which fails open (unknown is not a
// blocker) rather than refusing an update over a cosmetic setting.
const id = process.env.HOSTNAME || hostname();
if (!id) return null;
const out = tryExec('docker', ['inspect', '--format', '{{.HostConfig.RestartPolicy.Name}}', id]);
return out && out.length > 0 ? out : null;
}
/**
* Evaluate the environment gate for a candidate release tag. Reads the TARGET
* tag's files straight out of git (`git show`), so nothing is checked out and the
* answer is available at CHECK time — the UI can refuse before the user commits
* to an update.
*/
export function evaluateEnvironmentGate(installDir: string, tag: string): EnvironmentGate {
// `git show <tag>:<path>` needs the tag's objects locally, and neither the
// GitHub API nor `ls-remote` fetches anything — so a check that has never seen
// this tag would read nothing and report a falsely clean gate. Fetch the one
// ref first (cheap: it deltas against what the clone already has) and only
// then read. The updater fetches the same ref again; both are idempotent.
if (tryExec('git', ['rev-parse', '--verify', '--quiet', `${tag}^{commit}`], installDir) === null) {
tryExec(
'git',
['fetch', '--tags', '--force', 'origin', `refs/tags/${tag}:refs/tags/${tag}`],
installDir,
CHECK_TIMEOUT_MS
);
}
const targetDockerfile = gitShowAtTag(installDir, tag, DOCKERFILE_REL);
const targetCompose = gitShowAtTag(installDir, tag, COMPOSE_REL);
const targetExample = gitShowAtTag(installDir, tag, ENV_EXAMPLE_REL);
// No environment files at the target tag at all: we cannot judge, so say so
// rather than reporting a clean gate the caller would trust.
if (targetDockerfile === null && targetCompose === null && targetExample === null) {
return { checked: false, blockers: [], hostCommand: DOCKER_HOST_UPDATE_COMMAND };
}
const applied = readAppliedEnvironmentFingerprints();
const userEnv = readFileOrNull(join(installDir, ENV_REL));
const blockers = computeEnvironmentBlockers({
appliedDockerfileHash: applied.dockerfile,
targetDockerfileHash: targetDockerfile === null ? null : sha256(targetDockerfile),
appliedComposeHash: applied.compose,
targetComposeHash: targetCompose === null ? null : sha256(targetCompose),
// A missing/unreadable .env cannot be diffed — report no missing keys rather
// than every key, which would block on an install using a non-standard path.
missingEnvKeys: targetExample !== null && userEnv !== null ? diffRequiredEnvKeys(targetExample, userEnv) : [],
restartPolicy: detectOwnRestartPolicy(),
});
return { checked: true, blockers, hostCommand: DOCKER_HOST_UPDATE_COMMAND };
}
/** /**
* Detect which init system supervises us. Detection happens HERE (in the running * Detect which init system supervises us. Detection happens HERE (in the running
* server, which has a rich env) and the result is passed to the updater script — * server, which has a rich env) and the result is passed to the updater script —
* the detached child must not re-probe with a stripped-down environment. * the detached child must not re-probe with a stripped-down environment.
*/ */
export function detectSupervisor(): SupervisorKind { export function detectSupervisor(): SupervisorKind {
// Checked FIRST: a container has no init system of its own, and its "restart"
// is the server exiting so the Docker restart policy relaunches it. Probing
// systemd here would find nothing and report `none`, which stages the update
// and then asks the user to restart by hand for no reason.
if (isRunningInContainer()) return 'docker-compose';
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd'; if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level // Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
@@ -389,17 +644,29 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
checkedAt, checkedAt,
source: 'none', source: 'none',
}; };
if (info.installKind !== 'git') { if (!canSelfUpdateInPlace(info.installKind)) {
return { ...base, error: 'Not a git install — self-update is unavailable.' }; return {
...base,
error:
info.installKind === 'unknown' && isRunningInContainer()
? 'This container runs a baked image copy with no repository mounted — self-update is unavailable. See docs/docker-self-update.md.'
: 'Not a git install — self-update is unavailable.',
};
} }
/** Attach the container environment gate to a finished check result. */
const withGate = (result: UpdateCheckResult): UpdateCheckResult => {
if (info.installKind !== 'docker-compose' || !result.latestTag || !result.updateAvailable) return result;
return { ...result, environment: evaluateEnvironmentGate(info.installDir, result.latestTag) };
};
const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir); const remote = tryExec('git', ['remote', 'get-url', 'origin'], info.installDir);
const gh = remote ? parseGitHubRepo(remote) : null; const gh = remote ? parseGitHubRepo(remote) : null;
if (gh) { if (gh) {
const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo); const rel = await fetchLatestReleaseFromGitHub(gh.owner, gh.repo);
if (rel) { if (rel) {
return { return withGate({
...base, ...base,
latestVersion: rel.version, latestVersion: rel.version,
latestTag: rel.tag, latestTag: rel.tag,
@@ -407,20 +674,20 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
htmlUrl: rel.htmlUrl, htmlUrl: rel.htmlUrl,
updateAvailable: isNewerStableVersion(info.currentVersion, rel.version), updateAvailable: isNewerStableVersion(info.currentVersion, rel.version),
source: 'github-api', source: 'github-api',
}; });
} }
} }
// Fallback: enumerate remote tags directly (works for non-GitHub remotes too). // Fallback: enumerate remote tags directly (works for non-GitHub remotes too).
const viaGit = fetchLatestTagViaGit(info.installDir); const viaGit = fetchLatestTagViaGit(info.installDir);
if (viaGit) { if (viaGit) {
return { return withGate({
...base, ...base,
latestVersion: viaGit.version, latestVersion: viaGit.version,
latestTag: viaGit.tag, latestTag: viaGit.tag,
updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version), updateAvailable: isNewerStableVersion(info.currentVersion, viaGit.version),
source: 'git-ls-remote', source: 'git-ls-remote',
}; });
} }
return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' }; return { ...base, error: 'Could not reach the update server (GitHub API + git ls-remote both failed).' };
@@ -432,7 +699,11 @@ export async function checkForUpdate(): Promise<UpdateCheckResult> {
export type StartUpdateResult = export type StartUpdateResult =
| { ok: true; updateId: string; toTag: string; toVersion: string | null } | { ok: true; updateId: string; toTag: string; toVersion: string | null }
| { ok: false; code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'error'; message: string }; | {
ok: false;
code: 'disabled' | 'not-git' | 'in-flight' | 'up-to-date' | 'bad-tag' | 'env-blocked' | 'error';
message: string;
};
/** /**
* Copy the updater script OUT of the repo before running it. The script lives in * Copy the updater script OUT of the repo before running it. The script lives in
@@ -497,11 +768,13 @@ export async function startUpdate(): Promise<StartUpdateResult> {
if (!info.selfUpdateEnabled) { if (!info.selfUpdateEnabled) {
return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' }; return { ok: false, code: 'disabled', message: 'Self-update is disabled (CODEMAN_DISABLE_SELF_UPDATE=1).' };
} }
if (info.installKind !== 'git') { if (!canSelfUpdateInPlace(info.installKind)) {
return { return {
ok: false, ok: false,
code: 'not-git', code: 'not-git',
message: 'This is not a git install. Update with: npm i -g aicodeman@latest', message: isRunningInContainer()
? 'This container has no repository mounted. Update from the host with docker/Start-Codeman.sh.'
: 'This is not a git install. Update with: npm i -g aicodeman@latest',
}; };
} }
const existing = readUpdateStatus(); const existing = readUpdateStatus();
@@ -517,6 +790,20 @@ export async function startUpdate(): Promise<StartUpdateResult> {
return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` }; return { ok: false, code: 'bad-tag', message: `Refusing to update to an unrecognized tag: ${check.latestTag}` };
} }
// Re-evaluate rather than trusting the check the browser saw: the UI hides the
// button when the gate blocks, but the endpoint is reachable directly and the
// release could have moved between the check and the click.
if (info.installKind === 'docker-compose') {
const gate = evaluateEnvironmentGate(info.installDir, check.latestTag);
if (gate.blockers.length > 0) {
return {
ok: false,
code: 'env-blocked',
message: `${gate.blockers.map((b) => b.message).join(' ')} Run ${gate.hostCommand} on the Docker host to apply this release.`,
};
}
}
const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir); const prevSha = tryExec('git', ['rev-parse', 'HEAD'], info.installDir);
const runner = stageRunner(info.installDir); const runner = stageRunner(info.installDir);
if (!runner) { if (!runner) {
+81
View File
@@ -0,0 +1,81 @@
/**
* @fileoverview Static parity check between docker/docker-compose.yaml and
* docker/.env.example.
*
* This is the MERGE GATE for the container environment. A feature that needs a
* new setting must add it to BOTH files; forgetting one is what produces the
* failure the in-app updater cannot defend against, because Compose resolves an
* unset `${VAR}` to the EMPTY STRING and starts anyway — the container comes up
* with a silently blank setting and misbehaves later, far from the cause.
*
* Failing here costs a line in a PR. Failing in production costs a debugging
* session on someone else's server. Related: docs/docker-self-update.md.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseEnvKeys } from '../src/web/self-update.js';
const DOCKER_DIR = join(process.cwd(), 'docker');
const compose = readFileSync(join(DOCKER_DIR, 'docker-compose.yaml'), 'utf-8');
const example = readFileSync(join(DOCKER_DIR, '.env.example'), 'utf-8');
/**
* Every `${VAR}` / `${VAR:-default}` the compose file interpolates. Compose's
* own built-ins are excluded — they are supplied by Compose, not by .env.
*/
function composeVariables(text: string): string[] {
const found = new Set<string>();
for (const m of text.matchAll(/\$\{([A-Z_][A-Z0-9_]*)(?::?-[^}]*)?\}/g)) found.add(m[1]);
return [...found].sort();
}
/** Keys .env.example mentions at all, including the commented-out optional ones. */
function documentedKeys(text: string): Set<string> {
const keys = new Set(parseEnvKeys(text));
for (const m of text.matchAll(/^#\s*([A-Z_][A-Z0-9_]*)=/gm)) keys.add(m[1]);
return keys;
}
/**
* Variables Compose or the start script provides, which therefore need no entry
* in .env.example. Keep this list SHORT and justified — every addition is a
* setting the parity check stops guarding.
*/
const PROVIDED_ELSEWHERE = new Set([
// Derived by docker/Start-Codeman.sh from the appdata dir and socket owner.
'PUID',
'PGID',
'DOCKER_SOCKET_GID',
]);
/**
* Keys .env.example sets for an OVERRIDE documented in docker/README.md (the
* macvlan networking example), which the base compose file deliberately does not
* read. They are settings for a file that is not this one, not dead entries.
*/
const EXAMPLE_ONLY_KEYS = new Set([
'CODEMAN_MACVLAN_NETWORK',
'CODEMAN_IPV4_ADDRESS',
'CODEMAN_MAC_ADDRESS',
'CODEMAN_MACVLAN_PARENT',
'CODEMAN_MACVLAN_SUBNET',
'CODEMAN_MACVLAN_GATEWAY',
]);
describe('docker compose ↔ .env.example parity', () => {
it('every variable the compose file reads is documented in .env.example', () => {
const documented = documentedKeys(example);
const undocumented = composeVariables(compose).filter((v) => !documented.has(v) && !PROVIDED_ELSEWHERE.has(v));
expect(undocumented, `add these to docker/.env.example: ${undocumented.join(', ')}`).toEqual([]);
});
it('every key .env.example SETS is actually read by the compose file', () => {
// Commented-out entries are exempt: they document optional overrides and
// example-only values (the macvlan block) that the base file never reads.
const used = new Set(composeVariables(compose));
const unused = parseEnvKeys(example).filter((k) => !used.has(k) && !EXAMPLE_ONLY_KEYS.has(k));
expect(unused, `these are set in .env.example but unused: ${unused.join(', ')}`).toEqual([]);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @fileoverview Unit tests for the Docker Compose self-update path.
*
* Covers the PURE half of the container environment gate: which release changes
* can be applied by the container restarting itself, and which must go back to
* the host. The IO half (`evaluateEnvironmentGate`) shells out to git and docker
* and is exercised by hand — see docs/docker-self-update.md.
*/
import { describe, it, expect } from 'vitest';
import {
canSelfUpdateInPlace,
computeEnvironmentBlockers,
diffRequiredEnvKeys,
isAutoRestartPolicy,
parseEnvKeys,
type EnvironmentGateInput,
} from '../src/web/self-update.js';
/** A gate input where nothing has changed — each test perturbs one field. */
const CLEAN: EnvironmentGateInput = {
appliedDockerfileHash: 'aaa',
targetDockerfileHash: 'aaa',
appliedComposeHash: 'bbb',
targetComposeHash: 'bbb',
missingEnvKeys: [],
restartPolicy: 'unless-stopped',
};
describe('canSelfUpdateInPlace', () => {
it('accepts git and docker-compose, rejects npm and unknown', () => {
expect(canSelfUpdateInPlace('git')).toBe(true);
expect(canSelfUpdateInPlace('docker-compose')).toBe(true);
expect(canSelfUpdateInPlace('npm')).toBe(false);
// A container with no repo mounted: a pull would land in the writable layer.
expect(canSelfUpdateInPlace('unknown')).toBe(false);
});
});
describe('parseEnvKeys', () => {
it('reads set keys and ignores blanks, comments and values', () => {
expect(parseEnvKeys('A=1\n\nB=two words\n')).toEqual(['A', 'B']);
});
it('does NOT treat a commented-out key as set', () => {
// .env.example documents optional overrides as `# PUID=1000`. Counting those
// as required would block every update on settings the user should not set.
expect(parseEnvKeys('# PUID=1000\nCODEMAN_PORT=3000')).toEqual(['CODEMAN_PORT']);
});
it('handles `export` prefixes and repeated keys', () => {
expect(parseEnvKeys('export A=1\nA=2\n')).toEqual(['A']);
});
it('ignores lines that are not assignments', () => {
expect(parseEnvKeys('just a line\n=novalue\n1BAD=x\nOK=y')).toEqual(['OK']);
});
});
describe('diffRequiredEnvKeys', () => {
it('reports keys the release added that the user has no value for', () => {
expect(diffRequiredEnvKeys('A=\nB=\nC=', 'A=1\nC=3')).toEqual(['B']);
});
it('ignores keys the user set that the release dropped', () => {
expect(diffRequiredEnvKeys('A=', 'A=1\nOBSOLETE=2')).toEqual([]);
});
it('counts a key the user set to an EMPTY value as present', () => {
// `GEMINI_API_KEY=` is a deliberate opt-out, not a missing setting.
expect(diffRequiredEnvKeys('GEMINI_API_KEY=', 'GEMINI_API_KEY=')).toEqual([]);
});
});
describe('isAutoRestartPolicy', () => {
it('accepts the policies that relaunch the container after the server exits', () => {
expect(isAutoRestartPolicy('unless-stopped')).toBe(true);
expect(isAutoRestartPolicy('always')).toBe(true);
expect(isAutoRestartPolicy('on-failure')).toBe(true);
});
it('rejects "no" and unknown values', () => {
expect(isAutoRestartPolicy('no')).toBe(false);
expect(isAutoRestartPolicy('')).toBe(false);
expect(isAutoRestartPolicy(null)).toBe(false);
});
});
describe('computeEnvironmentBlockers', () => {
it('allows a code-only release', () => {
expect(computeEnvironmentBlockers(CLEAN)).toEqual([]);
});
it('blocks a release that changes the Dockerfile', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: 'zzz' });
expect(blockers.map((b) => b.kind)).toEqual(['dockerfile-changed']);
});
it('blocks a release that changes the compose file', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, targetComposeHash: 'zzz' });
expect(blockers.map((b) => b.kind)).toEqual(['compose-changed']);
});
it('blocks and NAMES missing env keys', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, missingEnvKeys: ['CODEMAN_NEW_THING'] });
expect(blockers[0].kind).toBe('env-keys-missing');
expect(blockers[0].details).toEqual(['CODEMAN_NEW_THING']);
});
it('blocks when the container would not come back', () => {
const blockers = computeEnvironmentBlockers({ ...CLEAN, restartPolicy: 'no' });
expect(blockers.map((b) => b.kind)).toEqual(['no-auto-restart']);
// The message says which policy, so the fix is obvious from the UI alone.
expect(blockers[0].message).toContain('"no"');
});
it('reports every blocker at once rather than stopping at the first', () => {
const blockers = computeEnvironmentBlockers({
...CLEAN,
targetDockerfileHash: 'zzz',
targetComposeHash: 'yyy',
missingEnvKeys: ['A'],
restartPolicy: 'no',
});
expect(blockers.map((b) => b.kind)).toEqual([
'dockerfile-changed',
'compose-changed',
'env-keys-missing',
'no-auto-restart',
]);
});
// ⚠️ Regression guards for the fail-OPEN decisions. An unknown baseline is not
// evidence of a change, and failing closed there would permanently block every
// container created before the fingerprint file existed.
it('does not block when the applied baseline is unknown', () => {
expect(computeEnvironmentBlockers({ ...CLEAN, appliedDockerfileHash: null, appliedComposeHash: null })).toEqual([]);
});
it('does not block when the target files cannot be read', () => {
expect(computeEnvironmentBlockers({ ...CLEAN, targetDockerfileHash: null, targetComposeHash: null })).toEqual([]);
});
it('does not block when the restart policy is unknown', () => {
// The probe needs the Docker socket, which a user may not have mounted.
expect(computeEnvironmentBlockers({ ...CLEAN, restartPolicy: null })).toEqual([]);
});
});