#!/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='' 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=... при развёртке)"