diff --git a/README.md b/README.md index 33fb90ee..155295ae 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,17 @@ codeman users add alice --admin # create the first admin account 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. +**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 `git pull` and 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. +
Keep it running in the background diff --git a/README.zh-CN.md b/README.zh-CN.md index e8fd544e..b97f8823 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,7 +87,14 @@ codeman users add alice --admin # 创建第一个管理员账号 codeman web --multiuser # 命名登录 + 按用户隔离的案例空间 ``` -**更喜欢 Docker Compose?** `docker/` 里附带一套本地镜像的 Compose 部署:把 `docker/.env.example` 复制为 `docker/.env`,设置 `CODEMAN_PASSWORD`,然后在 Linux 上运行 `bash docker/Start-Codeman.sh`。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新之后请再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。 +**更喜欢 Docker Compose?** 克隆仓库,运行一个脚本即可(Linux,需要 Docker 和 Compose v2 插件): + +```bash +git clone https://github.com/Ark0N/Codeman.git && cd Codeman +bash docker/Start-Codeman.sh +``` + +首次运行会问三个问题(数据目录、端口、密码;回车即采用默认值,包括自动生成的密码),替你写好 `docker/.env`,构建镜像,并在 Codeman 响应后给出访问地址。镜像已内置 Claude Code、Codex、Gemini CLI 和 OpenCode。Codeman 自己跑在容器里,并通过宿主机的 socket 把 Docker 案例作为并列容器拉起。更新时请使用 **App Settings → Updates**,或者先 `git pull` 再跑一次这个脚本,而不是直接 `docker compose up`,这样重建的镜像、刷新的卷和新的入口脚本会一起就位。Unraid、直接的 Compose 命令、存储与网络选项见 [Docker 部署指南](docker/README.md)(英文)。 详见下文[多用户模式](#多用户模式可选启用)。 diff --git a/docker/.env.example b/docker/.env.example index fbdb1229..966004ae 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1,9 +1,14 @@ # ============================================================================= # 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 # 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 # 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 # 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 # 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 # Required. Username for Codeman HTTP Basic authentication. diff --git a/docker/README.md b/docker/README.md index 5328fb65..54b5871c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,7 +4,36 @@ This folder contains the Compose configuration, server image Dockerfile, and env ## 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, shown on your terminal and kept in `docker/.env`. | + +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, a different answer typed at the question still wins, and a preset password skips its question. A password the script generates is only ever shown on a terminal: when the output is captured (a log, a pipe), read it from `docker/.env`. Every later run reads `docker/.env` as it is, asks nothing and never edits it, except that Compose prefers a variable exported in your shell over the file. 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 cp docker/.env.example docker/.env @@ -198,7 +227,7 @@ volumes: 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. diff --git a/docker/Start-Codeman.sh b/docker/Start-Codeman.sh index e380409a..9f94c058 100644 --- a/docker/Start-Codeman.sh +++ b/docker/Start-Codeman.sh @@ -1,17 +1,479 @@ #!/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 script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) env_file="$script_dir/.env" +example_file="$script_dir/.env.example" compose_file="$script_dir/docker-compose.yaml" -if [[ ! -f "$env_file" ]]; then - printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2 - printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2 +assume_yes=0 +setup_only=0 +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 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 + +# `docker version`, not `docker info`: on CLI 24 through 27 `info --format` exits +# 0 with the daemon unreachable (measured on 27.5.1), so this check never fired. +if ! docker_error=$(docker version --format '{{.Server.Version}}' 2>&1 >/dev/null); then + case "$docker_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_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/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. It cannot hold a +# single quote, and a backslash just before the closing quote escapes that quote, +# so the whole file fails to parse: the accept_* checks below refuse both. +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" == *//* ]]; do + p=${p//\/\//\/} + done + 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 + ;; + *\\) + printf ' The path cannot end in a backslash.\n' >&2 + return 1 + ;; + esac + if [[ "$p" != /* ]]; then + printf ' Use an absolute path, one that starts with /.\n' >&2 + return 1 + fi + # The refusals below compare strings, so a dot segment could spell any folder + # they exist to keep out. + case "$p/" in + */./* | */../*) + printf ' Spell the path out, without . or .. in it.\n' >&2 + return 1 + ;; + esac + # 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 + ;; + *\\) + printf ' The password cannot end in a backslash.\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 + # Compose prefers the shell environment over the env file, so a preset still + # exported here would beat a different typed answer: docker/.env and the + # summary would name one folder and port and the stack would run on another. + unset TZ CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH CODEMAN_PORT CODEMAN_PASSWORD + + # A generated password goes to a terminal only: a captured log would be a + # weaker copy of a credential that controls Docker on this host. + if [[ -z "$generated_password" ]]; then + password_note='the one you chose' + elif [[ -t 1 ]]; then + password_note="$generated_password (generated; it stays in docker/.env)" + else + password_note='generated; read it from docker/.env' + 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 [[ ! -e "$env_file" && ! -L "$env_file" ]]; then + run_first_run_setup +elif [[ ! -f "$env_file" ]]; then + # A dangling symlink or a directory is not "no .env yet": setup would replace + # the link, or leave its password file inside the directory and run again on + # every start. + printf 'Error: %s exists but is not a regular file.\n' "$env_file" >&2 + printf 'Move it out of the way or repoint it, then rerun this script.\n' >&2 + exit 1 +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 # 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 @@ -32,18 +494,54 @@ for override_file in "$override_yml" "$override_yaml"; do fi done compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}") -appdata_path=$( - "${compose_command[@]}" config --environment | - awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' -) -cases_path=$( - "${compose_command[@]}" config --environment | - awk -F= '$1 == "CODEMAN_CASES_PATH" { sub(/^[^=]*=/, ""); print; exit }' -) -docker_socket=$( - "${compose_command[@]}" config --environment | - awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }' -) + +# Every setting is read through Compose's own resolution (shell environment over +# .env, quoting, interpolation), once. A failure is reported here with what +# Compose said, instead of `set -e` ending the script at an empty value. +compose_stderr=$(mktemp "${TMPDIR:-/tmp}/codeman-compose.XXXXXX") +if ! compose_environment=$("${compose_command[@]}" config --environment 2>"$compose_stderr"); then + if grep -q -- 'unknown flag: --environment' "$compose_stderr"; then + printf 'Error: this Docker Compose (%s) is too old; Codeman needs %s or newer.\n' \ + "${compose_version:-unknown version}" "$min_compose_version" >&2 + else + 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 printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2 @@ -56,7 +554,11 @@ if [[ ! -d "$appdata_path" ]]; then printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2 exit 1 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 if [[ -z "$cases_path" ]]; then @@ -114,6 +616,7 @@ fi if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-}" >&2 + printf 'Set DOCKER_SOCKET in %s to the socket your Docker daemon listens on.\n' "$env_file" >&2 exit 1 fi @@ -209,6 +712,148 @@ else printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2 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://:%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" && -t 1 ]]; 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 git pull and 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 # 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 @@ -235,7 +880,9 @@ if [[ -n "$dockerfile_sha" ]]; then fi 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 # Runs even on this script's very first invocation against an EXISTING @@ -247,7 +894,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 # 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 - # a second stack on the same host (a beta instance started with a different @@ -306,4 +953,5 @@ fi # Already built above, so no --build here: a second build would only re-check # the cache. -exec "${compose_command[@]}" up -d +"${compose_command[@]}" up -d || compose_failed up +report_when_ready || exit 1 diff --git a/docker/Update-Codeman.sh b/docker/Update-Codeman.sh index d3915a1f..a8043de9 100644 --- a/docker/Update-Codeman.sh +++ b/docker/Update-Codeman.sh @@ -166,10 +166,11 @@ fi # appdata directory (which creates it): this script updates an EXISTING # deployment, so a missing appdata path means there is nothing here yet to # update, and creating one would just be this script quietly doing -# Start-Codeman.sh's first-run job worse. +# Start-Codeman.sh's first-run job worse. No early `exit` in awk: a closed pipe +# would end this script through pipefail, with no message. appdata_path=$( "${compose_command[@]}" config --environment | - awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }' + awk -F= '$1 == "CODEMAN_APPDATA_PATH" && !found { sub(/^[^=]*=/, ""); print; found = 1 }' ) if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then printf 'Error: CODEMAN_APPDATA_PATH is not set or does not exist: %s\n' "${appdata_path:-}" >&2 @@ -177,6 +178,22 @@ if [[ -z "$appdata_path" || ! -d "$appdata_path" ]]; then exit 1 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 # host, so both need to work. Identical to Start-Codeman.sh's own helper. owner_of() { diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 28312d27..7dd4e0cf 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -622,6 +622,10 @@ Adding the header changed the preamble, so `CODEMAN_PREAMBLE` was bumped (1.22.0 `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` drops `--memory-swap` (and filters only that one kernel warning) for hosts without swap accounting; `--memory` still applies. +⚠️ **`docker/.env` is generated FROM `.env.example`, at 0600, never from a list of keys.** `Start-Codeman.sh`'s first run (only when nothing exists at that path: a dangling symlink or a directory is refused) rewrites the five `KEY=` lines it asks about and copies every other line, because `diffRequiredEnvKeys` refuses an in-app update while the user's file lacks a key the target release's example sets, so a hand-picked subset would block the very next update. Compose's dotenv is not bash: a backslash escapes a single-quoted value's closing quote (measured on 2.33.0 and 5.5.0), so `accept_password` and `accept_appdata_path` refuse a trailing backslash as well as a single quote. Compose also prefers the shell environment over the file, so setup unsets what it wrote, or a preset would beat a typed answer. `test/docker-start-setup.test.ts` pins all of it, against a stub that reproduces both rules. + +⚠️ **`changeme` is refused by BOTH scripts**, before anything is built or stopped: the container publishes on every interface and holds the Docker socket, so that password is host root. `Update-Codeman.sh` needs its own check, ahead of its `down`, because a refusal found only at the handoff to `Start-Codeman.sh` would leave the stack stopped. + ⚠️ The deployment ALSO self-updates in place (the repo bind mount at `/opt/codeman` + a restart-by-exiting supervisor) — see Self-update and `docs/docker-self-update.md` before touching `server.Dockerfile`, the compose file or `.env.example`, since each is an input to the updater's environment gate. User guides: `docs/docker-compose.md` + `docker/README.md`. ### DeepSeek web UI diff --git a/docs/docker-compose.md b/docs/docker-compose.md index 8a838d6d..ca72e8c5 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -12,14 +12,25 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu ## Prerequisites -- Docker Engine or Docker Desktop with Docker Compose v2 -- A reachable Docker daemon +- Docker Engine or Docker Desktop with the Docker Compose v2 plugin, version 2.27.2 or newer +- 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. ## 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, shows it on your terminal and keeps it in `docker/.env`). 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 cp docker/.env.example docker/.env @@ -31,12 +42,6 @@ On PowerShell, use the following command instead. 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). ```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. -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 diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index ef0585ca..5dce20e8 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -144,16 +144,24 @@ See [Contributing](Contributing) for the rest of the development loop. ## Route D: Docker Compose 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 -`CODEMAN_PASSWORD`, then: +the host's Docker socket. You need Docker with the Compose v2 plugin (2.27.2 or newer), and +an account that can use Docker without sudo (on Linux, the `docker` group). Then, on Linux: ```bash +git clone https://github.com/Ark0N/Codeman.git && cd Codeman 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, shown on your terminal and kept in `docker/.env`. It then +writes that file, 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 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). ## Installing an agent CLI @@ -219,7 +227,7 @@ Full detail, including logs and the self-updater, is in | Installer | Re-run the one-liner, or **App Settings → System → Updates** in the UI. | | npm | `npm update -g aicodeman` | | git clone | `git pull && npm install && npm run build`, then restart. | -| Docker Compose | Re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. | +| Docker Compose | `git pull`, then re-run `Start-Codeman.sh`. The in-app updater works too, and refuses a release that changes the container definition until you re-run the script. | The in-app updater covers git-clone installs supervised by systemd or launchd. It restarts the process that is running it, so the actual work happens in a detached script and the diff --git a/docs/wiki/Running-As-A-Service.md b/docs/wiki/Running-As-A-Service.md index 88cc5518..4e00c507 100644 --- a/docs/wiki/Running-As-A-Service.md +++ b/docs/wiki/Running-As-A-Service.md @@ -147,7 +147,7 @@ log stream --predicate 'process == "node"' # macOS, noisy | Installer | Re-run the one-liner, or **App Settings → System → Updates**. | | npm | `npm update -g aicodeman` | | git clone | `git pull && npm install && npm run build`, then restart. | -| Docker Compose | Re-run `Start-Codeman.sh`, or the in-app updater, which restarts the container in place. | +| Docker Compose | The in-app updater, which restarts the container in place, or `git pull`, then re-run `Start-Codeman.sh`. | ### The in-app updater @@ -188,7 +188,13 @@ the bind-mounted directories with the right owner, honours a `docker-compose.ove and refreshes the build volumes when the checkout moved under them. The in-app updater applies code only and restarts by letting the container exit, so it refuses a release that changes the Dockerfile, the compose file, or adds a new `.env` key, until you re-run the -script. Guide: +script. + +With no `docker/.env` yet, the script first asks for a data folder, a port and a password and +writes that file. It then waits up to 180 seconds for Codeman to answer and exits 1 if the +container crash-loops or never answers; pass `--no-wait` for scripted use. `Start-Codeman.sh` +and `Update-Codeman.sh` both refuse to run while `CODEMAN_PASSWORD` is still the example +`changeme`. Guide: [`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md). ## The tunnel as a service diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 860832f1..4321a4e6 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -354,6 +354,21 @@ Start the stack with `bash docker/Start-Codeman.sh` rather than a plain `docker and update: since 1.29.0 the entrypoint corrects a root-owned bind mount before dropping privileges. See [Running As A Service](Running-As-A-Service). +### Compose: `CODEMAN_PASSWORD is still the example value "changeme"` + +`Start-Codeman.sh` and `Update-Codeman.sh` refuse that password before they build or stop +anything, because the container publishes its port on every interface and holds the Docker +socket, so whoever signs in controls Docker on the host. Set a real password in `docker/.env` +and run the script again. + +### Compose: `Codeman did not come up`, or no answer after 180 seconds + +`Start-Codeman.sh` waits for the server after `up -d`. `did not come up` means the container +exited, restarted or went unhealthy, and the script prints its last log lines. `has not +answered after 180 seconds` means it is still running but silent, so on a slow host it may +just need longer: follow it with `docker compose logs -f codeman` from `docker/`. Either way +the script exits 1, and `--no-wait` skips the wait for scripts that check on their own. + ### A remote SSH session dropped and did not come back A bounded-backoff watcher reattaches dropped sessions, and it is on by default. Intentional diff --git a/test/docker-start-setup.test.ts b/test/docker-start-setup.test.ts new file mode 100644 index 00000000..045a6de8 --- /dev/null +++ b/test/docker-start-setup.test.ts @@ -0,0 +1,641 @@ +/** + * @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 shown only on a + * terminal, 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. The tests + * of the questions themselves run on a real pty. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createServer, type Server } from 'node:net'; +import { + readFileSync, + mkdtempSync, + rmSync, + writeFileSync, + mkdirSync, + statSync, + lstatSync, + existsSync, + symlinkSync, + readdirSync, +} 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 run on a real terminal needs util-linux `script`; BSD's takes different arguments. */ +const HAS_PTY = process.platform === 'linux' && spawnSync('script', ['--version']).status === 0; + +/** 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 and prints the + * keys the script reads, with the shell environment beating the file as it does + * in Compose. Bash reads a single-quoted value the way Compose's dotenv does + * except for a backslash before the closing quote: see `composeReads`. + */ +const STUB = [ + '#!/usr/bin/env bash', + 'echo "docker $*" >> "$CMDLOG"', + // Docker CLI 24 to 27 answer `info --format` with exit 0 and the error on + // stderr when the daemon is unreachable (measured on 27.5.1); `version --format` + // exits 1 on every release. + 'if [[ "$1" == "info" ]]; then', + ' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 0; fi', + ' echo 27.0.0; exit 0', + 'fi', + 'if [[ "$1" == "version" ]]; then', + ' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_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', + ' keys="CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD"', + ' for k in $keys; do if [[ -n "${!k+x}" ]]; then printf -v "shell_$k" %s "${!k}"; fi; done', + ' set -a; source "$envfile"; set +a', + ' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"', + ' for k in $keys; do', + ' shell="shell_$k"', + ' printf "%s=%s\\n" "$k" "${!shell-${!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; + /** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */ + homeEntries: string[]; + /** What `docker/.env` is when the run ended, without following a symlink. */ + envKind: 'file' | 'directory' | 'symlink' | null; + /** What a `docker/.env` that is a directory holds. */ + envDirEntries: string[]; +} + +/** + * Lays out `/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; + existingEnv?: string; + /** Makes `docker/.env` something other than a regular file. */ + envShape?: 'directory' | 'dangling-symlink' | 'symlink-to-file'; + 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; + /** + * Keystrokes for a run on a REAL terminal (a pty from util-linux `script`, + * so Linux only), which is the only way to reach the questions: the script + * reads stdin only when `-t 0` holds. + */ + terminal?: 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); + if (opts.envShape === 'directory') mkdirSync(join(dockerDir, '.env')); + if (opts.envShape === 'dangling-symlink') symlinkSync(join(dir, 'no-such-file'), join(dockerDir, '.env')); + if (opts.envShape === 'symlink-to-file') { + writeFileSync(join(dir, 'real.env'), 'CODEMAN_PASSWORD=mine-and-only-mine\n'); + symlinkSync(join(dir, 'real.env'), join(dockerDir, '.env')); + } + + 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 = { ...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 = join(dockerDir, opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh'); + const res = + opts.terminal === undefined + ? spawnSync(BASH, [entry, ...args], { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }) + : spawnSync('script', ['-qefc', [BASH, entry, ...args].join(' '), '/dev/null'], { + env, + encoding: 'utf-8', + input: opts.terminal, + }); + const envPath = join(dockerDir, '.env'); + const envEntry = lstatSync(envPath, { throwIfNoEntry: false }); + const envKind = !envEntry + ? null + : envEntry.isSymbolicLink() + ? 'symlink' + : envEntry.isDirectory() + ? 'directory' + : 'file'; + const hasEnv = envKind === 'file' || (envKind === 'symlink' && existsSync(envPath)); + return { + status: res.status ?? 1, + // A terminal turns every line end into CRLF. + stdout: res.stdout.replace(/\r/g, ''), + 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, + homeEntries: readdirSync(home), + envKind, + envDirEntries: envKind === 'directory' ? readdirSync(envPath) : [], + }; + } 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; +} + +/** + * What Compose reads from a single-quoted dotenv value, or null when it refuses + * the file with `unterminated quoted value`. A backslash there escapes the next + * character, so `'longpass\\'` never closes, while bash (and so the stub) ends + * the same value at that quote. Measured on Compose 2.33.0 and 5.5.0. + */ +function composeReads(quoted: string): string | null { + let escaped = false; + for (let i = 1; i < quoted.length; i++) { + if (quoted[i] === "'" && !escaped) return i === quoted.length - 1 ? quoted.slice(1, i) : null; + escaped = quoted[i] === '\\' && !escaped; + } + return null; +} + +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_+/-]+$/); + // No terminal here, and a captured log must not hold the password. + expect(r.stdout).not.toContain(password); + expect(r.stdout).toMatch(/generated; read it from docker\/\.env/); + 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/], + // join() would resolve these, which is the whole point: the refusals above compare strings. + ['HOME spelled through ..', ({ home }: { home: string }) => `${home}/../home`, /without \. or \.\./], + ['the checkout spelled through ..', ({ home }: { home: string }) => `${home}/../repo/data`, /without \. or \.\./], + ['a path ending in ..', ({ home }: { home: string }) => `${home}/data/..`, /without \. or \.\./], + ['a native install spelled through .', ({ home }: { home: string }) => `${home}/./.codeman`, /without \. or \.\./], + [ + 'a native install behind a doubled slash', + ({ home }: { home: string }) => `${home}//.codeman`, + /installed directly/, + ], + ['the checkout behind a doubled slash', ({ repo }: { repo: string }) => `${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'], + ['a trailing backslash, which would escape the closing quote', 'longpass\\'], + ])('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('refuses a data folder ending in a backslash, even behind a trailing slash', () => { + for (const path of ['/srv/data\\', '/srv/data\\/']) { + const r = runStart(['--setup-only'], { env: { CODEMAN_APPDATA_PATH: path } }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/cannot end in a backslash/); + expect(r.env).toBeNull(); + } + }); + + it.each(['mid\\dle-pass', 'pa$$ #word', 'back`tick"quote', 'tab\there'])( + 'writes %j so Compose reads it back exactly', + (password) => { + const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } }); + expect(r.status, r.stderr).toBe(0); + const line = (r.env as string).split('\n').find((l) => l.startsWith('CODEMAN_PASSWORD=')) as string; + expect(composeReads(line.slice('CODEMAN_PASSWORD='.length))).toBe(password); + } + ); + + it('refuses a dangling docker/.env symlink instead of replacing the link', () => { + const r = runStart(['--setup-only'], { envShape: 'dangling-symlink' }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/not a regular file/); + expect(r.envKind).toBe('symlink'); + }); + + it('refuses a docker/.env directory instead of writing a password file into it', () => { + const r = runStart(['--setup-only'], { envShape: 'directory' }); + expect(r.status).toBe(1); + expect(r.stderr).toMatch(/not a regular file/); + expect(r.envKind).toBe('directory'); + expect(r.envDirEntries).toEqual([]); + }); + + it('treats a docker/.env symlink to a real file as an existing install', () => { + const r = runStart(['--setup-only'], { envShape: 'symlink-to-file' }); + expect(r.status, r.stderr).toBe(0); + expect(r.stdout).toMatch(/already exists/); + expect(r.envKind).toBe('symlink'); + }); + + it('keeps a folder name that merely starts with a dot or dots', () => { + const r = runStart(['--setup-only'], { appdata: ({ home }) => `${home}/..data/.state` }); + expect(r.status, r.stderr).toBe(0); + expect(envValue(r.env as string, 'CODEMAN_APPDATA_PATH')).toBe(`${r.home}/..data/.state`); + }); + + 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_DAEMON_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_DAEMON_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((resolve) => server!.listen(sockPath, resolve)); + }); + + afterAll(async () => { + await new Promise((resolve) => (server ? server.close(() => resolve()) : resolve())); + rmSync(sockDir, { recursive: true, force: true }); + }); + + it('ends on the URL, where the password is 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'); + // Stdout is captured here, so docker/.env is the only place the password appears. + expect(r.stdout).not.toContain(password); + expect(r.stdout).toMatch(/Sign in +admin, with the CODEMAN_PASSWORD from .*docker\/\.env/); + expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/); + expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/); + // Rerunning alone rebuilds the same checkout: nothing here fetches. + expect(r.stdout).toMatch(/Update +App Settings > Updates in the dashboard, or git pull and rerun this script/); + 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('runs on the port docker/.env holds, not on the preset the shell still exports', () => { + const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '04444' } }); + expect(r.status, r.stderr).toBe(0); + expect(envValue(r.env as string, 'CODEMAN_PORT')).toBe('4444'); + expect(r.stdout).toContain('http://localhost:4444'); + expect(r.stdout).not.toContain('04444'); + }); + + it.skipIf(!HAS_PTY)('a typed answer beats a preset for the stack itself, not just the file', () => { + const r = runStart([], { + socket: sockPath, + appdata: ({ home }) => join(home, 'preset-data'), + env: { CODEMAN_PORT: '4444' }, + terminal: '~/typed-data\n5555\n\n', + }); + expect(r.status, r.stdout).toBe(0); + const env = r.env as string; + expect(envValue(env, 'CODEMAN_APPDATA_PATH')).toBe(join(r.home, 'typed-data')); + expect(envValue(env, 'CODEMAN_PORT')).toBe('5555'); + expect(r.stdout).toContain('http://localhost:5555'); + expect(r.homeEntries).toContain('typed-data'); + expect(r.homeEntries).not.toContain('preset-data'); + }); + + it.skipIf(!HAS_PTY)('shows a generated password twice at a terminal', () => { + const r = runStart([], { socket: sockPath, terminal: '\n\n\n' }); + expect(r.status, r.stdout).toBe(0); + const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string; + // Again at the end, since the build output has scrolled the first one away. + expect(r.stdout.split(password).length - 1).toBe(2); + }); + + it.skipIf(!HAS_PTY)('promises no second showing after --setup-only', () => { + const r = runStart(['--setup-only'], { terminal: '\n\n\n' }); + expect(r.status, r.stdout).toBe(0); + const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string; + expect(r.stdout.split(password).length - 1).toBe(1); + expect(r.stdout).toMatch(/it stays in docker\/\.env/); + expect(r.stdout).not.toMatch(/shown again/); + }); + + 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'); + }); +});