simbiot-harness/scripts/deploy-claude-code.sh

289 lines
14 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# ============================================================================
# deploy-claude-code.sh — развернуть Claude Code + ttyd web-TUI на любом сервере
# ============================================================================
# Воспроизводит боевую развёртку Лунара (t-261005155643: 2.28.121.212, user
# lunar, Claude Code 2.1.289 + ttyd 1.7.7, systemd-юнит ttyd-claude.service):
# 1) node/npm (если нет: apt → nodesource 22.x, иначе статический tarball)
# 2) npm i -g @anthropic-ai/claude-code
# 3) ttyd (бинарник с github-releases под архитектуру)
# 4) OS-юзер (если нет), systemd-юнит ttyd-claude.service
# 5) daemon-reload + enable --now + финальная проверка версий и здоровья
#
# Использование (root или sudo):
# bash scripts/deploy-claude-code.sh # дефолты: :7681, юзер claude, токен сгенерится
# bash scripts/deploy-claude-code.sh --port 7681 --user lunar --token MySecret
# bash scripts/deploy-claude-code.sh --dry-run --port 9000 --user bob --token x
# bash scripts/deploy-claude-code.sh --check # только здоровье, без установки
#
# Опции:
# --port N порт ttyd (по умолчанию 7681, как на Лунаре)
# --user NAME OS-юзер сервиса И логин basic-auth (по умолчанию claude)
# --token SECRET пароль basic-auth (по умолчанию: openssl rand -hex 12)
# --service NAME имя systemd-юнита (по умолчанию ttyd-claude)
# --ttyd-version V версия ttyd (по умолчанию 1.7.7)
# --node-version V версия node для tarball-фолбэка (по умолчанию 22.14.0)
# --claude-version V пин версии claude-code (по умолчанию latest)
# --interface IF на чём слушать (по умолчанию 0.0.0.0)
# --env K=V доп. Environment= в юнит (повторяемый), например
# --env ANTHROPIC_BASE_URL=https://api.z.ai
# --readonly терминал только для чтения (без -W)
# --dry-run показать план, ничего не делать (root не нужен)
# --check только проверка здоровья (версии + HTTP + сервис)
# -h | --help эта справка
#
# Безопасность: веб-терминнал закрыт basic-auth (-c user:token) — как на Лунаре
# (логин lunar / Zm9yZXZ0b25lClaude01). Сервис работает от непривилегированного
# юзера (User=), никакого root-shell в браузере.
# ============================================================================
set -euo pipefail
PORT="7681"
USER_NAME="claude"
TOKEN=""
SERVICE="ttyd-claude"
TTYD_VERSION="1.7.7"
NODE_VERSION="22.14.0"
CLAUDE_VERSION=""
INTERFACE="0.0.0.0"
READONLY=0
DRY_RUN=0
CHECK_ONLY=0
UNIT_ENV=()
log() { printf '[deploy-claude-code] %s\n' "$*"; }
die() { printf '[deploy-claude-code] ОШИБКА: %s\n' "$*" >&2; exit 2; }
have() { command -v "$1" >/dev/null 2>&1; }
usage() { sed -n '2,37p' "$0" | sed 's/^# \{0,1\}//'; }
# ── парсинг аргументов ──────────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="${2:-}"; shift 2 ;;
--user) USER_NAME="${2:-}"; shift 2 ;;
--token) TOKEN="${2:-}"; shift 2 ;;
--service) SERVICE="${2:-}"; shift 2 ;;
--ttyd-version) TTYD_VERSION="${2:-}"; shift 2 ;;
--node-version) NODE_VERSION="${2:-}"; shift 2 ;;
--claude-version) CLAUDE_VERSION="${2:-}"; shift 2 ;;
--interface) INTERFACE="${2:-}"; shift 2 ;;
--env) UNIT_ENV+=("${2:-}"); shift 2 ;;
--readonly) READONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--check) CHECK_ONLY=1; shift ;;
-h|--help) usage; exit 0 ;;
--port=*|--user=*|--token=*|--service=*|--ttyd-version=*|--node-version=*|\
--claude-version=*|--interface=*|--env=*)
# форма --opt=value
opt="${1%%=*}"; val="${1#*=}"
case "$opt" in
--port) PORT="$val" ;; --user) USER_NAME="$val" ;;
--token) TOKEN="$val" ;; --service) SERVICE="$val" ;;
--ttyd-version) TTYD_VERSION="$val" ;; --node-version) NODE_VERSION="$val" ;;
--claude-version) CLAUDE_VERSION="$val" ;; --interface) INTERFACE="$val" ;;
--env) UNIT_ENV+=("$val") ;;
esac; shift ;;
*) usage >&2; die "неизвестная опция: $1" ;;
esac
done
# ── валидация ────────────────────────────────────────────────────────────────
[[ "$PORT" =~ ^[0-9]+$ ]] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] \
|| die "--port должен быть числом 1..65535 (получили: '$PORT')"
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "--user должен быть валидным unix-логином (a-z, 0-9, _, -; получили: '$USER_NAME')"
[[ "$SERVICE" =~ ^[a-zA-Z0-9_.@-]+$ ]] || die "--service: некорректное имя юнита '$SERVICE'"
case "$TOKEN" in
*:*) die "--token не может содержать ':' (ломает -c user:token)" ;;
*' '*) die "--token не может содержать пробелы" ;;
esac
if [ "${#UNIT_ENV[@]}" -gt 0 ]; then
for e in "${UNIT_ENV[@]}"; do
[[ "$e" =~ ^[A-Za-z_][A-Za-z0-9_]*=.+$ ]] || die "--env ожидает KEY=VALUE (получили: '$e')"
done
fi
# токен: явный или сгенерированный (в dry-run без openssl — плейсхолдер)
if [ -z "$TOKEN" ]; then
if have openssl; then TOKEN="$(openssl rand -hex 12)"; else TOKEN="CHANGE-ME-$(date +%s)"; fi
fi
RUN() { # RUN 'описание' cmd args... — единая точка для dry-run
local desc="$1"; shift
if [ "$DRY_RUN" -eq 1 ]; then
printf '[dry] %s\n -> %s\n' "$desc" "$*"
else
log "$desc"
"$@"
fi
}
# ── --check: только здоровье ─────────────────────────────────────────────────
health_check() {
local fail=0
if have claude; then
printf 'claude : %s\n' "$(claude --version 2>&1 | head -1)"
else
printf 'claude : НЕ УСТАНОВЛЕН\n'; fail=1
fi
if have ttyd; then
printf 'ttyd : %s\n' "$(ttyd --version 2>&1 | head -1)"
else
printf 'ttyd : НЕ УСТАНОВЛЕН\n'; fail=1
fi
if [ "$(id -u)" -eq 0 ] && have systemctl; then
if systemctl is-active --quiet "$SERVICE.service"; then
printf 'service: %s active\n' "$SERVICE"
else
printf 'service: %s НЕ активен\n' "$SERVICE"; fail=1
fi
fi
local code
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://127.0.0.1:${PORT}/" || true)"
[ -n "$code" ] || code=000
# 200 = отвечает; 401 = basic-auth требует логин — тоже «жив»
if [ "$code" = "200" ] || [ "$code" = "401" ]; then
printf 'http : :%s -> %s (жив)\n' "$PORT" "$code"
else
printf 'http : :%s -> %s (НЕ отвечает)\n' "$PORT" "$code"; fail=1
fi
[ "$fail" -eq 0 ] && log 'здоровье: OK' || log 'здоровье: есть проблемы'
return "$fail"
}
if [ "$CHECK_ONLY" -eq 1 ]; then
health_check || exit 1
exit 0
fi
# ── предварительные проверки окружения ───────────────────────────────────────
[ "$(id -u)" -eq 0 ] || [ "$DRY_RUN" -eq 1 ] || die "запусти от root (sudo bash $0 ...)"
have curl || die "нужен curl (apt-get install -y curl)"
if [ "$DRY_RUN" -eq 0 ]; then
have systemctl || die "нужен systemd (systemctl не найден)"
fi
log "план: порт=$PORT user=$USER_NAME service=$SERVICE ttyd=$TTYD_VERSION readonly=$READONLY"
[ "$DRY_RUN" -eq 1 ] && log 'режим DRY-RUN: ничего не устанавливается'
# ── 1) node/npm ──────────────────────────────────────────────────────────────
node_major() { node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1; }
node_ok() {
have node && have npm && [ "$(node_major)" -ge 18 ] 2>/dev/null
}
if node_ok; then
log "node $(node -v) / npm $(npm -v) уже есть — пропускаю"
else
if have apt-get; then
# путь Лунара: nodesource setup_22.x + apt
RUN 'устанавливаю node 22 (nodesource + apt)' \
bash -c 'set -e; curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/n.sh && bash /tmp/n.sh >/dev/null 2>&1; apt-get install -y nodejs >/dev/null 2>&1 || apt-get install -y nodejs'
else
# фолбэк: статический tarball с nodejs.org (любой дистрибутив)
NARCH=''; case "$(uname -m)" in
x86_64) NARCH='x64' ;;
aarch64) NARCH='arm64' ;;
armv7l) NARCH='armv7l' ;;
*) die "неподдерживаемая архитектура для node-tarball: $(uname -m)" ;;
esac
RUN "устанавливаю node $NODE_VERSION (tarball linux-$NARCH в /usr/local)" \
bash -c "set -e; curl -fsSL -o /tmp/node.tar.xz https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-$NARCH.tar.xz && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1"
fi
[ "$DRY_RUN" -eq 1 ] || { node_ok || die 'node не установился (см. вывод выше)'; }
fi
# ── 2) Claude Code ───────────────────────────────────────────────────────────
if have claude && [ -z "$CLAUDE_VERSION" ] && [ "$DRY_RUN" -eq 0 ]; then
log "claude уже установлен: $(claude --version 2>&1 | head -1) — обновляю до latest"
fi
if [ -n "$CLAUDE_VERSION" ]; then
RUN "npm i -g @anthropic-ai/claude-code@$CLAUDE_VERSION" npm install -g "@anthropic-ai/claude-code@$CLAUDE_VERSION"
else
RUN 'npm i -g @anthropic-ai/claude-code (latest)' npm install -g @anthropic-ai/claude-code
fi
if [ "$DRY_RUN" -eq 0 ]; then
have claude || ln -sf "$(npm prefix -g)/bin/claude" /usr/local/bin/claude 2>/dev/null || true
have claude || die 'claude не найден после npm i'
log "claude: $(claude --version 2>&1 | head -1)"
fi
# ── 3) ttyd ──────────────────────────────────────────────────────────────────
TARCH=''; case "$(uname -m)" in
x86_64) TARCH='x86_64' ;;
aarch64) TARCH='aarch64' ;;
armv7l|armv6l) TARCH='arm' ;;
i686) TARCH='i686' ;;
*) die "неподдерживаемая архитектура для ttyd: $(uname -m)" ;;
esac
if have ttyd && [ "$DRY_RUN" -eq 0 ]; then
log "ttyd уже есть: $(ttyd --version 2>&1 | head -1) — пропускаю скачивание"
else
RUN "ставлю ttyd $TTYD_VERSION ($TARCH) в /usr/local/bin" \
bash -c "set -e; curl -fsSL -o /usr/local/bin/ttyd https://github.com/tsl0922/ttyd/releases/download/$TTYD_VERSION/ttyd.$TARCH && chmod +x /usr/local/bin/ttyd"
fi
# ── 4) OS-юзер и systemd-юнит ────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 0 ]; then
if ! id -u "$USER_NAME" >/dev/null 2>&1; then
log "создаю юзера $USER_NAME (без root-shell в веб-терминале)"
useradd -m -s /bin/bash "$USER_NAME"
fi
fi
WRITABLE='-W'; [ "$READONLY" -eq 1 ] && WRITABLE=''
UNIT_PATH="/etc/systemd/system/${SERVICE}.service"
ENV_LINES="Environment=HOME=/home/${USER_NAME}
Environment=PATH=/usr/local/bin:/usr/bin:/bin"
if [ ${#UNIT_ENV[@]} -gt 0 ]; then
for e in "${UNIT_ENV[@]}"; do ENV_LINES+="
Environment=$e"; done
fi
UNIT_CONTENT="[Unit]
Description=ttyd web terminal -> Claude Code (deploy-claude-code.sh)
After=network-online.target
Wants=network-online.target
[Service]
User=${USER_NAME}
${ENV_LINES}
ExecStart=/usr/local/bin/ttyd ${WRITABLE} -c ${USER_NAME}:${TOKEN} -p ${PORT} -i ${INTERFACE} claude
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
"
if [ "$DRY_RUN" -eq 1 ]; then
printf '[dry] пишу %s:\n%s\n' "$UNIT_PATH" "$UNIT_CONTENT"
else
CHANGED=1
if [ -f "$UNIT_PATH" ] && [ "$(cat "$UNIT_PATH")" = "$UNIT_CONTENT" ]; then
CHANGED=0; log 'юнит не изменился — перезапуск не нужен'
fi
printf '%s\n' "$UNIT_CONTENT" > "$UNIT_PATH"
systemctl daemon-reload
systemctl enable --now "$SERVICE.service" >/dev/null 2>&1 || true
if [ "$CHANGED" -eq 1 ] || ! systemctl is-active --quiet "$SERVICE.service"; then
systemctl restart "$SERVICE.service"
fi
sleep 1
fi
# ── 5) финальная проверка ────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
log 'dry-run завершён: план выше, запускай без --dry-run'
exit 0
fi
IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -n "$IP" ] || IP='<ip-сервера>'
log '─'
health_check || die 'финальная проверка не прошла (см. выше)'
log '─'
log "ГОТОВО: Claude Code + ttyd web-TUI"
log " URL : http://${IP}:${PORT}"
log " логин : ${USER_NAME}"
log " пароль : ${TOKEN}"
log " сервис : systemctl status ${SERVICE}"
log " первый вход: открой URL, пройди авторизацию claude (или передай --env ANTHROPIC_BASE_URL=... при развёртке)"