Ctrl K
ComunidadGuías…

setup.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/bin/bash
# ==========================================================
# QuickSite — Post-Clone Setup
# ==========================================================
#
# Run this after cloning, before your first page load.
#
# Usage:
#   chmod +x setup.sh
#   ./setup.sh                        # menu
#   ./setup.sh <public_name>          # pre-answer the public-folder rename,
#                                     # then show the menu
#   PHP=/usr/bin/php8.3 ./setup.sh    # check that PHP's version rather than
#                                     # the php on the PATH
#
# A MENU, NOT A WALK. Every item is independent and re-runnable: come back
# later to change one thing without answering the other five again. The
# settings you choose are remembered in .quicksite.conf and read back the next
# time you run this.
#
#   1. Public folder name   — match your vhost DocumentRoot (www, public_html…)
#   2. Secure folder name   — rename/nest the engine folder for obscurity
#   3. URL space            — serve from a subdirectory (http://domain/web/)
#   4. Environment          — production (default) or development
#   5. Self-registration    — may visitors create their own accounts?
#   6. Setup token          — read the first-run credential off disk
#   7. Storage quotas       — per-account size and upload-rate ceilings
#   8. Command console      — offer the raw command runner at /admin/command?
#   9. Default language     — the language a new project starts in
#  10. Idle sign-out        — how long the panel may go unused before it signs
#                             you out
#  11. Embed security page  — show the embed policy's page in the panel?
#
# Items 1-3 rewrite init.php constants and the .htaccess routing. Items 4-5 and
# 7-11 write config files under the secure folder. Item 6 only reads.
#
# Before anything else, it checks the PHP version: older than 8.3, it stops.
#
# WHAT THIS SCRIPT CANNOT DO: create your account. No account exists yet, so
# there is nobody to be one. That happens in the browser at /admin/, and the
# setup token (item 6) is what authorises it — see the notes there.
# ==========================================================

set -euo pipefail

# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
DIM='\033[2m'
BOLD='\033[1m'
NC='\033[0m'

# ==========================================================
# The PHP floor — checked before anything is written
# ==========================================================
# QuickSite needs PHP 8.3 or later. This script runs no PHP of its own, so it asks
# the php on the PATH — or the one the PHP variable names — for its version, and
# stops at once when it is older, before the ownership fix or any file below.
# That is the command-line PHP: on some servers the web server's own (mod_php,
# php-fpm) is a separate install, which is why the README asks for a patched
# release of the branch either way. Only the floor is checked here: patch numbers
# change with every advisory, so they live in the README.
check_php_floor() {
    local php="${PHP:-}" out=""
    [ -n "$php" ] || php="$(command -v php 2>/dev/null || true)"
    if [ -n "$php" ]; then
        # -n: no php.ini, so an extension that fails to load prints nothing here
        out="$("$php" -n -r 'echo PHP_VERSION_ID, " ", PHP_VERSION;' 2>/dev/null || true)"
    fi
    if [[ ! "$out" =~ ^([0-9]+)\ ([0-9][^[:space:]]*)$ ]]; then
        echo ""
        if [ -z "$php" ]; then
            echo -e "  ${YELLOW}⚠ The PHP version could not be checked: no php command was found${NC}"
            echo -e "  ${YELLOW}  (name one with PHP=/path/to/php). QuickSite needs PHP 8.3 or later.${NC}"
        else
            echo -e "  ${YELLOW}⚠ The PHP version could not be checked: $php answered no version.${NC}"
            echo -e "  ${YELLOW}  QuickSite needs PHP 8.3 or later.${NC}"
        fi
        return 0
    fi
    if [ "${BASH_REMATCH[1]}" -lt 80300 ]; then
        echo ""
        echo -e "  ${RED}✗ QuickSite needs PHP 8.3 or later; the PHP found here ($php, ${BASH_REMATCH[2]}) is older.${NC}"
        echo "    Install a supported PHP, a patched release of its branch (see the README), and run setup again."
        exit 1
    fi
}
check_php_floor

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PUBLIC_DIR="$SCRIPT_DIR/public"
SECURE_DIR="$SCRIPT_DIR/secure"
PUBLIC_FOLDER_NAME="public"
SECURE_FOLDER_NAME="secure"
PUBLIC_SPACE=""

# Read existing config if available (re-run detection)
CONF_FILE="$SCRIPT_DIR/.quicksite.conf"
# Has setup ever run here? Captured BEFORE save_conf writes the file, because
# the landing-page drop below is a first-launch-only action.
FIRST_LAUNCH=yes
[ -f "$CONF_FILE" ] && FIRST_LAUNCH=no
if [ -f "$CONF_FILE" ]; then
    source "$CONF_FILE"
    # Backward compat: old conf format used PUBLIC_FOLDER_SPACE
    PUBLIC_SPACE="${PUBLIC_SPACE:-${PUBLIC_FOLDER_SPACE:-}}"
    PUBLIC_DIR="$SCRIPT_DIR/$PUBLIC_FOLDER_NAME"
    SECURE_DIR="$SCRIPT_DIR/$SECURE_FOLDER_NAME"
fi

# Helper: save current state to conf (crash recovery + re-run detection)
save_conf() {
    cat > "$CONF_FILE" << CONFEOF
PUBLIC_FOLDER_NAME=$PUBLIC_FOLDER_NAME
SECURE_FOLDER_NAME=$SECURE_FOLDER_NAME
PUBLIC_SPACE=$PUBLIC_SPACE
CONFEOF
}

# Where init.php lives right now (it moves with the URL space).
init_file() {
    if [ -n "$PUBLIC_SPACE" ]; then
        echo "$PUBLIC_DIR/$PUBLIC_SPACE/init.php"
    else
        echo "$PUBLIC_DIR/init.php"
    fi
}

# The directory holding the served .htaccess files (also moves with the space).
served_dir() {
    if [ -n "$PUBLIC_SPACE" ]; then
        echo "$PUBLIC_DIR/$PUBLIC_SPACE"
    else
        echo "$PUBLIC_DIR"
    fi
}

CONFIG_DIR() { echo "$SECURE_DIR/management/config"; }

# First capture group of a BRE, or '' when the file or the pattern is absent.
#
# POSIX sed, deliberately: `grep -P` is a GNU extension that ALSO refuses to run
# under a non-UTF-8 multibyte locale ("-P supports only unibyte and UTF-8
# locales"), which is a property of the server this lands on, not of the
# pattern. A setup script that reads its own config back wrongly on somebody
# else's box is worse than one that never reads it at all — it reports settings
# that are not in effect.
first_capture() {
    local file="$1" pattern="$2" out
    [ -f "$file" ] || { echo ""; return 0; }
    out=$(sed -n "s/.*${pattern}.*/\\1/p" "$file" 2>/dev/null) || out=""
    echo "${out%%$'\n'*}"
}

pause_for_menu() {
    echo ""
    read -r -p "  Press Enter to return to the menu… " _ || true
}

# ==========================================================
# Crash recovery — locate folders that were renamed without us
# ==========================================================
if [ ! -d "$PUBLIC_DIR" ]; then
    for d in "$SCRIPT_DIR"/*/; do
        if [ -f "$d/init.php" ] || ls "$d"/*/init.php >/dev/null 2>&1; then
            PUBLIC_FOLDER_NAME="$(basename "$d")"
            PUBLIC_DIR="$SCRIPT_DIR/$PUBLIC_FOLDER_NAME"
            break
        fi
    done
fi

if [ ! -d "$SECURE_DIR" ]; then
    INIT_PROBE="$(init_file)"
    if [ -f "$INIT_PROBE" ]; then
        DETECTED=$(first_capture "$INIT_PROBE" "define('SECURE_FOLDER_NAME',[[:space:]]*'\\([^']*\\)')")
        if [ -n "$DETECTED" ] && [ -d "$SCRIPT_DIR/$DETECTED" ]; then
            SECURE_DIR="$SCRIPT_DIR/$DETECTED"
            SECURE_FOLDER_NAME="$DETECTED"
        fi
    fi
fi

# ==========================================================
# File ownership — PHP needs write access to the secure folder
# ==========================================================
# Runs once, before the menu: a clone made as root leaves every later item
# writing files the web server cannot then read back.
OWNERSHIP_WARNING=""
if [ "$(whoami)" = "root" ]; then
    OWNERSHIP_WARNING="yes"
    WEB_USER=""
    # CloudPanel: the site user matches the htdocs parent folder name
    HTDOCS_PARENT="$(basename "$(dirname "$SCRIPT_DIR")" 2>/dev/null || true)"
    if id "$HTDOCS_PARENT" &>/dev/null 2>&1; then
        WEB_USER="$HTDOCS_PARENT"
    elif id "www-data" &>/dev/null 2>&1; then
        WEB_USER="www-data"
    elif id "nginx" &>/dev/null 2>&1; then
        WEB_USER="nginx"
    elif id "apache" &>/dev/null 2>&1; then
        WEB_USER="apache"
    fi

    if [ -n "$WEB_USER" ]; then
        echo ""
        echo -e "  ${BOLD}Fixing file ownership…${NC}"
        chown -R "$WEB_USER:$WEB_USER" "$SCRIPT_DIR"
        echo -e "  ${GREEN}✓${NC} Ownership set to ${BOLD}$WEB_USER${NC}"
        OWNERSHIP_WARNING=""
    fi
fi

# ==========================================================
# Landing page — first launch only, and only into an empty root
# ==========================================================
# QuickSite serves nothing at the web root by design, so a brand-new install has
# nothing there and answers 403. That is correct and it looks broken, which is
# the worst combination — hence a placeholder that says where the panel is.
#
# TWO CONDITIONS, both required. It runs only on the FIRST setup launch, so
# deleting the page is permanent rather than something the next run undoes; and
# only when the root holds no index file of its own, so cloning QuickSite onto a
# server that already has a site never overwrites that site's front page.
maybe_place_landing_page() {
    [ "$FIRST_LAUNCH" = yes ] || return 0

    local dir; dir="$(served_dir)"
    local template="$SECURE_DIR/deploy/index.html.example"
    [ -d "$dir" ] || return 0
    [ -f "$template" ] || return 0

    # Any index.* at all — .php, .html, .htm, anything the server might pick as
    # a DirectoryIndex. If something is already there, it is not ours to touch.
    if compgen -G "$dir/index.*" > /dev/null 2>&1; then
        return 0
    fi

    cp "$template" "$dir/index.html" || return 0
    echo ""
    echo -e "  ${GREEN}✓${NC} Added a placeholder page at the web root"
    echo -e "    ${DIM}The root serves nothing by default, so it would answer 403.${NC}"
    echo -e "    ${DIM}Delete or replace $PUBLIC_FOLDER_NAME/index.html when you put${NC}"
    echo -e "    ${DIM}your own site there — nothing depends on it.${NC}"
}

# ==========================================================
# Embed policy — the install-wide iframe sandbox rules
# ==========================================================
# One file for the whole installation (embed-policy.json) decides which hosts an
# <iframe> in ANY project may embed with extra sandbox permissions. It is set at
# deployment and the panel only READS it — no user, owner or admin can widen it,
# which is the whole reason it is here rather than per project.
#
# Created from the shipped .example the first time setup runs, and NEVER
# overwritten afterwards, so a deployer's own edits are safe (same rule as
# auth.php / roles.php, which the engine copies from their .example on first
# load). An absent file means the strictest sandbox — every embed blocked — so
# the .example ships the YouTube default (the host projects most commonly embed).
ensure_embed_policy() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/embed-policy.json"
    local example="$cfg_dir/embed-policy.json.example"

    [ -d "$cfg_dir" ] || return 0
    [ -f "$live" ] && return 0            # never touch an existing policy
    [ -f "$example" ] || return 0

    if cp "$example" "$live"; then
        echo ""
        echo -e "  ${GREEN}✓${NC} Created the install-wide embed policy"
        echo -e "    ${DIM}Which hosts an <iframe> may embed lives in${NC}"
        echo -e "    ${DIM}$SECURE_FOLDER_NAME/management/config/embed-policy.json — edit that file to${NC}"
        echo -e "    ${DIM}allow more hosts. The admin panel only shows it, read-only.${NC}"
    fi
}

# ==========================================================
# Current-state readers (shown on the menu, so nothing is guessed)
# ==========================================================

# 'production' | 'development' | '' when the file has not been written.
env_current() {
    first_capture "$(CONFIG_DIR)/environment.php" \
        "'environment'[[:space:]]*=>[[:space:]]*'\\([^']*\\)'"
}

env_label() {
    local v; v="$(env_current)"
    case "$v" in
        development) echo -e "${YELLOW}development${NC}  ${DIM}(may call localhost / LAN addresses)${NC}" ;;
        production)  echo -e "production" ;;
        "")          echo -e "production  ${DIM}(default — no environment.php yet)${NC}" ;;
        *)           echo -e "production  ${DIM}(unrecognised value '$v' — treated as production)${NC}" ;;
    esac
}

# 'true' | 'false' | '' when auth.php has not been created yet.
selfreg_current() {
    first_capture "$(CONFIG_DIR)/auth.php" \
        "'allow_self_registration'[[:space:]]*=>[[:space:]]*\\([a-zA-Z]*\\)"
}

selfreg_label() {
    local v; v="$(selfreg_current)"
    case "$v" in
        true)  echo -e "${YELLOW}on${NC}   ${DIM}(anyone may create an account at /admin/register)${NC}" ;;
        false) echo -e "off" ;;
        *)     echo -e "off  ${DIM}(default — auth.php not created yet)${NC}" ;;
    esac
}

# 'true' | 'false' | '' when console.php has not been created yet.
#
# ⚠ ABSENT MEANS ALLOWED. The console writes nothing and grants nothing: every
# command it lists is already reachable at /management/ by any caller whose role
# allows it, so a fresh install that nobody configured should keep its developer
# tooling. Fail-closed is for a capability that GRANTS something.
#
# The trailing comma in the pattern is load-bearing: first_capture wraps what
# it is given in .*<pattern>.* and returns the FIRST match in the file, so a
# pattern that also fits a sentence in the file's own comments would report
# the documentation instead of the setting. A '^' anchor cannot help — inside
# that wrapper it is a literal caret.
console_current() {
    first_capture "$(CONFIG_DIR)/console.php" \
        "'allow_console'[[:space:]]*=>[[:space:]]*\([a-zA-Z]*\),"
}

# ⚠ This MUST distinguish "no file" from "file present, value unreadable": they
# are opposite answers. A broken console.php
# leaves the console OFF (the only reason to create the file is to turn it off,
# so a typo must not silently re-open it), and a label that reported the
# fresh-install default for it would contradict the engine.
console_label() {
    if [ ! -f "$(CONFIG_DIR)/console.php" ]; then
        echo -e "on   ${DIM}(default — console.php not created yet)${NC}"
        return
    fi
    local v; v="$(console_current)"
    case "$v" in
        true)  echo -e "on" ;;
        false) echo -e "${YELLOW}off${NC}  ${DIM}(/admin/command is not offered)${NC}" ;;
        *)     echo -e "${YELLOW}off${NC}  ${DIM}(value unreadable — check console.php)${NC}" ;;
    esac
}

# 'true' | 'false' | '' when embed-security.php has not been created yet.
#
# ⚠ ABSENT MEANS SHOWN, as for the console (item 8): the page is a read-only view
# of the embed policy and grants nothing. The trailing comma is load-bearing, for
# the reason console_current gives.
embed_current() {
    first_capture "$(CONFIG_DIR)/embed-security.php" \
        "'show_embed_security'[[:space:]]*=>[[:space:]]*\([a-zA-Z]*\),"
}

# A broken embed-security.php hides the page (the only reason to create the file
# is to hide it), so "no file" and "value unreadable" are opposite answers here too.
embed_label() {
    if [ ! -f "$(CONFIG_DIR)/embed-security.php" ]; then
        echo -e "shown  ${DIM}(default — embed-security.php not created yet)${NC}"
        return
    fi
    local v; v="$(embed_current)"
    case "$v" in
        true)  echo -e "shown" ;;
        false) echo -e "${YELLOW}hidden${NC}  ${DIM}(/admin/embed-security is not in the menu)${NC}" ;;
        *)     echo -e "${YELLOW}hidden${NC}  ${DIM}(value unreadable — check embed-security.php)${NC}" ;;
    esac
}

# The language list the engine reads: the installation's own languages.json when
# it has made one, the shipped list otherwise. (The engine also falls back to the
# shipped list when the own copy is not JSON; this reader does not parse JSON, so
# it cannot tell — a broken copy is the one case where the two disagree.)
language_list_file() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    if [ -f "$cfg_dir/languages.json" ]; then
        echo "$cfg_dir/languages.json"
    else
        echo "$cfg_dir/languages.json.example"
    fi
}

# Is $1 a code in that list? The shape is checked first — two or three lowercase
# letters, the engine's own rule — so the code is safe inside the grep pattern.
# A key is matched as "code": anywhere on a line, which a minified copy also
# satisfies; a quoted word inside the file's comments cannot match, because a
# quote inside a JSON string is written \".
deflang_listed() {
    local code="$1" list
    case "$code" in
        [a-z][a-z]|[a-z][a-z][a-z]) ;;
        *) return 1 ;;
    esac
    list="$(language_list_file)"
    [ -f "$list" ] && grep -q "\"$code\"[[:space:]]*:" "$list"
}

# The code default-language.php names, or '' when the file has not been created
# yet. The trailing comma in the pattern is load-bearing for the same reason it
# is in console_current above.
deflang_current() {
    first_capture "$(CONFIG_DIR)/default-language.php" \
        "'default_language'[[:space:]]*=>[[:space:]]*'\([a-z]*\)',"
}

# The default the engine actually uses: the file's code when it is in the list,
# and 'en' otherwise — absent, broken, or naming a code the list does not hold.
deflang_effective() {
    local v; v="$(deflang_current)"
    if [ -n "$v" ] && deflang_listed "$v"; then echo "$v"; else echo "en"; fi
}

# ABSENT MEANS 'en', and so does a file the engine cannot use. The label says
# which of the two it is, because a file somebody wrote that the engine ignores
# is worth a look — the engine logs why it ignored it.
deflang_label() {
    if [ ! -f "$(CONFIG_DIR)/default-language.php" ]; then
        echo -e "en   ${DIM}(default — default-language.php not created yet)${NC}"
        return
    fi
    local v; v="$(deflang_current)"
    if [ -n "$v" ] && deflang_listed "$v"; then
        echo -e "$v"
    else
        echo -e "en   ${YELLOW}(value unusable — check default-language.php)${NC}"
    fi
}

# The idle sign-out auth.php sets, in seconds, or '' when auth.php has not been
# created yet or sets none. Read from its own line, which starts with the key, as
# quota_setting reads its settings: a value the file's comments quote is not it.
idle_current() {
    local file line
    file="$(CONFIG_DIR)/auth.php"
    [ -f "$file" ] || { echo ""; return 0; }
    line=$(grep -E "^[[:space:]]*'idle_ttl'[[:space:]]*=>" "$file" 2>/dev/null | head -n 1 || true)
    if [[ "$line" =~ =\>[[:space:]]*([0-9]+)[[:space:]]*, ]]; then
        echo "${BASH_REMATCH[1]}"
    else
        echo ""
    fi
}

# What the engine applies: auth.php's value, 1 day without one, never under its
# 5-minute minimum (qs_session_config()).
idle_effective() {
    local v; v="$(idle_current)"
    [ -n "$v" ] || v=86400
    [ "$v" -ge 300 ] || v=300
    echo "$v"
}

# Seconds in the largest unit that holds them whole — 86400 is "1 day", 5400 is
# "90 minutes": the setting as it is, never rounded.
idle_words() {
    local s="$1" n unit
    if [ $((s % 86400)) -eq 0 ]; then n=$((s / 86400)); unit=day
    elif [ $((s % 3600)) -eq 0 ]; then n=$((s / 3600)); unit=hour
    elif [ $((s % 60)) -eq 0 ]; then n=$((s / 60)); unit=minute
    else n=$s; unit=second
    fi
    if [ "$n" -eq 1 ]; then echo "1 $unit"; else echo "$n ${unit}s"; fi
}

# ABSENT MEANS 1 DAY. The label tells "no auth.php yet" from "an auth.php that sets
# none", and says when a value is under the minimum the engine raises it to.
idle_label() {
    if [ ! -f "$(CONFIG_DIR)/auth.php" ]; then
        echo -e "1 day  ${DIM}(default — auth.php not created yet)${NC}"
        return
    fi
    local v; v="$(idle_current)"
    if [ -z "$v" ]; then
        echo -e "1 day  ${DIM}(default — auth.php does not set it)${NC}"
    elif [ "$v" -lt 300 ]; then
        echo -e "5 minutes  ${YELLOW}(auth.php says $(idle_words "$v"); the engine never goes below 5 minutes)${NC}"
    else
        echo -e "$(idle_words "$v")"
    fi
}

# Quota is the one config whose ABSENCE is a real setting: no quota.php means the
# defaults — no storage ceiling and no upload rate, while the size caps and the write
# rate apply at the values the engine ships — and a fresh install has none. So the
# label tells "no file" from "a file that sets no ceiling": they behave the same
# today, but the second is a deliberate choice somebody made.
#
# A setting is read from its own line, which starts with the key: the .example
# carries a commented example above each setting (// 'max_uploads' => 60, a
# multiplication for the bytes), and a reader that matched anywhere on a line took
# that example for the setting. A value that is not a plain number is "?".
quota_setting() {
    local file line
    file="$(CONFIG_DIR)/quota.php"
    [ -f "$file" ] || { echo ""; return 0; }
    line=$(grep -E "^[[:space:]]*'$1'[[:space:]]*=>" "$file" 2>/dev/null | head -n 1)
    [ -n "$line" ] || { echo ""; return 0; }
    if [[ "$line" =~ =\>[[:space:]]*([0-9]+)[[:space:]]*,?[[:space:]]*$ ]]; then
        echo "${BASH_REMATCH[1]}"
    else
        echo "?"
    fi
}

quota_bytes_current() { quota_setting max_total_bytes; }

quota_uploads_current() { quota_setting max_uploads; }

quota_label() {
    if [ ! -f "$(CONFIG_DIR)/quota.php" ]; then
        echo -e "size caps + write rate (defaults)  ${DIM}(quota.php not created yet; no storage ceiling, no upload rate)${NC}"
        return
    fi
    local b u parts=""
    b="$(quota_bytes_current)"; u="$(quota_uploads_current)"
    if [ "$b" = "?" ]; then
        parts="a storage ceiling"
    elif [ -n "$b" ] && [ "$b" != "0" ]; then
        parts="$(( b / 1024 / 1024 )) MB per user"
    fi
    if [ "$u" = "?" ]; then
        [ -n "$parts" ] && parts="$parts, "
        parts="${parts}an upload rate"
    elif [ -n "$u" ] && [ "$u" != "0" ]; then
        [ -n "$parts" ] && parts="$parts, "
        parts="${parts}${u} uploads per period"
    fi
    if [ -z "$parts" ]; then
        echo -e "no storage ceiling, no upload rate  ${DIM}(quota.php present; it also holds the size caps and the write rate)${NC}"
    else
        echo -e "${YELLOW}${parts}${NC}  ${DIM}(quota.php also holds the size caps and the write rate)${NC}"
    fi
}

space_label() {
    if [ -n "$PUBLIC_SPACE" ]; then
        echo "$PUBLIC_SPACE  → http://your-domain/$PUBLIC_SPACE/"
    else
        echo -e "(none)  ${DIM}— served from the domain root${NC}"
    fi
}

# ==========================================================
# Item 1 — public folder name
# ==========================================================
item_public() {
    local NEW_NAME="${1:-}"

    echo ""
    echo -e "${BOLD}Public folder name${NC}"
    echo ""
    echo "  Your vhost DocumentRoot must point at this folder. If the vhost"
    echo "  expects a specific name (e.g. 'www', 'www.example.com',"
    echo "  'public_html'), rename it here."
    echo ""
    echo -e "  Current name: ${BOLD}$PUBLIC_FOLDER_NAME${NC}"
    echo ""

    if [ -z "$NEW_NAME" ]; then
        read -r -p "  New name (Enter to keep '$PUBLIC_FOLDER_NAME'): " NEW_NAME || true
    fi

    if [ -z "$NEW_NAME" ] || [ "$NEW_NAME" = "$PUBLIC_FOLDER_NAME" ]; then
        echo -e "  ${GREEN}✓${NC} Keeping '$PUBLIC_FOLDER_NAME'"
        return 0
    fi
    if echo "$NEW_NAME" | grep -q '[/\\]'; then
        echo -e "  ${RED}✗ Error: folder name cannot contain slashes${NC}"
        return 0
    fi
    if [ "$NEW_NAME" = "$SECURE_FOLDER_NAME" ]; then
        echo -e "  ${RED}✗ Error: cannot use the same name as the secure folder${NC}"
        return 0
    fi
    if [ ! -d "$PUBLIC_DIR" ]; then
        echo -e "  ${RED}✗ Error: public folder '$PUBLIC_FOLDER_NAME' not found${NC}"
        return 0
    fi
    if [ -e "$SCRIPT_DIR/$NEW_NAME" ]; then
        echo -e "  ${RED}✗ Error: '$NEW_NAME' already exists${NC}"
        return 0
    fi

    mv "$PUBLIC_DIR" "$SCRIPT_DIR/$NEW_NAME"
    PUBLIC_DIR="$SCRIPT_DIR/$NEW_NAME"
    PUBLIC_FOLDER_NAME="$NEW_NAME"

    local INIT_FILE; INIT_FILE="$(init_file)"
    if [ -f "$INIT_FILE" ]; then
        sed -i "s#define('PUBLIC_FOLDER_NAME',[[:space:]]*'[^']*')#define('PUBLIC_FOLDER_NAME', '$NEW_NAME')#" "$INIT_FILE"
    fi
    save_conf
    echo -e "  ${GREEN}✓${NC} Renamed → ${BOLD}$NEW_NAME${NC}"
}

# ==========================================================
# Item 2 — secure folder name
# ==========================================================
item_secure() {
    echo ""
    echo -e "${BOLD}Secure folder name${NC}"
    echo ""
    echo "  The secure folder holds the QuickSite engine and every project's"
    echo "  data. It is never served. Rename it for obscurity, or nest it."
    echo "  Examples: 'backend', 'app', 'backends/project1'"
    echo ""
    echo -e "  Current name: ${BOLD}$SECURE_FOLDER_NAME${NC}"
    echo ""

    local NEW_NAME
    read -r -p "  New name (Enter to keep '$SECURE_FOLDER_NAME'): " NEW_NAME || true

    # Normalize backslashes to forward slashes, trim leading/trailing slashes
    NEW_NAME=$(echo "$NEW_NAME" | sed 's:\\:/:g; s:^/*::; s:/*$::')

    if [ -z "$NEW_NAME" ] || [ "$NEW_NAME" = "$SECURE_FOLDER_NAME" ]; then
        echo -e "  ${GREEN}✓${NC} Keeping '$SECURE_FOLDER_NAME'"
        return 0
    fi
    if [ "$NEW_NAME" = "$PUBLIC_FOLDER_NAME" ]; then
        echo -e "  ${RED}✗ Error: cannot use the same name as the public folder${NC}"
        return 0
    fi
    if [ ! -d "$SECURE_DIR" ]; then
        echo -e "  ${RED}✗ Error: secure folder '$SECURE_FOLDER_NAME' not found${NC}"
        return 0
    fi

    local DEPTH; DEPTH=$(echo "$NEW_NAME" | awk -F/ '{print NF}')
    if [ "$DEPTH" -gt 5 ]; then
        echo -e "  ${RED}✗ Error: path too deep (max 5 levels)${NC}"
        return 0
    fi

    local TARGET="$SCRIPT_DIR/$NEW_NAME"

    # Detect un-nesting (e.g. secure/test → secure): target is ancestor of source
    local IS_UNNEST=false
    if echo "$SECURE_DIR" | grep -q "^${TARGET}/"; then
        IS_UNNEST=true
    fi

    if [ -e "$TARGET" ] && [ "$IS_UNNEST" != true ]; then
        echo -e "  ${RED}✗ Error: '$NEW_NAME' already exists${NC}"
        return 0
    fi

    local OLD_SECURE_DIR="$SECURE_DIR"
    local PARENT_DIR; PARENT_DIR="$(dirname "$TARGET")"
    local TMP_DIR CLEANUP_DIR

    if [ "$IS_UNNEST" = true ]; then
        # Un-nesting (e.g. secure/test → secure): target is parent of source
        TMP_DIR="$SCRIPT_DIR/.secure_move_tmp"
        mv "$SECURE_DIR" "$TMP_DIR"
        CLEANUP_DIR="$(dirname "$SECURE_DIR")"
        while [ "$CLEANUP_DIR" != "$SCRIPT_DIR" ] && [ -d "$CLEANUP_DIR" ]; do
            if [ -z "$(ls -A "$CLEANUP_DIR" 2>/dev/null)" ]; then
                rmdir "$CLEANUP_DIR"
                CLEANUP_DIR="$(dirname "$CLEANUP_DIR")"
            else
                break
            fi
        done
        if [ -e "$TARGET" ]; then
            echo -e "  ${YELLOW}⚠ Warning: '$NEW_NAME' still contains other files.${NC}"
            echo -e "  ${YELLOW}  They will be merged into the secure folder.${NC}"
        fi
        mv "$TMP_DIR" "$TARGET"
    elif echo "$TARGET" | grep -q "^${SECURE_DIR}/"; then
        # Self-nesting (e.g. secure → secure/test): can't mv into itself
        TMP_DIR="$SCRIPT_DIR/.secure_move_tmp"
        mv "$SECURE_DIR" "$TMP_DIR"
        mkdir -p "$PARENT_DIR"
        mv "$TMP_DIR" "$TARGET"
    elif [ "$PARENT_DIR" != "$SCRIPT_DIR" ]; then
        mkdir -p "$PARENT_DIR"
        mv "$SECURE_DIR" "$TARGET"
    else
        mv "$SECURE_DIR" "$TARGET"
    fi

    SECURE_DIR="$TARGET"
    SECURE_FOLDER_NAME="$NEW_NAME"

    local INIT_FILE; INIT_FILE="$(init_file)"
    if [ -f "$INIT_FILE" ]; then
        sed -i "s#define('SECURE_FOLDER_NAME',[[:space:]]*'[^']*')#define('SECURE_FOLDER_NAME', '$NEW_NAME')#" "$INIT_FILE"
    fi

    # Cleanup empty parent directories from the old nested path
    local OLD_PARENT; OLD_PARENT="$(dirname "$OLD_SECURE_DIR")"
    while [ "$OLD_PARENT" != "$SCRIPT_DIR" ] && [ -d "$OLD_PARENT" ]; do
        if [ -z "$(ls -A "$OLD_PARENT" 2>/dev/null)" ]; then
            rmdir "$OLD_PARENT"
            OLD_PARENT="$(dirname "$OLD_PARENT")"
        else
            break
        fi
    done

    save_conf
    echo -e "  ${GREEN}✓${NC} Renamed → ${BOLD}$NEW_NAME${NC}"

    # The vhost holds an ABSOLUTE include pointing INTO the secure folder.
    # Renaming the folder moves that file, and nginx then refuses to reload on
    # a path that no longer exists — a failure that surfaces at the next reload
    # rather than here, which is precisely when nobody connects it to this menu.
    echo ""
    echo -e "  ${YELLOW}⚠ nginx: your vhost still points at the OLD folder.${NC}"
    echo "    Update the include line in your server {} block to:"
    echo ""
    echo -e "      ${BOLD}include $SECURE_DIR/nginx/dynamic_routes.conf;${NC}"
    echo ""
    echo "    then reload nginx. An Apache install needs nothing here."
}

# ==========================================================
# Item 3 — URL space / prefix
# ==========================================================
item_space() {
    echo ""
    echo -e "${BOLD}URL space / prefix${NC}"
    echo ""
    echo "  A URL space serves the site from a subdirectory of the domain."
    echo "  Example: 'web' → http://your-domain/web/"
    echo ""

    local DESIRED_SPACE="$PUBLIC_SPACE"
    local INPUT

    if [ -n "$PUBLIC_SPACE" ]; then
        echo -e "  Current space: ${BOLD}$PUBLIC_SPACE${NC}"
        echo ""
        echo "  Enter a new space, type 'none' to remove it, or press Enter"
        echo "  to keep the current one."
        echo ""
        read -r -p "  Space: " INPUT || true

        if [ -z "$INPUT" ] || [ "$INPUT" = "$PUBLIC_SPACE" ]; then
            echo -e "  ${GREEN}✓${NC} Keeping '$PUBLIC_SPACE'"
            return 0
        elif [ "$INPUT" = "none" ]; then
            DESIRED_SPACE=""
        else
            DESIRED_SPACE=$(echo "$INPUT" | sed 's:^[/\\]*::; s:[/\\]*$::')
        fi
    else
        echo "  No space currently set — the site serves from the domain root."
        echo ""
        read -r -p "  Space (Enter to keep serving from the root): " INPUT || true

        if [ -z "$INPUT" ]; then
            echo -e "  ${GREEN}✓${NC} No space — serving from the root"
            return 0
        fi
        DESIRED_SPACE=$(echo "$INPUT" | sed 's:^[/\\]*::; s:[/\\]*$::')
    fi

    if [ "$DESIRED_SPACE" = "$PUBLIC_SPACE" ]; then
        echo -e "  ${GREEN}✓${NC} Unchanged"
        return 0
    fi

    if [ -n "$DESIRED_SPACE" ]; then
        if echo "$DESIRED_SPACE" | grep -q '[^a-zA-Z0-9._/-]'; then
            echo -e "  ${RED}✗ Error: invalid characters in space name${NC}"
            echo "  Allowed: a-z A-Z 0-9 . - _ /"
            return 0
        fi
        local DEPTH; DEPTH=$(echo "$DESIRED_SPACE" | awk -F/ '{print NF}')
        if [ "$DEPTH" -gt 5 ]; then
            echo -e "  ${RED}✗ Error: space path too deep (max 5 levels)${NC}"
            return 0
        fi
        # Every segment is a folder name: an empty one, '.' or '..' names no folder of its own.
        if echo "$DESIRED_SPACE" | grep -q '//' || echo "$DESIRED_SPACE" | grep -Eq '(^|/)\.{1,2}(/|$)'; then
            echo -e "  ${RED}✗ Error: every segment of a space is a folder name (no empty segment, no . or ..)${NC}"
            return 0
        fi
        # No segment may be 'p': /p/<projectId>/ takes the project from the segment after the
        # FIRST 'p' in the address, so a space holding one would bind the wrong project.
        if echo "$DESIRED_SPACE" | grep -Eq '(^|/)p(/|$)'; then
            echo -e "  ${RED}✗ Error: a space cannot hold a segment named p — /p/ is where each project is served${NC}"
            return 0
        fi
        if [ -d "$PUBLIC_DIR/$DESIRED_SPACE" ]; then
            echo -e "  ${RED}✗ Error: directory '$DESIRED_SPACE' already exists${NC}"
            return 0
        fi
    fi

    local SPACE_DIR TOP_SEGMENT item BASENAME

    # Remove the current space (move files back to the public root)
    if [ -n "$PUBLIC_SPACE" ]; then
        SPACE_DIR="$PUBLIC_DIR/$PUBLIC_SPACE"
        if [ -d "$SPACE_DIR" ]; then
            for item in "$SPACE_DIR"/* "$SPACE_DIR"/.*; do
                BASENAME="$(basename "$item")"
                case "$BASENAME" in .|..) continue ;; esac
                mv "$item" "$PUBLIC_DIR/" 2>/dev/null || true
            done
            TOP_SEGMENT=$(echo "$PUBLIC_SPACE" | cut -d/ -f1)
            rm -rf "$PUBLIC_DIR/$TOP_SEGMENT" 2>/dev/null || true
        fi
    fi

    # Set the new space (move files from the root into the space dir)
    if [ -n "$DESIRED_SPACE" ]; then
        TOP_SEGMENT=$(echo "$DESIRED_SPACE" | cut -d/ -f1)
        SPACE_DIR="$PUBLIC_DIR/$DESIRED_SPACE"
        mkdir -p "$SPACE_DIR"
        for item in "$PUBLIC_DIR"/* "$PUBLIC_DIR"/.*; do
            BASENAME="$(basename "$item")"
            case "$BASENAME" in .|..) continue ;; esac
            [ "$BASENAME" = "$TOP_SEGMENT" ] && continue
            mv "$item" "$SPACE_DIR/" 2>/dev/null || true
        done
    fi

    PUBLIC_SPACE="$DESIRED_SPACE"

    local INIT_FILE; INIT_FILE="$(init_file)"
    if [ -f "$INIT_FILE" ]; then
        sed -i "s#define('PUBLIC_FOLDER_SPACE',[[:space:]]*'[^']*')#define('PUBLIC_FOLDER_SPACE', '$PUBLIC_SPACE')#" "$INIT_FILE"
    fi

    # ---- Apache: retarget every FallbackResource to the new prefix ----------
    # FallbackResource takes a URL path relative to the DocumentRoot, so each of
    # these has to carry the space or Apache looks for the entry point where it
    # no longer is. /p/ is in this list because /p/<projectId>/ is how EVERY
    # project is served (public/p/.htaccess) — miss it and the whole public
    # surface 404s on a space install while /admin/ still works, which reads
    # like a routing bug and is not one.
    local HT_DIR PREFIX
    HT_DIR="$(served_dir)"
    if [ -n "$PUBLIC_SPACE" ]; then PREFIX="/$PUBLIC_SPACE"; else PREFIX=""; fi
    [ -f "$HT_DIR/.htaccess" ]            && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/index.php|"            "$HT_DIR/.htaccess"
    [ -f "$HT_DIR/p/.htaccess" ]          && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/p/index.php|"          "$HT_DIR/p/.htaccess"
    [ -f "$HT_DIR/management/.htaccess" ] && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/management/index.php|" "$HT_DIR/management/.htaccess"
    [ -f "$HT_DIR/admin/.htaccess" ]      && sed -i "s|FallbackResource .*|FallbackResource $PREFIX/admin/index.php|"      "$HT_DIR/admin/.htaccess"

    # ---- nginx: drop the stale routing config, do NOT rewrite it here -------
    # The generator lives in ONE place — secure/src/functions/NginxConfig.php —
    # and init.php recreates the file on the next page load. This script used to
    # keep its own copy of that generator, which silently went stale: it still
    # emitted a root catch-all into an entry point that no longer exists and no
    # /p/ block at all, so re-running setup broke the routing of an install that
    # was working. Deleting is the one operation that cannot go out of date.
    local NGINX_CONF="$SECURE_DIR/nginx/dynamic_routes.conf"
    if [ -f "$NGINX_CONF" ]; then
        rm -f "$NGINX_CONF"
        echo -e "  ${GREEN}✓${NC} Removed the old nginx routing config"
        echo -e "  ${YELLOW}⚠ nginx: load any page once${NC} — that regenerates"
        echo "    $NGINX_CONF"
        echo "    for the new prefix. Reload nginx after that, not before: the"
        echo "    include points at a file that does not exist right now."
        echo ""
        # The regenerated include cannot fix the ONE block that lives in the
        # vhost instead: `location @quicksite_project` carries the URL prefix
        # inside its SCRIPT_FILENAME, and it is pasted by hand because it needs
        # a fastcgi_pass only the operator knows. Leave it stale and every
        # /p/<id>/ request posts at an entry point that is not there, which
        # php-fpm answers with a bare "File not found." while the admin panel —
        # served entirely from the regenerated include — keeps working.
        echo -e "  ${YELLOW}⚠ nginx: also update the block you pasted by hand.${NC}"
        echo "    In your server {} block, location @quicksite_project must read:"
        echo ""
        if [ -n "$PUBLIC_SPACE" ]; then
            echo -e "      ${BOLD}fastcgi_param SCRIPT_FILENAME \$document_root/$PUBLIC_SPACE/p/index.php;${NC}"
        else
            echo -e "      ${BOLD}fastcgi_param SCRIPT_FILENAME \$document_root/p/index.php;${NC}"
        fi
        echo ""
        echo "    Miss this and /p/<id>/ answers \"File not found.\" while the"
        echo "    admin panel still works — the two are served by different blocks."
    fi

    save_conf
    if [ -n "$PUBLIC_SPACE" ]; then
        echo -e "  ${GREEN}✓${NC} Space set → http://your-domain/${BOLD}$PUBLIC_SPACE${NC}/"
    else
        echo -e "  ${GREEN}✓${NC} Space removed — serving from the root"
    fi
}

# ==========================================================
# Item 4 — environment (production / development)
# ==========================================================
item_environment() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/environment.php"
    local example="$cfg_dir/environment.php.example"

    echo ""
    echo -e "${BOLD}Environment${NC}"
    echo ""
    echo "  QuickSite fetches URLs from the server for you — data resolvers,"
    echo "  API endpoint tests, OAuth back-channels, importing an asset by URL."
    echo ""
    echo -e "  ${BOLD}production${NC}  those fetches may NOT reach loopback, private or"
    echo "              cloud-metadata addresses. This is what a public or"
    echo "              shared deployment must run as."
    echo -e "  ${BOLD}development${NC} lifts the internal-address block, so a local author"
    echo "              can call http://localhost:3000 or a LAN API. The"
    echo "              http/https scheme rule and DNS pinning stay on."
    echo ""
    echo -e "  Currently: $(env_label)"
    echo ""
    echo -e "  ${DIM}Only ever choose development on a machine you fully trust and${NC}"
    echo -e "  ${DIM}that untrusted callers cannot reach.${NC}"
    echo ""

    # Both answers are SPELLED OUT. A bare [y/N] makes the reader infer what N
    # means, and here it is not "no" — it is the other named mode. Getting that
    # backwards on a public server is the one mistake this item can cause.
    local ANSWER
    read -r -p "  Is this a local/dev install?  y = development, Enter = production: " ANSWER || true

    local VALUE="production"
    case "${ANSWER:-}" in
        [yY]|[yY][eE][sS]) VALUE="development" ;;
    esac

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    # Start from the .example so the live file keeps the full explanation of
    # what the setting does — the operator edits this file by hand later, and a
    # bare two-line array tells them nothing.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            printf "<?php\n\nreturn [\n    'environment' => 'production',\n];\n" > "$live"
        fi
    fi

    # '#' as the delimiter, not '|': BRE alternation is written \| and sed reads
    # a backslash-escaped delimiter as a LITERAL, so a '|'-delimited s/// that
    # contains \| silently matches nothing. That is how the sibling
    # self-registration edit failed its first test run.
    sed -i "s#'environment'[[:space:]]*=>[[:space:]]*'[^']*'#'environment' => '$VALUE'#" "$live"

    if [ "$(env_current)" = "$VALUE" ]; then
        echo -e "  ${GREEN}✓${NC} Environment: ${BOLD}$VALUE${NC}"
        if [ "$VALUE" = "development" ]; then
            echo -e "  ${YELLOW}⚠ Do not run a publicly reachable install this way.${NC}"
        fi
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'environment' => '$VALUE':"
        echo "    $live"
    fi

    echo ""
    echo -e "  ${DIM}This is a server-side file, on purpose: there is no API that${NC}"
    echo -e "  ${DIM}changes it, so a leaked credential cannot switch the install${NC}"
    echo -e "  ${DIM}into development and re-open outbound access.${NC}"
}

# ==========================================================
# Item 5 — self-registration
# ==========================================================
item_selfreg() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/auth.php"
    local example="$cfg_dir/auth.php.example"

    echo ""
    echo -e "${BOLD}Self-registration${NC}"
    echo ""
    echo "  May visitors create their own accounts at /admin/register?"
    echo ""
    echo "  off  accounts are created by you, from the panel. This is the"
    echo "       default, and what a private install wants."
    echo "  on   anyone who can reach /admin/register can make an account,"
    echo "       subject to the per-IP and per-hour limits in auth.php."
    echo ""
    echo -e "  Currently: $(selfreg_label)"
    echo ""
    echo -e "  ${DIM}Either way, the FIRST account is created from the first-run page${NC}"
    echo -e "  ${DIM}using the setup token — self-registration never bootstraps an${NC}"
    echo -e "  ${DIM}install.${NC}"
    echo ""

    local ANSWER
    read -r -p "  Allow visitors to create their own accounts? [y/N]: " ANSWER || true

    local VALUE="false"
    case "${ANSWER:-}" in
        [yY]|[yY][eE][sS]) VALUE="true" ;;
    esac

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    # auth.php does not exist yet on a fresh clone — the engine creates it from
    # the .example on the first page load. Creating it here is the same copy,
    # done earlier; the engine's copy is create-if-absent and leaves ours alone.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            echo -e "  ${RED}✗ Error: neither auth.php nor auth.php.example is present${NC}"
            echo "    $cfg_dir"
            return 0
        fi
    fi

    sed -i "s#'allow_self_registration'[[:space:]]*=>[[:space:]]*[a-zA-Z]*#'allow_self_registration' => $VALUE#" "$live"

    if [ "$(selfreg_current)" = "$VALUE" ]; then
        if [ "$VALUE" = "true" ]; then
            echo -e "  ${GREEN}✓${NC} Self-registration: ${BOLD}on${NC}"
            echo -e "  ${YELLOW}⚠ /admin/register is now open to anyone who can reach it.${NC}"
        else
            echo -e "  ${GREEN}✓${NC} Self-registration: ${BOLD}off${NC}"
        fi
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'allow_self_registration' => $VALUE:"
        echo "    $live"
    fi
}

# ==========================================================
# Item 6 — show my setup token
# ==========================================================
item_token() {
    local token_file; token_file="$(CONFIG_DIR)/setup-token.txt"

    echo ""
    echo -e "${BOLD}Setup token${NC}"
    echo ""
    echo "  QuickSite ships no default password. The first account is created"
    echo "  in the browser, and what proves you are the person who installed"
    echo "  this — rather than a passer-by who found the address — is being"
    echo "  able to read a file on the server. That file is the token."
    echo ""

    if [ ! -f "$token_file" ]; then
        # THE ORDERING THAT SURPRISES PEOPLE. The token is minted by the engine
        # when the first-run page renders, not by this script. On a fresh clone
        # it genuinely does not exist yet, and printing an empty value here
        # would look like a broken install.
        echo -e "  ${YELLOW}The token has not been created yet.${NC}"
        echo ""
        echo "  It is written the first time the admin panel is loaded. Do this,"
        echo "  in this order:"
        echo ""
        echo -e "    1. Point your vhost DocumentRoot at ${BOLD}$PUBLIC_FOLDER_NAME/${NC} and"
        echo "       restart your web server."
        if [ -n "$PUBLIC_SPACE" ]; then
            echo -e "    2. Open ${BOLD}http://your-domain/$PUBLIC_SPACE/admin/${NC} in a browser."
        else
            echo -e "    2. Open ${BOLD}http://your-domain/admin/${NC} in a browser."
        fi
        echo "       Leave the page open — it is the form you will fill in."
        echo "    3. Come back here and choose this item again."
        echo ""
        echo "  Expected location:"
        echo "    $token_file"
        return 0
    fi

    # ⚠ EXISTS BUT UNREADABLE IS NOT "ALREADY USED". The engine mints this file
    # as the WEB SERVER's user and chmods it 0600 (setupToken.php), so on a real
    # Linux host it belongs to php-fpm rather than to whoever runs this script.
    # `-f` above still succeeds — that needs only search permission on the
    # directory — and the `cat` below then fails silently behind its `|| true`,
    # leaving an empty value that fails the shape test and lands in the
    # "already been used" branch. A live token was reported as spent, with
    # nothing pointing at permissions. Windows never showed it: chmod is a no-op
    # there, which is why this only appears on a deployed install.
    if [ ! -r "$token_file" ]; then
        echo -e "  ${YELLOW}The token exists, but this account cannot read it.${NC}"
        echo ""
        echo "  It is created by the web server and is owner-only, so it belongs"
        echo "  to the PHP-FPM user and not to you:"
        echo ""
        echo "    $(ls -l "$token_file" 2>/dev/null || echo "$token_file")"
        echo "    you are $(id -un 2>/dev/null || echo 'this shell user')"
        echo ""
        echo "  Read it as that user, or with elevated rights:"
        echo ""
        echo -e "    ${BOLD}sudo cat $token_file${NC}"
        echo ""
        echo -e "  ${DIM}This is not a broken install — the token is live and the${NC}"
        echo -e "  ${DIM}first-run form will accept it.${NC}"
        return 0
    fi

    local RAW TOKEN
    RAW="$(cat "$token_file" 2>/dev/null || true)"
    TOKEN="$(echo "$RAW" | tr -d '[:space:]')"

    if echo "$TOKEN" | grep -qE '^[0-9a-f]{64}$'; then
        echo -e "  ${GREEN}Your setup token:${NC}"
        echo ""
        echo -e "    ${BOLD}$TOKEN${NC}"
        echo ""
        echo "  Paste it into the first-run form at /admin/. It is used once and"
        echo "  destroyed the moment your account is created."
    else
        # Consumed: qs_setup_token_consume() overwrites the value with a marker
        # before unlinking, so anything here that is not token-shaped means the
        # bootstrap is over.
        echo -e "  ${YELLOW}This token has already been used.${NC}"
        echo ""
        echo "  An account exists on this installation, so the first-run page is"
        echo "  gone for good and the token grants nothing. Sign in at /admin/"
        echo "  instead."
        echo ""
        echo -e "  ${DIM}Lost the password? There is no reset from here. Delete${NC}"
        echo -e "  ${DIM}$(CONFIG_DIR)/users.php${NC}"
        echo -e "  ${DIM}to start the bootstrap over — every account goes with it.${NC}"
    fi
}

# ==========================================================
# Item 7 — storage quotas
# ==========================================================
# ⚠ ABSENT MEANS THE DEFAULTS, AND THAT IS A REAL SETTING, NOT A MISSING ONE: no
# storage ceiling and no upload rate, with the size caps and the write rate at the
# values the engine ships. A fresh install has no quota.php and must not refuse
# uploads out of the box, so declining every prompt here LEAVES THE FILE ABSENT
# rather than writing zeros. The two are equivalent to the engine today, but writing
# a file for "no ceiling" would turn an install that opted out into one that opted in
# to nothing, and the next person to read the folder could not tell them apart.
item_quota() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/quota.php"
    local example="$cfg_dir/quota.php.example"

    echo ""
    echo -e "${BOLD}Storage quotas${NC}"
    echo ""
    echo "  Limits applied per ACCOUNT, on two independent axes. Any signed-in"
    echo "  user can otherwise upload until the disk is full."
    echo ""
    echo "    total bytes   the combined size of everything the account owns."
    echo "                  Refused with 507 before the write, not after."
    echo "    upload rate   how many uploads it may perform per period. Bounds"
    echo "                  churn (upload, delete, repeat) rather than volume."
    echo ""
    echo -e "  Currently: $(quota_label)"
    echo ""
    echo -e "  ${DIM}Leave both blank for no storage ceiling and no upload rate; the size${NC}"
    echo -e "  ${DIM}caps and the write rate still apply. Full documentation of every${NC}"
    echo -e "  ${DIM}option lives in quota.php.example, which is never modified.${NC}"
    echo ""

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    local MB UPLOADS HOURS
    read -r -p "  Max total size per user, in MB [blank = unlimited]: " MB || true
    read -r -p "  Max uploads per period       [blank = unlimited]: " UPLOADS || true

    # Non-numeric input is refused rather than silently coerced: "2GB" would
    # otherwise become 0, i.e. unlimited, which is the opposite of the intent.
    case "${MB:-}" in
        "") ;;
        *[!0-9]*) echo -e "  ${RED}✗ Not a whole number of MB: $MB${NC}"; return 0 ;;
    esac
    case "${UPLOADS:-}" in
        "") ;;
        *[!0-9]*) echo -e "  ${RED}✗ Not a whole number: $UPLOADS${NC}"; return 0 ;;
    esac

    HOURS=""
    if [ -n "$UPLOADS" ] && [ "$UPLOADS" != "0" ]; then
        read -r -p "  ...per how many hours [1]: " HOURS || true
        [ -z "$HOURS" ] && HOURS=1
        case "$HOURS" in
            *[!0-9]*|0) echo -e "  ${RED}✗ Not a whole number of hours: $HOURS${NC}"; return 0 ;;
        esac
    fi

    local BYTES=0 MAXUP=0 PERIOD=3600
    [ -n "$MB" ]      && BYTES=$(( MB * 1024 * 1024 ))
    [ -n "$UPLOADS" ] && MAXUP="$UPLOADS"
    [ -n "$HOURS" ]   && PERIOD=$(( HOURS * 3600 ))

    # Both axes unlimited — do NOT write a file to say so.
    if [ "$BYTES" = "0" ] && [ "$MAXUP" = "0" ]; then
        if [ ! -f "$live" ]; then
            echo ""
            echo -e "  ${GREEN}✓${NC} No storage ceiling or upload rate — quota.php left absent, which"
            echo "    is what a fresh install has. The size caps and the write rate apply at"
            echo "    their defaults."
            return 0
        fi
        echo ""
        echo -e "  ${DIM}quota.php already exists. To go back to the defaults, delete it:${NC}"
        echo -e "  ${DIM}$live${NC}"
        echo -e "  ${DIM}Setup does not delete a config file you wrote.${NC}"
        return 0
    fi

    # quota.php.example is the shipped documentation and is never edited: the
    # live file is a COPY with the numbers patched, so every explanatory comment
    # survives into the file the deployer will actually read later.
    if [ ! -f "$live" ]; then
        if [ ! -f "$example" ]; then
            echo -e "  ${RED}✗ Error: quota.php.example is missing${NC}"
            return 0
        fi
        cp "$example" "$live"
    fi

    # Anchored to start-of-line so the COMMENTED example value above each
    # setting (those lines begin with //) is left alone.
    sed -i "s|^\( *\)'max_total_bytes'[[:space:]]*=>.*|\1'max_total_bytes' => ${BYTES},|" "$live"
    sed -i "s|^\( *\)'max_uploads'[[:space:]]*=>.*|\1'max_uploads'    => ${MAXUP},|" "$live"
    sed -i "s|^\( *\)'period_seconds'[[:space:]]*=>.*|\1'period_seconds' => ${PERIOD},|" "$live"

    echo ""
    echo -e "  ${GREEN}✓${NC} Quotas written to quota.php"
    echo -e "     $(quota_label)"
}

# ==========================================================
# Item 8 — the command console
# ==========================================================
# ⚠ ABSENT MEANS ALLOWED, so this item declines to write a file when the answer
# is ON: the file exists only to say no.
#
# ⚠ AND IT IS NOT A SECURITY CONTROL. Every command the console lists is
# already reachable at /management/ by any caller whose role allows it — the
# console runs them through the same permission check. Turning it off removes
# reach and discoverability, not access. The prompt says so, because an operator
# who reads this as "my users can no longer run commands" has been misled.
item_console() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/console.php"
    local example="$cfg_dir/console.php.example"

    echo ""
    echo -e "${BOLD}Command console${NC}"
    echo ""
    echo "  Should the admin panel offer the raw command runner at"
    echo "  /admin/command — a form for every command, and a page listing them?"
    echo ""
    echo "  on   the default. Anyone signed in sees the Commands entry in the"
    echo "       panel and can run any command their role allows."
    echo "  off  the page and its nav entry are gone. This is what a shared or"
    echo "       hosted install wants: your users author sites through the"
    echo "       visual editor, sitemap and media pages, and have no business"
    echo "       holding a generic runner for the whole command surface."
    echo ""
    echo -e "  Currently: $(console_label)"
    echo ""
    echo -e "  ${DIM}⚠ This is NOT a security control. Every command stays reachable${NC}"
    echo -e "  ${DIM}at /management/ to any caller whose role allows it, and the${NC}"
    echo -e "  ${DIM}console checks the same permissions. Turning it off removes${NC}"
    echo -e "  ${DIM}reach and discoverability, not access — do not skip a role${NC}"
    echo -e "  ${DIM}review because of it. The command history tab stays either way.${NC}"
    echo ""

    local ANSWER
    read -r -p "  Offer the command console on this installation? [Y/n]: " ANSWER || true

    # Enter accepts the SHIPPED DEFAULT, which here is on.
    local VALUE="true"
    case "${ANSWER:-}" in
        [nN]|[nN][oO]) VALUE="false" ;;
    esac

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    # Accepting when no file exists LEAVES IT ABSENT — absent already means the
    # console is offered, and writing a file to say so would turn an install
    # that never opted out into one that opted in, which reads as a decision
    # somebody made.
    if [ "$VALUE" = "true" ] && [ ! -f "$live" ]; then
        echo ""
        echo -e "  ${GREEN}✓${NC} Console stays on — console.php left absent, which is what a"
        echo "    fresh install has."
        return 0
    fi

    # console.php.example is the shipped documentation and is never edited: the
    # live file is a COPY with the value patched, so every explanatory comment
    # survives into the file the deployer will actually read later.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            echo -e "  ${RED}✗ Error: neither console.php nor console.php.example is present${NC}"
            echo "    $cfg_dir"
            return 0
        fi
    fi

    # Anchored to start-of-line so the value quoted inside the file's own
    # comments is left alone.
    sed -i "s|^\( *\)'allow_console'[[:space:]]*=>[[:space:]]*[a-zA-Z]*|\1'allow_console' => $VALUE|" "$live"

    if [ "$(console_current)" = "$VALUE" ]; then
        if [ "$VALUE" = "false" ]; then
            echo -e "  ${GREEN}✓${NC} Command console: ${BOLD}off${NC}"
            echo -e "  ${DIM}  /admin/command no longer renders the runner and the nav entry${NC}"
            echo -e "  ${DIM}  is gone. The commands themselves are unchanged.${NC}"
        else
            echo -e "  ${GREEN}✓${NC} Command console: ${BOLD}on${NC}"
        fi
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'allow_console' => $VALUE:"
        echo "    $live"
    fi
}

# ==========================================================
# Item 9 — the default language for new projects
# ==========================================================
# The language a NEW project starts in when nobody chooses one: createProject's
# default, what the panel's New Project dialog preselects, and what an imported
# archive that lists no language gets. A project that exists never reads it.
#
# A free-text value, so Enter KEEPS what is there (items 1-3's rule), rather than
# resetting to the shipped default the way the yes/no items do.
#
# ABSENT MEANS 'en', and choosing 'en' when no file exists LEAVES IT ABSENT —
# item 8's rule: a file that says what the default already is would read as a
# decision somebody made. A code that is not in the language list is refused
# here and nothing is written: the engine would ignore it (and log why) anyway.
item_deflang() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/default-language.php"
    local example="$cfg_dir/default-language.php.example"

    echo ""
    echo -e "${BOLD}Default language for new projects${NC}"
    echo ""
    echo "  The language a new project starts in when nobody chooses one. The"
    echo "  admin panel's New Project dialog preselects it, and an imported"
    echo "  project whose archive lists no language gets it. Projects that"
    echo "  already exist keep their languages."
    echo ""
    echo -e "  Currently: $(deflang_label)"
    echo ""
    echo "  Type a code from this installation's language list, lowercase as the"
    echo "  list writes it: en, fr, es, de, pt, zh, ja, fil… The list is"
    echo "    $(language_list_file)"
    echo ""

    local ANSWER
    read -r -p "  Language code (Enter keeps $(deflang_effective)): " ANSWER || true
    local VALUE="${ANSWER:-}"
    VALUE="${VALUE//[[:space:]]/}"

    if [ -z "$VALUE" ]; then
        echo -e "  ${DIM}Unchanged.${NC}"
        return 0
    fi

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    if ! deflang_listed "$VALUE"; then
        echo -e "  ${RED}✗ '$VALUE' is not a code in this installation's language list.${NC}"
        echo "    Nothing was written."
        return 0
    fi

    if [ "$VALUE" = "en" ] && [ ! -f "$live" ]; then
        echo ""
        echo -e "  ${GREEN}✓${NC} Default language stays en — default-language.php left absent,"
        echo "    which is what a fresh install has."
        return 0
    fi

    # default-language.php.example is the shipped documentation and is never
    # edited: the live file is a COPY with the value patched, so every
    # explanatory comment survives into the file the operator will read later.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            echo -e "  ${RED}✗ Error: neither default-language.php nor default-language.php.example is present${NC}"
            echo "    $cfg_dir"
            return 0
        fi
    fi

    # Anchored to start-of-line so the value quoted inside the file's own
    # comments is left alone. '#' as the delimiter — item 4 says why.
    sed -i "s#^\( *\)'default_language'[[:space:]]*=>[[:space:]]*'[^']*',#\1'default_language' => '$VALUE',#" "$live"

    if [ "$(deflang_current)" = "$VALUE" ]; then
        echo -e "  ${GREEN}✓${NC} Default language for new projects: ${BOLD}$VALUE${NC}"
        echo -e "  ${DIM}  Only projects created from now on start in it.${NC}"
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'default_language' => '$VALUE':"
        echo "    $live"
    fi
}

# ==========================================================
# Item 10 — idle sign-out
# ==========================================================
# How long a session may go unused before the panel signs it out: auth.php's
# session.idle_ttl, in seconds. The login page states it under "Keep me signed
# in". A free-text value, so Enter KEEPS what is there (item 9's rule). The
# answer is a number and m, h or d, at least 5 minutes — the engine's own
# minimum — and written as seconds.
#
# ABSENT MEANS 1 DAY, and choosing 1 day when auth.php does not exist yet LEAVES
# IT ABSENT (item 9's rule). Otherwise auth.php is created from its .example as
# item 5 creates it, and only the number on the idle_ttl line changes.
item_idle() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/auth.php"
    local example="$cfg_dir/auth.php.example"

    echo ""
    echo -e "${BOLD}Idle sign-out${NC}"
    echo ""
    echo "  How long the admin panel may go unused before it signs you out. Ticking"
    echo "  \"Keep me signed in\" keeps a session across browser restarts, never past"
    echo "  this limit; the login page states it under that box."
    echo ""
    echo -e "  Currently: $(idle_label)"
    echo ""

    local ANSWER
    read -r -p "  Sign out after how long without using the panel? A number and m, h or d (Enter keeps $(idle_words "$(idle_effective)")): " ANSWER || true
    local VALUE="${ANSWER:-}"
    VALUE="${VALUE//[[:space:]]/}"
    VALUE="$(printf '%s' "$VALUE" | tr '[:upper:]' '[:lower:]')"

    if [ -z "$VALUE" ]; then
        echo -e "  ${DIM}Unchanged.${NC}"
        return 0
    fi

    if [[ ! "$VALUE" =~ ^([0-9]{1,4})([mhd])$ ]]; then
        echo -e "  ${RED}✗ '$ANSWER' is not a number followed by m, h or d.${NC}"
        echo "    Nothing was written."
        return 0
    fi
    # 10#: a leading zero is a decimal digit here, not octal
    local NUM=$((10#${BASH_REMATCH[1]})) MULT=60
    case "${BASH_REMATCH[2]}" in
        h) MULT=3600 ;;
        d) MULT=86400 ;;
    esac
    local SECS=$((NUM * MULT))

    if [ "$SECS" -lt 300 ]; then
        echo -e "  ${RED}✗ The shortest idle sign-out is 5 minutes.${NC}"
        echo "    Nothing was written."
        return 0
    fi

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    if [ "$SECS" -eq 86400 ] && [ ! -f "$live" ]; then
        echo ""
        echo -e "  ${GREEN}✓${NC} Idle sign-out stays 1 day — auth.php left absent, which is what a"
        echo "    fresh install has."
        return 0
    fi

    # auth.php does not exist yet on a fresh clone — item 5 says why creating it
    # here is safe.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            echo -e "  ${RED}✗ Error: neither auth.php nor auth.php.example is present${NC}"
            echo "    $cfg_dir"
            return 0
        fi
    fi

    # Anchored to start-of-line, and only the number changes: the key's spacing and
    # the comment after it stay. '#' as the delimiter — item 4 says why.
    sed -i "s#^\([[:space:]]*'idle_ttl'[[:space:]]*=>[[:space:]]*\)[0-9][0-9]*#\1$SECS#" "$live"

    if [ "$(idle_current)" = "$SECS" ]; then
        echo -e "  ${GREEN}✓${NC} Idle sign-out: ${BOLD}$(idle_words "$SECS")${NC}"
        echo -e "  ${DIM}  Every session follows it from its next request.${NC}"
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'idle_ttl' => $SECS:"
        echo "    $live"
    fi
}

# ==========================================================
# Item 11 — the Embed Security page
# ==========================================================
# The page shows the installation's embed policy (embed-policy.json), read-only:
# no author can change it from the panel, so a deployer may decide nobody needs
# to see it. ABSENT MEANS SHOWN, so this item declines to write a file when the
# answer is "show": the file exists only to say no.
#
# ⚠ AND IT IS NOT A SECURITY CONTROL. Hiding the page removes a page, not a rule:
# the policy stays in force, and every command stays reachable at /management/
# to any caller whose role allows it.
item_embed() {
    local cfg_dir; cfg_dir="$(CONFIG_DIR)"
    local live="$cfg_dir/embed-security.php"
    local example="$cfg_dir/embed-security.php.example"

    echo ""
    echo -e "${BOLD}Embed Security page${NC}"
    echo ""
    echo "  Should the admin panel show the Embed Security page at"
    echo "  /admin/embed-security — a read-only view of the embed policy, the"
    echo "  sandbox the installation puts around iframes embedded in a page?"
    echo ""
    echo "  show  the default. The page is in the panel's Settings menu, for"
    echo "        the advanced mode."
    echo "  hide  the page leaves the menu and says it was turned off. Nobody"
    echo "        can change the policy from the panel anyway: you set it in"
    echo "        embed-policy.json."
    echo ""
    echo -e "  Currently: $(embed_label)"
    echo ""
    echo -e "  ${DIM}⚠ This is NOT a security control. The embed policy stays in${NC}"
    echo -e "  ${DIM}force either way, and every command stays reachable at${NC}"
    echo -e "  ${DIM}/management/ to any caller whose role allows it.${NC}"
    echo ""

    local ANSWER
    read -r -p "  Show the Embed Security page on this installation? [Y/n]: " ANSWER || true

    # Enter accepts the SHIPPED DEFAULT, which here is show.
    local VALUE="true"
    case "${ANSWER:-}" in
        [nN]|[nN][oO]) VALUE="false" ;;
    esac

    if [ ! -d "$cfg_dir" ]; then
        echo -e "  ${RED}✗ Error: config folder not found:${NC} $cfg_dir"
        return 0
    fi

    # Accepting when no file exists LEAVES IT ABSENT, as item 8 does: absent
    # already means shown.
    if [ "$VALUE" = "true" ] && [ ! -f "$live" ]; then
        echo ""
        echo -e "  ${GREEN}✓${NC} Embed Security stays shown — embed-security.php left absent,"
        echo "    which is what a fresh install has."
        return 0
    fi

    # The .example is the shipped documentation and is never edited: the live
    # file is a COPY with the value patched.
    if [ ! -f "$live" ]; then
        if [ -f "$example" ]; then
            cp "$example" "$live"
        else
            echo -e "  ${RED}✗ Error: neither embed-security.php nor embed-security.php.example is present${NC}"
            echo "    $cfg_dir"
            return 0
        fi
    fi

    # Anchored to start-of-line so the value quoted inside the file's own
    # comments is left alone.
    sed -i "s|^\( *\)'show_embed_security'[[:space:]]*=>[[:space:]]*[a-zA-Z]*|\1'show_embed_security' => $VALUE|" "$live"

    if [ "$(embed_current)" = "$VALUE" ]; then
        if [ "$VALUE" = "false" ]; then
            echo -e "  ${GREEN}✓${NC} Embed Security page: ${BOLD}hidden${NC}"
            echo -e "  ${DIM}  It is no longer in the menu, and says it was turned off. The${NC}"
            echo -e "  ${DIM}  embed policy is unchanged.${NC}"
        else
            echo -e "  ${GREEN}✓${NC} Embed Security page: ${BOLD}shown${NC}"
        fi
    else
        echo -e "  ${RED}✗ Could not set the value automatically.${NC}"
        echo "    Edit this file by hand and set 'show_embed_security' => $VALUE:"
        echo "    $live"
    fi
}

# ==========================================================
# The menu
# ==========================================================
show_header() {
    echo ""
    echo -e "${BOLD}========================================${NC}"
    echo -e "${BOLD}       QuickSite Setup${NC}"
    echo -e "${BOLD}========================================${NC}"
}

show_menu() {
    echo ""
    echo -e "  Public folder:      ${BOLD}$PUBLIC_FOLDER_NAME${NC}"
    echo -e "  Secure folder:      ${BOLD}$SECURE_FOLDER_NAME${NC}"
    echo -e "  URL space:          $(space_label)"
    echo -e "  Environment:        $(env_label)"
    echo -e "  Self-registration:  $(selfreg_label)"
    echo -e "  Storage quotas:     $(quota_label)"
    echo -e "  Command console:    $(console_label)"
    echo -e "  Default language:   $(deflang_label)"
    echo -e "  Idle sign-out:      $(idle_label)"
    echo -e "  Embed security:     $(embed_label)"
    echo ""
    echo "    1) Rename the public folder"
    echo "    2) Rename the secure folder"
    echo "    3) Change the URL space / prefix"
    echo "    4) Switch environment (development / production)"
    echo "    5) Turn self-registration on / off"
    echo "    6) Show my setup token"
    echo "    7) Storage quotas (per user)"
    echo "    8) Offer / withhold the command console (/admin/command)"
    echo "    9) Default language for new projects"
    echo "   10) Idle sign-out (how long the panel may go unused)"
    echo "   11) Show / hide the Embed Security page (/admin/embed-security)"
    echo "    q) Finish"
    echo ""
}

show_header
maybe_place_landing_page
ensure_embed_policy
save_conf

# A public folder name given on the command line pre-answers item 1.
if [ -n "${1:-}" ]; then
    item_public "$1"
fi

while true; do
    show_menu
    CHOICE=""
    read -r -p "  Choice: " CHOICE || { echo ""; break; }
    case "${CHOICE:-}" in
        1) item_public; pause_for_menu ;;
        2) item_secure; pause_for_menu ;;
        3) item_space; pause_for_menu ;;
        4) item_environment; pause_for_menu ;;
        5) item_selfreg; pause_for_menu ;;
        6) item_token; pause_for_menu ;;
        7) item_quota; pause_for_menu ;;
        8) item_console; pause_for_menu ;;
        9) item_deflang; pause_for_menu ;;
        10) item_idle; pause_for_menu ;;
        11) item_embed; pause_for_menu ;;
        q|Q|"") break ;;
        *) echo -e "  ${RED}Unknown choice: $CHOICE${NC}" ;;
    esac
done

save_conf

echo ""
echo -e "${BOLD}========================================${NC}"
echo -e "${GREEN}${BOLD}  Setup complete${NC}"
echo -e "${BOLD}========================================${NC}"
echo ""
echo "  Public folder:      $PUBLIC_FOLDER_NAME"
echo "  Secure folder:      $SECURE_FOLDER_NAME"
if [ -n "$PUBLIC_SPACE" ]; then
    echo "  URL space:          $PUBLIC_SPACE"
fi
echo -e "  Environment:        $(env_label)"
echo -e "  Self-registration:  $(selfreg_label)"
echo -e "  Storage quotas:     $(quota_label)"
echo -e "  Command console:    $(console_label)"
echo -e "  Default language:   $(deflang_label)"
echo -e "  Idle sign-out:      $(idle_label)"
echo -e "  Embed security:     $(embed_label)"
echo ""

if [ -n "$OWNERSHIP_WARNING" ]; then
    echo -e "  ${RED}${BOLD}⚠ IMPORTANT: file ownership${NC}"
    echo -e "  ${RED}You are running as root. PHP needs write access to $SECURE_FOLDER_NAME/.${NC}"
    echo -e "  ${RED}Run this (replace USER with your web server / site user):${NC}"
    echo ""
    echo -e "    ${BOLD}chown -R USER:USER $(basename "$SCRIPT_DIR")/${NC}"
    echo ""
    echo -e "  ${RED}Common users: www-data (Ubuntu), nginx, apache,${NC}"
    echo -e "  ${RED}or your CloudPanel site user (e.g. quicksite-test)${NC}"
    echo ""
fi

echo "  Next steps:"
echo "    1. Point your vhost DocumentRoot at the public folder"
echo "    2. Restart your web server"
echo "    3. Load http://your-domain/admin/ once — this creates the config"
echo "       files and mints your setup token"
echo "    4. On nginx: the page that appears tells you which file to include"
echo "       in your server block; add it and reload nginx. On Apache: nothing"
echo "       to do."
echo "    5. Run ./setup.sh again and choose item 6 to read your setup token,"
echo "       then create your first account on the /admin/ page."
echo ""
echo "  That first account becomes the owner of the starter project, so the"
echo "  panel has something to edit the moment you sign in."
echo ""