mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-13 18:59:42 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f61ca539 | ||
|
|
b59145effd |
@@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) pages the wheel and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||
|
||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||
|
||||
@@ -87,17 +87,10 @@ 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 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>
|
||||
<summary><strong>Keep it running in the background</strong></summary>
|
||||
|
||||
|
||||
+3
-10
@@ -1,14 +1,9 @@
|
||||
# =============================================================================
|
||||
# Codeman Docker Compose environment template
|
||||
#
|
||||
# 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.
|
||||
# Copy this file to .env and set the values for the Docker host.
|
||||
# =============================================================================
|
||||
|
||||
TZ=Etc/UTC
|
||||
TZ=Australia/Perth
|
||||
|
||||
# Optional overrides for direct `docker compose` use. The Bash start script
|
||||
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
|
||||
@@ -28,8 +23,7 @@ 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. The value below is the Unraid layout; the first
|
||||
# run of Start-Codeman.sh suggests ~/codeman-docker instead.
|
||||
# directory in the container.
|
||||
CODEMAN_APPDATA_PATH=/mnt/user/appdata/codeman
|
||||
|
||||
# Optional. Absolute host path of this Codeman checkout, mounted at
|
||||
@@ -51,7 +45,6 @@ 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.
|
||||
|
||||
+2
-31
@@ -4,36 +4,7 @@ This folder contains the Compose configuration, server image Dockerfile, and env
|
||||
|
||||
## Start
|
||||
|
||||
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:
|
||||
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
|
||||
|
||||
```sh
|
||||
cp docker/.env.example docker/.env
|
||||
@@ -227,7 +198,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` (an Unraid layout); the first run of `Start-Codeman.sh` suggests `~/codeman-docker` instead.
|
||||
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/codeman`.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
+19
-630
@@ -1,440 +1,15 @@
|
||||
#!/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"
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Naming a Compose file explicitly disables Compose's automatic discovery of
|
||||
@@ -457,54 +32,18 @@ for override_file in "$override_yml" "$override_yaml"; do
|
||||
fi
|
||||
done
|
||||
compose_command=(docker compose --env-file "$env_file" "${compose_files[@]}")
|
||||
|
||||
# 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
|
||||
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 }'
|
||||
)
|
||||
|
||||
if [[ -z "$appdata_path" ]]; then
|
||||
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
|
||||
@@ -517,11 +56,7 @@ if [[ ! -d "$appdata_path" ]]; then
|
||||
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
mkdir -p -- "$appdata_path"
|
||||
fi
|
||||
|
||||
if [[ -z "$cases_path" ]]; then
|
||||
@@ -579,7 +114,6 @@ fi
|
||||
|
||||
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -675,148 +209,6 @@ 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://<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
|
||||
# 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
|
||||
@@ -843,9 +235,7 @@ if [[ -n "$dockerfile_sha" ]]; then
|
||||
fi
|
||||
|
||||
if [[ ${#volumes_to_refresh[@]} -eq 0 ]]; then
|
||||
"${compose_command[@]}" up --build -d || compose_failed 'up --build'
|
||||
report_when_ready || exit 1
|
||||
exit 0
|
||||
exec "${compose_command[@]}" up --build -d
|
||||
fi
|
||||
|
||||
# Runs even on this script's very first invocation against an EXISTING
|
||||
@@ -857,7 +247,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_failed build
|
||||
"${compose_command[@]}" 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
|
||||
@@ -916,5 +306,4 @@ fi
|
||||
|
||||
# Already built above, so no --build here: a second build would only re-check
|
||||
# the cache.
|
||||
"${compose_command[@]}" up -d || compose_failed up
|
||||
report_when_ready || exit 1
|
||||
exec "${compose_command[@]}" up -d
|
||||
|
||||
@@ -177,22 +177,6 @@ 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() {
|
||||
|
||||
@@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles have no touch path and do not forward SGR wheel (tile-grid-plan follow-up 4). Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||
**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForTravel`, so the mode list stays in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`.
|
||||
|
||||
**Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing.
|
||||
|
||||
|
||||
+10
-15
@@ -12,25 +12,14 @@ It can also include the GitHub CLI (`gh`) and the Azure CLI (`az`) with the `azu
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 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)
|
||||
- Docker Engine or Docker Desktop with Docker Compose v2
|
||||
- A reachable Docker daemon
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```sh
|
||||
cp docker/.env.example docker/.env
|
||||
@@ -42,6 +31,12 @@ 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
|
||||
@@ -50,7 +45,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 the URL the script printed (`http://localhost:3000` by default) and sign in with the username and password from `docker/.env`.
|
||||
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
|
||||
|
||||
## Operations
|
||||
|
||||
|
||||
+11
-7
@@ -1008,13 +1008,17 @@ exits green. Use the browser runner for those files and read the file count.
|
||||
viewer keeps a stale width and renders garbled output (#464).
|
||||
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||
on drain) for grids over slow links.
|
||||
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
||||
a "Load full history" action inside a tile. (Done since: a tile pages a
|
||||
hollow buffer's CLI transcript with PageUp/PageDown, the primary pane's
|
||||
#555 route, and hand-reports a plain click while its session has
|
||||
`cliMouseTracking` on, both through the primary pane's gates aimed at the
|
||||
tile. The SGR wheel forwarding itself is still open: a fullscreen Claude
|
||||
tile leaves the wheel to xterm.)
|
||||
4. Tile parity extras: a "Load full history" action inside a tile. (Done
|
||||
since: a tile pages a hollow buffer's CLI transcript with PageUp/PageDown,
|
||||
the primary pane's #555 route, hand-reports a plain click while its session
|
||||
has `cliMouseTracking` on, and forwards the wheel to Claude's fullscreen
|
||||
renderer as SGR wheel reports from its own cells
|
||||
(`TerminalTile._maybeForwardWheelToCli`, encoding shared with the primary
|
||||
pane via `CodemanTerminalInput.sgrWheelReports`), all through the primary
|
||||
pane's gates aimed at the tile. Before that, a fullscreen Claude tile left
|
||||
the wheel to xterm, which scrolled only stale replayed frames. Shift+wheel
|
||||
scrolls the tile's local scrollback itself (`_maybeScrollLocalOnShift`),
|
||||
since xterm turns it into a horizontal no-op off macOS.)
|
||||
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
|
||||
6. Named grid presets, possibly per owner on the server.
|
||||
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
||||
|
||||
@@ -141,23 +141,16 @@ 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. 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:
|
||||
the host's Docker socket. Copy `docker/.env.example` to `docker/.env`, set
|
||||
`CODEMAN_PASSWORD`, then:
|
||||
|
||||
```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 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
|
||||
image, the refreshed volumes and the entrypoint arrive together. The full guide, including
|
||||
Unraid, storage and networking options, is
|
||||
storage and networking options, is
|
||||
[`docker/README.md`](https://github.com/Ark0N/Codeman/blob/master/docker/README.md).
|
||||
|
||||
## Installing an agent CLI
|
||||
|
||||
@@ -56,3 +56,5 @@ This session is managed by Codeman and runs inside tmux (`CODEMAN_MUX=1` confirm
|
||||
- NEVER kill your own session: no `tmux kill-session`, `pkill tmux`, or `pkill claude`.
|
||||
- The session persists across disconnects — your work is safe.
|
||||
- Hooks may auto-format or validate after writes; unexpected tool behavior usually means a hook ran. Keep working.
|
||||
- After creating a file, write out its full absolute path in your final reply, e.g. `/home/me/project/docs/report.md`. Codeman makes absolute paths in the terminal clickable and opens them in its file viewer; a relative path (`docs/report.md`), a `~/` path or a markdown link (`[report](...)`) cannot be clicked.
|
||||
- If the `codeman` skill is available, use it to start other Codeman sessions as workers, send them prompts, wait for them to finish, read their output and clean them up. When asked to parallelize work and the skill is missing, tell the user they can install it with `codeman skill install`.
|
||||
|
||||
@@ -11,14 +11,20 @@
|
||||
*
|
||||
* Deliberately plainer than the primary pane (this.terminal/this._ws in
|
||||
* terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile
|
||||
* handlers (a swipe on a touch screen pages nothing), no keyboard accessory
|
||||
* bar, and no SGR wheel forwarding to Claude's fullscreen renderer
|
||||
* (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see
|
||||
* docs/split-pane-sessions-plan.md and docs/tile-grid-plan.md.
|
||||
* handlers (a swipe on a touch screen pages nothing), and no keyboard
|
||||
* accessory bar. Built for wide screens; see docs/split-pane-sessions-plan.md
|
||||
* and docs/tile-grid-plan.md.
|
||||
*
|
||||
* What it does carry over from the primary pane, through the primary pane's
|
||||
* own code aimed at THIS pane (its terminal, its session, never the active
|
||||
* one):
|
||||
* - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen
|
||||
* renderer scrolls its own transcript on SGR wheel reports, while this
|
||||
* xterm holds only replayed repaint frames, so the wheel goes to the CLI
|
||||
* as reports at the pointer's cell in this pane, through the primary
|
||||
* pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel
|
||||
* scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the
|
||||
* primary pane does, since xterm turns it into a horizontal no-op.
|
||||
* - Hollow-buffer paging (#555): a CLI that draws in place (opencode on the
|
||||
* alternate screen, Claude's repaint mode) leaves the xterm no scrollback,
|
||||
* so the wheel pages the CLI's own transcript with PageUp/PageDown
|
||||
@@ -43,7 +49,7 @@
|
||||
*
|
||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||
* @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick)
|
||||
* @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands)
|
||||
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||
*/
|
||||
@@ -201,6 +207,9 @@
|
||||
// Hollow-buffer paging (_maybePageCliTranscript): wheel travel short of a
|
||||
// whole page, carried to the next wheel event.
|
||||
this._pageKeyPending = 0;
|
||||
// Shift+wheel travel short of a whole line, carried to the next wheel
|
||||
// event (_maybeScrollLocalOnShift).
|
||||
this._shiftScrollPending = 0;
|
||||
// Page keys waiting for the 40 ms flush, and its timer (_queueScrollBytes).
|
||||
this._scrollBytes = '';
|
||||
this._scrollFlushTimer = null;
|
||||
@@ -1027,16 +1036,18 @@
|
||||
|
||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||
// event it consumes, so a bubbling listener here would never see the wheel
|
||||
// while the pane still has scrollback to scroll. Not passive: the one route
|
||||
// this pane takes over, paging a hollow buffer's CLI transcript
|
||||
// (_maybePageCliTranscript), is consumed right here (preventDefault plus
|
||||
// while the pane still has scrollback to scroll. Not passive: the three
|
||||
// routes this pane takes over, forwarding the wheel to Claude's fullscreen
|
||||
// renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI
|
||||
// transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback
|
||||
// (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus
|
||||
// stopPropagation in the capture phase, the primary pane's technique), so
|
||||
// xterm's viewport, a descendant, never sees it. Every other wheel is left
|
||||
// xterm's viewport, a descendant, never sees them. Every other wheel is left
|
||||
// to xterm, which keeps doing the scrolling, and only observed for the
|
||||
// shell history pull.
|
||||
_installWheelListener() {
|
||||
this._onWheel = (ev) => {
|
||||
if (this._maybePageCliTranscript(ev)) {
|
||||
if (this._maybeForwardWheelToCli(ev) || this._maybePageCliTranscript(ev) || this._maybeScrollLocalOnShift(ev)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
return;
|
||||
@@ -1046,6 +1057,71 @@
|
||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: false });
|
||||
}
|
||||
|
||||
// SGR wheel forwarding, the twin of the primary pane's capture-phase wheel
|
||||
// handler and _forwardScrollToApp (terminal-ui.js; keep them in step).
|
||||
// Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is
|
||||
// on, cliMouseTracking) scrolls its own transcript on SGR wheel reports,
|
||||
// while this xterm holds only Codeman's replayed repaint frames (tmux keeps
|
||||
// no history for such a pane). Left to xterm, the wheel dragged those stale
|
||||
// frames, Claude's pinned input box with them, up the tile, or scrolled
|
||||
// nothing at all. The gate is the primary pane's own, asked for THIS pane
|
||||
// (its terminal, its session, never the active one), so the CLI rules stay
|
||||
// in terminal-ui.js and this file names no CLI; the reports go to this
|
||||
// pane's session through its own coalescer. Returns true when the wheel
|
||||
// belongs to the CLI: a gesture with no whole line or no measurable cell is
|
||||
// consumed too, as in the primary pane, so xterm never scrolls the stale
|
||||
// frames under a forwarding session. Shift fails the gate, so Shift+wheel
|
||||
// still scrolls the local scrollback (_maybeScrollLocalOnShift).
|
||||
_maybeForwardWheelToCli(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev) return false;
|
||||
const app = global.app;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
if (this.terminal.buffer?.active?.type === 'alternate') return false;
|
||||
if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false;
|
||||
// SGR coordinates address the live screen, so a report from a scrolled-up
|
||||
// viewport would hit-test another row: snap home first (_forwardScrollToApp).
|
||||
if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.();
|
||||
const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows);
|
||||
const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal);
|
||||
const bytes = input.sgrWheelReports(lines, pos);
|
||||
if (bytes) this._queueScrollBytes(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shift+wheel scrolls this xterm's local scrollback, the explicit "local
|
||||
// history" gesture, here as in the primary pane (whose capture-phase wheel
|
||||
// handler scrolls with terminal.scrollLines() for the same reason). Left to
|
||||
// xterm it was dead off macOS: Chrome on Windows sends Shift+wheel as a
|
||||
// HORIZONTAL wheel (deltaX), and xterm's own scroller turns a Shift+vertical
|
||||
// wheel into a horizontal one, so the viewport never moved. Reads the
|
||||
// dominant axis under Shift (wheelDeltaLines), keeps the sub-line remainder
|
||||
// for the next event (a trackpad's small deltas), and on the way up still
|
||||
// asks a shell pane for more history. Returns true when the wheel was
|
||||
// consumed here.
|
||||
_maybeScrollLocalOnShift(ev) {
|
||||
if (this._destroyed || !this.terminal || !ev?.shiftKey) return false;
|
||||
const input = global.CodemanTerminalInput;
|
||||
if (!input?.wheelDeltaLines) return false;
|
||||
// xterm's own encoder forwards the wheel while the CLI's tracking reaches
|
||||
// it, and its alt-scroll owns the alternate buffer, as in the primary pane.
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
if (this.terminal.buffer?.active?.type === 'alternate') return false;
|
||||
const total = this._shiftScrollPending + input.wheelDeltaLines(ev, this.terminal.rows);
|
||||
const lines = Math.trunc(total);
|
||||
this._shiftScrollPending = total - lines;
|
||||
if (lines) {
|
||||
this.terminal.scrollLines(lines);
|
||||
if (lines < 0) this._maybeLoadMoreHistory();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A plain left-click reported to the CLI, the primary pane's desktop click
|
||||
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
|
||||
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
|
||||
@@ -1087,9 +1163,9 @@
|
||||
const tracking = this.terminal.modes?.mouseTrackingMode;
|
||||
if (tracking && tracking !== 'none') return false;
|
||||
const target = { terminal: this.terminal, sessionId: this.sessionId };
|
||||
// The primary pane would forward this wheel to Claude's fullscreen
|
||||
// renderer as SGR reports. Tiles do not do that yet (docs/tile-grid-plan.md
|
||||
// follow-up 4), so the wheel stays with xterm, as before.
|
||||
// A wheel for Claude's fullscreen renderer was forwarded as SGR reports
|
||||
// before this ran (_maybeForwardWheelToCli); the gate is repeated so a
|
||||
// forwarding session is never paged.
|
||||
if (app._shouldForwardWheelToApp?.(ev, target)) return false;
|
||||
if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false;
|
||||
// Only from the live screen. The one gate the primary pane never needs: a
|
||||
@@ -1111,8 +1187,9 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coalesces the page keys into one send per 40 ms, bounded at 512 bytes so a
|
||||
// fling cannot build a backlog that keeps paging after it stops. A narrow
|
||||
// Coalesces the scroll bytes (SGR wheel reports and page keys alike) into
|
||||
// one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog
|
||||
// that keeps scrolling after it stops. A narrow
|
||||
// twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue
|
||||
// (terminal-ui.js; keep the two in step), which flushes to the active
|
||||
// session only. Sent ephemeral (no seq, never persisted) to THIS pane's
|
||||
|
||||
@@ -107,6 +107,32 @@
|
||||
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
|
||||
}
|
||||
|
||||
// The same travel rounded to whole lines for the SGR wheel reports: a pure
|
||||
// horizontal swipe is 0 (nothing to send), and anything else moves at least
|
||||
// one line, so the small pixel deltas of a precision touchpad still scroll.
|
||||
// The body of the primary pane's _wheelScrollLines.
|
||||
function wheelDeltaWholeLines(ev, rows) {
|
||||
const lines = wheelDeltaLines(ev, rows);
|
||||
if (!lines) return 0;
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Ticks one gesture batch may report: Claude applies its own scroll-speed
|
||||
// multiplier and acceleration on top, so a bigger batch only overshoots.
|
||||
const SGR_WHEEL_MAX_TICKS = 5;
|
||||
|
||||
// Whole wheel lines → SGR wheel reports at a 1-based cell `pos` ({ col, row },
|
||||
// live-screen relative): button 64 per line up, 65 per line down, capped at
|
||||
// SGR_WHEEL_MAX_TICKS. '' when there is nothing to send. The encoding of the
|
||||
// primary pane's _sendSyntheticSgrWheel, pure so a TerminalTile forwards
|
||||
// byte-identical reports to its own session.
|
||||
function sgrWheelReports(lines, pos) {
|
||||
if (!lines || !pos) return '';
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), SGR_WHEEL_MAX_TICKS);
|
||||
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
|
||||
}
|
||||
|
||||
// Gesture travel → PageUp/PageDown keys for a terminal `rows` tall: adds
|
||||
// `lines` to the sub-page travel already `pending`, and returns the travel
|
||||
// left over plus the keys to send ('' below one page). The arithmetic of the
|
||||
@@ -260,6 +286,9 @@
|
||||
PAGE_KEY_SCREEN_FRACTION,
|
||||
PAGE_KEY_MAX_PER_BATCH,
|
||||
wheelDeltaLines,
|
||||
wheelDeltaWholeLines,
|
||||
SGR_WHEEL_MAX_TICKS,
|
||||
sgrWheelReports,
|
||||
pageKeysForTravel,
|
||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||
@@ -5563,9 +5592,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In
|
||||
// Claude mode the same value also capped the forwarded SGR report at one tick.
|
||||
_wheelScrollLines(ev) {
|
||||
const lines = this._wheelScrollLinesFloat(ev);
|
||||
if (!lines) return 0; // pure horizontal swipe: don't fall through to -1
|
||||
return Math.round(lines) || (lines > 0 ? 1 : -1);
|
||||
// Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines).
|
||||
return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows);
|
||||
},
|
||||
|
||||
/** Unrounded variant for the smooth local-scroll path, which accumulates
|
||||
@@ -5639,13 +5667,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// scroll-speed multiplier and acceleration on top), and the queue is bounded
|
||||
// so a wild scroll can't build a backlog that keeps scrolling after the finger
|
||||
// stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue.
|
||||
// The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a
|
||||
// TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli).
|
||||
_sendSyntheticSgrWheel(clientX, clientY, lines) {
|
||||
if (!this.activeSessionId || !lines) return;
|
||||
const pos = this._clientPointToCell(clientX, clientY);
|
||||
if (!pos) return;
|
||||
const btn = lines < 0 ? 64 : 65;
|
||||
const ticks = Math.min(Math.abs(lines), 5);
|
||||
this._queueScrollBytes(`\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks));
|
||||
this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos));
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -199,6 +199,19 @@ export class FakeTerminal {
|
||||
}
|
||||
scrollToLine() {}
|
||||
scrollToTop() {}
|
||||
/** Every scrollLines() amount; the viewport moves within [0, baseY], as in xterm. */
|
||||
scrolledLines: number[] = [];
|
||||
scrollLines(amount: number) {
|
||||
this.scrolledLines.push(amount);
|
||||
const active = this.buffer.active;
|
||||
active.viewportY = Math.max(0, Math.min(active.baseY, active.viewportY + amount));
|
||||
}
|
||||
/** Times scrollToBottom() was called; the viewport moves to the live screen, as in xterm. */
|
||||
scrolledToBottom = 0;
|
||||
scrollToBottom() {
|
||||
this.scrolledToBottom++;
|
||||
this.buffer.active.viewportY = this.buffer.active.baseY;
|
||||
}
|
||||
dispose() {}
|
||||
type(data: string) {
|
||||
this.dataCb?.(data);
|
||||
|
||||
@@ -61,6 +61,20 @@ describe('generateClaudeMd', () => {
|
||||
expect(result).toContain('CODEMAN_MUX=1');
|
||||
});
|
||||
|
||||
it('should tell the agent to print absolute paths of files it creates', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('full absolute path');
|
||||
expect(result).toContain('clickable');
|
||||
});
|
||||
|
||||
it('should point the agent at the codeman skill', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
expect(result).toContain('`codeman` skill');
|
||||
expect(result).toContain('codeman skill install');
|
||||
});
|
||||
|
||||
it('should include workflow rules', () => {
|
||||
const result = generateClaudeMd('my-project');
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
* wheel-down scrolls xterm home instead of being paged, and paging resumes
|
||||
* from the live screen.
|
||||
*
|
||||
* - a fullscreen Claude tile sends the wheel to the CLI as SGR wheel reports
|
||||
* at the pointer's cell in the TILE (Claude scrolls its own transcript on
|
||||
* them), and xterm's viewport stays on the live screen instead of
|
||||
* scrolling the stale replayed frames above it. Shift+wheel scrolls the
|
||||
* local scrollback, which xterm alone turns into a horizontal no-op.
|
||||
*
|
||||
* The wheel is a real one (`page.mouse.wheel()`), and the last step proves it
|
||||
* reaches xterm: a wheel the tile does not page scrolls xterm's viewport, so
|
||||
* "xterm did not scroll" on a paged wheel means something. What keeps xterm
|
||||
@@ -200,4 +206,95 @@ describe('TerminalTile wheel paging in a real browser', () => {
|
||||
}, PROBE_ID);
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards a fullscreen Claude tile's wheel as SGR reports from its own cells, and keeps xterm on the live screen", async () => {
|
||||
await page.evaluate(async (id) => {
|
||||
const w = window as any;
|
||||
const app = w.app;
|
||||
const CAPTURE_ROWS = 40;
|
||||
const capture = Array.from({ length: CAPTURE_ROWS }, (_, i) => `frame ${i}`).join('\r\n');
|
||||
|
||||
// The tile's session: Claude in its fullscreen renderer (mouse tracking on).
|
||||
app.sessions.set(id, { id, mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true });
|
||||
const sent: Array<[string, string]> = [];
|
||||
const realEphemeral = app._sendInputEphemeral;
|
||||
app._sendInputEphemeral = (sessionId: string, data: string) => sent.push([sessionId, data]);
|
||||
const realFetch = w.fetch;
|
||||
w.fetch = async (url: string, init?: unknown) =>
|
||||
String(url).includes(`/api/sessions/${id}/terminal`)
|
||||
? new Response(JSON.stringify({ data: { terminalBuffer: capture, captureRows: CAPTURE_ROWS } }))
|
||||
: realFetch(url, init);
|
||||
const RealWebSocket = w.WebSocket;
|
||||
w.WebSocket = class {
|
||||
static OPEN = 1;
|
||||
readyState = 0;
|
||||
send() {}
|
||||
close() {}
|
||||
};
|
||||
|
||||
// Offset from the page corner, so a cell computed against the wrong
|
||||
// element (the primary pane's) would come out different.
|
||||
const mount = document.createElement('div');
|
||||
mount.style.cssText =
|
||||
'position:fixed;left:100px;top:120px;width:640px;height:300px;z-index:99999;background:#000';
|
||||
document.body.appendChild(mount);
|
||||
const tile = new w.TerminalTile(id, mount, { mode: 'claude' });
|
||||
try {
|
||||
await tile.connect();
|
||||
} finally {
|
||||
w.fetch = realFetch;
|
||||
w.WebSocket = RealWebSocket;
|
||||
}
|
||||
w.__tileProbe = { tile, mount, sent, realEphemeral };
|
||||
}, PROBE_ID);
|
||||
|
||||
try {
|
||||
// Where the pointer will be, as the tile's own screen maps it (1-based).
|
||||
const expected = await page.evaluate(() => {
|
||||
const term = (window as any).__tileProbe.tile.terminal;
|
||||
const rect = term.element.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const cell = term._core._renderService.dimensions.css.cell;
|
||||
const x = rect.left + cell.width * 7.5;
|
||||
const y = rect.top + cell.height * 3.5;
|
||||
return { x, y, col: 8, row: 4 };
|
||||
});
|
||||
// The stale frames sit above the live screen: something xterm COULD scroll.
|
||||
const before = await snap();
|
||||
expect(before.baseY).toBeGreaterThan(0);
|
||||
expect(before.viewportY).toBe(before.baseY);
|
||||
|
||||
await page.mouse.move(expected.x, expected.y);
|
||||
await page.mouse.wheel(0, -100); // one Chrome-on-Windows notch up
|
||||
await page.waitForTimeout(150);
|
||||
await page.mouse.wheel(0, 50); // half a notch down
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const after = await snap();
|
||||
expect(after.viewportY).toBe(before.baseY); // xterm never scrolled the stale frames
|
||||
expect(after.sent.every(([sid]) => sid === PROBE_ID)).toBe(true);
|
||||
const up = `\x1b[<64;${expected.col};${expected.row}M`;
|
||||
const down = `\x1b[<65;${expected.col};${expected.row}M`;
|
||||
expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]);
|
||||
|
||||
// Shift+wheel is the explicit "local scrollback" gesture: scrolled locally
|
||||
// by the tile (xterm alone turns it into a horizontal no-op), unforwarded.
|
||||
await page.keyboard.down('Shift');
|
||||
await page.mouse.wheel(0, -100);
|
||||
await page.keyboard.up('Shift');
|
||||
await page.waitForTimeout(150);
|
||||
const shifted = await snap();
|
||||
expect(shifted.sent.length).toBe(after.sent.length);
|
||||
expect(shifted.viewportY).toBeLessThan(before.baseY);
|
||||
} finally {
|
||||
await page.evaluate((id) => {
|
||||
const w = window as any;
|
||||
const probe = w.__tileProbe;
|
||||
probe.tile.destroy();
|
||||
probe.mount.remove();
|
||||
w.app.sessions.delete(id);
|
||||
w.app._sendInputEphemeral = probe.realEphemeral;
|
||||
delete w.__tileProbe;
|
||||
}, PROBE_ID);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
* history, so the tile discounts them (`_localRows`); the overflow cases below
|
||||
* fail without that discount.
|
||||
*
|
||||
* Claude's fullscreen renderer is the other half: it scrolls its own
|
||||
* transcript on SGR wheel reports, which the primary pane sends it, while a
|
||||
* tile's xterm holds only replayed repaint frames. A tile left that wheel to
|
||||
* xterm too, so in a grid of fullscreen Claude sessions the wheel dragged
|
||||
* stale frames around or did nothing; it now forwards the reports to its own
|
||||
* session, from its own geometry (`_maybeForwardWheelToCli`).
|
||||
*
|
||||
* Real code under test: constants.js + app.js + terminal-ui.js +
|
||||
* terminal-tile.js in one `vm` context, as in terminal-tile-input.test.ts.
|
||||
* xterm, the fit addon and WebSocket are fakes (test/mocks/terminal-tile-fakes.ts),
|
||||
@@ -260,7 +267,8 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
fireAndCheck(wheelLines(-12, { shiftKey: true })); // the explicit "local scrollback" gesture
|
||||
// The explicit "local scrollback" gesture: scrolled locally, never paged.
|
||||
mount.fire('wheel', wheelLines(-12, { shiftKey: true }));
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-12));
|
||||
@@ -280,18 +288,207 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
||||
it('leaves a fullscreen Claude tile to xterm (tiles do not forward SGR wheel yet)', async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
||||
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
||||
describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports', () => {
|
||||
const FULLSCREEN: Session = { mode: 'claude', cliVersion: '2.1.295', cliMouseTracking: true };
|
||||
// 10 + 8 * 20 + 1, 20 + 16 * 5 + 1 inside the tile's own screen: column 21, row 6.
|
||||
const AT = { clientX: 171, clientY: 101 };
|
||||
const UP = '\x1b[<64;21;6M';
|
||||
const DOWN = '\x1b[<65;21;6M';
|
||||
|
||||
const ev = wheelLines(-12);
|
||||
mount.fire('wheel', ev);
|
||||
it("sends ephemeral reports on the tile's socket, from the tile's own geometry, and keeps xterm out", async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
const up = wheelLines(-3, AT);
|
||||
mount.fire('wheel', up);
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted
|
||||
|
||||
mount.fire('wheel', wheelLines(2, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) });
|
||||
});
|
||||
|
||||
it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-100, AT));
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
|
||||
mount.fire('wheel', wheelLines(-1000, AT));
|
||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
|
||||
});
|
||||
|
||||
it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
const sideways = wheel(0, { ...AT, deltaX: 120 });
|
||||
mount.fire('wheel', sideways);
|
||||
expect(sideways.preventDefault).toHaveBeenCalled(); // never xterm's stale frames
|
||||
expect(flushed(ws)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('coalesces the reports of wheels within 40 ms into one frame', async () => {
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
vi.advanceTimersByTime(20);
|
||||
mount.fire('wheel', wheelLines(-2, AT));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]);
|
||||
});
|
||||
|
||||
it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
term.buffer.active.baseY = 10;
|
||||
term.buffer.active.viewportY = 0;
|
||||
|
||||
mount.fire('wheel', wheelLines(-1, AT));
|
||||
|
||||
expect(term.scrolledToBottom).toBe(1);
|
||||
expect(term.buffer.active.viewportY).toBe(10);
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
});
|
||||
|
||||
it("follows the tile session's gate, never the active session's", async () => {
|
||||
// Active session is a shell; the tile shows fullscreen Claude: the tile forwards.
|
||||
const forwarded = await connectTile(makeApp({ other: { mode: 'shell' }, 's-tile': FULLSCREEN }, 'other'), {
|
||||
mode: 'claude',
|
||||
});
|
||||
forwarded.mount.fire('wheel', wheelLines(-1, AT));
|
||||
expect(flushed(forwarded.ws)).toEqual([{ t: 'i', d: UP }]);
|
||||
|
||||
// Active session is fullscreen Claude; the tile shows inline Claude (no
|
||||
// tracking): no reports, the hollow buffer is paged instead.
|
||||
const inline = await connectTile(
|
||||
makeApp({ other: FULLSCREEN, 's-tile': { ...FULLSCREEN, cliMouseTracking: false } }, 'other'),
|
||||
{ mode: 'claude' }
|
||||
);
|
||||
inline.mount.fire('wheel', wheelLines(-12, AT));
|
||||
expect(flushed(inline.ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('scrolls Shift locally, and leaves a tracking xterm and the alternate buffer to xterm', async () => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
const fireAndCheck = (ev: ReturnType<typeof wheel>) => {
|
||||
mount.fire('wheel', ev);
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(ev.stopPropagation).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
const shifted = wheelLines(-3, { ...AT, shiftKey: true }); // the explicit "local scrollback" gesture
|
||||
mount.fire('wheel', shifted);
|
||||
expect(shifted.preventDefault).toHaveBeenCalled();
|
||||
expect(term.scrolledLines).toEqual([-3]);
|
||||
|
||||
term.modes.mouseTrackingMode = 'any'; // xterm's own encoder forwards the wheel
|
||||
fireAndCheck(wheelLines(-3, AT));
|
||||
term.modes.mouseTrackingMode = 'none';
|
||||
|
||||
term.buffer.active.type = 'alternate'; // xterm's alt-scroll owns it
|
||||
fireAndCheck(wheelLines(-3, AT));
|
||||
|
||||
expect(ev.preventDefault).not.toHaveBeenCalled();
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it('pages, never reports, a Claude older than 2.1.187', async () => {
|
||||
const app = makeApp({ 's-tile': { ...FULLSCREEN, cliVersion: '2.1.100' } });
|
||||
const { ws, mount } = await connectTile(app, { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheelLines(-12, AT));
|
||||
|
||||
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||
});
|
||||
|
||||
it('sends exactly the bytes the primary pane sends for the same wheel sequence', async () => {
|
||||
const sequence = [-1, -3, 2, -100, 40, -0.2, 7].map((n) => wheelLines(n, AT));
|
||||
const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' });
|
||||
const tileBytes: string[] = [];
|
||||
for (const ev of sequence) {
|
||||
mount.fire('wheel', ev);
|
||||
tileBytes.push(
|
||||
...flushed(ws)
|
||||
.splice(tileBytes.length)
|
||||
.map((f) => f.d as string)
|
||||
);
|
||||
}
|
||||
|
||||
const primary = makeApp({ other: FULLSCREEN }, 'other') as App & {
|
||||
terminal: unknown;
|
||||
_sendSyntheticSgrWheel(x: number, y: number, lines: number): void;
|
||||
_wheelScrollLines(ev: unknown): number;
|
||||
_flushWheelSgrQueue(): void;
|
||||
};
|
||||
const sent: string[] = [];
|
||||
primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
|
||||
primary.terminal = new FakeTerminal({});
|
||||
for (const ev of sequence) {
|
||||
primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev));
|
||||
primary._flushWheelSgrQueue();
|
||||
}
|
||||
|
||||
expect(tileBytes.join('')).not.toBe('');
|
||||
expect(tileBytes).toEqual(sent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shift+wheel scrolls the tile's local scrollback itself", () => {
|
||||
// xterm's own scroller turns a Shift+vertical wheel into a horizontal one off
|
||||
// macOS, and Chrome on Windows already delivers Shift+wheel as deltaX, so
|
||||
// left to xterm the gesture moved nothing.
|
||||
it.each(['claude', 'opencode', 'shell'])('scrolls a %s tile with real history, and sends nothing', async (mode) => {
|
||||
const { ws, term, mount } = await connectTile(makeApp({ 's-tile': { mode } }), { mode });
|
||||
term.write(lines(40));
|
||||
const bottom = term.buffer.active.baseY;
|
||||
expect(bottom).toBeGreaterThan(5);
|
||||
|
||||
const up = wheelLines(-3, { shiftKey: true });
|
||||
mount.fire('wheel', up);
|
||||
|
||||
expect(up.preventDefault).toHaveBeenCalled();
|
||||
expect(up.stopPropagation).toHaveBeenCalled();
|
||||
expect(term.buffer.active.viewportY).toBe(bottom - 3);
|
||||
mount.fire('wheel', wheelLines(3, { shiftKey: true }));
|
||||
expect(term.buffer.active.viewportY).toBe(bottom);
|
||||
expect(flushed(ws)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads Chrome-on-Windows' horizontal Shift+wheel (deltaX) as vertical travel", async () => {
|
||||
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(0, { deltaX: -100, shiftKey: true }));
|
||||
|
||||
expect(term.scrolledLines).toEqual([-4]);
|
||||
});
|
||||
|
||||
it('carries sub-line travel to the next event, so a trackpad still scrolls', async () => {
|
||||
const { term, mount } = await connectTile(makeApp({ 's-tile': { mode: 'claude' } }), { mode: 'claude' });
|
||||
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true })); // 0.4 of a line
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
||||
expect(term.scrolledLines).toEqual([]);
|
||||
mount.fire('wheel', wheel(-10, { shiftKey: true }));
|
||||
|
||||
expect(term.scrolledLines).toEqual([-1]);
|
||||
});
|
||||
|
||||
it('still pulls history on a Shift+wheel-up at the top of a shell tile', async () => {
|
||||
const { mount } = await connectTile(makeApp({ 's-tile': { mode: 'shell' } }), { mode: 'shell' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1); // the initial load
|
||||
|
||||
mount.fire('wheel', wheelLines(-3, { shiftKey: true }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(fetchMock.mock.calls[1][0])).toContain('full=1&tail=');
|
||||
});
|
||||
});
|
||||
|
||||
describe("the forwarding gate behaves as in the primary pane, for the tile's session", () => {
|
||||
it('pages that same tile under the "Wheel scrolls local history" opt-out (the footgun rescue)', async () => {
|
||||
const app = makeApp({ 's-tile': { mode: 'claude', cliVersion: '2.1.223', cliMouseTracking: true } });
|
||||
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true });
|
||||
|
||||
Reference in New Issue
Block a user