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:
1 parent
81ee084ff2
commit
d152ad836d
26 files changed
+836
-52
No files matched your search
Executable
+190
@@ -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
|
||||
Executable
+36
@@ -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
|
||||
Reference in new issue
Block a user