289 lines
14 KiB
Bash
Executable file
289 lines
14 KiB
Bash
Executable file
#!/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=... при развёртке)"
|