#!/usr/bin/env bash # Neta - Freelancer Operating System installer set -euo pipefail REPO_URL="${NETA_REPO_URL:-https://github.com/poyrazavsever/neta.git}" TARGET_DIR="${NETA_TARGET_DIR:-neta-os}" INSTALL_MODE="${NETA_INSTALL_MODE:-}" APPLY_MIGRATIONS="${NETA_APPLY_MIGRATIONS:-}" TTY_PATH="${NETA_TTY_PATH:-/dev/tty}" info() { printf "\n%s\n" "$1" } fail() { echo "Error: $1" >&2 exit 1 } require_command() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required." } ensure_tty() { if ! ( : < "$TTY_PATH" ) 2>/dev/null || ! ( : > "$TTY_PATH" ) 2>/dev/null; then fail "Interactive input requires a TTY. Run with a terminal attached or provide the required NETA_* environment variables." fi } read_from_tty() { local label="$1" local value ensure_tty printf "%s" "$label" > "$TTY_PATH" IFS= read -r value < "$TTY_PATH" || fail "Input cancelled." printf "%s" "$value" } read_secret_from_tty() { local label="$1" local value ensure_tty printf "%s" "$label" > "$TTY_PATH" IFS= read -r -s value < "$TTY_PATH" || fail "Input cancelled." printf "\n" > "$TTY_PATH" printf "%s" "$value" } compose_cmd() { if docker compose version >/dev/null 2>&1; then echo "docker compose" elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose" else fail "Docker Compose is required." fi } prompt_required() { local var_name="$1" local label="$2" local current_value="${!var_name:-}" local value if [ -n "$current_value" ]; then return fi while true; do value="$(read_from_tty "$label: ")" if [ -n "$value" ]; then printf -v "$var_name" "%s" "$value" export "$var_name" return fi echo "This value is required." done } prompt_optional() { local var_name="$1" local label="$2" local default_value="$3" local current_value="${!var_name:-}" local value if [ -n "$current_value" ]; then return fi value="$(read_from_tty "$label [$default_value]: ")" printf -v "$var_name" "%s" "${value:-$default_value}" export "$var_name" } prompt_secret_required() { local var_name="$1" local label="$2" local current_value="${!var_name:-}" local value if [ -n "$current_value" ]; then return fi while true; do value="$(read_secret_from_tty "$label: ")" if [ -n "$value" ]; then printf -v "$var_name" "%s" "$value" export "$var_name" return fi echo "This value is required." done } choose_install_mode() { if [ -n "$INSTALL_MODE" ]; then case "$INSTALL_MODE" in full|full-stack|bundled) INSTALL_MODE="full-stack" ;; app|app-only|external) INSTALL_MODE="app-only" ;; *) fail "Invalid NETA_INSTALL_MODE. Use full-stack or app-only." ;; esac export INSTALL_MODE return fi echo "Choose install mode:" echo " 1) full-stack Neta + bundled Supabase/Postgres/Auth/Storage" echo " 2) app-only Neta app connected to an existing Supabase project" while true; do answer="$(read_from_tty "Install mode [full-stack]: ")" case "${answer:-full-stack}" in 1|full|full-stack|bundled) INSTALL_MODE="full-stack" export INSTALL_MODE return ;; 2|app|app-only|external) INSTALL_MODE="app-only" export INSTALL_MODE return ;; *) echo "Please choose full-stack or app-only." ;; esac done } run_node_script() { local script="$1" if command -v node >/dev/null 2>&1; then node -e "$script" else docker run --rm \ -e ROLE="${ROLE:-}" \ -e JWT_SECRET="${JWT_SECRET:-}" \ node:22-alpine node -e "$script" fi } random_secret() { run_node_script "console.log(require('crypto').randomBytes(32).toString('hex'))" } generate_supabase_jwt() { local role="$1" local secret="$2" ROLE="$role" JWT_SECRET="$secret" run_node_script "const crypto=require('crypto'); const b64=(v)=>Buffer.from(v).toString('base64url'); const header=b64(JSON.stringify({alg:'HS256',typ:'JWT'})); const payload=b64(JSON.stringify({iss:'supabase',ref:'neta',role:process.env.ROLE,iat:1700000000,exp:4102444800})); const unsigned=header+'.'+payload; const sig=crypto.createHmac('sha256', process.env.JWT_SECRET).update(unsigned).digest('base64url'); console.log(unsigned+'.'+sig);" } write_env_file() { cat > .env <> .env </dev/null info "Building and starting bundled Neta stack" $compose -f docker-compose.full.yml up -d --build else info "Validating Docker Compose configuration" $compose config >/dev/null info "Building and starting Neta" $compose up -d --build fi } main() { info "Neta self-host installer" require_command git require_command docker docker info >/dev/null 2>&1 || fail "Docker is installed but the daemon is not reachable." if [ -d "$TARGET_DIR" ]; then fail "'$TARGET_DIR' already exists. Set NETA_TARGET_DIR or remove the directory." fi info "Cloning Neta into $TARGET_DIR" git clone "$REPO_URL" "$TARGET_DIR" cd "$TARGET_DIR" choose_install_mode if [ "$INSTALL_MODE" = "full-stack" ]; then configure_full_stack else configure_app_only fi write_env_file info "Wrote .env" if [ "$INSTALL_MODE" = "app-only" ]; then local apply_migrations="$APPLY_MIGRATIONS" if [ -z "$apply_migrations" ]; then apply_migrations="$(read_from_tty "Apply Neta database migrations now? Requires a direct Postgres DATABASE_URL. [y/N]: ")" fi if [ "$apply_migrations" = "y" ] || [ "$apply_migrations" = "Y" ]; then prompt_secret_required DATABASE_URL "Postgres DATABASE_URL" DATABASE_URL="$DATABASE_URL" sh ./scripts/apply-migrations.sh else echo "Skipping migrations. Run them later with:" echo " DATABASE_URL='postgresql://...' sh ./scripts/apply-migrations.sh" fi else echo "Bundled mode applies migrations automatically through the neta-migrations service." fi local compose compose="$(compose_cmd)" run_compose "$compose" info "Neta is starting" echo "Open: $NEXT_PUBLIC_SITE_URL" echo "Create the first admin account at: $NEXT_PUBLIC_SITE_URL/register" if [ "$INSTALL_MODE" = "full-stack" ]; then echo "Bundled Supabase API: $NEXT_PUBLIC_SUPABASE_URL" echo "Bundled Postgres host port: $POSTGRES_PORT" fi } main "$@"