#!/usr/bin/env bash set -euo pipefail # NovelWorld one-click start. The default profile needs PostgreSQL and the # bootstrap roots only; Redis is selected explicitly through CACHE_MODE. cd "$(dirname "$0")" RED='\033[0;31m' GREEN='\033[0;32m' CYAN='\033[0;36m' NC='\033[0m' printf '%b\n' "${CYAN}NovelWorld — One-Click Start${NC}" CHECK_MODE=false RESUME_AFTER_L0=false case "${1:-}" in '') ;; --check) CHECK_MODE=true ;; --l0-resume) RESUME_AFTER_L0=true ;; *) printf 'Usage: %s [--check]\n' "$0" >&2; exit 2 ;; esac [[ "$#" -le 1 ]] || { printf 'Usage: %s [--check]\n' "$0" >&2; exit 2; } random_hex() { openssl rand -hex "$1" 2>/dev/null \ || od -An -N"$1" -tx1 /dev/urandom | tr -d ' \n' } env_value() { local file=$1 key=$2 count count=$(grep -c "^${key}=" "$file" || true) [[ "$count" -le 1 ]] || { printf 'Duplicate %s entries in %s\n' "$key" "$file" >&2; return 1; } sed -n "s/^${key}=//p" "$file" } set_env_value() { local file=$1 key=$2 value=$3 count count=$(grep -c "^${key}=" "$file" || true) [[ "$count" -le 1 ]] || { printf 'Duplicate %s entries in %s\n' "$key" "$file" >&2; return 1; } if [[ "$count" -eq 1 ]]; then sed -i "s|^${key}=.*$|${key}=${value}|" "$file" else printf '\n%s=%s\n' "$key" "$value" >>"$file" fi } valid_postgres_identifier() { [[ "$1" =~ ^[a-z_][a-z0-9_]{0,62}$ ]] } valid_postgres_password() { local value=$1 lowered distinct lowered=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') distinct=$(printf '%s' "$value" | fold -w1 | sort -u | wc -l | tr -d ' ') [[ "$value" =~ ^[A-Za-z0-9._~-]{16,}$ ]] \ && [[ "$distinct" -ge 8 ]] \ && [[ "$lowered" != *placeholder* ]] \ && [[ "$lowered" != *change_me* ]] \ && [[ "$lowered" != your_strong_password_here ]] } assert_l0_configuration() { local file=$1 user database password user=$(env_value "$file" POSTGRES_USER) database=$(env_value "$file" POSTGRES_DB) password=$(env_value "$file" POSTGRES_PASSWORD) valid_postgres_identifier "$user" || { printf 'POSTGRES_USER must be a lowercase PostgreSQL identifier (1-63 characters).\n' >&2 return 1 } valid_postgres_identifier "$database" || { printf 'POSTGRES_DB must be a lowercase PostgreSQL identifier (1-63 characters).\n' >&2 return 1 } valid_postgres_password "$password" || { printf 'POSTGRES_PASSWORD must be URL-safe, non-placeholder, at least 16 characters, and contain at least 8 distinct characters.\n' >&2 return 1 } } initialize_l0_configuration() { local file=$1 allow_prompt=$2 marker user database password default_user default_database L0_CONFIGURED_NOW=0 marker=$(env_value "$file" BOOTSTRAP_L0_COMPLETE) case "$marker" in true) assert_l0_configuration "$file"; return ;; ''|false) ;; *) printf 'BOOTSTRAP_L0_COMPLETE must be exactly true or false.\n' >&2; return 1 ;; esac user=$(env_value "$file" POSTGRES_USER) database=$(env_value "$file" POSTGRES_DB) password=$(env_value "$file" POSTGRES_PASSWORD) if valid_postgres_identifier "$user" \ && valid_postgres_identifier "$database" \ && valid_postgres_password "$password"; then # Existing and automation-preseeded installations migrate without a prompt. set_env_value "$file" BOOTSTRAP_L0_COMPLETE true return fi if [[ "$allow_prompt" != true || ! -t 0 ]]; then printf 'L0 database setup is incomplete. Run ./start.sh interactively, or preseed POSTGRES_USER, POSTGRES_DB, and a strong POSTGRES_PASSWORD in .env.\n' >&2 return 1 fi printf '\n%b\n' "${CYAN}首次启动:配置必需的本地 PostgreSQL(L0)${NC}" printf '%s\n' '数据库密码将自动生成;模型、Redis 和对象存储可稍后配置。' if valid_postgres_identifier "$user"; then default_user=$user; else default_user=novel; fi if valid_postgres_identifier "$database"; then default_database=$database; else default_database=novel_world; fi read -r -p "PostgreSQL 用户名 [${default_user}]: " user read -r -p "PostgreSQL 数据库名 [${default_database}]: " database user=${user:-$default_user} database=${database:-$default_database} valid_postgres_identifier "$user" || { printf 'PostgreSQL 用户名必须是 1-63 位小写字母、数字或下划线,且不能以数字开头。\n' >&2 return 1 } valid_postgres_identifier "$database" || { printf 'PostgreSQL 数据库名必须是 1-63 位小写字母、数字或下划线,且不能以数字开头。\n' >&2 return 1 } set_env_value "$file" POSTGRES_USER "$user" set_env_value "$file" POSTGRES_DB "$database" set_env_value "$file" POSTGRES_PASSWORD "$(random_hex 16)" # Commit point: never mark L0 complete until every hard database value is persisted. set_env_value "$file" BOOTSTRAP_L0_COMPLETE true assert_l0_configuration "$file" L0_CONFIGURED_NOW=1 } valid_redis_password() { local value=$1 lowered distinct lowered=$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]') distinct=$(printf '%s' "$value" | fold -w1 | sort -u | wc -l | tr -d ' ') [[ "$value" =~ ^[A-Za-z0-9._~-]{16,}$ ]] \ && [[ "$distinct" -ge 8 ]] \ && [[ "$lowered" != *placeholder* ]] \ && [[ "$lowered" != *change_me* ]] \ && [[ "$lowered" != your_redis_password_here ]] \ && [[ "$lowered" != runtime-redis-only ]] } resolve_cache_mode() { local mode_count redis_password mode_count=$(grep -c '^CACHE_MODE=' .env || true) [[ "$mode_count" -le 1 ]] || { printf 'Duplicate CACHE_MODE entries in .env\n' >&2; return 1; } redis_password=$(env_value .env REDIS_PASSWORD) if [[ "$mode_count" -eq 0 ]]; then # Pre-decision launchers generated Redis credentials. Migrate those installs # once; a copied current template already contains CACHE_MODE=postgres. if [[ -n "$redis_password" && "$redis_password" != your_redis_password_here ]]; then CACHE_MODE_VALUE=redis else CACHE_MODE_VALUE=postgres [[ "$redis_password" != your_redis_password_here ]] || set_env_value .env REDIS_PASSWORD '' fi set_env_value .env CACHE_MODE "$CACHE_MODE_VALUE" else CACHE_MODE_VALUE=$(env_value .env CACHE_MODE) fi case "$CACHE_MODE_VALUE" in postgres) ;; redis) valid_redis_password "$redis_password" || { printf 'CACHE_MODE=redis requires a URL-safe, non-placeholder REDIS_PASSWORD of at least 16 characters with 8 distinct characters.\n' >&2 return 1 } ;; *) printf 'CACHE_MODE must be exactly postgres or redis.\n' >&2; return 1 ;; esac readonly CACHE_MODE_VALUE } ensure_secret() { local key=$1 placeholder=$2 bytes=$3 current value current=$(env_value .env "$key") if [[ -z "$current" || "$current" == "$placeholder" ]]; then value=$(random_hex "$bytes") set_env_value .env "$key" "$value" fi } if [[ "$CHECK_MODE" == true ]]; then temporary_directory=$(mktemp -d) trap 'rm -rf -- "$temporary_directory"' EXIT fresh_env="$temporary_directory/fresh.env" cp .env.example "$fresh_env" if initialize_l0_configuration "$fresh_env" false 2>/dev/null; then printf 'An unconfigured non-interactive L0 launch was accepted.\n' >&2 exit 1 fi seeded_password=0123456789abcdef0123456789abcdef set_env_value "$fresh_env" POSTGRES_USER novel set_env_value "$fresh_env" POSTGRES_DB novel_world set_env_value "$fresh_env" POSTGRES_PASSWORD "$seeded_password" initialize_l0_configuration "$fresh_env" false [[ "$(env_value "$fresh_env" BOOTSTRAP_L0_COMPLETE)" == true ]] [[ "$(env_value "$fresh_env" POSTGRES_PASSWORD)" == "$seeded_password" ]] invalid_env="$temporary_directory/invalid.env" cp .env.example "$invalid_env" set_env_value "$invalid_env" BOOTSTRAP_L0_COMPLETE true if initialize_l0_configuration "$invalid_env" false 2>/dev/null; then printf 'A committed L0 marker bypassed database validation.\n' >&2 exit 1 fi grep -Fq 'exec bash "$0" --l0-resume' start.sh || { printf 'Unix launcher must commit L0 and automatically restart itself.\n' >&2 exit 1 } check_startup_order() { local mode=$1 fail_build=$2 label case_directory fake_bin docker_log output status compose_prefix expected label="startup-${mode}-${fail_build}" case_directory="$temporary_directory/$label" fake_bin="$case_directory/bin" docker_log="$case_directory/docker-calls" output="$case_directory/output" mkdir -p "$fake_bin" cp start.sh "$case_directory/start.sh" cp .env.example "$case_directory/.env" set_env_value "$case_directory/.env" COMPOSE_PROJECT_NAME startup-test-project set_env_value "$case_directory/.env" POSTGRES_USER startup_test_user set_env_value "$case_directory/.env" POSTGRES_DB startup_test_db set_env_value "$case_directory/.env" POSTGRES_PASSWORD SyntheticPostgres-0123456789 set_env_value "$case_directory/.env" BOOTSTRAP_L0_COMPLETE true set_env_value "$case_directory/.env" CACHE_MODE "$mode" compose_prefix=compose [[ "$mode" != redis ]] || compose_prefix='compose|--profile|redis' set_env_value "$case_directory/.env" REDIS_PASSWORD SyntheticRedis-0123456789 set_env_value "$case_directory/.env" JWT_SECRET 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef set_env_value "$case_directory/.env" RUNTIME_CONFIG_KEY abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789 set_env_value "$case_directory/.env" INTERNAL_SERVICE_TOKEN SyntheticInternal-0123456789 cat >"$fake_bin/docker" <<'FAKE_DOCKER' #!/usr/bin/env bash set -euo pipefail [[ "${1:-}" == compose ]] || exit 97 args=("$@") operation= for argument in "${args[@]:1}"; do case "$argument" in version|build|down|up) operation=$argument; break ;; esac done [[ -n "$operation" ]] || exit 98 { printf '%s' "$operation" printf '|%s' "${args[@]}" printf '\n' } >>"$DOCKER_CALL_LOG" if [[ "${FAKE_DOCKER_BUILD_FAIL:-0}" == 1 ]]; then if [[ "$operation" == build ]] || { [[ "$operation" == up ]] && [[ " ${args[*]} " == *' --build '* ]]; }; then exit 41 fi fi if [[ "$operation" == version ]]; then printf 'Docker Compose version v2.39.0\n' fi FAKE_DOCKER cat >"$fake_bin/xdg-open" <<'FAKE_OPEN' #!/usr/bin/env bash exit 0 FAKE_OPEN chmod +x "$fake_bin/docker" "$fake_bin/xdg-open" if (cd "$case_directory" && PATH="$fake_bin:$PATH" DOCKER_CALL_LOG="$docker_log" \ FAKE_DOCKER_BUILD_FAIL="$fail_build" bash ./start.sh) >"$output" 2>&1; then status=0 else status=$? fi for expected in \ 'COMPOSE_PROJECT_NAME=startup-test-project' \ 'POSTGRES_USER=startup_test_user' \ 'POSTGRES_DB=startup_test_db' \ 'POSTGRES_PASSWORD=SyntheticPostgres-0123456789'; do grep -Fxq "$expected" "$case_directory/.env" || { printf 'Launcher changed seeded Compose or PostgreSQL identity (%s).\n' "$label" >&2 return 1 } done mapfile -t operations < <(cut -d'|' -f1 "$docker_log") if [[ "$fail_build" == 1 ]]; then [[ "$status" -ne 0 && "${operations[*]}" == 'version build' ]] || { printf 'Launcher must stop after a failed image build without stopping the old stack (%s).\n' "$label" >&2 return 1 } else [[ "$status" -eq 0 && "${operations[*]}" == 'version build down up' ]] || { printf 'Launcher must build before down, then start without rebuilding (%s).\n' "$label" >&2 return 1 } grep -Fxq "build|${compose_prefix}|build" "$docker_log" && grep -Fxq 'down|compose|--profile|redis|down' "$docker_log" && grep -Fxq "up|${compose_prefix}|up|-d|--no-build|--wait|--wait-timeout|180" "$docker_log" || { printf 'Launcher must preserve selected build, all-profile shutdown, and bounded no-build startup (%s).\n' "$label" >&2 return 1 } fi } for mode in postgres redis; do check_startup_order "$mode" 1 check_startup_order "$mode" 0 done printf '%b\n' "${GREEN}Unix launcher self-check passed.${NC}" exit 0 fi [[ -f .env ]] || cp .env.example .env chmod 600 .env initialize_l0_configuration .env true if [[ "$L0_CONFIGURED_NOW" -eq 1 ]]; then [[ "$RESUME_AFTER_L0" == false ]] || { printf 'L0 setup restart loop detected.\n' >&2 exit 1 } printf '%b\n' "${GREEN}L0 数据库设置已保存,正在自动重启启动器…${NC}" exec bash "$0" --l0-resume fi if ! command -v docker >/dev/null 2>&1; then printf '%b\n' "${RED}Docker is not installed. See https://docs.docker.com/get-docker/${NC}" >&2 exit 1 fi if ! docker compose version >/dev/null 2>&1; then printf '%b\n' "${RED}Docker Compose v2 is not available.${NC}" >&2 exit 1 fi printf '%b\n' "${GREEN}Docker detected${NC}" resolve_cache_mode # Generate L1/root values only. Redis credentials are never invented. ensure_secret JWT_SECRET change_me_to_a_random_32_char_string 32 ensure_secret RUNTIME_CONFIG_KEY change_me_to_a_random_64_char_hex_string 32 ensure_secret INTERNAL_SERVICE_TOKEN change_me_to_a_random_internal_service_token 32 sed -i 's|^LLM_API_KEY=sk-your-api-key$|LLM_API_KEY=|' .env sed -i 's|^IMAGE_GEN_API_KEY=sk-your-api-key$|IMAGE_GEN_API_KEY=|' .env chmod 600 .env printf '%b\n' "${GREEN}Bootstrap roots ready; AI and administrator setup continue in the browser.${NC}" export CACHE_MODE=$CACHE_MODE_VALUE export COMPOSE_PROFILES= compose_args=(docker compose) if [[ "$CACHE_MODE_VALUE" == redis ]]; then redis_password=$(env_value .env REDIS_PASSWORD) export REDIS_PASSWORD=$redis_password export REDIS_URL="redis://:${redis_password}@redis:6379" compose_args+=(--profile redis) else export REDIS_PASSWORD= export REDIS_URL=memory:// fi printf '%b\n' "${CYAN}Building images before stopping old writers...${NC}" "${compose_args[@]}" build printf '%b\n' "${CYAN}Stopping old writers before migrations...${NC}" docker compose --profile redis down printf '%b\n' "${CYAN}Starting NovelWorld (cache: ${CACHE_MODE_VALUE})...${NC}" "${compose_args[@]}" up -d --no-build --wait --wait-timeout 180 printf '%b\n' "${GREEN}NovelWorld is ready at http://localhost${NC}" printf '%s\n' 'Stop: docker compose --profile redis down' 'Logs: docker compose logs -f' if command -v xdg-open >/dev/null 2>&1; then xdg-open http://localhost >/dev/null 2>&1 & elif command -v open >/dev/null 2>&1; then open http://localhost >/dev/null 2>&1 & fi