start.sh
Rama main · réplica de lectura del repositorio original.
#!/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