#!/bin/bash # cmd/main - LangBot lifecycle manager for fnOS # Handles start / stop / status via standalone-runtime Python process. # Node.js path is injected into PATH so Box sandbox npx MCP servers can run. PID_FILE="${TRIM_PKGVAR}/langbot.pid" APP_DIR="${TRIM_APPDEST}/langbot" LOG_FILE="${TRIM_PKGVAR}/langbot.log" # --- Locate fnOS Node.js bin path --- # fnOS appname-based path: /var/apps/nodejs_vXX/target/bin # This is a stable symlink regardless of which volume the app is on. NODE_VERSION="${wizard_node_version:-22}" NODE_BIN_DIR="/var/apps/nodejs_v${NODE_VERSION}/target/bin" if [ -d "${NODE_BIN_DIR}" ]; then export PATH="${NODE_BIN_DIR}:${PATH}" fi # --- Persistent data root --- # LangBot loads data/config.yaml CWD-RELATIVE (see core/stages/load_config.py: # load_yaml_config('data/config.yaml', ...)) and resolves its data root to # /data in source-install mode — it does NOT honour LANGBOT_DATA_ROOT for # config.yaml. So the real fix is the symlink below: APP_DIR/data -> DATA_DIR. DATA_DIR="${TRIM_DATA_SHARE_PATHS%%:*}" if [ -z "${DATA_DIR}" ]; then DATA_DIR="${TRIM_PKGVAR}/data" fi export LANGBOT_DATA_ROOT="${DATA_DIR}" mkdir -p "${DATA_DIR}" 2>/dev/null || true # --- Locate Python --- PYTHON_BIN="python3" ! command -v "${PYTHON_BIN}" >/dev/null 2>&1 && PYTHON_BIN="python" # --- Locate uv --- # install_callback puts the bundled uv binary at ${TRIM_PKGVAR}/bin/uv UV_BIN="${TRIM_PKGVAR}/bin/uv" if [ ! -x "${UV_BIN}" ]; then UV_BIN="uv" fi if ! command -v "${UV_BIN}" >/dev/null 2>&1; then UV_BIN="${HOME}/.local/bin/uv" fi if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then UV_BIN="${HOME}/.cargo/bin/uv" fi if ! command -v "${UV_BIN}" >/dev/null 2>&1 && [ ! -x "${UV_BIN}" ]; then UV_BIN="${APP_DIR}/.venv/bin/uv" fi case $1 in start) if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then exit 0 fi rm -f "${PID_FILE}" fi if [ ! -d "${APP_DIR}" ]; then echo "LangBot app directory missing: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}" exit 1 fi cd "${APP_DIR}" || { echo "Cannot enter app directory: ${APP_DIR}" > "${TRIM_TEMP_LOGFILE}" exit 1 } if [ ! -d ".venv" ]; then echo "Python virtual environment not found. Please reinstall LangBot." > "${TRIM_TEMP_LOGFILE}" exit 1 fi # --- Unify data location: APP_DIR/data -> symlink to persistent DATA_DIR --- # LangBot reads config CWD-relative (data/config.yaml), so without this a # fresh data/ with a default 5300 config gets recreated inside target/ on # every install/upgrade. The symlink keeps everything on the persistent # share; it is recreated here on each start (upgrades wipe target/). APP_DATA="${APP_DIR}/data" if [ -L "${APP_DATA}" ]; then # already a symlink; re-point if the persistent dir changed [ "$(readlink "${APP_DATA}")" != "${DATA_DIR}" ] && ln -sfn "${DATA_DIR}" "${APP_DATA}" elif [ -d "${APP_DATA}" ]; then # legacy real dir (created by LangBot before this fix): merge into the # persistent dir without overwriting newer files already there mkdir -p "${DATA_DIR}" cp -an "${APP_DATA}/." "${DATA_DIR}/" 2>/dev/null || cp -a "${APP_DATA}/." "${DATA_DIR}/" rm -rf "${APP_DATA}" ln -s "${DATA_DIR}" "${APP_DATA}" else ln -s "${DATA_DIR}" "${APP_DATA}" fi # Ensure LangBot's actual listen port always matches what fnOS shows in # "应用设置 → 访问端口" (which is the single source of truth from the user's # POV). Two sources, checked in priority order: # 1. ${wizard_port} — only set during install/upgrade callbacks (not on # normal `start`; kept for completeness). # 2. target/ui/config — read the "port" field written by fnOS after # ${wizard_port} substitution AND any later edit the user made via # "应用设置 → 自定义 URL" pencil button. # Without this: user picks 5303 in wizard, LangBot still listens on its # default 5300, desktop shortcut hits 5303 → connection refused. CONFIG_FILE="${DATA_DIR}/config.yaml" _patch_port() { local _port="$1" case "${_port}" in ''|*[!0-9]*) return ;; esac if [ ! -f "${CONFIG_FILE}" ]; then local _tmpl="${APP_DIR}/src/langbot/templates/config.yaml" [ -f "${_tmpl}" ] && cp "${_tmpl}" "${CONFIG_FILE}" fi if [ -f "${CONFIG_FILE}" ]; then sed -i -E "/^api:/,/^[a-z_]+:/ s/^([[:space:]]*port:).*/\1 ${_port}/" "${CONFIG_FILE}" sed -i "s#webhook_prefix: 'http://127\.0\.0\.1:[0-9]*'#webhook_prefix: 'http://127.0.0.1:${_port}'#" "${CONFIG_FILE}" fi } if [ -n "${wizard_port:-}" ]; then _patch_port "${wizard_port}" fi if [ -f "${TRIM_APPDEST}/ui/config" ]; then _port_from_ui=$(python3 -c 'import json,sys try: d = json.load(open(sys.argv[1])) for _name, _entry in (d.get(".url") or {}).items(): p = _entry.get("port") if isinstance(p, (int, float)): print(int(p)) elif isinstance(p, str) and p.isdigit(): print(int(p)) break except Exception: pass ' "${TRIM_APPDEST}/ui/config" 2>/dev/null) if [ -n "${_port_from_ui}" ]; then _patch_port "${_port_from_ui}" fi fi # Native deployment: no --standalone-runtime flag, LangBot spawns the # plugin runtime as a stdio subprocess (same as official `uv run main.py`). # (--standalone-runtime would require an external runtime at # ws://langbot_plugin_runtime:5400, which only exists in Docker Compose.) # --standalone-box omitted: Box sandbox defaults off, users enable via Web UI nohup "${UV_BIN}" run --no-sync main.py \ > "${LOG_FILE}" 2>&1 & echo $! > "${PID_FILE}" sleep 3 if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then exit 0 fi fi echo "LangBot failed to start. Check ${LOG_FILE}" > "${TRIM_TEMP_LOGFILE}" exit 1 ;; stop) if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ]; then kill "${PID}" 2>/dev/null for _ in 1 2 3 4 5 6 7 8 9 10; do kill -0 "${PID}" 2>/dev/null || break sleep 1 done kill -9 "${PID}" 2>/dev/null fi rm -f "${PID_FILE}" fi exit 0 ;; status) if [ -f "${PID_FILE}" ]; then PID=$(cat "${PID_FILE}" | tr -d '[:space:]') if [ -n "${PID}" ] && kill -0 "${PID}" 2>/dev/null; then exit 0 fi rm -f "${PID_FILE}" fi exit 3 ;; *) exit 1 ;; esac