feat(docker): guided first run for the Compose deployment

A new Docker install was: clone (undocumented), copy .env.example by hand,
fix an Unraid data path and a Perth time zone, replace `changeme`, run the
start script, and then guess when the server was up. Start-Codeman.sh now
does all of it from a fresh clone:

- Preflight names the fix for a missing docker CLI, a missing or too-old
  Compose plugin (config --environment needs 2.27.2, docker/compose#11891),
  and an unreachable daemon (docker group, never sudo).
- With no docker/.env, it asks three questions (data folder, port,
  password; Enter takes each default: ~/codeman-docker, 3000 or the next
  free port, a generated password) and writes docker/.env FROM the example,
  so every key the updater's diffRequiredEnvKeys expects is present. Mode
  0600, host time zone, values Compose would interpolate single-quoted.
  Refuses $HOME, ~/.codeman (a native install's state dir) and anything
  inside the checkout (the image build context). --yes / no TTY take the
  defaults, --setup-only stops after writing the file. An existing .env is
  never edited, and root never runs the setup (Unraid keeps the hand route).
- After `up`, it waits until the server answers (docker exec probe, crash
  loop caught by the restart count) and prints the URL, the LAN URL, the
  generated password and the logs/stop commands; --no-wait skips the wait.
- `changeme` is refused before anything starts (the container publishes on
  every interface and holds the Docker socket); Update-Codeman.sh checks it
  before its build and down so the stack is never left stopped.
- Compose settings are read with ONE config --environment call, with its
  error reported, instead of three silent ones.

docker-compose.yaml and server.Dockerfile are untouched (their hashes gate
the in-app updater), and .env.example changes values and comments only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 19:14:49 +02:00
parent 3a0cee6b90
commit 0a63588716
8 changed files with 1191 additions and 39 deletions
+9 -2
View File
@@ -87,10 +87,17 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces codeman web --multiuser # named logins + per-user case spaces
``` ```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. After updating, run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below. Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
**Prefer Docker Compose?** Clone the repo and run one script (Linux, Docker with the Compose v2 plugin):
```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run asks three questions (data folder, port, password; Enter takes the default, including a generated password), writes `docker/.env` for you, builds the image and ends on the URL once Codeman answers. The image already includes Claude Code, Codex, Gemini CLI and OpenCode. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. To update, use **App Settings → Updates** or run the script again rather than a plain `docker compose up`, so the rebuilt image, refreshed volumes and entrypoint arrive together. See the [Docker deployment guide](docker/README.md) for Unraid, direct Compose commands, storage and networking options.
<details> <details>
<summary><strong>Keep it running in the background</strong></summary> <summary><strong>Keep it running in the background</strong></summary>
+10 -3
View File
@@ -1,9 +1,14 @@
# ============================================================================= # =============================================================================
# Codeman Docker Compose environment template # Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host. #
# Usually there is no need to copy this by hand: on its first run,
# `bash docker/Start-Codeman.sh` writes docker/.env from this file, asking for
# the data folder, port and password and filling in this host's time zone.
# Copy it yourself (to docker/.env) only for a hand-built setup, such as a host
# where everything runs as root (Unraid) or Compose started without the script.
# ============================================================================= # =============================================================================
TZ=Australia/Perth TZ=Etc/UTC
# Optional overrides for direct `docker compose` use. The Bash start script # Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses # detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
@@ -23,7 +28,8 @@ CODEMAN_RUNTIME_USER=codeman
# Required. Persistent Codeman application data, CLI credentials, and session # Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home # state are stored here on the host and mounted at the runtime account's home
# directory in the container. # directory in the container. The value below is the Unraid layout; the first
# run of Start-Codeman.sh suggests ~/codeman-docker instead.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
# Optional. Absolute host path of this Codeman checkout, mounted at # Optional. Absolute host path of this Codeman checkout, mounted at
@@ -45,6 +51,7 @@ CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong # Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value. # password. This file is safe to commit; copy it to .env and set the value.
# Start-Codeman.sh refuses to start while it is still `changeme`.
CODEMAN_PASSWORD=changeme CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication. # Required. Username for Codeman HTTP Basic authentication.
+31 -2
View File
@@ -4,7 +4,36 @@ This folder contains the Compose configuration, server image Dockerfile, and env
## Start ## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`. From a fresh clone, on Linux:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks that Docker, the Compose v2 plugin (2.27.2 or newer) and the daemon are usable, naming the fix when one is not. It then asks three questions, and Enter takes the default for each:
| Question | Default |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Data folder | `~/codeman-docker`. It becomes the container's home: Codeman's state, CLI logins, and the `codeman-cases` folder for projects. |
| Port | 3000, or the next free port when something on the machine already uses 3000. |
| Password | A generated 24-character password, printed once. |
It writes `docker/.env` from `.env.example` (readable only by you, with this host's time zone filled in), builds the image, starts the container, waits until Codeman answers, and prints the URL to open, the address for other devices on your network, and the commands for logs and stopping. The first build takes a few minutes.
Your account has to be able to use Docker without sudo. If it cannot, the script says so: run `sudo usermod -aG docker $USER`, then log out and back in. The first run refuses to set up as root, because Codeman's data folder must belong to a normal account.
| Option | Effect |
| -------------- | --------------------------------------------------------------------------------------------------------- |
| `--yes`, `-y` | Take every default without asking. This also happens when no terminal is attached. |
| `--setup-only` | Write `docker/.env` and stop, so you can review it (or add the optional settings below) before the build. |
| `--no-wait` | Start the container without waiting for Codeman to answer. |
On a first run, `CODEMAN_APPDATA_PATH`, `CODEMAN_PORT` and `CODEMAN_PASSWORD` set in the environment replace the defaults. Every later run reads `docker/.env` as it is, asks nothing and never edits it. Change a value by editing the file and running the script again. The script refuses to start while `CODEMAN_PASSWORD` is still the example's `changeme`, because the container is reachable from your network and controls Docker on the host.
### Setting it up by hand
Hosts where everything runs as root (Unraid), and Compose run without the script, take a hand-written `.env` instead. From the repository root, copy the template and set the required values, especially `CODEMAN_PASSWORD` and a `CODEMAN_APPDATA_PATH` owned by an unprivileged account:
```sh ```sh
cp docker/.env.example docker/.env cp docker/.env.example docker/.env
@@ -198,7 +227,7 @@ volumes:
target: /home/${CODEMAN_RUNTIME_USER} target: /home/${CODEMAN_RUNTIME_USER}
``` ```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`. Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman` (an Unraid layout); the first run of `Start-Codeman.sh` suggests `~/codeman-docker` instead.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere. `CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
+630 -19
View File
@@ -1,17 +1,442 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#
# Sets up (on the first run) and starts the Docker Compose deployment.
#
# A new install is two commands, from a fresh clone:
#
# git clone https://github.com/Ark0N/Codeman.git && cd Codeman
# bash docker/Start-Codeman.sh
#
# With no docker/.env yet, this asks three questions (data folder, port,
# password; Enter takes the default each time), writes docker/.env from
# .env.example, builds the image, starts the container, waits until Codeman
# answers and prints the URL to open. Every later run (after a `git pull`, or
# when the in-app updater asks for it) skips the questions and rebuilds and
# restarts the stack.
#
# Usage: bash docker/Start-Codeman.sh [--yes] [--setup-only] [--no-wait]
# --yes, -y First run: take every default without asking. Also what
# happens when no terminal is attached.
# --setup-only Write docker/.env and stop, so it can be reviewed first.
# --no-wait Do not wait for Codeman to answer after starting it.
#
# A first run takes its defaults from CODEMAN_APPDATA_PATH, CODEMAN_PORT and
# CODEMAN_PASSWORD when they are set in the environment.
#
# Bash 3.2 clean on purpose: Docker Desktop on macOS runs this with
# /bin/bash 3.2 (no ${x,,}, mapfile, associative arrays or here-strings).
set -euo pipefail set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env" env_file="$script_dir/.env"
example_file="$script_dir/.env.example"
compose_file="$script_dir/docker-compose.yaml" compose_file="$script_dir/docker-compose.yaml"
if [[ ! -f "$env_file" ]]; then assume_yes=0
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 setup_only=0
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2 no_wait=0
for arg in "$@"; do
case "$arg" in
--yes | -y) assume_yes=1 ;;
--setup-only) setup_only=1 ;;
--no-wait) no_wait=1 ;;
--help | -h)
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0"
printf ' --yes, -y First run: take every default without asking\n'
printf ' --setup-only Write docker/.env and stop, so it can be reviewed first\n'
printf ' --no-wait Do not wait for Codeman to answer after starting it\n'
exit 0
;;
*)
printf 'Error: unrecognised argument: %s\n' "$arg" >&2
printf 'Usage: bash %s [--yes] [--setup-only] [--no-wait]\n' "$0" >&2
exit 1
;;
esac
done
# ── Preflight ────────────────────────────────────────────────────────────────
# The three things a new machine most often lacks, each named with its fix
# before anything else runs (a missing daemon used to surface as a bare Compose
# error from the first `config` call below).
# PURE: is dotted version $1 older than $2? An unparseable $1 is never "older":
# the `config --environment` failure handler below still catches a real miss.
version_older_than() {
local re='^([0-9]+)\.([0-9]+)\.([0-9]+)'
local a b c x y z
[[ "$1" =~ $re ]] || return 1
a=$((10#${BASH_REMATCH[1]})) b=$((10#${BASH_REMATCH[2]})) c=$((10#${BASH_REMATCH[3]}))
[[ "$2" =~ $re ]] || return 1
x=$((10#${BASH_REMATCH[1]})) y=$((10#${BASH_REMATCH[2]})) z=$((10#${BASH_REMATCH[3]}))
if ((a != x)); then
((a < x))
return
fi
if ((b != y)); then
((b < y))
return
fi
((c < z))
}
# `docker compose config --environment`, which everything below reads the
# settings through, first shipped in Compose v2.27.2 (docker/compose#11891).
min_compose_version='2.27.2'
if ! command -v docker >/dev/null 2>&1; then
printf 'Error: Docker is not installed (no `docker` command on PATH).\n' >&2
printf 'Install Docker Engine (Linux: https://docs.docker.com/engine/install/) or\n' >&2
printf 'Docker Desktop (macOS, Windows), then rerun this script.\n' >&2
exit 1 exit 1
fi fi
if ! docker compose version >/dev/null 2>&1; then
printf 'Error: the Docker Compose v2 plugin is missing (`docker compose version` failed).\n' >&2
if command -v docker-compose >/dev/null 2>&1; then
printf 'The standalone `docker-compose` found on PATH is not a substitute for it.\n' >&2
fi
printf 'Install it from https://docs.docker.com/compose/install/linux/\n' >&2
printf '(Debian/Ubuntu with Docker'"'"'s apt repository: sudo apt-get install docker-compose-plugin).\n' >&2
exit 1
fi
compose_version=$(docker compose version --short 2>/dev/null || true)
compose_version=${compose_version#v}
if version_older_than "$compose_version" "$min_compose_version"; then
printf 'Error: Docker Compose %s is too old; Codeman needs %s or newer.\n' \
"$compose_version" "$min_compose_version" >&2
printf 'Update the Compose plugin (https://docs.docker.com/compose/install/linux/), then rerun.\n' >&2
exit 1
fi
if ! docker_info_error=$(docker info --format '{{.ServerVersion}}' 2>&1 >/dev/null); then
case "$docker_info_error" in
*[Pp]ermission\ denied*)
account=$(id -un 2>/dev/null || printf 'your account')
printf 'Error: %s is not allowed to use Docker yet.\n' "$account" >&2
printf 'Add it to the docker group, then log out and back in (or run `newgrp docker`):\n' >&2
printf ' sudo usermod -aG docker %s\n' "$account" >&2
printf 'Prefer that over running this script with sudo: Codeman'"'"'s data folder has to\n' >&2
printf 'belong to a normal account, and a first run refuses to set it up as root.\n' >&2
;;
*)
printf 'Error: the Docker daemon is not reachable. Start it (Linux: sudo systemctl start\n' >&2
printf 'docker; macOS and Windows: open Docker Desktop), then rerun this script.\n' >&2
printf 'Docker said: %s\n' "$docker_info_error" >&2
;;
esac
exit 1
fi
# ── First-run setup ──────────────────────────────────────────────────────────
# Runs only while docker/.env does not exist, and never edits an existing one.
# The file is generated FROM .env.example (its KEY= lines rewritten in place),
# so every key the example sets is present: the in-app updater refuses an
# update while the user's .env lacks a key the target release's example sets
# (diffRequiredEnvKeys, src/web/self-update.ts), and a hand-picked subset would
# trip that on the very next release.
first_run=0
generated_password=''
is_interactive() {
[[ "$assume_yes" != '1' && "${CODEMAN_NONINTERACTIVE:-0}" != '1' && -t 0 ]]
}
# Reads one answer into $answer (with -s, without echo). End of input (Ctrl+D)
# cancels the setup rather than looping on a default that was just refused.
ask() {
if ! IFS= read -r "$@" answer; then
printf '\nSetup cancelled; nothing was written.\n' >&2
exit 1
fi
}
# True when something on this host already accepts connections on the port.
# bash's /dev/tcp needs no extra tool on Linux or macOS.
port_in_use() {
(exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null || (exec 3<>"/dev/tcp/::1/$1") 2>/dev/null
}
first_free_port() {
local port=$1
local last=$(($1 + 99))
while ((port <= last)); do
if ! port_in_use "$port"; then
printf '%s' "$port"
return 0
fi
port=$((port + 1))
done
printf '%s' "$1"
}
host_timezone() {
local tz='' re='^[A-Za-z0-9_+/-]+$'
if [[ -r /etc/timezone ]]; then
tz=$(head -n1 /etc/timezone 2>/dev/null) || tz=''
fi
if [[ -z "$tz" ]] && command -v timedatectl >/dev/null 2>&1; then
tz=$(timedatectl show -p Timezone --value 2>/dev/null) || tz=''
fi
if [[ -z "$tz" && -L /etc/localtime ]]; then
tz=$(readlink /etc/localtime 2>/dev/null) || tz=''
tz=${tz##*zoneinfo/}
fi
if [[ ! "$tz" =~ $re ]]; then
tz='Etc/UTC'
fi
printf '%s' "$tz"
}
generate_password() {
local pw=''
# `|| true`: head closing the pipe early is the normal case, not a failure.
pw=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom 2>/dev/null | head -c 24) || true
if ((${#pw} != 24)) && command -v openssl >/dev/null 2>&1; then
pw=$(openssl rand -base64 48 | LC_ALL=C tr -dc 'A-Za-z0-9' | head -c 24) || true
fi
if ((${#pw} != 24)); then
printf 'Error: could not generate a password; set CODEMAN_PASSWORD and rerun.\n' >&2
exit 1
fi
printf '%s' "$pw"
}
# Compose reads .env values with its own dotenv rules: `$` interpolates and an
# unquoted ` #` starts a comment. A single-quoted value is taken literally, so
# anything beyond plain path characters is written that way (the accept_*
# checks below refuse the one character it cannot hold, a single quote).
env_quote() {
local re='^[A-Za-z0-9._/@:+-]*$'
if [[ "$1" =~ $re ]]; then
printf '%s' "$1"
else
printf "'%s'" "$1"
fi
}
repo_root=$(cd -- "$script_dir/.." && pwd)
# Sets setup_appdata, or says why the answer cannot be used and returns 1.
accept_appdata_path() {
local p=$1
case "$p" in
'~') p=$HOME ;;
'~/'*) p="$HOME/${p#\~/}" ;;
esac
while [[ "$p" == */ && "$p" != / ]]; do
p=${p%/}
done
case "$p" in
*"'"* | *$'\n'*)
printf ' The path cannot contain a single quote or a line break.\n' >&2
return 1
;;
esac
if [[ "$p" != /* ]]; then
printf ' Use an absolute path, one that starts with /.\n' >&2
return 1
fi
# The folder becomes the container's home directory, so its `.codeman` is
# the server's state directory: $HOME itself would share state.json with a
# Codeman installed directly on this machine.
if [[ "$p" == / || "$p" == "$HOME" ]]; then
printf ' Pick a folder of its own; it becomes the container'"'"'s home directory.\n' >&2
return 1
fi
if [[ "$p" == "$HOME/.codeman" || "$p" == "$HOME/.codeman/"* ]]; then
printf ' %s belongs to a Codeman installed directly on this machine; pick another folder.\n' "$HOME/.codeman" >&2
return 1
fi
# Inside the checkout it would sit in the image build context (COPY . .),
# CLI logins and all.
if [[ "$p" == "$repo_root" || "$p" == "$repo_root/"* ]]; then
printf ' Pick a folder outside %s; that folder is copied into the image when it is built.\n' "$repo_root" >&2
return 1
fi
setup_appdata=$p
}
accept_port() {
local re='^[0-9]+$'
if [[ ! "$1" =~ $re ]] || ((10#$1 < 1 || 10#$1 > 65535)); then
printf ' Use a port number from 1 to 65535.\n' >&2
return 1
fi
setup_port=$((10#$1))
}
accept_password() {
case "$1" in
*"'"* | *$'\n'*)
printf ' The password cannot contain a single quote or a line break.\n' >&2
return 1
;;
esac
if ((${#1} < 8)); then
printf ' Use at least 8 characters.\n' >&2
return 1
fi
if [[ "$1" == 'changeme' ]]; then
printf ' That is the published example password; pick another.\n' >&2
return 1
fi
setup_password=$1
}
write_env_file() {
local tmp="$env_file.tmp.$$" line
(
umask 077
{
printf '# Written by Start-Codeman.sh on its first run, from .env.example.\n'
printf '# Change any value here, then rerun: bash docker/Start-Codeman.sh\n'
printf '\n'
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in
TZ=*) printf 'TZ=%s\n' "$(env_quote "$setup_tz")" ;;
CODEMAN_APPDATA_PATH=*) printf 'CODEMAN_APPDATA_PATH=%s\n' "$(env_quote "$setup_appdata")" ;;
CODEMAN_CASES_PATH=*) printf 'CODEMAN_CASES_PATH=%s\n' "$(env_quote "$setup_cases")" ;;
CODEMAN_PORT=*) printf 'CODEMAN_PORT=%s\n' "$setup_port" ;;
CODEMAN_PASSWORD=*) printf 'CODEMAN_PASSWORD=%s\n' "$(env_quote "$setup_password")" ;;
*) printf '%s\n' "$line" ;;
esac
done <"$example_file"
} >"$tmp"
)
chmod 600 "$tmp"
mv -- "$tmp" "$env_file"
}
run_first_run_setup() {
local answer again default_appdata default_port port_note='' password_note username
if [[ "$EUID" == '0' ]]; then
printf 'Error: %s does not exist yet, and the first-run setup does not run as root.\n' "$env_file" >&2
printf 'Run it as the normal account that should own Codeman'"'"'s data (that account\n' >&2
printf 'needs to be in the docker group). On a root-only host such as Unraid, copy\n' >&2
printf '%s to %s by hand instead,\n' "$example_file" "$env_file" >&2
printf 'point CODEMAN_APPDATA_PATH at a folder an unprivileged account owns, set\n' >&2
printf 'CODEMAN_PASSWORD, and rerun.\n' >&2
exit 1
fi
if [[ ! -f "$example_file" ]]; then
printf 'Error: %s is missing, so there is nothing to build docker/.env from.\n' "$example_file" >&2
exit 1
fi
default_appdata=${CODEMAN_APPDATA_PATH:-$HOME/codeman-docker}
if [[ -n "${CODEMAN_PORT:-}" ]]; then
default_port=$CODEMAN_PORT
else
default_port=$(first_free_port 3000)
if [[ "$default_port" != '3000' ]]; then
port_note=' (3000 is already taken on this machine)'
fi
fi
setup_tz=$(host_timezone)
username=$(sed -n 's/^CODEMAN_USERNAME=//p' "$example_file" | head -n1)
printf '\nCodeman Docker setup\n'
printf 'There is no docker/.env yet, so this first run writes one. Enter takes the [default].\n\n'
if is_interactive; then
while :; do
printf ' Data folder (state, CLI logins, projects) [%s]: ' "$default_appdata"
ask
if [[ -z "$answer" ]]; then
answer=$default_appdata
fi
if accept_appdata_path "$answer"; then
break
fi
done
while :; do
printf ' Port [%s]%s: ' "$default_port" "$port_note"
ask
if [[ -z "$answer" ]]; then
answer=$default_port
fi
if accept_port "$answer"; then
break
fi
done
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
printf ' Password: taken from CODEMAN_PASSWORD\n'
else
while :; do
printf ' Password [Enter generates a strong one]: '
ask -s
printf '\n'
if [[ -z "$answer" ]]; then
setup_password=$(generate_password)
generated_password=$setup_password
break
fi
if ! accept_password "$answer"; then
continue
fi
printf ' Repeat the password: '
again=$answer
ask -s
printf '\n'
if [[ "$again" == "$answer" ]]; then
break
fi
printf ' The two entries differ; try again.\n' >&2
done
fi
else
printf ' No questions asked (no terminal attached, or --yes): taking the defaults.\n'
accept_appdata_path "$default_appdata" || exit 1
accept_port "$default_port" || exit 1
if [[ -n "${CODEMAN_PASSWORD:-}" ]]; then
accept_password "$CODEMAN_PASSWORD" || exit 1
else
setup_password=$(generate_password)
generated_password=$setup_password
fi
fi
if port_in_use "$setup_port"; then
printf ' Note: something on this machine already listens on port %s, so starting will\n' "$setup_port" >&2
printf ' fail until it stops or CODEMAN_PORT in docker/.env names a free port.\n' >&2
fi
setup_cases="$setup_appdata/codeman-cases"
write_env_file
first_run=1
if [[ -n "$generated_password" ]]; then
password_note="$generated_password (generated; shown again once Codeman is up)"
else
password_note='the one you chose'
fi
printf '\nWrote %s (readable only by you):\n' "$env_file"
printf ' Data folder %s\n' "$setup_appdata"
printf ' Projects %s\n' "$setup_cases"
printf ' Port %s\n' "$setup_port"
printf ' Time zone %s\n' "$setup_tz"
printf ' Username %s\n' "${username:-admin}"
printf ' Password %s\n' "$password_note"
printf 'Everything else in it is optional (Git identity, private repositories, reverse\n'
printf 'proxy); docker/README.md explains each setting.\n\n'
}
if [[ ! -f "$env_file" ]]; then
run_first_run_setup
elif [[ "$setup_only" == '1' ]]; then
printf '%s already exists; the setup only runs when it does not, and never edits it.\n' "$env_file"
fi
if [[ "$setup_only" == '1' ]]; then
printf 'Start Codeman with: bash %s\n' "$script_dir/Start-Codeman.sh"
exit 0
fi
# Naming a Compose file explicitly disables Compose's automatic discovery of # Naming a Compose file explicitly disables Compose's automatic discovery of
# the override file, so it has to be added back by hand. Without this, local # the override file, so it has to be added back by hand. Without this, local
# customisation in docker-compose.override.yml is silently ignored. The # customisation in docker-compose.override.yml is silently ignored. The
@@ -32,18 +457,54 @@ for override_file in "$override_yml" "$override_yaml"; do
fi fi
done done
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}") compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
appdata_path=$(
"${compose_command[@]}" config --environment | # Every setting is read through Compose's own resolution (shell environment over
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' # .env, quoting, interpolation), once. A failure is reported here with what
) # Compose said, instead of `set -e` ending the script at an empty value.
cases_path=$( compose_stderr=$(mktemp "${TMPDIR:-/tmp}/codeman-compose.XXXXXX")
"${compose_command[@]}" config --environment | if ! compose_environment=$("${compose_command[@]}" config --environment 2>"$compose_stderr"); then
awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }' if grep -q -- 'unknown flag: --environment' "$compose_stderr"; then
) printf 'Error: this Docker Compose (%s) is too old; Codeman needs %s or newer.\n' \
docker_socket=$( "${compose_version:-unknown version}" "$min_compose_version" >&2
"${compose_command[@]}" config --environment | else
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }' cat -- "$compose_stderr" >&2
) printf 'Error: `docker compose config` could not read %s and the Compose files (see above).\n' "$env_file" >&2
fi
rm -f -- "$compose_stderr"
exit 1
fi
# Compose's own warnings (an unset variable, say) still reach the terminal, once.
cat -- "$compose_stderr" >&2
rm -f -- "$compose_stderr"
# No early `exit` in the awk program: it reads all of its input, so printf never
# meets a closed pipe (a SIGPIPE would end the script under pipefail).
compose_env_value() {
printf '%s\n' "$compose_environment" |
awk -F= -v key="$1" '$1 == key && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
}
appdata_path=$(compose_env_value CODEMAN_APPDATA_PATH)
cases_path=$(compose_env_value CODEMAN_CASES_PATH)
docker_socket=$(compose_env_value DOCKER_SOCKET)
codeman_port=$(compose_env_value CODEMAN_PORT)
codeman_username=$(compose_env_value CODEMAN_USERNAME)
codeman_password=$(compose_env_value CODEMAN_PASSWORD)
# The container publishes its port on every interface and holds the host's
# Docker socket, so whoever signs in to Codeman can run anything on this host.
# The example's placeholder is a published password: refuse it outright.
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script.\n' "$env_file" >&2
exit 1
fi
if [[ -z "$codeman_password" ]]; then
printf 'Warning: CODEMAN_PASSWORD is empty, so anyone who can reach port %s can use Codeman,\n' "${codeman_port:-?}" >&2
printf 'which controls Docker on this machine. Set one in %s unless something in front of it\n' "$env_file" >&2
printf 'already asks for a login.\n' >&2
fi
unset codeman_password
if [[ -z "$appdata_path" ]]; then if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2 printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
@@ -56,7 +517,11 @@ if [[ ! -d "$appdata_path" ]]; then
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2 printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1 exit 1
fi fi
mkdir -p -- "$appdata_path" if ! mkdir -p -- "$appdata_path"; then
printf 'Error: cannot create CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
printf 'Create it as the account that should run Codeman, or pick another folder in %s.\n' "$env_file" >&2
exit 1
fi
fi fi
if [[ -z "$cases_path" ]]; then if [[ -z "$cases_path" ]]; then
@@ -114,6 +579,7 @@ fi
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2 printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
printf 'Set DOCKER_SOCKET in %s to the socket your Docker daemon listens on.\n' "$env_file" >&2
exit 1 exit 1
fi fi
@@ -209,6 +675,148 @@ else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2 printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi fi
# ── Start, then wait until Codeman answers ──────────────────────────────────
# `up -d` returns as soon as the container exists, which says nothing about the
# server inside it. The wait asks the server itself through `docker exec` (the
# same request as the compose healthcheck, which first runs only after its 30 s
# interval), notices a crash loop by the restart count moving, and ends on the
# URL to open. Docker's own view works with any network setup, macvlan included.
logs_hint="cd $(printf '%q' "$script_dir") && docker compose logs -f codeman"
# Docker's own output above says what broke; this adds the next step. A first
# build fetches hundreds of packages, so a network hiccup is the usual cause of
# a failed build, and a rerun picks up from Docker's layer cache.
compose_failed() {
printf '\nError: `docker compose %s` failed (see the output above).\n' "$1" >&2
case "$1" in
build)
printf 'A network hiccup while building is the usual cause; rerunning this script\n' >&2
printf 'resumes from Docker'"'"'s cache.\n' >&2
;;
*--build*)
printf 'If it stopped while building the image, a network hiccup is the usual cause,\n' >&2
printf 'and rerunning this script resumes from Docker'"'"'s cache.\n' >&2
;;
esac
exit 1
}
lan_ip() {
local ip=''
if [[ "$(uname -s)" == 'Darwin' ]]; then
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null) || ip=''
else
# The address the default route leaves from: the first one `hostname -I`
# lists is often docker0 or a VPN interface.
ip=$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p') || ip=''
ip=${ip%%$'\n'*}
if [[ -z "$ip" ]]; then
ip=$(hostname -I 2>/dev/null) || ip=''
ip=${ip%% *}
fi
fi
printf '%s' "$ip"
}
print_access_summary() {
local cid=$1 published host_port bind_host lan addresses dir_q
dir_q=$(printf '%q' "$script_dir")
published=$("${compose_command[@]}" port codeman "$codeman_port" 2>/dev/null) || published=''
published=${published%%$'\n'*}
printf '\n'
if [[ -n "$published" ]]; then
host_port=${published##*:}
bind_host=${published%:*}
printf ' Open http://localhost:%s\n' "$host_port"
case "$bind_host" in
127.0.0.1 | '[::1]' | ::1) ;;
*)
lan=$(lan_ip)
if [[ -n "$lan" ]]; then
printf ' http://%s:%s (from other devices on your network)\n' "$lan" "$host_port"
fi
;;
esac
else
addresses=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$cid" 2>/dev/null) || addresses=''
printf ' Open http://<container address>:%s (no host port is published; container addresses: %s)\n' \
"$codeman_port" "${addresses:-unknown}"
fi
printf ' Sign in %s, with the CODEMAN_PASSWORD from %s\n' "${codeman_username:-admin}" "$env_file"
if [[ -n "$generated_password" ]]; then
printf ' Password %s (generated on this first run)\n' "$generated_password"
fi
if [[ "$first_run" == '1' ]]; then
printf '\n Next: start a session from the dashboard and log its CLI in once. Logins are\n'
printf ' kept in %s, so they survive rebuilds.\n' "$appdata_path"
fi
printf '\n'
printf ' Logs cd %s && docker compose logs -f codeman\n' "$dir_q"
printf ' Stop cd %s && docker compose down\n' "$dir_q"
printf ' Update App Settings > Updates in the dashboard, or rerun this script\n'
}
report_when_ready() {
local cid state status health restarts first_restarts='' waited=0 limit=180 probe dots=0
cid=$("${compose_command[@]}" ps -q codeman 2>/dev/null) || cid=''
cid=${cid%%$'\n'*}
if [[ -z "$cid" ]]; then
printf 'Error: Compose started no codeman container. Look at: %s\n' "$logs_hint" >&2
return 1
fi
if [[ "$no_wait" == '1' ]]; then
printf '\nCodeman is starting (--no-wait given, so not waiting for it).\n'
print_access_summary "$cid"
return 0
fi
if [[ -t 1 ]]; then
dots=1
fi
probe="fetch('http://127.0.0.1:${codeman_port}/api/status').then((r) => process.exit(r.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
printf 'Waiting for Codeman to answer...'
while :; do
state=$(docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.RestartCount}}' "$cid" 2>/dev/null) || state='missing||0'
status=${state%%|*}
health=${state#*|}
health=${health%%|*}
restarts=${state##*|}
if [[ -z "$first_restarts" ]]; then
first_restarts=$restarts
fi
if [[ "$health" == 'healthy' ]] ||
{ [[ "$status" == 'running' ]] && docker exec "$cid" node -e "$probe" >/dev/null 2>&1; }; then
printf ' ready.\n'
print_access_summary "$cid"
return 0
fi
if [[ "$status" == 'exited' || "$status" == 'dead' || "$status" == 'missing' || "$status" == 'restarting' ||
"$health" == 'unhealthy' || "$restarts" != "$first_restarts" ]]; then
printf '\n'
printf 'Error: Codeman did not come up (container %s). Its last log lines:\n\n' "${status:-unknown}" >&2
"${compose_command[@]}" logs --tail 40 codeman >&2 || true
printf '\nFollow the full log with: %s\n' "$logs_hint" >&2
return 1
fi
if ((waited >= limit)); then
printf '\n'
printf 'Warning: Codeman has not answered after %s seconds. It may still be starting;\n' "$limit" >&2
printf 'follow it with: %s\n' "$logs_hint" >&2
return 1
fi
if ((dots)); then
printf '.'
fi
sleep 2
waited=$((waited + 2))
done
}
if [[ "$first_run" == '1' ]]; then
printf 'Building the image. The first build downloads and compiles everything and takes a\n'
printf 'few minutes; later starts reuse most of it.\n'
fi
# codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from # codeman-node-modules and codeman-dist (docker-compose.yaml) are seeded from
# the image only while EMPTY, so a rebuilt image's fresh output sits unused # the image only while EMPTY, so a rebuilt image's fresh output sits unused
# behind old volume content until something clears it. The in-app self-updater # behind old volume content until something clears it. The in-app self-updater
@@ -235,7 +843,9 @@ if [[ -n "$dockerfile_sha" ]]; then
fi fi
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
exec "${compose_command[@]}" up --build -d "${compose_command[@]}" up --build -d || compose_failed 'up --build'
report_when_ready || exit 1
exit 0
fi fi
# Runs even on this script's very first invocation against an EXISTING # Runs even on this script's very first invocation against an EXISTING
@@ -247,7 +857,7 @@ printf 'Source changed since the last start; refreshing: %s\n' "${volumes_to_ref
# Build BEFORE taking the stack down: the image build is the slow part and needs # Build BEFORE taking the stack down: the image build is the slow part and needs
# no container stopped, so the deployment is offline only for the recreate. # no container stopped, so the deployment is offline only for the recreate.
"${compose_command[@]}" build "${compose_command[@]}" build || compose_failed build
# `com.docker.compose.volume` is the volume KEY, not a project-qualified name - # `com.docker.compose.volume` is the volume KEY, not a project-qualified name -
# a second stack on the same host (a beta instance started with a different # a second stack on the same host (a beta instance started with a different
@@ -306,4 +916,5 @@ fi
# Already built above, so no --build here: a second build would only re-check # Already built above, so no --build here: a second build would only re-check
# the cache. # the cache.
exec "${compose_command[@]}" up -d "${compose_command[@]}" up -d || compose_failed up
report_when_ready || exit 1
+16
View File
@@ -177,6 +177,22 @@ if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then
exit 1 exit 1
fi fi
# Start-Codeman.sh refuses to start while the password is still the example's
# `changeme`. Checked here too, BEFORE the build and the `down` below: found
# only at the handoff, that refusal would leave the stack this script just
# stopped down. No early `exit` in awk, so printf never meets a closed pipe.
codeman_password=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_PASSWORD" && !found { sub(/^[^=]*=/, ""); print; found = 1 }'
)
if [[ "$codeman_password" == 'changeme' ]]; then
printf 'Error: CODEMAN_PASSWORD is still the example value "changeme".\n' >&2
printf 'Codeman is reachable from your network and controls Docker on this machine, so\n' >&2
printf 'set a real password in %s, then rerun this script. Nothing was stopped.\n' "$env_file" >&2
exit 1
fi
unset codeman_password
# `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker # `stat -c` is GNU, `stat -f` is BSD/macOS; the bind source lives on the Docker
# host, so both need to work. Identical to Start-Codeman.sh's own helper. # host, so both need to work. Identical to Start-Codeman.sh's own helper.
owner_of() { owner_of() {
+15 -10
View File
@@ -12,14 +12,25 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu
## Prerequisites ## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2 - Docker Engine or Docker Desktop with the Docker Compose v2 plugin, version 2.27.2 or newer
- A reachable Docker daemon - A reachable Docker daemon, usable by your account without sudo (on Linux, membership of the `docker` group)
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access. The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start ## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes. On Linux, clone the repository and run the start script:
```sh
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh
```
The first run checks Docker, Compose and the daemon, then asks for a data folder (default `~/codeman-docker`), a port (default 3000, or the next free one) and a password (Enter generates one and prints it once). It writes `docker/.env` from `docker/.env.example`, builds the image, starts the container and waits until Codeman answers, then prints the URL and how to sign in. `--yes` takes every default without asking, and `--setup-only` writes `docker/.env` and stops so it can be reviewed first. The full description, including the options, is the Start section of the [Docker deployment guide](../docker/README.md#start).
The data folder is mounted at `/home/${CODEMAN_RUNTIME_USER}` in the container, so Codeman state and CLI credentials stay on the host outside Docker-managed volumes. On every start the script determines `PUID` and `PGID` from the owner of that folder, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned data folder is rejected so the runtime account cannot become UID 0.
To write `docker/.env` by hand instead (Unraid and other root-only hosts, or Compose without the script), copy the template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example value `/mnt/user/appdata/codeman` is an Unraid layout.
```sh ```sh
cp docker/.env.example docker/.env cp docker/.env.example docker/.env
@@ -31,12 +42,6 @@ On PowerShell, use the following command instead.
Copy-Item docker/.env.example docker/.env Copy-Item docker/.env.example docker/.env
``` ```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation). On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner. Naming the file with `-f` disables Compose's own discovery of `docker/docker-compose.override.yml`, so add a second `-f` for it when you keep one (see `docker/README.md`, Local customisation).
```sh ```sh
@@ -45,7 +50,7 @@ docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -
The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them. The container starts as root, corrects the ownership of a bind source the daemon had to create, and drops to `PUID:PGID` with `setpriv` before Codeman starts; the capabilities that needs are declared in `docker/docker-compose.yaml` and named by the entrypoint when a compose file written elsewhere lacks them.
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`. Open the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`.
## Operations ## Operations
+10 -3
View File
@@ -141,16 +141,23 @@ See [Contributing](Contributing) for the rest of the development loop.
## Route D: Docker Compose ## Route D: Docker Compose
Codeman itself can run in a container and spawn Docker cases as sibling containers through Codeman itself can run in a container and spawn Docker cases as sibling containers through
the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set the host's Docker socket. You need Docker with the Compose v2 plugin (2.27.2 or newer), and
`CODEMAN_PASSWORD`, then: an account that can use Docker without sudo (on Linux, the `docker` group). Then, on Linux:
```bash ```bash
git clone https://github.com/Ark0N/Codeman.git && cd Codeman
bash docker/Start-Codeman.sh bash docker/Start-Codeman.sh
``` ```
The first run asks for a data folder, a port and a password. Enter takes each default,
including a generated password that is printed once. It then writes `docker/.env`, builds the
image (a few minutes the first time) and ends on the URL once Codeman answers. The image
already includes Claude Code, Codex, Gemini CLI and OpenCode: start a session and log the CLI
in once, and the login is kept in the data folder.
Run the script again after updating rather than a plain `docker compose up`, so the rebuilt Run the script again after updating rather than a plain `docker compose up`, so the rebuilt
image, the refreshed volumes and the entrypoint arrive together. The full guide, including image, the refreshed volumes and the entrypoint arrive together. The full guide, including
storage and networking options, is Unraid, storage and networking options, is
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md). [`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
## Installing an agent CLI ## Installing an agent CLI
+470
View File
@@ -0,0 +1,470 @@
/**
* @fileoverview The Docker Compose deployment's first-run path: what
* `docker/Start-Codeman.sh` does on a machine that has never run it.
*
* 1. Preflight: a missing `docker`, a missing or too-old Compose plugin and an
* unreachable daemon each stop the script with the fix named, before any
* question is asked or any file is written. The permission case points at
* the docker group, never at sudo (a root run cannot do the first-run setup).
* 2. Setup: with no `docker/.env`, the script writes one generated FROM
* `.env.example`, so every key the example sets is present. That is the
* exact check the in-app updater runs (`diffRequiredEnvKeys`), and a
* generated file missing a key would block the user's next update. The file
* is 0600, the generated password is alphanumeric (Compose's dotenv
* interpolates `$` and treats ` #` as a comment), and a data folder that
* would collide with a native install's `~/.codeman`, `$HOME` itself or the
* image build context is refused.
* 3. An existing `docker/.env` is never rewritten: the update path
* (Update-Codeman.sh hands off to this script) must stay byte-for-byte.
* 4. A `.env` still carrying the example password `changeme` is refused before
* anything is built or started.
*
* Runs the REAL script against a stub `docker` on PATH with stdin not a TTY,
* which is also the "no terminal attached, take the defaults" path.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, type Server } from 'node:net';
import {
readFileSync,
mkdtempSync,
rmSync,
writeFileSync,
mkdirSync,
statSync,
existsSync,
symlinkSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { diffRequiredEnvKeys, parseEnvKeys } from '../src/web/self-update.js';
const ROOT = process.cwd();
const startScript = readFileSync(join(ROOT, 'docker/Start-Codeman.sh'), 'utf-8');
const updateScript = readFileSync(join(ROOT, 'docker/Update-Codeman.sh'), 'utf-8');
const example = readFileSync(join(ROOT, 'docker/.env.example'), 'utf-8');
const compose = readFileSync(join(ROOT, 'docker/docker-compose.yaml'), 'utf-8');
/** Absolute, so a run whose PATH deliberately lacks most tools can still start bash. */
const BASH = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf-8' }).trim();
/** The values the first run fills in; every other line must be the example's. */
const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD'];
/**
* A stub `docker` that logs every invocation and answers the calls the script
* makes. `compose ... config --environment` sources the env file (bash reads
* single-quoted values the way Compose's dotenv does) and prints the keys the
* script reads.
*/
const STUB = [
'#!/usr/bin/env bash',
'echo "docker $*" >> "$CMDLOG"',
'if [[ "$1" == "info" ]]; then',
' if [[ -n "${STUB_INFO_ERR:-}" ]]; then echo "$STUB_INFO_ERR" >&2; exit 1; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "compose" ]]; then',
' if [[ -n "${STUB_NO_COMPOSE:-}" ]]; then echo "docker: unknown command: docker compose" >&2; exit 1; fi',
' if [[ "$2" == "version" ]]; then',
' if [[ "${3:-}" == "--short" ]]; then echo "${STUB_COMPOSE_VERSION:-2.30.0}"; else echo "Docker Compose version v${STUB_COMPOSE_VERSION:-2.30.0}"; fi',
' exit 0',
' fi',
' prev=""; envfile=""',
' for a in "$@"; do',
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
' prev="$a"',
' done',
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
' set -a; source "$envfile"; set +a',
' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"',
' for k in CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD; do',
' printf "%s=%s\\n" "$k" "${!k}"',
' done',
' exit 0',
' fi',
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then printf \'{\\n "name": "codeman"\\n}\\n\'; exit 0; fi',
' if [[ " $* " == *" up "* && -n "${STUB_UP_FAIL:-}" ]]; then echo "network error pulling a layer" >&2; exit 1; fi',
' if [[ " $* " == *" ps -q codeman "* ]]; then echo cid123; exit 0; fi',
' if [[ " $* " == *" port codeman "* ]]; then echo "0.0.0.0:${@: -1}"; exit 0; fi',
' if [[ " $* " == *" logs "* ]]; then echo "FAKE-LOG: server crashed"; exit 0; fi',
' exit 0',
'fi',
'if [[ "$1" == "inspect" ]]; then echo "${STUB_STATE:-running|healthy|0}"; exit 0; fi',
'if [[ "$1" == "exec" ]]; then exit 1; fi',
'exit 0',
].join('\n');
interface Run {
status: number;
stdout: string;
stderr: string;
log: string[];
env: string | null;
envMode: number | null;
home: string;
dir: string;
}
/**
* Lays out `<dir>/repo/docker/{Start-Codeman.sh,.env.example,docker-compose.yaml}`
* plus a stub `docker`, runs the script with a temp HOME and stdin closed (not a
* TTY), and returns what happened. `existingEnv` seeds `docker/.env` first.
*/
function runStart(
args: string[],
opts: {
env?: Record<string, string>;
existingEnv?: string;
noDocker?: boolean;
/** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */
socket?: string;
/** Run Update-Codeman.sh (which hands off to Start-Codeman.sh) instead. */
update?: boolean;
/** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */
appdata?: (p: { home: string; repo: string }) => string;
} = {}
): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-'));
try {
const home = join(dir, 'home');
const dockerDir = join(dir, 'repo', 'docker');
mkdirSync(home);
mkdirSync(dockerDir, { recursive: true });
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
writeFileSync(join(dockerDir, '.env.example'), example);
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
// Hashed by the start path for the updater's fingerprint baseline.
writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile')));
if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv);
const binDir = join(dir, 'bin');
mkdirSync(binDir);
let path: string;
if (opts.noDocker) {
// Only what the script runs before its `command -v docker` check, so the
// host's own docker (if any) cannot be found.
symlinkSync(
execFileSync('bash', ['-c', 'command -v dirname'], { encoding: 'utf-8' }).trim(),
join(binDir, 'dirname')
);
path = binDir;
} else {
const stubPath = join(binDir, 'docker');
writeFileSync(stubPath, STUB);
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
path = `${binDir}:${process.env.PATH}`;
}
const logPath = join(dir, 'cmdlog.txt');
writeFileSync(logPath, '');
const env: Record<string, string> = { ...process.env, HOME: home, PATH: path, CMDLOG: logPath } as Record<
string,
string
>;
// A developer shell exporting any of these would change the defaults under test.
for (const k of ['CODEMAN_APPDATA_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD', 'CODEMAN_NONINTERACTIVE'])
delete env[k];
Object.assign(env, opts.env ?? {});
if (opts.appdata) env.CODEMAN_APPDATA_PATH = opts.appdata({ home, repo: join(dir, 'repo') });
if (opts.socket) env.STUB_DOCKER_SOCKET = opts.socket;
const entry = opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh';
const res = spawnSync(BASH, [join(dockerDir, entry), ...args], {
env,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const envPath = join(dockerDir, '.env');
const hasEnv = existsSync(envPath);
return {
status: res.status ?? 1,
stdout: res.stdout,
stderr: res.stderr,
log: readFileSync(logPath, 'utf-8')
.split('\n')
.filter((l) => l.trim()),
env: hasEnv ? readFileSync(envPath, 'utf-8') : null,
envMode: hasEnv ? statSync(envPath).mode & 0o777 : null,
home,
dir,
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** The single (unquoted or single-quoted) value of KEY in a dotenv text. */
function envValue(text: string, key: string): string | undefined {
const line = text.split('\n').find((l) => l.startsWith(`${key}=`));
if (line === undefined) return undefined;
const raw = line.slice(key.length + 1);
return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
}
describe('Start-Codeman.sh first run (no docker/.env yet)', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
});
it('writes docker/.env from .env.example with every key the updater requires, mode 0600', () => {
const r = runStart(['--setup-only']);
expect(r.status, r.stderr).toBe(0);
expect(r.env).not.toBeNull();
const env = r.env as string;
expect(r.envMode).toBe(0o600);
// The in-app updater's own check: no key the example sets may be missing.
expect(diffRequiredEnvKeys(example, env)).toEqual([]);
expect(parseEnvKeys(env)).toEqual(parseEnvKeys(example));
// --setup-only stops before Compose is asked anything about the stack.
expect(r.log.some((l) => / (up|build|config)( |$)/.test(l))).toBe(false);
});
it('changes only the five first-run values and keeps every other line of the example', () => {
const r = runStart(['--setup-only']);
const generated = (r.env as string).split('\n');
const exampleLines = example.split('\n');
// Header comments first, then the example line for line.
const offset = generated.length - exampleLines.length;
expect(offset).toBeGreaterThan(0);
for (let i = 0; i < exampleLines.length; i++) {
const want = exampleLines[i];
const got = generated[i + offset];
const key = want.match(/^([A-Z_][A-Z0-9_]*)=/)?.[1];
if (key && REWRITTEN_KEYS.includes(key)) {
expect(got.startsWith(`${key}=`)).toBe(true);
} else {
expect(got).toBe(want);
}
}
});
it('defaults: a data folder of its own under HOME, cases inside it, a strong alphanumeric password', () => {
const r = runStart(['--setup-only']);
const env = r.env as string;
const appdata = envValue(env, 'CODEMAN_APPDATA_PATH');
expect(appdata).toBe(join(r.home, 'codeman-docker'));
expect(envValue(env, 'CODEMAN_CASES_PATH')).toBe(join(r.home, 'codeman-docker', 'codeman-cases'));
expect(appdata).not.toContain('.codeman');
const password = envValue(env, 'CODEMAN_PASSWORD') as string;
expect(password).toMatch(/^[A-Za-z0-9]{24}$/);
expect(password).not.toBe('changeme');
expect(envValue(env, 'CODEMAN_PORT')).toMatch(/^\d+$/);
expect(envValue(env, 'TZ')).toMatch(/^[A-Za-z0-9_+/-]+$/);
// A generated password is shown once, since nobody else knows it.
expect(r.stdout).toContain(password);
expect(r.stdout).toMatch(/No questions asked/);
});
it('takes presets from the environment and quotes values Compose would otherwise interpolate', () => {
const r = runStart(['--setup-only'], {
env: { CODEMAN_PASSWORD: 'pa$$ #word', CODEMAN_PORT: '4567', CODEMAN_APPDATA_PATH: '/srv/My Data/codeman/' },
});
expect(r.status, r.stderr).toBe(0);
const lines = (r.env as string).split('\n');
expect(lines).toContain("CODEMAN_PASSWORD='pa$$ #word'");
expect(lines).toContain('CODEMAN_PORT=4567');
// Trailing slash dropped, the space kept by quoting.
expect(lines).toContain("CODEMAN_APPDATA_PATH='/srv/My Data/codeman'");
expect(lines).toContain("CODEMAN_CASES_PATH='/srv/My Data/codeman/codeman-cases'");
// A password the user chose is never echoed.
expect(r.stdout).not.toContain('pa$$ #word');
});
it.each([
['HOME itself', ({ home }: { home: string }) => home, /folder of its own/],
['HOME typed as ~', () => '~', /folder of its own/],
['a native install state dir', ({ home }: { home: string }) => join(home, '.codeman'), /installed directly/],
['inside a native install state dir', () => '~/.codeman/docker', /installed directly/],
['a relative path', () => 'codeman-data', /absolute path/],
['the checkout, which is the image build context', ({ repo }: { repo: string }) => repo, /copied into the image/],
['a folder inside the checkout', ({ repo }: { repo: string }) => join(repo, 'data'), /copied into the image/],
])('refuses %s as the data folder and writes nothing', (_name, appdata, reason) => {
const r = runStart(['--setup-only'], { appdata });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(reason);
expect(r.env).toBeNull();
});
it.each([
['a single quote', "it's-a-password"],
['the example placeholder', 'changeme'],
['fewer than 8 characters', 'short'],
])('refuses a preset password with %s', (_name, password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status).toBe(1);
expect(r.env).toBeNull();
});
it('never rewrites an existing docker/.env', () => {
const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n';
const r = runStart(['--setup-only'], { existingEnv: existing });
expect(r.status, r.stderr).toBe(0);
expect(r.env).toBe(existing);
expect(r.stdout).toMatch(/already exists/);
});
});
describe('Start-Codeman.sh preflight', () => {
it('names the docker group (not sudo) when the account cannot reach the daemon', () => {
const r = runStart(['--setup-only'], {
env: {
STUB_INFO_ERR: 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock',
},
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/sudo usermod -aG docker /);
expect(r.stderr).toMatch(/log out and back in/);
expect(r.env).toBeNull();
});
it('says to start Docker when the daemon is not running, quoting what Docker said', () => {
const r = runStart(['--setup-only'], {
env: { STUB_INFO_ERR: 'failed to connect to the docker API at unix:///var/run/docker.sock' },
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/daemon is not reachable/);
expect(r.stderr).toContain('failed to connect to the docker API');
expect(r.env).toBeNull();
});
it('refuses a Compose older than 2.27.2, which has no `config --environment`', () => {
const r = runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '2.27.0' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose 2\.27\.0 is too old; Codeman needs 2\.27\.2 or newer/);
expect(r.env).toBeNull();
});
it('accepts newer Compose majors (v5 here) and a v-prefixed version', () => {
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '5.5.0' } }).status).toBe(0);
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: 'v2.27.2' } }).status).toBe(0);
});
it('explains a missing Compose plugin', () => {
const r = runStart(['--setup-only'], { env: { STUB_NO_COMPOSE: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose v2 plugin is missing/);
});
it('explains a missing docker command', () => {
const r = runStart(['--setup-only'], { noDocker: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Docker is not installed/);
});
});
describe('Start-Codeman.sh on an existing install', () => {
it('refuses to start while CODEMAN_PASSWORD is still `changeme`, before building anything', () => {
const existing = example; // a straight copy of the example, never edited
const r = runStart([], { existingEnv: existing });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.log.some((l) => / (up|build|down)( |$)/.test(l))).toBe(false);
expect(r.env).toBe(existing);
});
it('Update-Codeman.sh refuses `changeme` BEFORE its build and `down`, so the stack is never left stopped', () => {
// An existing appdata dir, so the check this is about is the one reached.
const existing = example.replace(/^CODEMAN_APPDATA_PATH=.*$/m, `CODEMAN_APPDATA_PATH=${tmpdir()}`);
const r = runStart([], { existingEnv: existing, update: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.stderr).toMatch(/Nothing was stopped/);
expect(r.log.some((l) => / (build|down|up)( |$)/.test(l))).toBe(false);
});
it('rejects an unrecognised argument and prints usage for --help', () => {
expect(runStart(['--bogus']).status).toBe(1);
const help = runStart(['--help']);
expect(help.status).toBe(0);
expect(help.stdout).toMatch(/--setup-only/);
});
});
/**
* The whole start path against the stub: no `docker/.env`, so setup, then
* `up`, the readiness wait and the summary. Needs a real Unix socket for the
* `DOCKER_SOCKET` check, which Windows cannot provide reliably (see the note in
* docker-entrypoint.test.ts), so it runs on Linux and macOS only.
*/
describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start to summary', () => {
let sockDir = '';
let sockPath = '';
let server: Server | null = null;
beforeAll(async () => {
sockDir = mkdtempSync(join(tmpdir(), 'codeman-start-sock-'));
sockPath = join(sockDir, 'docker.sock');
server = createServer();
await new Promise<void>((resolve) => server!.listen(sockPath, resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
rmSync(sockDir, { recursive: true, force: true });
});
it('ends on the URL, the generated password and the log/stop commands once the container is healthy', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '4321' } });
expect(r.status, r.stderr).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout).toMatch(/Waiting for Codeman to answer\.\.\. ready\./);
expect(r.stdout).toContain('http://localhost:4321');
// Printed again at the end, since the build output has scrolled the first one away.
expect(r.stdout.split(password).length - 1).toBe(2);
expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/);
expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/);
const up = r.log.findIndex((l) => / up --build -d/.test(l));
expect(up).toBeGreaterThan(-1);
expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up);
});
it('reports a container that keeps restarting, with its last log lines, and exits 1', () => {
const r = runStart([], { socket: sockPath, env: { STUB_STATE: 'restarting||1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/did not come up \(container restarting\)/);
expect(r.stderr).toContain('FAKE-LOG: server crashed');
expect(r.stdout).not.toMatch(/http:\/\/localhost/);
});
it('names the next step when `docker compose up` fails', () => {
const r = runStart([], { socket: sockPath, env: { STUB_UP_FAIL: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/`docker compose up --build` failed/);
expect(r.stderr).toMatch(/rerunning this script resumes/);
});
it('--no-wait prints the summary without waiting on the container', () => {
const r = runStart(['--no-wait'], { socket: sockPath, env: { STUB_STATE: 'running|starting|0' } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/--no-wait given/);
expect(r.log.some((l) => l.startsWith('docker inspect'))).toBe(false);
});
});
describe('version_older_than', () => {
const cases: Array<[string, string, boolean]> = [
['2.27.0', '2.27.2', true],
['2.27.1', '2.27.2', true],
['2.27.2', '2.27.2', false],
['2.28.0', '2.27.2', false],
['2.9.0', '2.27.2', true],
['5.5.0', '2.27.2', false],
['1.29.2', '2.27.2', true],
['', '2.27.2', false],
['dev', '2.27.2', false],
['2.27.2-desktop.1', '2.27.2', false],
];
it.each(cases)('%s older than %s: %s', (have, need, older) => {
const script = [
'set -euo pipefail',
`eval "$(sed -n '/^version_older_than() {/,/^}/p' "$1")"`,
'if version_older_than "$2" "$3"; then echo yes; else echo no; fi',
].join('\n');
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), have, need], {
encoding: 'utf-8',
}).trim();
expect(out).toBe(older ? 'yes' : 'no');
});
});