| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | CODEWHALE_USER="${CODEWHALE_USER:-${DEEPSEEK_USER:-codewhale}}" |
| 5 | CODEWHALE_ROOT="${CODEWHALE_ROOT:-${DEEPSEEK_ROOT:-/opt/codewhale}}" |
| 6 | WHALEBRO_ROOT="${WHALEBRO_ROOT:-/opt/whalebro}" |
| 7 | if [[ -z "${RUNTIME_ENV:-}" ]]; then |
| 8 | if [[ -f /etc/codewhale/runtime.env || ! -f /etc/deepseek/runtime.env ]]; then |
| 9 | RUNTIME_ENV="/etc/codewhale/runtime.env" |
| 10 | else |
| 11 | RUNTIME_ENV="/etc/deepseek/runtime.env" |
| 12 | fi |
| 13 | fi |
| 14 | REPO_ROOT="${REPO_ROOT:-${WHALEBRO_ROOT}/codewhale}" |
| 15 | BRIDGE_KIND="${CODEWHALE_BRIDGE:-${DEEPSEEK_BRIDGE:-feishu}}" |
| 16 | |
| 17 | case "${BRIDGE_KIND}" in |
| 18 | feishu|lark) |
| 19 | if [[ -z "${BRIDGE_ENV:-}" ]]; then |
| 20 | if [[ -f /etc/codewhale/feishu-bridge.env || ! -f /etc/deepseek/feishu-bridge.env ]]; then |
| 21 | BRIDGE_ENV="/etc/codewhale/feishu-bridge.env" |
| 22 | else |
| 23 | BRIDGE_ENV="/etc/deepseek/feishu-bridge.env" |
| 24 | fi |
| 25 | fi |
| 26 | BRIDGE_DIR="${BRIDGE_DIR:-${CODEWHALE_ROOT}/bridge}" |
| 27 | BRIDGE_UNIT="${BRIDGE_UNIT:-codewhale-feishu-bridge}" |
| 28 | BRIDGE_PACKAGE="${BRIDGE_PACKAGE:-integrations/feishu-bridge}" |
| 29 | ;; |
| 30 | telegram) |
| 31 | if [[ -z "${BRIDGE_ENV:-}" ]]; then |
| 32 | if [[ -f /etc/codewhale/telegram-bridge.env || ! -f /etc/deepseek/telegram-bridge.env ]]; then |
| 33 | BRIDGE_ENV="/etc/codewhale/telegram-bridge.env" |
| 34 | else |
| 35 | BRIDGE_ENV="/etc/deepseek/telegram-bridge.env" |
| 36 | fi |
| 37 | fi |
| 38 | BRIDGE_DIR="${BRIDGE_DIR:-${CODEWHALE_ROOT}/telegram-bridge}" |
| 39 | BRIDGE_UNIT="${BRIDGE_UNIT:-codewhale-telegram-bridge}" |
| 40 | BRIDGE_PACKAGE="${BRIDGE_PACKAGE:-integrations/telegram-bridge}" |
| 41 | ;; |
| 42 | *) |
| 43 | echo "Unknown bridge '${BRIDGE_KIND}'. Use CODEWHALE_BRIDGE=feishu or CODEWHALE_BRIDGE=telegram." >&2 |
| 44 | exit 1 |
| 45 | ;; |
| 46 | esac |
| 47 | |
| 48 | failures=0 |
| 49 | warnings=0 |
| 50 | |
| 51 | section() { |
| 52 | printf '\n== %s ==\n' "$1" |
| 53 | } |
| 54 | |
| 55 | pass() { |
| 56 | printf '[ok] %s\n' "$1" |
| 57 | } |
| 58 | |
| 59 | warn() { |
| 60 | warnings=$((warnings + 1)) |
| 61 | printf '[warn] %s\n' "$1" |
| 62 | } |
| 63 | |
| 64 | fail() { |
| 65 | failures=$((failures + 1)) |
| 66 | printf '[fail] %s\n' "$1" |
| 67 | } |
| 68 | |
| 69 | have_command() { |
| 70 | command -v "$1" >/dev/null 2>&1 |
| 71 | } |
| 72 | |
| 73 | env_value() { |
| 74 | local file="$1" |
| 75 | local key="$2" |
| 76 | [[ -f "${file}" ]] || return 0 |
| 77 | grep -E "^[[:space:]]*(export[[:space:]]+)?${key}=" "${file}" \ |
| 78 | | tail -n 1 \ |
| 79 | | sed -E "s/^[[:space:]]*(export[[:space:]]+)?${key}=//; s/^[[:space:]]+//; s/[[:space:]]+$//; s/^['\"]//; s/['\"]$//" \ |
| 80 | || true |
| 81 | } |
| 82 | |
| 83 | env_value_any() { |
| 84 | local file="$1" |
| 85 | shift |
| 86 | local value |
| 87 | for key in "$@"; do |
| 88 | value="$(env_value "${file}" "${key}")" |
| 89 | if [[ -n "${value}" ]]; then |
| 90 | printf '%s\n' "${value}" |
| 91 | return 0 |
| 92 | fi |
| 93 | done |
| 94 | return 0 |
| 95 | } |
| 96 | |
| 97 | is_placeholder() { |
| 98 | local value |
| 99 | value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" |
| 100 | [[ -z "${value}" || "${value}" == *replace-with* || "${value}" == *xxxxxxxx* || "${value}" == "changeme" ]] |
| 101 | } |
| 102 | |
| 103 | file_mode() { |
| 104 | if stat -c '%a' "$1" >/dev/null 2>&1; then |
| 105 | stat -c '%a' "$1" |
| 106 | else |
| 107 | stat -f '%Lp' "$1" |
| 108 | fi |
| 109 | } |
| 110 | |
| 111 | check_commands() { |
| 112 | section "Runtime tools" |
| 113 | for cmd in git curl node npm systemctl ss; do |
| 114 | if have_command "${cmd}"; then |
| 115 | pass "${cmd} is installed" |
| 116 | else |
| 117 | warn "${cmd} is not on PATH" |
| 118 | fi |
| 119 | done |
| 120 | } |
| 121 | |
| 122 | check_node() { |
| 123 | section "Node" |
| 124 | if ! have_command node; then |
| 125 | fail "node is required for the phone bridge" |
| 126 | return |
| 127 | fi |
| 128 | local major |
| 129 | major="$(node -p "Number(process.versions.node.split('.')[0])" 2>/dev/null || echo 0)" |
| 130 | if [[ "${major}" =~ ^[0-9]+$ ]] && (( major >= 18 )); then |
| 131 | pass "Node.js major version is ${major}" |
| 132 | else |
| 133 | fail "Node.js 18+ is required; found ${major}" |
| 134 | fi |
| 135 | } |
| 136 | |
| 137 | check_workspace() { |
| 138 | section "Workspace" |
| 139 | [[ -d "${WHALEBRO_ROOT}" ]] && pass "${WHALEBRO_ROOT} exists" || fail "${WHALEBRO_ROOT} is missing" |
| 140 | [[ -d "${REPO_ROOT}/.git" ]] && pass "${REPO_ROOT} is a git checkout" || fail "${REPO_ROOT} is not a git checkout" |
| 141 | [[ -d "${WHALEBRO_ROOT}/worktrees" ]] && pass "${WHALEBRO_ROOT}/worktrees exists" || warn "${WHALEBRO_ROOT}/worktrees is missing" |
| 142 | if [[ -f "${WHALEBRO_ROOT}/AGENTS.md" ]]; then |
| 143 | pass "${WHALEBRO_ROOT}/AGENTS.md exists" |
| 144 | else |
| 145 | warn "${WHALEBRO_ROOT}/AGENTS.md is missing" |
| 146 | fi |
| 147 | } |
| 148 | |
| 149 | check_binaries() { |
| 150 | section "CodeWhale binaries" |
| 151 | local cargo_bin="/home/${CODEWHALE_USER}/.cargo/bin" |
| 152 | local codewhale="${cargo_bin}/codewhale" |
| 153 | local tui="${cargo_bin}/codewhale-tui" |
| 154 | if [[ -x "${codewhale}" ]]; then |
| 155 | pass "${codewhale} is executable" |
| 156 | "${codewhale}" --version 2>/dev/null | sed 's/^/[info] codewhale version: /' || warn "codewhale --version failed" |
| 157 | else |
| 158 | fail "${codewhale} is missing or not executable" |
| 159 | fi |
| 160 | if [[ -x "${tui}" ]]; then |
| 161 | pass "${tui} is executable" |
| 162 | "${tui}" --version 2>/dev/null | sed 's/^/[info] codewhale-tui version: /' || warn "codewhale-tui --version failed" |
| 163 | else |
| 164 | fail "${tui} is missing or not executable" |
| 165 | fi |
| 166 | } |
| 167 | |
| 168 | check_env_file() { |
| 169 | local file="$1" |
| 170 | local label="$2" |
| 171 | if [[ ! -f "${file}" ]]; then |
| 172 | fail "${label} env file is missing: ${file}" |
| 173 | return |
| 174 | fi |
| 175 | pass "${label} env file exists" |
| 176 | local mode |
| 177 | mode="$(file_mode "${file}")" |
| 178 | local world="${mode: -1}" |
| 179 | if [[ "${world}" =~ ^[0-9]+$ ]] && (( world > 0 )); then |
| 180 | fail "${label} env file is world-readable (${mode})" |
| 181 | else |
| 182 | pass "${label} env file is not world-readable (${mode})" |
| 183 | fi |
| 184 | } |
| 185 | |
| 186 | check_env() { |
| 187 | section "Environment" |
| 188 | check_env_file "${RUNTIME_ENV}" "runtime" |
| 189 | check_env_file "${BRIDGE_ENV}" "bridge" |
| 190 | |
| 191 | local runtime_token bridge_token workspace domain allow_groups allow_unlisted provider |
| 192 | runtime_token="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)" |
| 193 | bridge_token="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)" |
| 194 | workspace="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_WORKSPACE DEEPSEEK_WORKSPACE)" |
| 195 | provider="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_PROVIDER DEEPSEEK_PROVIDER)" |
| 196 | |
| 197 | if [[ "${BRIDGE_KIND}" == "telegram" ]]; then |
| 198 | allow_groups="$(env_value "${BRIDGE_ENV}" TELEGRAM_ALLOW_GROUPS)" |
| 199 | allow_unlisted="$(env_value_any "${BRIDGE_ENV}" TELEGRAM_ALLOW_UNLISTED CODEWHALE_ALLOW_UNLISTED DEEPSEEK_ALLOW_UNLISTED)" |
| 200 | else |
| 201 | domain="$(env_value "${BRIDGE_ENV}" FEISHU_DOMAIN)" |
| 202 | allow_groups="$(env_value "${BRIDGE_ENV}" FEISHU_ALLOW_GROUPS)" |
| 203 | allow_unlisted="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_ALLOW_UNLISTED DEEPSEEK_ALLOW_UNLISTED)" |
| 204 | fi |
| 205 | |
| 206 | if is_placeholder "${runtime_token}"; then |
| 207 | fail "runtime token is missing or still a placeholder" |
| 208 | else |
| 209 | pass "runtime token is set" |
| 210 | fi |
| 211 | if is_placeholder "${bridge_token}"; then |
| 212 | fail "bridge token is missing or still a placeholder" |
| 213 | else |
| 214 | pass "bridge token is set" |
| 215 | fi |
| 216 | if [[ -n "${runtime_token}" && -n "${bridge_token}" && "${runtime_token}" != "${bridge_token}" ]]; then |
| 217 | fail "runtime and bridge tokens do not match" |
| 218 | elif [[ -n "${runtime_token}" && -n "${bridge_token}" ]]; then |
| 219 | pass "runtime and bridge tokens match" |
| 220 | fi |
| 221 | if is_placeholder "${provider}"; then |
| 222 | warn "runtime provider is missing or still a placeholder" |
| 223 | else |
| 224 | pass "runtime provider is ${provider}" |
| 225 | fi |
| 226 | [[ "${workspace}" == "${WHALEBRO_ROOT}" || "${workspace}" == "${WHALEBRO_ROOT}/"* ]] \ |
| 227 | && pass "bridge workspace is under ${WHALEBRO_ROOT}" \ |
| 228 | || warn "bridge workspace is outside ${WHALEBRO_ROOT}: ${workspace:-unset}" |
| 229 | if [[ "${BRIDGE_KIND}" != "telegram" ]]; then |
| 230 | [[ "${domain:-feishu}" == "feishu" || "${domain:-feishu}" == "lark" || "${domain:-feishu}" == https://open.* ]] \ |
| 231 | && pass "FEISHU_DOMAIN is ${domain:-feishu}" \ |
| 232 | || fail "FEISHU_DOMAIN must be feishu, lark, or an https://open.* URL" |
| 233 | fi |
| 234 | [[ "${allow_groups:-false}" == "true" && "${allow_unlisted:-false}" == "true" ]] \ |
| 235 | && fail "group control cannot run with allow-unlisted=true" \ |
| 236 | || pass "group/unlisted mode is not openly combined" |
| 237 | } |
| 238 | |
| 239 | check_validator() { |
| 240 | section "Bridge config validator" |
| 241 | local validator="${BRIDGE_DIR}/scripts/validate-config.mjs" |
| 242 | if [[ ! -f "${validator}" ]]; then |
| 243 | validator="${REPO_ROOT}/${BRIDGE_PACKAGE}/scripts/validate-config.mjs" |
| 244 | fi |
| 245 | if [[ ! -f "${validator}" ]]; then |
| 246 | warn "bridge config validator is not installed" |
| 247 | return |
| 248 | fi |
| 249 | local runner=(node) |
| 250 | if [[ "${EUID}" -eq 0 ]] && id -u "${CODEWHALE_USER}" >/dev/null 2>&1 && have_command sudo; then |
| 251 | runner=(sudo -u "${CODEWHALE_USER}" node) |
| 252 | fi |
| 253 | if "${runner[@]}" "${validator}" --env "${BRIDGE_ENV}" --runtime-env "${RUNTIME_ENV}" --workspace-root "${WHALEBRO_ROOT}" --check-filesystem; then |
| 254 | pass "bridge config validator passed" |
| 255 | else |
| 256 | fail "bridge config validator reported blocking issues" |
| 257 | fi |
| 258 | } |
| 259 | |
| 260 | check_systemd() { |
| 261 | section "systemd" |
| 262 | if ! have_command systemctl || [[ ! -d /run/systemd/system ]]; then |
| 263 | warn "systemd is not available in this environment" |
| 264 | return |
| 265 | fi |
| 266 | for unit in codewhale-runtime "${BRIDGE_UNIT}"; do |
| 267 | [[ -f "/etc/systemd/system/${unit}.service" ]] \ |
| 268 | && pass "${unit}.service is installed" \ |
| 269 | || fail "${unit}.service is missing" |
| 270 | systemctl is-enabled --quiet "${unit}" \ |
| 271 | && pass "${unit} is enabled" \ |
| 272 | || warn "${unit} is not enabled" |
| 273 | systemctl is-active --quiet "${unit}" \ |
| 274 | && pass "${unit} is active" \ |
| 275 | || fail "${unit} is not active" |
| 276 | done |
| 277 | } |
| 278 | |
| 279 | check_bridge_install() { |
| 280 | section "Bridge install" |
| 281 | [[ -f "${BRIDGE_DIR}/package.json" ]] && pass "${BRIDGE_DIR}/package.json exists" || fail "bridge package.json is missing" |
| 282 | [[ -f "${BRIDGE_DIR}/src/index.mjs" ]] && pass "${BRIDGE_DIR}/src/index.mjs exists" || fail "bridge entrypoint is missing" |
| 283 | if [[ "${BRIDGE_KIND}" == "telegram" ]]; then |
| 284 | pass "Telegram bridge has no required production npm dependencies" |
| 285 | elif [[ -d "${BRIDGE_DIR}/node_modules/@larksuiteoapi/node-sdk" ]]; then |
| 286 | pass "Lark SDK dependency is installed" |
| 287 | else |
| 288 | warn "Lark SDK dependency is not installed under ${BRIDGE_DIR}/node_modules" |
| 289 | fi |
| 290 | } |
| 291 | |
| 292 | check_localhost_health() { |
| 293 | section "Localhost health" |
| 294 | local port token |
| 295 | port="$(env_value_any "${RUNTIME_ENV}" CODEWHALE_RUNTIME_PORT DEEPSEEK_RUNTIME_PORT)" |
| 296 | port="${port:-7878}" |
| 297 | token="$(env_value_any "${BRIDGE_ENV}" CODEWHALE_RUNTIME_TOKEN DEEPSEEK_RUNTIME_TOKEN)" |
| 298 | |
| 299 | if have_command ss; then |
| 300 | local listeners |
| 301 | listeners="$(ss -ltn 2>/dev/null | awk -v port=":${port}" '$4 ~ port {print $4}' || true)" |
| 302 | if grep -qE "^127\\.0\\.0\\.1:${port}$|^\\[::1\\]:${port}$" <<<"${listeners}"; then |
| 303 | pass "runtime port ${port} is bound to localhost" |
| 304 | elif [[ -n "${listeners}" ]]; then |
| 305 | fail "runtime port ${port} is listening on a non-local address: ${listeners//$'\n'/, }" |
| 306 | else |
| 307 | fail "runtime port ${port} is not listening" |
| 308 | fi |
| 309 | else |
| 310 | warn "ss is unavailable; skipping bind-address check" |
| 311 | fi |
| 312 | |
| 313 | if ! have_command curl; then |
| 314 | warn "curl is unavailable; skipping HTTP checks" |
| 315 | return |
| 316 | fi |
| 317 | |
| 318 | if curl -fsS --max-time 3 "http://127.0.0.1:${port}/health" >/dev/null; then |
| 319 | pass "/health responds on localhost" |
| 320 | else |
| 321 | fail "/health did not respond on localhost:${port}" |
| 322 | fi |
| 323 | |
| 324 | if is_placeholder "${token}"; then |
| 325 | warn "runtime token is not usable; skipping /v1/runtime/info auth check" |
| 326 | return |
| 327 | fi |
| 328 | |
| 329 | local tmp |
| 330 | tmp="$(mktemp)" |
| 331 | if curl -fsS --max-time 3 -H "Authorization: Bearer ${token}" \ |
| 332 | "http://127.0.0.1:${port}/v1/runtime/info" >"${tmp}"; then |
| 333 | if node -e ' |
| 334 | const fs = require("fs"); |
| 335 | const data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); |
| 336 | if (data.bind_host !== "127.0.0.1") process.exit(2); |
| 337 | if (data.auth_required !== true) process.exit(3); |
| 338 | ' "${tmp}"; then |
| 339 | pass "/v1/runtime/info reports localhost bind and auth_required=true" |
| 340 | else |
| 341 | fail "/v1/runtime/info did not report localhost bind with auth enabled" |
| 342 | fi |
| 343 | else |
| 344 | fail "/v1/runtime/info did not respond with bearer auth" |
| 345 | fi |
| 346 | rm -f "${tmp}" |
| 347 | } |
| 348 | |
| 349 | main() { |
| 350 | printf 'Tencent Lighthouse CodeWhale doctor (%s bridge)\n' "${BRIDGE_KIND}" |
| 351 | check_commands |
| 352 | check_node |
| 353 | check_workspace |
| 354 | check_binaries |
| 355 | check_env |
| 356 | check_bridge_install |
| 357 | check_validator |
| 358 | check_systemd |
| 359 | check_localhost_health |
| 360 | |
| 361 | section "Summary" |
| 362 | printf '%s failure(s), %s warning(s)\n' "${failures}" "${warnings}" |
| 363 | (( failures == 0 )) |
| 364 | } |
| 365 | |
| 366 | main "$@" |
| 367 |