#!/bin/sh # ───────────────────────────────────────────────────────────────────────────── # Semitexa Ultimate — one-liner installer # # Usage: # curl -fsSL https://semitexa.com/install.sh | bash # curl -fsSL https://semitexa.com/install.sh | bash -s my-project # curl -fsSL https://semitexa.com/install.sh | bash -s my-project --start # curl -fsSL https://semitexa.com/install.sh | bash -s my-project --local-domain # # Options: # Project directory name (prompted if omitted) # --start Auto-start server after install (non-interactive) # --local-domain Also register .test as a local domain (opt-in). This # starts the shared router containers on host port 80 and, # with sudo, changes system DNS (systemd-resolved / # /etc/resolv.conf) or /etc/hosts. Never done without it in a # non-interactive run; the interactive prompt defaults to No. # # Requirements: Docker with Compose v2 (no PHP, no Composer on host) # Compatible: macOS, Linux, Windows (Git Bash / WSL) # Architecture: AMD64, ARM64 # # ── HOW TO USE SAFELY ──────────────────────────────────────────────────────── # # BEFORE RUNNING — verify these from the directory where you want the project: # docker info # Docker daemon must be running # docker compose version # Must show v2 (not docker-compose v1) # # RECOMMENDED ONE-LINER (avoids TTY / CI hang issues): # curl -fsSL https://semitexa.com/install.sh | bash -s my-project # # NON-INTERACTIVE / CI (skips all prompts, starts server immediately): # curl -fsSL https://semitexa.com/install.sh | bash -s my-project --start # # WINDOWS: # Prefer WSL2 over Git Bash — Docker socket and id -u/id -g behave # correctly in WSL2. In Git Bash: sh install.sh my-project --start # Line endings must be LF not CRLF, or sh will fail with obscure parse errors. # # PITFALLS: # - Run from the PARENT directory of where you want the project created. # - Edit .env only if you need local overrides before start. # - A directory named PROJECT_NAME must not already exist. # - SWOOLE_PORT (default 9502) must be free. Check: lsof -i :9502 # - On Docker rootless setups, ensure your user can run 'docker info'. # - If the install is interrupted (Ctrl+C, network drop), the partial # directory is automatically removed so you can re-run without errors. # # ───────────────────────────────────────────────────────────────────────────── set -e # set -e aborts on any non-zero exit. A partial install is worse than a clean # failure. The last printed step number tells you exactly where it stopped. INSTALLER_IMAGE="${SEMITEXA_INSTALLER_IMAGE:-semitexa/installer}" # Host-side installer delegates project scaffolding to the Semitexa installer # image. The generated project then performs dependency bootstrap in its own # `setup` container on first `docker compose up -d`. # ── Colour helpers ─────────────────────────────────────────────────────────── if [ -t 1 ] && command -v tput >/dev/null 2>&1; then C_RESET="$(tput sgr0 2>/dev/null || true)" C_GREEN="$(tput setaf 2 2>/dev/null || true)" C_YELLOW="$(tput setaf 3 2>/dev/null || true)" C_RED="$(tput setaf 1 2>/dev/null || true)" C_CYAN="$(tput setaf 6 2>/dev/null || true)" C_BOLD="$(tput bold 2>/dev/null || true)" else # Colour is automatically suppressed in pipes and non-TTY contexts # (CI logs, cron, redirected output). No flags needed. C_RESET="" C_GREEN="" C_YELLOW="" C_RED="" C_CYAN="" C_BOLD="" fi # tty_available: returns true if /dev/tty can actually be opened for reading. # [ -e /dev/tty ] is NOT sufficient — the file exists even when no controlling # terminal is attached (e.g. CI containers, tool-spawned shells), but any # attempt to read from it then fails with "No such device or address". # We test by actually opening the fd in a subshell; the || true keeps set -e # from aborting the caller. tty_available() { [ -t 0 ] || ( exec 0/dev/null; } info() { printf "%s →%s %s\n" "$C_CYAN" "$C_RESET" "$*"; } success() { printf "%s ✓%s %s\n" "$C_GREEN" "$C_RESET" "$*"; } warn() { printf "%s ⚠%s %s\n" "$C_YELLOW" "$C_RESET" "$*" >&2; } error() { printf "%s ✗%s %s\n" "$C_RED" "$C_RESET" "$*" >&2; } step() { printf "\n%s%s %s %s\n\n" "$C_BOLD" "$C_CYAN" "$*" "$C_RESET"; } banner() { printf "\n" printf "%s╔══════════════════════════════════════════════════════════════╗%s\n" "$C_CYAN$C_BOLD" "$C_RESET" printf "%s║ Semitexa Ultimate — Project Installer ║%s\n" "$C_CYAN$C_BOLD" "$C_RESET" printf "%s║ https://semitexa.com ║%s\n" "$C_CYAN$C_BOLD" "$C_RESET" printf "%s╚══════════════════════════════════════════════════════════════╝%s\n" "$C_CYAN$C_BOLD" "$C_RESET" printf "\n" } # ── Cleanup on failure ─────────────────────────────────────────────────────── # _CLEANUP_PROJECT is armed just before we start writing files (step 3). # It is disarmed once the install is confirmed complete (after step 5). # On any premature exit — set -e abort, Ctrl+C, SIGTERM — the partial # project directory is removed so the user can re-run without hitting the # "directory already exists" guard in validate_project_name. _CLEANUP_PROJECT="" _on_exit() { _p="${_CLEANUP_PROJECT:-}" _CLEANUP_PROJECT="" # idempotent — prevents double-run if both signal + EXIT fire if [ -n "$_p" ] && [ -d "$_p" ]; then printf "\n" >&2 warn "Install did not complete — removing partial directory: ./${_p}" # Docker containers can create root-owned files inside the project dir. # Plain rm -rf fails on those. # Fall back to a throwaway Alpine container that has root on the volume. if ! rm -rf "$_p" 2>/dev/null; then if command -v docker >/dev/null 2>&1; then docker run --rm -v "$(pwd):/work" alpine \ rm -rf "/work/${_p}" 2>/dev/null \ || warn "Could not remove '${_p}'. Run: sudo rm -rf ./${_p}" else warn "Could not remove '${_p}' (permission denied). Run: sudo rm -rf ./${_p}" fi fi fi } # EXIT fires on: normal exit, set -e abort, and explicit exit N calls. # INT/TERM are trapped separately with explicit exit codes so EXIT fires too, # even on shells that do not propagate signal exits through the EXIT trap. trap '_on_exit' EXIT trap '_on_exit; exit 130' INT trap '_on_exit; exit 143' TERM # ── Argument parsing ───────────────────────────────────────────────────────── # Only two inputs matter: PROJECT_NAME and --start. # Unknown --flags warn but do NOT abort (non-fatal) — check for typos like # --Start or --autostart if --start seems to have no effect. PROJECT_NAME="" AUTO_START=0 # SKIP_START is mutated by check_port_conflicts(). It MUST be declared here # (not inside main) so check_port_conflicts can write to it and main can read # the updated value. Calling check_port_conflicts in a subshell ($(...)) would # isolate variable writes, causing SKIP_START to always remain 0. SKIP_START=0 # LOCAL_DOMAIN is set by ask_local_domain() and read by print_next_steps(). # Same scope requirement as SKIP_START — must live outside both functions. LOCAL_DOMAIN="" # WANT_LOCAL_DOMAIN is 1 only when --local-domain was passed: the explicit # consent to touch system DNS / /etc/hosts and host port 80. Without it a # non-interactive run never registers a local domain, and the interactive # prompt defaults to No. WANT_LOCAL_DOMAIN=0 # APP_REGISTERED is set to 1 by register_local_app() once the project has been # entered into the shared local app registry (~/.semitexa/router/registry/apps) # with a broker-allocated Swoole port written to .env. main() uses it to skip # the legacy linear-scan check_port_conflicts: when registration succeeds, the # port in .env is already broker-canonical and a second scan can only pick a # port outside the broker range — corrupting cross-app uniqueness. APP_REGISTERED=0 for _arg in "$@"; do case "$_arg" in --start) AUTO_START=1 ;; --local-domain) WANT_LOCAL_DOMAIN=1 ;; --*) warn "Unknown option: $_arg (known: --start, --local-domain)" ;; *) [ -z "$PROJECT_NAME" ] && PROJECT_NAME="$_arg" ;; esac done # ── Prerequisites ──────────────────────────────────────────────────────────── check_docker() { # PITFALL: Docker installed but daemon not running gives a misleading # "permission denied" on rootless Linux setups. # Fix: systemctl start docker (or start Docker Desktop on mac/Windows). if ! command -v docker >/dev/null 2>&1; then error "Docker is not installed." printf "\n Install Docker Desktop: %shttps://docs.docker.com/get-docker/%s\n\n" "$C_CYAN" "$C_RESET" exit 1 fi if ! docker info >/dev/null 2>&1; then _derr="$(docker info 2>&1 || true)" case "$_derr" in *"permission denied"*|*"Got permission denied"*|*"connect: permission denied"*) error "Docker permission denied." printf " Add your user to the 'docker' group:\n" printf " sudo usermod -aG docker \$(whoami)\n" printf " Then log out and back in, and re-run the installer.\n" ;; *) error "Docker daemon is not running. Start Docker and try again." ;; esac exit 1 fi # Compose v2: `docker compose` (plugin) — required. # PITFALL: `docker-compose` (v1, standalone binary) will NOT satisfy this. # If you have both installed, upgrade: https://docs.docker.com/compose/migrate/ if ! docker compose version >/dev/null 2>&1; then error "Docker Compose v2 is required ('docker compose' not found)." printf "\n Upgrade to Docker Desktop 3.6+ or install the Compose plugin:\n" printf " %shttps://docs.docker.com/compose/install/%s\n\n" "$C_CYAN" "$C_RESET" exit 1 fi success "Docker $(docker --version | awk '{print $3}' | tr -d ',')" \ "/ Compose $(docker compose version --short 2>/dev/null || echo 'v2')" } # ── Docker permission check (Linux only) ───────────────────────────────────── # Docker Desktop on macOS/Windows manages socket access automatically. # On Linux, /var/run/docker.sock is owned by root:docker. The user must be in # the 'docker' group — otherwise every docker call requires sudo, and container # volumes produce root-owned files that break every subsequent step. # # Called with the script arguments ("$@") so that check_docker_permissions can # re-exec this script via 'sg docker' after adding the user to the group, # allowing the install to continue in the same terminal without a re-login. check_docker_permissions() { # macOS / Windows: Docker Desktop owns the socket — nothing to check. _os="$(uname -s 2>/dev/null || echo Unknown)" case "$_os" in Linux) ;; *) return ;; esac # ── Running as root ─────────────────────────────────────────────────────── # Root can run docker, but containers create root-owned files in volumes. # That breaks setup_env, make_bin_executable, and server:start. if [ "$(id -u)" -eq 0 ]; then warn "Running as root." warn "Docker containers will create files owned by root inside ./${PROJECT_NAME:-}." warn "Recommended: use a regular user that belongs to the 'docker' group." printf "\n" if tty_available; then printf " %sContinue as root anyway?%s [y/N]: " "$C_BOLD" "$C_RESET" read -r _root_ans /dev/null 2>&1 && return # ── Classify the failure ────────────────────────────────────────────────── _derr="$(docker info 2>&1 || true)" case "$_derr" in *"permission denied"*|*"Got permission denied"*|*"connect: permission denied"*) ;; *) # Daemon not running, socket missing, etc. — let check_docker handle it. return ;; esac _user="$(id -un)" printf "\n" error "Docker permission denied for user '${_user}'." # ── docker group does not exist ─────────────────────────────────────────── if ! grep -q "^docker:" /etc/group 2>/dev/null && \ ! getent group docker >/dev/null 2>&1; then printf "\n" warn "The 'docker' group does not exist — Docker may be installed incorrectly." printf " Reinstall: %shttps://docs.docker.com/engine/install/%s\n\n" "$C_CYAN" "$C_RESET" exit 1 fi # ── Determine group membership ──────────────────────────────────────────── # _in_effective: user is in docker group in THIS session (id -nG shows it) # _in_configured: user is in docker group in /etc/group (takes effect after re-login) _in_effective=0 id -nG 2>/dev/null | tr ' ' '\n' | grep -qx "docker" && _in_effective=1 _in_configured=0 _gmembers="$(getent group docker 2>/dev/null | cut -d: -f4 \ || grep "^docker:" /etc/group 2>/dev/null | cut -d: -f4 \ || true)" printf "%s" "$_gmembers" | tr ',' '\n' | grep -qx "$_user" && _in_configured=1 # ── In /etc/group but session not refreshed yet ─────────────────────────── if [ "$_in_effective" -eq 0 ] && [ "$_in_configured" -eq 1 ]; then printf "\n" warn "'${_user}' is already in the docker group, but this terminal session" warn "was opened before the change took effect." printf "\n" printf " %sOption A%s — open a new terminal and re-run the installer.\n" \ "$C_BOLD" "$C_RESET" if command -v sg >/dev/null 2>&1 && [ -f "$0" ]; then printf " %sOption B%s — continue right now (activates the group in this shell):\n" \ "$C_BOLD" "$C_RESET" printf "\n" if tty_available; then printf " %sContinue without re-login?%s [Y/n]: " "$C_BOLD" "$C_RESET" read -r _sg_ans /dev/null 2>&1; then error "'sudo' is not available on this system." printf " As root, run: usermod -aG docker %s\n" "$_user" printf " Then log out and back in, and re-run the installer.\n\n" exit 1 fi if ! tty_available; then error "Non-interactive mode: cannot run sudo interactively." printf " Fix manually:\n" printf " sudo usermod -aG docker %s\n" "$_user" printf " Then log out and back in, and re-run the installer.\n\n" exit 1 fi printf " %sAdd '${_user}' to the docker group now?%s (requires sudo) [Y/n]: " \ "$C_BOLD" "$C_RESET" read -r _add_ans /dev/null 2>&1 && [ -f "$0" ]; then printf " %sContinue the installer with the new group active now?%s [Y/n]: " \ "$C_BOLD" "$C_RESET" read -r _reexec_ans " fi } validate_project_name() { # ALLOWED: letters, digits, hyphens, underscores, dots. # REJECTED: spaces, slashes, @, #, etc. — these break Docker volume mounts # and Composer directory handling across all platforms. case "$PROJECT_NAME" in ''|-*|*[!a-zA-Z0-9._-]*) error "Invalid project name: '$PROJECT_NAME'. Use only letters, digits, hyphens, underscores, dots, and do not start with '-'." exit 1 ;; esac # An existing directory blocks the install intentionally — this prevents # accidentally clobbering a working project. Remove or rename it first. # PITFALL: If a previous install failed and left a partial directory, the # cleanup trap should have removed it automatically. If it persists (e.g. # the trap was bypassed by SIGKILL), remove it manually: rm -rf if [ -e "$PROJECT_NAME" ]; then error "A file or directory named '$PROJECT_NAME' already exists." printf "\n Remove it first, or choose a different name.\n\n" exit 1 fi } run_create_project() { info "Running host-side scaffold via Docker image: ${INSTALLER_IMAGE}" info "Refreshing installer image before scaffolding..." if docker pull "$INSTALLER_IMAGE" >/dev/null 2>&1; then success "Installer image refreshed." elif docker image inspect "$INSTALLER_IMAGE" >/dev/null 2>&1; then warn "Could not refresh ${INSTALLER_IMAGE}; using the locally cached image." else error "Could not pull ${INSTALLER_IMAGE}, and no local cached image is available." exit 1 fi mkdir -p "$PROJECT_NAME" docker run --rm \ -v "$(pwd)/${PROJECT_NAME}:/app" \ "$INSTALLER_IMAGE" \ install # ── Fix file ownership ───────────────────────────────────────────────────── # The installer image runs as root (UID 0), so every file it writes to the # volume ends up owned by root on the host — including the project directory # itself when Docker creates it. Always chown regardless of current owner: # # Fix order: # 1. Plain chown -R — works when the user already owns the socket (docker group) # 2. Alpine container — needs only docker (no sudo), uses root inside the # volume to chown on the host filesystem # 3. Manual sudo hint — last resort if both above fail _uid="$(id -u)" _gid="$(id -g)" info "Fixing file ownership (container writes files as root)..." if chown -R "${_uid}:${_gid}" "$PROJECT_NAME" 2>/dev/null; then success "Ownership fixed → $(id -un):$(id -gn)" elif docker run --rm \ -v "$(pwd):/work" \ alpine \ chown -R "${_uid}:${_gid}" "/work/${PROJECT_NAME}" 2>/dev/null; then success "Ownership fixed via Docker Alpine → $(id -un):$(id -gn)" else warn "Could not fix file ownership automatically." warn "Run before continuing: sudo chown -R $(id -un):$(id -gn) ./${PROJECT_NAME}" fi } # ── Post-install setup ─────────────────────────────────────────────────────── setup_env() { if [ ! -f "$PROJECT_NAME/.env.default" ]; then error "No environment baseline was generated (.env.default)." error "The scaffold output is incomplete; aborting before later Docker/CLI steps fail." exit 1 fi success ".env.default created with the Semitexa local baseline" if [ ! -f "$PROJECT_NAME/.env" ]; then cat <<'EOF' > "$PROJECT_NAME/.env" # Local overrides for Semitexa. # Keep this file uncommitted. # Add machine-specific values here when you need them. EOF success ".env created as a local override file" fi # The first server:start makes the project a git repository with one # commit, so ai:verify --dirty can see changes. Not inside an existing # repository: the setup container cannot see parent directories, and a # nested .git would make the project an embedded repo of the outer one. if command -v git >/dev/null 2>&1 \ && git -C "$PROJECT_NAME/.." rev-parse --is-inside-work-tree >/dev/null 2>&1; then printf '\n# Inside an existing git repository: no separate repository for this project.\nSEMITEXA_GIT_INIT=0\n' >> "$PROJECT_NAME/.env" info "Project is inside an existing git repository; it will not get its own." fi info "Edit $PROJECT_NAME/.env to override defaults when needed." } make_bin_executable() { _bin="$PROJECT_NAME/bin/semitexa" if [ -f "$_bin" ]; then chmod +x "$_bin" fi # PITFALL: rsync without -p drops permissions. Fix: chmod +x bin/semitexa # PITFALL: On Windows NTFS volumes mounted in WSL, chmod has no effect. # Workaround: sh bin/semitexa (call sh explicitly). } # ── Shared port helper ─────────────────────────────────────────────────────── # Reads SWOOLE_PORT from .env, then .env.default: # - strips inline comments (SWOOLE_PORT=9502 # default → 9502) # - strips double and single quotes # - strips whitespace # - validates it is a pure integer; falls back to 9502 if not # Used by both print_next_steps and check_port_conflicts to guarantee # they operate on the same value. get_swoole_port() { _p="9502" for _env_file in "$PROJECT_NAME/.env" "$PROJECT_NAME/.env.default"; do [ -f "$_env_file" ] || continue _candidate="$(grep -E '^SWOOLE_PORT=' "$_env_file" 2>/dev/null \ | head -1 \ | cut -d= -f2 \ | cut -d'#' -f1 \ | tr -d "\"'" \ | tr -d ' \t' \ || true)" case "$_candidate" in ''|*[!0-9]*) continue ;; *) _p="$_candidate"; break ;; esac done printf "%s" "$_p" } # ── Port utilities ─────────────────────────────────────────────────────────── # Scan upward from PORT+1 until a port is free on both host and Docker. # Prints the free port number, or nothing if none found within 100 tries. find_free_port() { _base="$1" _try="$(expr "$_base" + 1)" _limit="$(expr "$_base" + 100)" while [ "$_try" -le "$_limit" ]; do _busy=0 if command -v lsof >/dev/null 2>&1; then lsof -i :"$_try" >/dev/null 2>&1 && _busy=1 || true elif command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -qE "[: ]${_try}( |$)" && _busy=1 || true elif command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | grep -qE "[: ]${_try}( |$)" && _busy=1 || true fi if [ "$_busy" -eq 0 ] && command -v docker >/dev/null 2>&1; then docker ps --format '{{.Ports}}' 2>/dev/null \ | grep -q ":${_try}->" && _busy=1 || true fi if [ "$_busy" -eq 0 ]; then printf "%s" "$_try" return fi _try="$(expr "$_try" + 1)" done } # Rewrite SWOOLE_PORT in .env in-place (POSIX-safe: tmp file + mv). update_swoole_port() { _new="$1" _env="${PROJECT_NAME}/.env" _tmp="${_env}.tmp" if [ -f "$_env" ]; then if grep -q '^SWOOLE_PORT=' "$_env" 2>/dev/null; then sed "s/^SWOOLE_PORT=.*/SWOOLE_PORT=${_new}/" "$_env" > "$_tmp" && mv "$_tmp" "$_env" else cp "$_env" "$_tmp" printf '\nSWOOLE_PORT=%s\n' "$_new" >> "$_tmp" mv "$_tmp" "$_env" fi else printf 'SWOOLE_PORT=%s\n' "$_new" > "$_env" fi success "SWOOLE_PORT updated to ${_new} in .env" } # ── Port conflict resolution ────────────────────────────────────────────────── # Detects port conflicts from TWO independent sources, then actively resolves # them so the customer never has to fix anything manually: # # Source A — Docker-allocated ports (`docker ps`) # The most common case in dev environments. Docker's network driver # reserves ports through its proxy/iptables layer — they are invisible # to lsof/ss/netstat, which is why the naive check always missed these. # # Source B — host-level sockets (lsof / ss / netstat) # Processes listening directly on the host (nginx, other apps). # # Resolution options offered to the user: # [1] Stop the conflicting Docker container → keep the original port # [2] Auto-select the next free port → update .env automatically # # Non-interactive (no TTY): option 2 is applied silently. # # MUST be called directly (not in a subshell) so SKIP_START propagates. check_port_conflicts() { SKIP_START=0 _port="$(get_swoole_port)" _in_use=0 _docker_owner="" # Source A: Docker-allocated ports if command -v docker >/dev/null 2>&1; then _docker_owner="$(docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \ | grep ":${_port}->" | awk '{print $1}' | head -1)" [ -n "$_docker_owner" ] && _in_use=1 fi # Source B: host-level sockets (fallback / belt-and-suspenders) if [ "$_in_use" -eq 0 ]; then if command -v lsof >/dev/null 2>&1; then lsof -i :"$_port" >/dev/null 2>&1 && _in_use=1 || true elif command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -qE "[: ]${_port}( |$)" && _in_use=1 || true elif command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | grep -qE "[: ]${_port}( |$)" && _in_use=1 || true fi fi [ "$_in_use" -eq 0 ] && return # no conflict — nothing to do # ── Conflict detected: resolve it ──────────────────────────────────────── _free="$(find_free_port "$_port")" if [ -n "$_docker_owner" ]; then warn "Port ${_port} is held by Docker container: ${_docker_owner}" else warn "Port ${_port} is already in use by a host process." fi # Non-interactive or --start: silently switch to the next free port if ! tty_available || [ "$AUTO_START" -eq 1 ]; then if [ -n "$_free" ]; then info "Non-interactive: switching to port ${_free} automatically." update_swoole_port "$_free" # SKIP_START stays 0 — start will proceed on the new port else warn "Could not find a free port. Skipping server start." SKIP_START=1 fi return fi # Interactive: present options printf "\n" if [ -n "$_docker_owner" ]; then printf " %s[1]%s Stop container '%s' and start on port %s\n" \ "$C_BOLD" "$C_RESET" "$_docker_owner" "$_port" fi if [ -n "$_free" ]; then printf " %s[2]%s Switch to port %s — updates .env automatically\n" \ "$C_BOLD" "$C_RESET" "$_free" fi printf " %s[s]%s Skip — I will start manually\n" "$C_BOLD" "$C_RESET" printf "\n" printf " Choice: " read -r _choice /dev/null 2>&1; then success "Container stopped. Starting on port ${_port}." # SKIP_START stays 0 else warn "Could not stop '${_docker_owner}'." warn "Try manually: docker stop ${_docker_owner}" SKIP_START=1 fi else warn "Option 1 is only available for Docker containers." SKIP_START=1 fi ;; 2) if [ -n "$_free" ]; then update_swoole_port "$_free" # SKIP_START stays 0 — start on the new port else warn "Could not find a free port automatically." SKIP_START=1 fi ;; *) info "Skipping server start." info "Run manually: cd ${PROJECT_NAME} && bin/semitexa server:start" SKIP_START=1 ;; esac } # ── Local app registration ─────────────────────────────────────────────────── # Register the freshly scaffolded project in the shared local app registry # (~/.semitexa/router/registry/apps/.env) BEFORE the first server:start. # # Why this runs at install time, not at first start: # - The broker owns Swoole port allocation in a fixed range (default # 9501-9599) and uniqueness across every Semitexa project on this machine. # - Without pre-registration, .env carries the scaffold default port (9502), # and the legacy linear-scan check_port_conflicts can pick a port already # owned by another local Semitexa app — colliding when that app starts. # - Pre-registering guarantees this project gets a broker-canonical port # that no other registered project can hold. # # `bin/semitexa local-app:register --write-env`: # - Idempotent: keyed by absolute project_root in the registry; re-running # reuses the existing entry instead of duplicating it. # - Preserves the existing port if still in-range and still free. # - Allocates a fresh port from the broker range otherwise. # - Writes both SEMITEXA_APP_ID and SWOOLE_PORT to .env via env_upsert_kv, # which replaces existing keys in place (no duplicate lines). # # --app-id is passed through when the installer scaffold pre-seeded one in # .env, so the broker registers the SAME UUID the scaffold wrote — keeping # .env and the broker entry coherent without a second random UUID. # # Failure here is non-fatal: server:start will retry the same logic via # local_app_reconcile, so the install completes even if the registry is # unwritable (e.g. read-only home, broker lock contention). register_local_app() { _bin="$PROJECT_NAME/bin/semitexa" if [ ! -f "$_bin" ]; then warn "bin/semitexa not found in scaffold — skipping local app registration." warn "It will be performed on first 'bin/semitexa server:start'." return fi # Reuse the SEMITEXA_APP_ID that the installer scaffold pre-seeded (when # present) so the .env id and the broker-registered id stay aligned. _existing_id="" if [ -f "$PROJECT_NAME/.env" ]; then _existing_id="$(grep -E '^SEMITEXA_APP_ID=' "$PROJECT_NAME/.env" 2>/dev/null \ | head -1 \ | cut -d= -f2- \ | tr -d "\"' " \ | tr -d '\r' \ || true)" fi info "Registering app in local registry (allocates Swoole port)..." # Stdout from local-app:register is suppressed (it duplicates the success # line we emit below). Stderr is left visible so allocation warnings and # broker errors still reach the user. _rc=0 set +e if [ -n "$_existing_id" ]; then ( cd "$PROJECT_NAME" \ && sh ./bin/semitexa local-app:register \ "--app-id=${_existing_id}" --write-env >/dev/null ) else ( cd "$PROJECT_NAME" \ && sh ./bin/semitexa local-app:register --write-env >/dev/null ) fi _rc=$? set -e if [ "$_rc" -eq 0 ]; then # Re-read the assigned port from .env so the user sees the canonical # value the broker recorded (not whatever the scaffold default was). _new_port="$(get_swoole_port)" success "App registered → SWOOLE_PORT=${_new_port}" APP_REGISTERED=1 else warn "Local app registration failed (exit ${_rc})." warn "server:start will retry via local_app_reconcile on first run." fi } # ── Optional: start server ─────────────────────────────────────────────────── ask_start_server() { # In automated pipelines (CI, Dockerfile RUN, provisioning scripts), # always pass --start. Without it the script blocks waiting for TTY input. if [ "$AUTO_START" -eq 1 ]; then return 0 fi if ! tty_available; then info "Non-interactive mode: skipping server start. Pass --start to auto-start." return 1 fi printf "\n%s Start the server now?%s [Y/n]: " "$C_BOLD" "$C_RESET" _ans="Y" read -r _ans /dev/null 2>&1; then _owner="$(docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \ | grep ":${_ffp}[^0-9]" | awk '{print $1}' | head -1)" [ -n "$_owner" ] || { if command -v lsof >/dev/null 2>&1; then lsof -i :"$_ffp" >/dev/null 2>&1 && _owner="[host process]" || true elif command -v ss >/dev/null 2>&1; then ss -ltn 2>/dev/null | grep -qE "[: ]${_ffp}( |$)" \ && _owner="[host process]" || true fi } fi [ -z "$_owner" ] && return 0 # port is free # Shared Semitexa router containers are infrastructure, not conflicts. # They are reused across all local projects — do not stop or warn. case "$_owner" in semitexa-router-*) return 0 ;; esac warn "Port ${_ffp} is held by: ${_owner}" if ! tty_available || [ "$AUTO_START" -eq 1 ]; then warn "Port ${_ffp} is busy in auto mode — skipping local domain registration." return 1 fi printf " %s[1]%s Stop '%s' and continue\n" "$C_BOLD" "$C_RESET" "$_owner" printf " %s[s]%s Skip domain registration (app will still start)\n" "$C_BOLD" "$C_RESET" printf "\n Choice [1/s]: " read -r _ffp_c /dev/null 2>&1; then success "Stopped '${_owner}'." return 0 fi ;; esac return 1 } # Calls the shared local router registration module via # `bin/semitexa local-domain:add`. # Non-fatal: a failure here warns the user but does NOT abort the installer. # # PORT GATE — all fixed ports used by the shared local router are checked # BEFORE calling local-domain:add. If any conflict cannot be resolved we skip # domain registration entirely, leaving LOCAL_DOMAIN="" so server:start still # starts the app cleanly on its own port. # # Port 80 — shared Nginx reverse proxy # Port 5553 — shared local DNS (dns mode only) # # Related commands the user can run manually inside the project directory: # bin/semitexa local-domain:add — register a .test domain # bin/semitexa local-domain:list — list registered domains # bin/semitexa local-domain:remove — remove a registered domain # bin/semitexa local-domain:mode [dns|hosts] — inspect or switch mode register_local_domain() { _domain="$1" _router_mode="hosts" _ports="80" _dns_bin="${PROJECT_NAME}/bin/semitexa" if [ -f "$_dns_bin" ]; then _router_mode="$( cd "$PROJECT_NAME" && sh ./bin/semitexa local-domain:mode 2>/dev/null || printf 'hosts' )" fi if [ "$_router_mode" = "dns" ]; then _ports="80 5553" fi info "Checking ports required by the shared local router (${_router_mode} mode)..." for _dns_port in $_ports; do if ! _free_fixed_port "$_dns_port"; then warn "Cannot free port ${_dns_port} — skipping domain registration." warn "App will still start on its own port." warn "Register later: cd ${PROJECT_NAME} && bin/semitexa local-domain:add ${_domain}" LOCAL_DOMAIN="" return fi done # ── Register ────────────────────────────────────────────────────────────── info "Registering ${_domain} with the shared local router..." if [ -f "$_dns_bin" ]; then if ( cd "$PROJECT_NAME" && sh ./bin/semitexa local-domain:add "$_domain" ); then success "Local domain registered: http://${_domain}" info "To list domains: sh bin/semitexa local-domain:list" info "To remove later: sh bin/semitexa local-domain:remove ${_domain}" else warn "Local domain registration failed — run manually once the server is up:" warn " cd ${PROJECT_NAME} && sh bin/semitexa local-domain:add ${_domain}" LOCAL_DOMAIN="" fi else warn "bin/semitexa not found — skipping local domain registration." warn "Register manually after start: cd ${PROJECT_NAME} && sh bin/semitexa local-domain:add ${_domain}" LOCAL_DOMAIN="" fi } # Orchestrates the full domain prompt flow. # Sets the global LOCAL_DOMAIN variable — called directly (not in a subshell) # so the assignment propagates to main() and print_next_steps(). # # A local domain is OPT-IN: it starts the shared router containers on host # port 80 and, with sudo, changes system DNS or /etc/hosts. Behaviour: # TTY present — explain the changes, then ask; default answer is No # (Yes when --local-domain was passed) # No TTY or --start — register only when --local-domain was passed, # otherwise skip with a tip for later explain_local_domain() { printf " A local domain (%s) is optional. Setting it up will:\n" "$1" printf " - start the shared Semitexa router containers, bound to host port 80;\n" printf " - with sudo, change your system DNS (systemd-resolved, /etc/resolv.conf)\n" printf " or add a line to /etc/hosts.\n" printf " Without it the app is still served at http://localhost:.\n" } ask_local_domain() { LOCAL_DOMAIN="" _suggested="$(sanitize_for_domain "$PROJECT_NAME").test" # Guard: if PROJECT_NAME sanitizes to nothing (e.g. all special chars), # we cannot generate a safe suggestion — skip gracefully. if [ "$_suggested" = ".test" ]; then warn "Could not derive a safe domain from project name '${PROJECT_NAME}'." warn "Register manually later: bin/semitexa local-domain:add .test" return fi # ── Non-interactive / automated path ──────────────────────────────────── # Two conditions skip the interactive prompts: # 1. No usable TTY — can't ask anything # 2. --start passed — caller explicitly requested zero interaction if ! tty_available || [ "$AUTO_START" -eq 1 ]; then if [ "$WANT_LOCAL_DOMAIN" -eq 1 ]; then explain_local_domain "$_suggested" info "--local-domain passed: registering default domain '${_suggested}'." LOCAL_DOMAIN="$_suggested" register_local_domain "$LOCAL_DOMAIN" else info "Local domain skipped (opt-in; pass --local-domain to set one up)." info "Register later: cd ${PROJECT_NAME} && bin/semitexa local-domain:add .test" fi return fi # ── Interactive path ────────────────────────────────────────────────────── printf "\n%s Local domain setup (optional)%s\n" "$C_BOLD" "$C_RESET" explain_local_domain "$_suggested" if [ "$WANT_LOCAL_DOMAIN" -eq 1 ]; then printf " Register a local .test domain for this project? [Y/n]: " else printf " Register a local .test domain for this project? [y/N]: " fi read -r _confirm .test" return ;; *) if [ "$WANT_LOCAL_DOMAIN" -ne 1 ]; then info "Local domain registration skipped." info "Register later: cd ${PROJECT_NAME} && bin/semitexa local-domain:add .test" return fi ;; esac printf " Enter domain name (or press Enter to use %s%s%s): " \ "$C_BOLD" "$_suggested" "$C_RESET" read -r _input .test" return fi success "Domain confirmed: ${LOCAL_DOMAIN}" register_local_domain "$LOCAL_DOMAIN" } # ── Print next steps ───────────────────────────────────────────────────────── print_next_steps() { # Port is read from .env/.env.default via get_swoole_port(). _port="$(get_swoole_port)" printf "\n" printf "%s╔══════════════════════════════════════════════════════════════╗%s\n" "$C_GREEN$C_BOLD" "$C_RESET" printf "%s║ Installation complete! ║%s\n" "$C_GREEN$C_BOLD" "$C_RESET" printf "%s╚══════════════════════════════════════════════════════════════╝%s\n" "$C_GREEN$C_BOLD" "$C_RESET" printf "\n" printf " %sProject created in:%s ./%s\n" "$C_BOLD" "$C_RESET" "$PROJECT_NAME" printf "\n" printf " %sNext steps:%s\n" "$C_BOLD" "$C_RESET" printf " cd %s\n" "$PROJECT_NAME" printf " \$EDITOR .env %s# local overrides%s\n" "$C_YELLOW" "$C_RESET" printf " bin/semitexa server:start %s# start Swoole + Docker%s\n" "$C_YELLOW" "$C_RESET" printf "\n" printf " %sOnce running:%s\n" "$C_BOLD" "$C_RESET" printf " http://localhost:%s %s# app%s\n" "$_port" "$C_YELLOW" "$C_RESET" if [ -n "$LOCAL_DOMAIN" ]; then printf " http://%s %s# local domain%s\n" "$LOCAL_DOMAIN" "$C_YELLOW" "$C_RESET" fi printf " bin/semitexa orm:sync %s# create the database tables%s\n" "$C_YELLOW" "$C_RESET" printf " bin/semitexa list %s# all CLI commands%s\n" "$C_YELLOW" "$C_RESET" printf " docker compose logs -f %s# live logs%s\n" "$C_YELLOW" "$C_RESET" printf "\n" printf " %sDocs:%s https://semitexa.com/docs\n" "$C_BOLD" "$C_RESET" printf "\n" } # ── Main ───────────────────────────────────────────────────────────────────── # STEP ORDER — do not skip steps when recovering from a partial failure: # 1. check_docker_permissions — Linux only: ensure user is in docker group # check_docker — abort before any writes if environment is wrong # 2. ask/validate name — resolve name before touching the filesystem # 3. run_create_project — Docker-based scaffold + fix file ownership # 4. setup_env — baseline env files must exist before server:start reads them # 5. make_bin_executable — chmod must run before server:start calls the bin # register_local_app — broker-allocate SWOOLE_PORT + SEMITEXA_APP_ID in .env # BEFORE any server:start, so first start consumes the # already-registered port instead of racing the broker # check_port_conflicts — fallback only when registration could not run # 6. ask_local_domain — opt-in (default No); requires bin to be executable # 7. start_server — only when env + permissions + port are confirmed # # RECOVERING FROM A PARTIAL FAILURE: # The cleanup trap removes the partial directory on abort. Just re-run the # full installer. If you prefer to resume manually, execute the individual # function bodies directly — do NOT re-run the full script while the # directory exists, as validate_project_name will block you. main() { banner step "[1/7] Checking prerequisites..." # check_docker_permissions must run before check_docker: it distinguishes # "permission denied" (fixable by adding to docker group) from "daemon not # running" and offers an interactive resolution with sudo + optional re-exec. check_docker_permissions "$@" check_docker step "[2/7] Project name..." ask_project_name validate_project_name info "Creating project: ${C_BOLD}${PROJECT_NAME}${C_RESET}" step "[3/7] Installing Semitexa Ultimate..." # Arm the cleanup trap. From this point onward, any unexpected exit will # remove the partial project directory so the user can re-run cleanly. _CLEANUP_PROJECT="$PROJECT_NAME" run_create_project # Explicit post-install guard: the Docker-based scaffold can still fail to # materialize the project directory fully (e.g. disk full, volume mount # permission issue). Catching this here prevents cryptic downstream errors. if [ ! -f "$PROJECT_NAME/bin/semitexa" ] || [ ! -f "$PROJECT_NAME/docker-compose.yml" ]; then error "Project scaffold in '${PROJECT_NAME}' is incomplete." error "Possible causes: disk full, Docker volume permission error, or installer image failure." exit 1 fi step "[4/7] Environment setup..." setup_env make_bin_executable step "[5/7] Registering local app..." # Pre-register the project in the shared local app registry so the first # server:start consumes a broker-canonical SWOOLE_PORT instead of racing # the broker. Must run AFTER make_bin_executable (uses bin/semitexa) and # BEFORE ask_local_domain + start_server (both depend on the registered # port). See register_local_app() for the full rationale. register_local_app # check_port_conflicts is the legacy linear-scan fallback. When # register_local_app succeeded the port in .env is already broker-canonical # and a second scan can only damage cross-app uniqueness. Only run it when # registration could not be performed. if [ "$APP_REGISTERED" -eq 0 ]; then check_port_conflicts fi step "[6/7] Local domain (optional)..." # ask_local_domain must run AFTER make_bin_executable so that # register_local_domain can call bin/semitexa local-domain:add. # It must also run BEFORE print_next_steps so LOCAL_DOMAIN is available # for display. Called directly (not in a subshell) — see LOCAL_DOMAIN note. ask_local_domain step "[7/7] Done!" # Disarm the cleanup trap — the install is complete and the directory is # intentional. From here, no automatic removal should happen on exit. _CLEANUP_PROJECT="" print_next_steps if ask_start_server; then step "Starting server..." if [ "$SKIP_START" -eq 1 ]; then warn "Server start skipped due to port conflict." warn "Resolve the conflict, then: cd ${PROJECT_NAME} && bin/semitexa server:start" else start_server if [ -n "$LOCAL_DOMAIN" ]; then printf "\n" success "Your app is also available at: http://${LOCAL_DOMAIN}" fi fi else info "Run 'bin/semitexa server:start' when you're ready." fi } main "$@"