Compare commits

..
Author SHA1 Message Date
Codeman maintainer 82aeeaec02 docs: CLI Logos on Tabs in the settings reference and the dashboard page
The Settings Reference gets the new row in the Appearance tab table, the
Dashboard's Session tabs section says what the logo is and where to turn
it off, and the CliEntry.shortBadge comment in docs/cli-registry.md no
longer implies every tab always shows a logo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
Codeman maintainer ad57394618 feat(tabs): a per-device switch to hide the CLI logos on tabs
Since 1.40.0 every agent tab draws its CLI logo through the run-mode-dot
slot, and there was no way to turn that off. App Settings → Appearance →
Tabs now has "CLI Logos on Tabs" (showTabCliLogos), right after Tall Tabs.

- Modelled on tabTwoRows: a per-device display key in the server-settings
  merge and an optional boolean in the .strict() SettingsUpdateSchema,
  loaded and saved by the id appSettingsShowTabCliLogos. Default ON on
  every device; only an explicit false turns it off (tabCliLogosEnabled).
- CSS only, no tab re-render: applyTabOrientation() and the pre-paint
  script in index.html stamp html[data-tab-logos="on"|"off"] from the
  same stored blob (so a reload never flashes the logos), and one rule in
  styles.css hides .session-tab .tab-harness and .home-sessions-harness.
  The rows space their children with flex gap, so nothing is left behind.
- A live flip resizes every agent tab with no render behind it, so
  applyTabOrientation() then re-takes the strip's one-row wrap decision
  (updateTabOverflowMode) and re-anchors the lines drawn from tab rects;
  a header that changes height reaches the PTY through the terminal
  container's ResizeObserver, as any header change does.
- Covered: the header strip, vertical rail, sidebar, grouped rail, ledger
  and case clusters and phone chips (all render the same tab markup) and
  the desktop home rail. Untouched: tile and split headers, the Run menus,
  the welcome launchers. The phone overview rows, the command palette and
  the tab action menu draw no logo. The shell's SH pill and the status dot
  stay.
- zh-CN for the label and the description.

test/tab-cli-logos-setting.test.ts drives the real openAppSettings() and
saveAppSettings() in JSDOM (the saved PUT body must pass the schema), the
server-settings merge, the defaults on desktop and phone, the live stamp
and its re-measure without a re-render, the real pre-paint script (on,
off, the phone key, the catch fallback), the CSS rule's exact selectors,
and the translations.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:34:09 +02:00
19 changed files with 561 additions and 1196 deletions
+2 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-16
View File
@@ -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() {
+1 -1
View File
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
+10 -15
View File
@@ -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
+3 -10
View File
@@ -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
+1
View File
@@ -99,6 +99,7 @@ every session or only the active tab.
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
| Tall Tabs | Taller tab strip. |
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
+4
View File
@@ -73,6 +73,10 @@ precedence there.
One tab per session, in your order, and that order syncs across your devices.
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
headers and the Run menus keep theirs.
**Status is carried by the dot and the tab's own styling:**
| Look | Meaning |
+3 -1
View File
@@ -6709,7 +6709,9 @@ class CodemanApp {
// every agent CLI, claude included, shows its logo through PR #532's
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
// headers and the Run menus draw the same mark. An id with no logo rule (a
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
const tabModeHtml = mode === 'shell'
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
+2 -1
View File
@@ -428,7 +428,8 @@ Object.assign(CodemanApp.prototype, {
line1.appendChild(badge);
} else {
// The agent's logo: PR #532's slot, the mode id as data (an id with no
// logo rule gets the slot's plain dot).
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
const logo = document.createElement('span');
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
logo.setAttribute('aria-hidden', 'true');
+3
View File
@@ -369,6 +369,9 @@
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
'CLI Logos on Tabs': '标签页上的 CLI 图标',
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
// Tab Layout and Header Stats Style (Discussion #426). The header style's
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
// button), and "Tiles (label over value)" must not read as the grid.
+8 -1
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -2216,6 +2216,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
<div class="set-row-text">
<span class="set-row-label">CLI Logos on Tabs</span>
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
</div>
<div class="set-row" data-search="pop out detach tab window">
<div class="set-row-text">
<span class="set-row-label">Pop-out Button on Tabs</span>
+30 -1
View File
@@ -487,6 +487,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
@@ -2577,6 +2578,7 @@ Object.assign(CodemanApp.prototype, {
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
@@ -3533,6 +3535,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: false,
ralphTrackerEnabled: false,
tabTwoRows: false,
showTabCliLogos: true,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
@@ -3669,6 +3672,16 @@ Object.assign(CodemanApp.prototype, {
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
},
/**
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
* device). Anything but an explicit false reads as on, the same test the
* pre-paint script in index.html applies, so a reload and a Save never
* disagree about an odd stored value.
*/
tabCliLogosEnabled(settings) {
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
},
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
resolveTabStateOrder(settings) {
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
@@ -3956,6 +3969,14 @@ Object.assign(CodemanApp.prototype, {
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
const stateOrder = this.resolveTabStateOrder(settings);
root.dataset.tabStateOrder = stateOrder;
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
// of `changed` below: the logo spans are always in the markup. It still
// resizes every agent tab, which the tail of this function settles.
const previousLogos = root.dataset.tabLogos;
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
root.dataset.tabLogos = logos;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
@@ -4005,6 +4026,14 @@ Object.assign(CodemanApp.prototype, {
if (!wrapRendered) this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
} else if (previousLogos !== logos) {
// A logo flip narrows or widens every agent tab with no render behind
// it, so re-take what a render would have: the strip's one-row wrap
// decision and the lines anchored to tab rects (lineage, subagent
// connectors). A header that gains or loses a row resizes the terminal
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
this.updateTabOverflowMode?.();
this._updateConnectionLinesImmediate?.();
}
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
@@ -4253,7 +4282,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
'language',
+13
View File
@@ -3010,6 +3010,19 @@ body.solo-mode .btn-lifecycle-log {
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
colour lives here any more. Only the shell keeps a pill. */
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
applyTabOrientation() and the pre-paint script in index.html stamp the
attribute). The logo leaves the session tabs (header strip, vertical rail,
sidebar, grouped rail, phone chips) and the desktop home rail; the status
dot and the shell's SH pill stay. Every row it sits in spaces its children
with flex `gap`, which skips a display:none item, so no empty slot is left.
Tile and split headers (.tile-harness, .split-harness) and the Run menus
keep their logos: only these two classes are named here. */
html[data-tab-logos='off'] .session-tab .tab-harness,
html[data-tab-logos='off'] .home-sessions-harness {
display: none;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
+7
View File
@@ -1391,6 +1391,13 @@ export const SettingsUpdateSchema = z
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/**
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
* false hides the agent logo on session tabs and the desktop home rail
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
* and the Run menus keep their logos.
*/
showTabCliLogos: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
-470
View File
@@ -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');
});
});
+450
View File
@@ -0,0 +1,450 @@
/**
* @fileoverview CLI Logos on Tabs (`showTabCliLogos`): the per-device switch
* that hides the agent logo on the session tabs and the desktop home rail.
*
* What is pinned, and why it matters:
* - The switch is a row in App Settings → Appearance → Tabs, right after Tall
* Tabs, and the REAL openAppSettings() / saveAppSettings() load and save it
* by its id. The load/save contract is getElementById by id, so a renamed
* control would otherwise just stop loading or saving, silently.
* - It is modelled on tabTwoRows: a display key in the server-settings merge
* (a phone never overwrites a desktop's choice) AND an optional boolean in
* the .strict() SettingsUpdateSchema (the save PUTs it; a key the schema did
* not declare would 400 the whole settings save).
* - Default ON on every device; only an explicit false turns it off.
* - The pre-paint script in index.html stamps `data-tab-logos` from the same
* stored blob, so a reload never flashes the logos it is about to hide, and
* a Save stamps it live through applyTabOrientation() without re-rendering
* the tabs (the logo spans are always in the markup; CSS hides them).
* - The CSS names exactly the tab logo and the home-rail logo. Tile and split
* headers, the Run menus and the welcome launchers keep theirs.
* - The row reads in Chinese under zh-CN.
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import postcss, { type Rule } from 'postcss';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const STYLES = read('styles.css');
const MOBILE = read('mobile.css');
const I18N = read('i18n.js');
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
const DESKTOP_KEY = 'codeman-app-settings';
const PHONE_KEY = 'codeman-app-settings-mobile';
type Device = 'desktop' | 'mobile';
type Settings = Record<string, unknown>;
interface SettingsApp {
openAppSettings(): void;
saveAppSettings(): Promise<void>;
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
loadAppSettingsFromStorage(): Settings;
getDefaultSettings(): Settings;
tabCliLogosEnabled(s: Settings): boolean;
applyTabOrientation(): void;
_cachedAppSettings?: unknown;
_apiPut: (path: string, body: Settings) => Promise<unknown>;
_fullRenderSessionTabs: ReturnType<typeof vi.fn>;
updateTabOverflowMode: ReturnType<typeof vi.fn>;
_updateConnectionLinesImmediate: ReturnType<typeof vi.fn>;
}
/**
* Methods that run for real. Every other method of the app is a no-op here:
* openAppSettings() and saveAppSettings() fan out into the voice, webhook,
* tunnel, model and push panels, none of which this setting touches, and the
* load/save lines under test sit between those calls.
*/
const REAL = new Set([
'openAppSettings',
'saveAppSettings',
'loadAppSettingsFromServer',
'loadAppSettingsFromStorage',
'saveAppSettingsToStorage',
'getSettingsStorageKey',
'getDefaultSettings',
'tabCliLogosEnabled',
'applyTabOrientation',
'resolveTabArrangement',
'resolveTabStateOrder',
// Real so the save builds the body a browser would send (the schema check).
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
]);
/**
* Methods the load/save paths call that other modules add to the prototype
* (terminal-ui.js, panels-ui.js, session-ui.js), which this harness does not
* load. Stubbed as own properties.
*/
const ELSEWHERE = [
'showToast',
'_updateLocalEchoState',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'showWelcome',
'_applyRunMode',
];
const windows: { close(): void }[] = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
});
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
async function boot(device: Device = 'desktop', stored?: Settings) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
windows.push(win);
// After load, so app.js's DOMContentLoaded boot (new CodemanApp(): SSE,
// timers, the terminal) never runs; only the prototype is under test.
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
if (stored) win.localStorage.setItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY, JSON.stringify(stored));
win.eval(
[
`var MobileDetection = {
getDeviceType: () => ${JSON.stringify(device)},
isHandheldDevice: () => ${JSON.stringify(device)} !== 'desktop',
isMobile: () => false,
isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
const target = Object.create(win.__App.prototype);
target.sessions = new Map();
target.sessionOrder = [];
target._apiPut = vi.fn(async () => ({ ok: true }));
target._fullRenderSessionTabs = vi.fn();
target.updateTabOverflowMode = vi.fn();
target._updateConnectionLinesImmediate = vi.fn();
const noop = () => undefined;
for (const name of ELSEWHERE) target[name] = noop;
const app = new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
const stubbed =
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
return stubbed ? noop : value;
},
}) as SettingsApp;
const doc = win.document;
const checkbox = () => doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
const storedBlob = () =>
JSON.parse(win.localStorage.getItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY) || '{}') as Settings;
return { win, doc, app, checkbox, storedBlob };
}
describe('the App Settings row', () => {
const doc = new JSDOM(INDEX).window.document;
const input = doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
const row = input?.closest('.set-row') as HTMLElement;
it('is a switch in Appearance → Tabs (device scope), right after Tall Tabs', () => {
expect(input?.type).toBe('checkbox');
expect(input.parentElement!.matches('label.switch.switch-sm')).toBe(true);
const group = row.closest('.set-group')!;
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Tabs');
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('device');
expect(row.closest('.set-section')!.id).toBe('settings-appearance');
expect(row.previousElementSibling!.querySelector('#appSettingsTabTwoRows')).not.toBeNull();
});
it('says what it shows, what stays, and what it does not touch', () => {
expect(row.querySelector('.set-row-label')!.textContent).toBe('CLI Logos on Tabs');
const desc = row.querySelector('.set-row-desc')!.textContent!;
for (const part of ['logo', 'status dot', 'SH badge', 'Tiles', 'split headers', 'Run menus']) {
expect(desc, part).toContain(part);
}
});
it('is found by the settings search for logo, icon, harness, agent, cli, tab and hide', () => {
const words = row.getAttribute('data-search')!.split(/\s+/);
for (const word of ['logo', 'icon', 'harness', 'agent', 'cli', 'tab', 'hide']) expect(words, word).toContain(word);
});
});
describe('load and save through settings-ui.js', () => {
it('loads the stored value into the switch, and an absent key as on', async () => {
for (const [stored, expected] of [
[{ showTabCliLogos: false }, false],
[{ showTabCliLogos: true }, true],
[{}, true],
] as const) {
const { app, checkbox } = await boot('desktop', stored);
checkbox().checked = !expected;
app.openAppSettings();
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
}
});
it('saves the switch to this device and sends it in the settings PUT, which the schema accepts', async () => {
const { app, checkbox, storedBlob } = await boot('desktop', {});
app.openAppSettings();
checkbox().checked = false;
await app.saveAppSettings();
expect(storedBlob().showTabCliLogos).toBe(false);
expect(app._apiPut).toHaveBeenCalledTimes(1);
const [path, body] = (app._apiPut as unknown as { mock: { calls: [string, Settings][] } }).mock.calls[0];
expect(path).toBe('/api/settings');
expect(body.showTabCliLogos).toBe(false);
// The whole body, not just this key: an undeclared key 400s every save.
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
});
it('a Save stamps the attribute live', async () => {
const { app, checkbox, doc } = await boot('desktop', {});
app.openAppSettings();
checkbox().checked = false;
await app.saveAppSettings();
expect(doc.documentElement.dataset.tabLogos).toBe('off');
checkbox().checked = true;
await app.saveAppSettings();
expect(doc.documentElement.dataset.tabLogos).toBe('on');
});
});
describe('per-device like tabTwoRows', () => {
it('is a display key: a server value only seeds a device that has none', async () => {
// Local choice made: the server's value (another device's) never replaces it,
// while a synced key in the same payload does (the merge really ran).
const kept = await boot('desktop', { showTabCliLogos: true, autoNameSessions: false });
const merged = await kept.app.loadAppSettingsFromServer(
Promise.resolve({ showTabCliLogos: false, autoNameSessions: true })
);
expect(merged.showTabCliLogos).toBe(true);
expect(merged.autoNameSessions).toBe(true);
// A fresh device takes the server's value as its seed.
const fresh = await boot('desktop');
expect(
(await fresh.app.loadAppSettingsFromServer(Promise.resolve({ showTabCliLogos: false }))).showTabCliLogos
).toBe(false);
});
it('is an optional boolean in SettingsUpdateSchema, and nothing else passes', () => {
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: true }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: false }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({}).success).toBe(true);
for (const bad of ['off', 'false', 0, 1, null, {}]) {
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: bad }).success, JSON.stringify(bad)).toBe(false);
}
});
it('defaults to on, on a desktop and on a phone', async () => {
for (const device of ['desktop', 'mobile'] as const) {
const { app, checkbox } = await boot(device);
expect(app.tabCliLogosEnabled(app.loadAppSettingsFromStorage()), device).toBe(true);
checkbox().checked = false;
app.openAppSettings();
expect(checkbox().checked, device).toBe(true);
}
// The phone's defaults blob carries it explicitly, like its other tab keys.
expect((await boot('mobile')).app.getDefaultSettings().showTabCliLogos).toBe(true);
});
it('only an explicit false turns it off', async () => {
const { app } = await boot();
expect(app.tabCliLogosEnabled({ showTabCliLogos: false })).toBe(false);
for (const value of [true, undefined, 'off', 0, null]) {
expect(app.tabCliLogosEnabled({ showTabCliLogos: value }), String(value)).toBe(true);
}
});
});
describe('applyTabOrientation() stamps data-tab-logos without re-rendering', () => {
async function settled(stored: Settings) {
const booted = await boot('desktop', stored);
booted.app.applyTabOrientation();
for (const fn of [
booted.app._fullRenderSessionTabs,
booted.app.updateTabOverflowMode,
booted.app._updateConnectionLinesImmediate,
]) {
fn.mockClear();
}
const apply = (next: Settings) => {
booted.win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(next));
delete booted.app._cachedAppSettings;
booted.app.applyTabOrientation();
};
return { ...booted, apply };
}
it('flips the attribute and leaves the tab markup alone', async () => {
const { doc, app, apply } = await settled({ showTabCliLogos: true });
expect(doc.documentElement.dataset.tabLogos).toBe('on');
apply({ showTabCliLogos: false });
expect(doc.documentElement.dataset.tabLogos).toBe('off');
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
});
it('re-measures the strip wrap and re-anchors the lines on a flip, and only on a flip', async () => {
// Every agent tab just got narrower (or wider) with no render behind it: the
// one-row wrap decision and the lines drawn from tab rects are stale.
const { app, apply } = await settled({ showTabCliLogos: true });
apply({ showTabCliLogos: false });
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
apply({ showTabCliLogos: false });
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
apply({ showTabCliLogos: true });
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(2);
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(2);
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
});
});
describe('the pre-paint script in index.html', () => {
// The layout one: there is an earlier inline script with the same opening.
const PRE_PAINT =
[...INDEX.matchAll(/<script>(try\{var m=window\.innerWidth[\s\S]*?)<\/script>/g)]
.map((m) => m[1])
.find((src) => src.includes('dataset.tabStateOrder')) ?? '';
/**
* Runs the real inline script in a fresh window as `device` (a phone is a
* 393px viewport, the width test the script uses), with `blob` stored under
* that device's key, or under `storeAs`'s key when given.
*/
function prePaint(blob: string | null, device: Device = 'desktop', storeAs: Device = device) {
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
url: 'http://localhost/',
runScripts: 'outside-only',
});
const win = dom.window as unknown as Window & { eval(src: string): void };
Object.defineProperty(win, 'innerWidth', { value: device === 'desktop' ? 1440 : 393, configurable: true });
if (blob !== null) win.localStorage.setItem(storeAs === 'desktop' ? DESKTOP_KEY : PHONE_KEY, blob);
win.eval(PRE_PAINT);
return win.document.documentElement;
}
it('is found (the checks below are not vacuous)', () => {
expect(PRE_PAINT).toContain('localStorage.getItem(k)');
});
it('stamps off from the stored setting, on a desktop and on a phone', () => {
expect(prePaint(JSON.stringify({ showTabCliLogos: false })).dataset.tabLogos).toBe('off');
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile').dataset.tabLogos).toBe('off');
});
it('leaves the logos on otherwise', () => {
expect(prePaint(null).dataset.tabLogos).toBe('on');
expect(prePaint('{}').dataset.tabLogos).toBe('on');
expect(prePaint(JSON.stringify({ showTabCliLogos: true })).dataset.tabLogos).toBe('on');
// A phone reads its own blob: a desktop's choice does not reach it.
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile', 'desktop').dataset.tabLogos).toBe('on');
});
it('falls back to on, beside the other fallbacks, when the stored blob does not parse', () => {
const root = prePaint('{not json');
expect(root.dataset.tabLogos).toBe('on');
expect(root.dataset.tabArrangement).toBe('classic');
expect(root.dataset.headerStats).toBe('classic');
});
});
describe('the CSS', () => {
const rulesNaming = (css: string, pattern: RegExp) => {
const found: Rule[] = [];
postcss.parse(css).walkRules((rule: Rule) => {
if (pattern.test(rule.selector)) found.push(rule);
});
return found;
};
const selectorsOf = (rule: Rule) => rule.selectors.map((s) => s.replace(/\s+/g, ' ').trim());
it('hides the tab logo and the home-rail logo under the attribute, in one rule, and nothing else', () => {
const rules = rulesNaming(STYLES, /data-tab-logos/);
expect(rules).toHaveLength(1);
expect(selectorsOf(rules[0])).toEqual([
"html[data-tab-logos='off'] .session-tab .tab-harness",
"html[data-tab-logos='off'] .home-sessions-harness",
]);
const decls: string[] = [];
rules[0].walkDecls((d) => {
decls.push(`${d.prop}: ${d.value}${d.important ? ' !important' : ''}`);
});
expect(decls).toEqual(['display: none']);
expect(rules[0].parent?.type).toBe('root');
});
it('leaves the tile and split headers, the Run menus and the welcome launchers their logos', () => {
const selectors = rulesNaming(STYLES, /data-tab-logos/)
.flatMap(selectorsOf)
.join(' ');
for (const other of ['tile-harness', 'split-harness', 'run-mode-option', 'welcome', 'mobile-overview']) {
expect(selectors, other).not.toContain(other);
}
// Not the shared slot either: the Run menus draw the same `.run-mode-dot`.
expect(selectors).not.toMatch(/(^|\s)\.run-mode-dot/);
});
it('nothing on a phone or in a skin brings the logo back over the rule', () => {
expect(MOBILE).not.toContain('data-tab-logos');
for (const css of [STYLES, MOBILE]) {
for (const rule of rulesNaming(css, /tab-harness|home-sessions-harness|run-mode-dot/)) {
rule.walkDecls('display', (d) => {
// The only other display rule on the logo is the compact rail's rename
// rule, which hides it too.
expect(d.value, rule.selector).toBe('none');
});
}
}
});
});
describe('zh-CN', () => {
const rowText = (doc: Document) => {
const row = doc.getElementById('appSettingsShowTabCliLogos')!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
};
const english = rowText(new JSDOM(INDEX).window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowText(dom.window.document);
/** What may stay Latin: the CLI and SH names. */
const leftover = (text: string) => text.replace(/\b(CLI|SH|Shell)\b/g, '').match(/[A-Za-z]+/g) ?? [];
it('translates the label and the description, with no English left', () => {
expect(chinese.label).toBe('标签页上的 CLI 图标');
expect(chinese.description).not.toBe(english.description);
expect(leftover(chinese.label)).toEqual([]);
expect(leftover(chinese.description)).toEqual([]);
});
it('adds each key to the dictionary once', () => {
for (const key of [english.label, english.description]) {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
expect(I18N.match(new RegExp(`^\\s*(?:'${escaped}'|"${escaped}"):`, 'gm')) ?? [], key).toHaveLength(1);
}
});
});