Split view, personal modes, budget warnings, server move script

- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
  for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
  scripts/smoke-test.sh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 12:06:20 +01:00
1 parent 81ee084ff2
commit d152ad836d
26 files changed
+836 -52

No files matched your search

+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Move this CodeCraft installation to another Docker host (e.g. Unraid) that keeps its
# data in bind mounts instead of the Docker volume "codecraft-data".
#
# Run it on the CURRENT host, in the repository folder (where .env lives):
#
# scripts/move-server.sh root@unraid:/mnt/user/appdata/codecraft \
# --workspace /mnt/user/projects --uid 99 --gid 100 --bind 0.0.0.0
#
# What it does:
# 1. copies the workspace while CodeCraft keeps running (rsync, first pass),
# 2. stops the CodeCraft container here (it is kept for a rollback),
# 3. streams the data volume (database, config, backups, agent home) to <dir>/data,
# 4. syncs the workspace again (only what changed),
# 5. writes <dir>/docker-compose.yml, <dir>/.env and <dir>/start.sh (docker run, for hosts
# without Compose) with bind mounts, and starts the published image there,
# 6. runs scripts/smoke-test.sh on the new host.
# Afterwards point the reverse proxy to the new host. To roll back: start the old container
# here again (docker compose start) – nothing is deleted on this host.
#
# Target: user@host:/dir (SSH; rsync and docker on the target), or local:/dir to try it on
# this host (use --name/--port so it does not clash with the running installation).
#
# Options:
# --workspace DIR project folder on the target (default <dir>/workspace)
# --uid N --gid N owner of data and workspace on the target (default: UID/GID of .env)
# --port N host port on the target (default: PORT of .env)
# --bind ADDR bind address on the target (default: BIND_ADDR of .env or 127.0.0.1)
# --name NAME container name on the target (default codecraft)
# --source-container C container to stop here (default codecraft)
# --source-volume V data volume here (default codecraft-data)
# --source-workspace D project folder here (default: WORKSPACE of .env)
# --no-stop do not stop the source (only for tests – the copy may be inconsistent)
# --dry-run show the steps only
set -euo pipefail
target="" ws_target="" uid="" gid="" port="" bind="" name="codecraft"
src_container="codecraft" src_volume="codecraft-data" ws_source="" stop=1 dry=0
while (($#)); do
case "$1" in
--workspace) ws_target="$2"; shift ;;
--uid) uid="$2"; shift ;;
--gid) gid="$2"; shift ;;
--port) port="$2"; shift ;;
--bind) bind="$2"; shift ;;
--name) name="$2"; shift ;;
--source-container) src_container="$2"; shift ;;
--source-volume) src_volume="$2"; shift ;;
--source-workspace) ws_source="$2"; shift ;;
--no-stop) stop=0 ;;
--dry-run) dry=1 ;;
-h|--help) sed -n '2,36p' "$0"; exit 0 ;;
-*) echo "unknown option $1" >&2; exit 2 ;;
*) target="$1" ;;
esac
shift
done
[[ "$target" == *:/* ]] || { echo "usage: $0 user@host:/dir | local:/dir [options] (see --help)" >&2; exit 2; }
cd "$(git rev-parse --show-toplevel)"
[[ -f .env ]] || { echo "no .env in $(pwd)" >&2; exit 1; }
env_get() { sed -n "s/^$1=//p" .env | tail -1; }
host="${target%%:*}"
dir="${target#*:}"
dir="${dir%/}"
ws_target="${ws_target:-$dir/workspace}"
uid="${uid:-$(env_get UID)}"; uid="${uid:-1000}"
gid="${gid:-$(env_get GID)}"; gid="${gid:-1000}"
port="${port:-$(env_get PORT)}"; port="${port:-8787}"
bind="${bind:-$(env_get BIND_ADDR)}"; bind="${bind:-127.0.0.1}"
version="$(env_get CODECRAFT_VERSION)"; version="${version:-latest}"
ws_source="${ws_source:-$(env_get WORKSPACE)}"; ws_source="${ws_source:-./workspace}"
image="$(docker inspect "$src_container" --format '{{.Config.Image}}' 2>/dev/null || true)"
image_repo="${image%:*}"
[[ -n "$image_repo" && "$image_repo" != codecraft ]] || image_repo="git.familie-zink.org/zink/codecraft"
step() { printf '\n==> %s\n' "$*"; }
fail() { echo "error: $*" >&2; exit 1; }
# Run a shell command on the target.
remote() {
if (( dry )); then echo "[dry-run] on $host: $1"; return 0; fi
if [[ "$host" == local ]]; then bash -c "$1"; else ssh -o BatchMode=yes "$host" "$1"; fi
}
q() { printf '%q' "$1"; }
sync_workspace() {
if (( dry )); then echo "[dry-run] rsync $ws_source/ -> $host:$ws_target/"; return; fi
if [[ "$host" == local ]]; then rsync -a --numeric-ids "$ws_source/" "$ws_target/"
else rsync -a --numeric-ids -e "ssh -o BatchMode=yes" "$ws_source/" "$host:$ws_target/"; fi
}
put_file() { # put_file <remote path> < content
if (( dry )); then echo "[dry-run] write $host:$1"; cat >/dev/null; return; fi
if [[ "$host" == local ]]; then cat >"$1"; else ssh -o BatchMode=yes "$host" "cat > $(q "$1")"; fi
}
step "Checking source and target"
docker volume inspect "$src_volume" >/dev/null || fail "volume $src_volume not found"
[[ -d "$ws_source" ]] || fail "workspace $ws_source not found"
echo "source: container $src_container, volume $src_volume, workspace $ws_source"
echo "target: $host:$dir (data in $dir/data, workspace $ws_target), uid $uid:$gid, ${bind}:${port}, image $image_repo:$version"
remote "command -v docker >/dev/null && command -v tar >/dev/null && command -v rsync >/dev/null" || fail "the target needs docker, tar and rsync"
remote "mkdir -p $(q "$dir/data") $(q "$dir/docker") $(q "$ws_target") && test -z \"\$(ls -A $(q "$dir/data"))\"" \
|| fail "$dir/data on the target is not empty – refusing to overwrite (move it away first)"
step "Copying the workspace (first pass, CodeCraft keeps running)"
sync_workspace
if (( stop )); then
step "Stopping $src_container here (running agents and terminals end)"
(( dry )) && echo "[dry-run] docker stop $src_container" || docker stop "$src_container" >/dev/null
fi
step "Copying the data volume"
if (( dry )); then
echo "[dry-run] tar of volume $src_volume -> $host:$dir/data"
else
docker run --rm --entrypoint tar --user 0:0 -v "$src_volume:/data:ro" "${image:-$image_repo:$version}" -C /data -cf - . \
| remote "tar -C $(q "$dir/data") -xpf -"
fi
step "Copying the workspace (changes since the first pass)"
sync_workspace
remote "chown -R $uid:$gid $(q "$dir/data") $(q "$ws_target") 2>/dev/null || echo 'note: could not chown (not root) – check file owners'"
step "Writing docker-compose.yml, .env and start.sh"
put_file "$dir/docker/seccomp-sandbox.json" <docker/seccomp-sandbox.json
grep -vE '^(COMPOSE_FILE|WORKSPACE|UID|GID|PORT|BIND_ADDR)=' .env | {
cat
printf '\n# Written by scripts/move-server.sh\nWORKSPACE=%s\nUID=%s\nGID=%s\nPORT=%s\nBIND_ADDR=%s\n' "$ws_target" "$uid" "$gid" "$port" "$bind"
} | put_file "$dir/.env"
put_file "$dir/docker-compose.yml" <<EOF
# CodeCraft with bind mounts (written by scripts/move-server.sh).
# Update: set CODECRAFT_VERSION in .env, then docker compose pull && docker compose up -d
services:
codecraft:
image: $image_repo:\${CODECRAFT_VERSION:-latest}
container_name: $name
restart: unless-stopped
user: "\${UID}:\${GID}"
ports:
- "\${BIND_ADDR}:\${PORT}:8787"
env_file:
- path: .env
required: false
volumes:
- $dir/data:/data
- \${WORKSPACE}:/workspace
extra_hosts:
- "host.docker.internal:host-gateway"
shm_size: "1gb"
security_opt:
- seccomp=$dir/docker/seccomp-sandbox.json
- apparmor=unconfined
- systempaths=unconfined
EOF
put_file "$dir/start.sh" <<EOF
#!/bin/sh
# Same as docker-compose.yml, for hosts without Docker Compose.
set -e
cd "\$(dirname "\$0")"
# Read values instead of sourcing .env: UID is read-only in bash.
val() { sed -n "s/^\$1=//p" .env | tail -1; }
docker rm -f $name >/dev/null 2>&1 || true
exec docker run -d --name $name --restart unless-stopped --user "\$(val UID):\$(val GID)" \\
-p "\$(val BIND_ADDR):\$(val PORT):8787" --env-file .env \\
-v $dir/data:/data -v "\$(val WORKSPACE):/workspace" \\
--add-host host.docker.internal:host-gateway --shm-size 1g \\
--security-opt seccomp=$dir/docker/seccomp-sandbox.json \\
--security-opt apparmor=unconfined --security-opt systempaths=unconfined \\
$image_repo:\$(val CODECRAFT_VERSION)
EOF
remote "chmod +x $(q "$dir/start.sh")"
step "Starting CodeCraft on the target"
remote "cd $(q "$dir") && docker pull -q $image_repo:$version >/dev/null && if docker compose version >/dev/null 2>&1; then docker compose up -d; else ./start.sh; fi" \
|| fail "start failed – if the image pull was refused, run 'docker login ${image_repo%%/*}' on the target and start again with: cd $dir && docker compose up -d (or ./start.sh)"
step "Smoke test on the target"
put_file "$dir/smoke-test.sh" <scripts/smoke-test.sh
remote "bash $(q "$dir/smoke-test.sh") http://127.0.0.1:$port ${version#v}" || fail "smoke test failed – the old installation here is stopped but intact (docker start $src_container)"
step "Done"
cat <<EOF
CodeCraft runs on $host ($bind:$port). Next:
- point the reverse proxy (e.g. Nginx Proxy Manager) for your domain to the new host and port,
- check the agents' tools in a chat (git credentials, MCP servers, LAN addresses),
- when everything works, remove the old container here: docker rm $src_container
(the volume $src_volume stays until you delete it: docker volume rm $src_volume).
Rollback: on the target 'docker stop $name', here 'docker start $src_container'.
EOF
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Quick check of a running CodeCraft: health, version, web UI, terminal client download.
#
# Usage: scripts/smoke-test.sh [URL] [EXPECTED_VERSION]
# URL base URL (default http://127.0.0.1:8787)
# EXPECTED_VERSION e.g. 202611-00003 (without "v"); checked when given
#
# Exit code 0 when everything answers, 1 otherwise.
set -uo pipefail
url="${1:-http://127.0.0.1:8787}"
url="${url%/}"
want="${2:-}"
want="${want#v}"
fail=0
check() { if [[ "$2" == ok ]]; then printf ' ok %s\n' "$1"; else printf ' FAIL %s (%s)\n' "$1" "$2"; fail=1; fi; }
echo "smoke test: $url"
health=""
for _ in $(seq 1 60); do
health=$(curl -fsS -m 5 "$url/api/health" 2>/dev/null) && break
sleep 2
done
[[ -n "$health" ]] && check "health" ok || check "health" "no answer after 2 minutes"
if [[ -n "$health" ]]; then
version=$(sed -n 's/.*"version":"\([^"]*\)".*/\1/p' <<<"$health")
if [[ -n "$want" && "$version" != "$want" ]]; then check "version $version" "expected $want"; else check "version ${version:-?}" ok; fi
grep -q '"ok":true' <<<"$health" && check "server reports ok" ok || check "server reports ok" "$health"
fi
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/")
[[ "$code" == 200 ]] && check "web UI" ok || check "web UI" "HTTP $code"
code=$(curl -s -o /dev/null -m 10 -w '%{http_code}' "$url/cli/install.sh")
[[ "$code" == 200 ]] && check "terminal client install script" ok || check "terminal client install script" "HTTP $code"
code=$(curl -s -o /dev/null -m 60 -w '%{http_code}' "$url/cli/download/linux-x86_64")
[[ "$code" == 200 ]] && check "terminal client download" ok || check "terminal client download" "HTTP $code"
exit $fail