diff --git a/.env.example b/.env.example index 1c09af4454..97c132f5ab 100644 --- a/.env.example +++ b/.env.example @@ -21,14 +21,23 @@ APP_ENV= # 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production. MULTICA_DEV_VERIFICATION_CODE= PORT=8080 +# Optional aliases for the local/self-host backend port. If one is set, it +# takes precedence over PORT in compose, Makefile, and installer helpers. +# BACKEND_PORT=8080 +# API_PORT=8080 +# SERVER_PORT=8080 # Prometheus metrics are disabled by default. When enabled, bind to loopback # unless you protect the listener with private networking, allowlists, or # proxy auth. Do not expose this endpoint through the public app/API ingress. # HTTP request metrics start accumulating only when this listener is enabled. # METRICS_ADDR=127.0.0.1:9090 JWT_SECRET=change-me-in-production -MULTICA_SERVER_URL=ws://localhost:8080/ws -MULTICA_APP_URL=http://localhost:3000 +# Derived by Makefile / local scripts from the backend port. +# Set explicitly only when the daemon reaches the API through a different URL. +# MULTICA_SERVER_URL=ws://localhost:8080/ws +# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT. +# Set explicitly only when the app's public URL differs from local frontend. +# MULTICA_APP_URL=http://localhost:3000 # Public URL the API is reachable at from the open internet (no trailing # slash). Used to mint absolute webhook URLs for autopilot webhook # triggers. Leave unset behind a same-origin reverse proxy or for plain @@ -91,7 +100,9 @@ SMTP_TLS_INSECURE=false # rebuild is needed. GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT. +# Set explicitly only when your OAuth callback URL differs from local frontend. +# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback # S3 / CloudFront # S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the @@ -121,7 +132,9 @@ COOKIE_DOMAIN= # Local file storage (fallback when S3_BUCKET is not set) LOCAL_UPLOAD_DIR=./data/uploads -LOCAL_UPLOAD_BASE_URL=http://localhost:8080 +# Derived by Makefile / local scripts from the backend port. +# Set explicitly only when uploads are served through a different public URL. +# LOCAL_UPLOAD_BASE_URL=http://localhost:8080 # Security # Comma-separated list of allowed origins for CORS and WebSocket connections. @@ -170,9 +183,11 @@ GITHUB_WEBHOOK_SECRET= # Frontend FRONTEND_PORT=3000 -FRONTEND_ORIGIN=http://localhost:3000 +# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT. +# Set explicitly only when serving frontend on a different origin/domain. +# FRONTEND_ORIGIN=http://localhost:3000 # Leave empty — auto-derived from page origin in browser, set by Makefile for local dev. -# Only set explicitly if frontend and backend are on different domains. +# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set. NEXT_PUBLIC_API_URL= NEXT_PUBLIC_WS_URL= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a56929fe69..3753706724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Test self-host env derivation + run: bash scripts/selfhost-config.test.sh + - name: Verify reserved-slugs.ts is up to date # Re-runs the generator and fails on any drift from the # checked-in TypeScript output. The Go side embeds the JSON diff --git a/Makefile b/Makefile index ea2c58f038..3186295dd1 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ POSTGRES_DB ?= multica POSTGRES_USER ?= multica POSTGRES_PASSWORD ?= multica POSTGRES_PORT ?= 5432 -PORT ?= 8080 +PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080) FRONTEND_PORT ?= 3000 FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT) MULTICA_APP_URL ?= $(FRONTEND_ORIGIN) @@ -21,6 +21,7 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT) NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws +LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT) export diff --git a/apps/web/config/runtime-urls.test.ts b/apps/web/config/runtime-urls.test.ts new file mode 100644 index 0000000000..dae7927869 --- /dev/null +++ b/apps/web/config/runtime-urls.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { resolveRemoteApiUrl } from "./runtime-urls"; + +describe("resolveRemoteApiUrl", () => { + it("prefers REMOTE_API_URL when explicitly configured", () => { + expect( + resolveRemoteApiUrl({ + REMOTE_API_URL: "http://backend:8080", + NEXT_PUBLIC_API_URL: "http://localhost:19000", + PORT: "18080", + }), + ).toBe("http://backend:8080"); + }); + + it("uses NEXT_PUBLIC_API_URL when REMOTE_API_URL is unset", () => { + expect( + resolveRemoteApiUrl({ + NEXT_PUBLIC_API_URL: "http://localhost:19000", + PORT: "18080", + }), + ).toBe("http://localhost:19000"); + }); + + it("derives localhost backend URL from PORT when no API URL is set", () => { + expect(resolveRemoteApiUrl({ PORT: "19080" })).toBe("http://localhost:19080"); + }); + + it("supports explicit backend port aliases before PORT", () => { + expect(resolveRemoteApiUrl({ BACKEND_PORT: "28080", PORT: "19080" })).toBe( + "http://localhost:28080", + ); + expect(resolveRemoteApiUrl({ API_PORT: "38080", PORT: "19080" })).toBe( + "http://localhost:38080", + ); + expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe( + "http://localhost:48080", + ); + }); + + it("prefers backend port aliases by documented precedence", () => { + expect( + resolveRemoteApiUrl({ + BACKEND_PORT: "28080", + API_PORT: "38080", + SERVER_PORT: "48080", + PORT: "19080", + }), + ).toBe("http://localhost:28080"); + + expect( + resolveRemoteApiUrl({ + API_PORT: "38080", + SERVER_PORT: "48080", + PORT: "19080", + }), + ).toBe("http://localhost:38080"); + + expect(resolveRemoteApiUrl({ SERVER_PORT: "48080", PORT: "19080" })).toBe( + "http://localhost:48080", + ); + }); + + it("ignores whitespace-only backend URL values", () => { + expect( + resolveRemoteApiUrl({ + REMOTE_API_URL: " ", + NEXT_PUBLIC_API_URL: " ", + BACKEND_PORT: " ", + API_PORT: " ", + SERVER_PORT: " ", + PORT: "19080", + }), + ).toBe("http://localhost:19080"); + + expect(resolveRemoteApiUrl({ PORT: " " })).toBe("http://localhost:8080"); + }); + + it("falls back to the historical backend port when no env is configured", () => { + expect(resolveRemoteApiUrl({})).toBe("http://localhost:8080"); + }); +}); diff --git a/apps/web/config/runtime-urls.ts b/apps/web/config/runtime-urls.ts new file mode 100644 index 0000000000..419243e4b3 --- /dev/null +++ b/apps/web/config/runtime-urls.ts @@ -0,0 +1,18 @@ +type RuntimeEnv = Record; + +export function resolveRemoteApiUrl(env: RuntimeEnv): string { + const explicitRemote = env.REMOTE_API_URL?.trim(); + if (explicitRemote) return explicitRemote; + + const publicApi = env.NEXT_PUBLIC_API_URL?.trim(); + if (publicApi) return publicApi; + + const port = + env.BACKEND_PORT?.trim() || + env.API_PORT?.trim() || + env.SERVER_PORT?.trim() || + env.PORT?.trim(); + if (port) return `http://localhost:${port}`; + + return "http://localhost:8080"; +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 2b72f46efd..63689fb933 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,11 +1,12 @@ import type { NextConfig } from "next"; import { config } from "dotenv"; import { resolve } from "path"; +import { resolveRemoteApiUrl } from "./config/runtime-urls"; // Load root .env so REMOTE_API_URL is available to next.config.ts config({ path: resolve(__dirname, "../../.env") }); -const remoteApiUrl = process.env.REMOTE_API_URL || "http://localhost:8080"; +const remoteApiUrl = resolveRemoteApiUrl(process.env); const docsUrl = process.env.DOCS_URL || "http://localhost:4000"; // Parse hostnames from CORS_ALLOWED_ORIGINS so that Next.js dev server diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml index 0ef30d0034..5fc6db2d81 100644 --- a/docker-compose.selfhost.yml +++ b/docker-compose.selfhost.yml @@ -13,8 +13,8 @@ # # Edit .env — change JWT_SECRET at minimum # docker compose -f docker-compose.selfhost.yml up -d # -# Frontend: http://localhost:3000 -# Backend: http://localhost:8080 (also used by CLI/daemon) +# Frontend: http://localhost:${FRONTEND_PORT:-3000} +# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}} name: multica @@ -42,7 +42,7 @@ services: postgres: condition: service_healthy ports: - - "127.0.0.1:${PORT:-8080}:8080" + - "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080" volumes: - backend_uploads:/app/data/uploads environment: @@ -50,7 +50,7 @@ services: PORT: "8080" METRICS_ADDR: ${METRICS_ADDR:-} JWT_SECRET: ${JWT_SECRET:-change-me-in-production} - FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai} @@ -61,7 +61,7 @@ services: SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} - GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback} + GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback} S3_BUCKET: ${S3_BUCKET:-} S3_REGION: ${S3_REGION:-us-west-2} CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-} @@ -70,7 +70,7 @@ services: COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} APP_ENV: ${APP_ENV:-production} MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-} - MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000} + MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}} ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true} ALLOWED_EMAILS: ${ALLOWED_EMAILS:-} ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-} diff --git a/scripts/check.sh b/scripts/check.sh index 2a024c06e7..23421767c2 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -18,13 +18,8 @@ set -a . "$ENV_FILE" set +a -POSTGRES_DB="${POSTGRES_DB:-multica}" -POSTGRES_USER="${POSTGRES_USER:-multica}" -POSTGRES_PORT="${POSTGRES_PORT:-5432}" -PORT="${PORT:-8080}" -FRONTEND_PORT="${FRONTEND_PORT:-3000}" -PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-http://localhost:${FRONTEND_PORT}}" -export PLAYWRIGHT_BASE_URL +# shellcheck disable=SC1091 +. scripts/local-env.sh BACKEND_PID="" FRONTEND_PID="" diff --git a/scripts/dev.sh b/scripts/dev.sh index e83ffe6725..71089542e1 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -40,6 +40,9 @@ set -a . "$ENV_FILE" set +a +# shellcheck disable=SC1091 +. scripts/local-env.sh + # ---------- Install dependencies ---------- if [ ! -d node_modules ]; then echo "==> Installing dependencies..." diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5eaa8acd3e..f2aad96059 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -30,6 +30,46 @@ function Test-CommandExists { $null -ne (Get-Command $Name -ErrorAction SilentlyContinue) } +function Get-EnvFileValue { + param( + [string]$Path, + [string]$Name, + [string]$Default + ) + + if (-not (Test-Path $Path)) { + return $Default + } + + $prefix = "$Name=" + $line = Get-Content $Path | + Where-Object { $_.StartsWith($prefix) } | + Select-Object -Last 1 + if (-not $line) { + return $Default + } + + $value = $line.Substring($prefix.Length).Trim().Trim('"').Trim("'") + if ([string]::IsNullOrWhiteSpace($value)) { + return $Default + } + return $value +} + +function Get-SelfHostBackendPort { + foreach ($name in @("BACKEND_PORT", "API_PORT", "SERVER_PORT", "PORT")) { + $value = Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name $name -Default "" + if (-not [string]::IsNullOrWhiteSpace($value)) { + return $value + } + } + return "8080" +} + +function Get-SelfHostFrontendPort { + return Get-EnvFileValue -Path (Join-Path $InstallDir ".env") -Name "FRONTEND_PORT" -Default "3000" +} + function Get-LatestVersion { try { $release = Invoke-RestMethod -Uri "https://api.github.com/repos/multica-ai/multica/releases/latest" -ErrorAction Stop @@ -386,10 +426,11 @@ function Install-Server { docker compose -f docker-compose.selfhost.yml up -d Write-Info "Waiting for backend to be ready..." + $backendPort = Get-SelfHostBackendPort $ready = $false for ($i = 1; $i -le 45; $i++) { try { - $null = Invoke-WebRequest -Uri "http://localhost:8080/health" -UseBasicParsing -TimeoutSec 2 + $null = Invoke-WebRequest -Uri "http://localhost:$backendPort/health" -UseBasicParsing -TimeoutSec 2 $ready = $true break } catch { @@ -451,8 +492,10 @@ function Start-LocalInstall { Write-Host " [OK] Multica server is running and CLI is ready!" -ForegroundColor Green Write-Host " ============================================" -ForegroundColor Green Write-Host "" - Write-Host " Frontend: http://localhost:3000" - Write-Host " Backend: http://localhost:8080" + $frontendPort = Get-SelfHostFrontendPort + $backendPort = Get-SelfHostBackendPort + Write-Host " Frontend: http://localhost:$frontendPort" + Write-Host " Backend: http://localhost:$backendPort" Write-Host " Server at: $InstallDir" Write-Host "" Write-Host " Next: configure your CLI to connect" diff --git a/scripts/install.sh b/scripts/install.sh index 4a82ad541e..6a6ad2dc0e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,6 +41,46 @@ fail() { printf "${BOLD}${RED}✗ %s${RESET}\n" "$*" >&2; exit 1; } command_exists() { command -v "$1" >/dev/null 2>&1; } +env_file_value() { + local file="$1" + local key="$2" + local default="$3" + local line value + line="$(grep -E "^${key}=" "$file" 2>/dev/null | tail -n 1 || true)" + if [ -z "$line" ]; then + printf "%s" "$default" + return + fi + value="${line#*=}" + value="${value%$'\r'}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + if [ -z "$value" ]; then + printf "%s" "$default" + else + printf "%s" "$value" + fi +} + +selfhost_backend_port() { + local file="${1:-.env}" + local value + for key in BACKEND_PORT API_PORT SERVER_PORT PORT; do + value="$(env_file_value "$file" "$key" "")" + if [ -n "$value" ]; then + printf "%s" "$value" + return + fi + done + printf "8080" +} + +selfhost_frontend_port() { + env_file_value "${1:-.env}" "FRONTEND_PORT" "3000" +} + detect_os() { case "$(uname -s)" in Darwin) OS="darwin" ;; @@ -339,9 +379,11 @@ setup_server() { # Wait for health check info "Waiting for backend to be ready..." + local backend_port + backend_port="$(selfhost_backend_port .env)" local ready=false for i in $(seq 1 45); do - if curl -sf http://localhost:8080/health >/dev/null 2>&1; then + if curl -sf "http://localhost:${backend_port}/health" >/dev/null 2>&1; then ready=true break fi @@ -403,8 +445,11 @@ run_with_server() { printf "${BOLD}${GREEN} ✓ Multica server is running and CLI is ready!${RESET}\n" printf "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}\n" printf "\n" - printf " ${BOLD}Frontend:${RESET} http://localhost:3000\n" - printf " ${BOLD}Backend:${RESET} http://localhost:8080\n" + local frontend_port backend_port + frontend_port="$(selfhost_frontend_port "$INSTALL_DIR/.env")" + backend_port="$(selfhost_backend_port "$INSTALL_DIR/.env")" + printf " ${BOLD}Frontend:${RESET} http://localhost:%s\n" "$frontend_port" + printf " ${BOLD}Backend:${RESET} http://localhost:%s\n" "$backend_port" printf " ${BOLD}Server at:${RESET} %s\n" "$INSTALL_DIR" printf "\n" printf " ${BOLD}Next: configure your CLI to connect${RESET}\n" diff --git a/scripts/local-env.sh b/scripts/local-env.sh new file mode 100644 index 0000000000..c4100f7c4f --- /dev/null +++ b/scripts/local-env.sh @@ -0,0 +1,20 @@ +# Shared local development env derivation. Source this after loading .env. + +POSTGRES_DB="${POSTGRES_DB:-multica}" +POSTGRES_USER="${POSTGRES_USER:-multica}" +POSTGRES_PORT="${POSTGRES_PORT:-5432}" + +PORT="${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}" +FRONTEND_PORT="${FRONTEND_PORT:-3000}" +FRONTEND_ORIGIN="${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT}}" + +MULTICA_APP_URL="${MULTICA_APP_URL:-${FRONTEND_ORIGIN}}" +GOOGLE_REDIRECT_URI="${GOOGLE_REDIRECT_URI:-${FRONTEND_ORIGIN}/auth/callback}" +MULTICA_SERVER_URL="${MULTICA_SERVER_URL:-ws://localhost:${PORT}/ws}" +LOCAL_UPLOAD_BASE_URL="${LOCAL_UPLOAD_BASE_URL:-http://localhost:${PORT}}" +PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-${FRONTEND_ORIGIN}}" + +export POSTGRES_DB POSTGRES_USER POSTGRES_PORT +export PORT FRONTEND_PORT FRONTEND_ORIGIN +export MULTICA_APP_URL GOOGLE_REDIRECT_URI MULTICA_SERVER_URL LOCAL_UPLOAD_BASE_URL +export PLAYWRIGHT_BASE_URL diff --git a/scripts/selfhost-config.test.sh b/scripts/selfhost-config.test.sh new file mode 100755 index 0000000000..c1ef085efa --- /dev/null +++ b/scripts/selfhost-config.test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +require_config() { + local config=$1 + local expected=$2 + + if ! grep -Fq "$expected" <<<"$config"; then + echo "Missing expected docker compose config value:" + echo " $expected" + exit 1 + fi +} + +require_env() { + local output=$1 + local expected=$2 + + if ! grep -Fxq "$expected" <<<"$output"; then + echo "Missing expected derived env value:" + echo " $expected" + echo "Observed:" + echo "$output" + exit 1 + fi +} + +config="$( + FRONTEND_PORT=3100 BACKEND_PORT=9100 docker compose \ + --env-file .env.example \ + -f docker-compose.selfhost.yml \ + config +)" + +require_config "$config" 'published: "3100"' +require_config "$config" 'published: "9100"' +require_config "$config" 'FRONTEND_ORIGIN: http://localhost:3100' +require_config "$config" 'GOOGLE_REDIRECT_URI: http://localhost:3100/auth/callback' +require_config "$config" 'MULTICA_APP_URL: http://localhost:3100' + +for script in scripts/dev.sh scripts/check.sh; do + if ! grep -Fq '. scripts/local-env.sh' "$script"; then + echo "$script must source scripts/local-env.sh for shared local env derivation." + exit 1 + fi +done + +tmp_env="$(mktemp)" +trap 'rm -f "$tmp_env"' EXIT +sed 's/^FRONTEND_PORT=.*/FRONTEND_PORT=3100/' .env.example >"$tmp_env" +printf '\nBACKEND_PORT=9100\n' >>"$tmp_env" + +local_env="$( + env -i PATH="$PATH" bash -c ' + set -euo pipefail + env_file=$1 + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a + # shellcheck disable=SC1091 + . scripts/local-env.sh + printf "%s\n" \ + "PORT=${PORT}" \ + "FRONTEND_PORT=${FRONTEND_PORT}" \ + "FRONTEND_ORIGIN=${FRONTEND_ORIGIN}" \ + "MULTICA_APP_URL=${MULTICA_APP_URL}" \ + "GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI}" \ + "MULTICA_SERVER_URL=${MULTICA_SERVER_URL}" \ + "LOCAL_UPLOAD_BASE_URL=${LOCAL_UPLOAD_BASE_URL}" \ + "PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}" + ' _ "$tmp_env" +)" + +require_env "$local_env" 'PORT=9100' +require_env "$local_env" 'FRONTEND_PORT=3100' +require_env "$local_env" 'FRONTEND_ORIGIN=http://localhost:3100' +require_env "$local_env" 'MULTICA_APP_URL=http://localhost:3100' +require_env "$local_env" 'GOOGLE_REDIRECT_URI=http://localhost:3100/auth/callback' +require_env "$local_env" 'MULTICA_SERVER_URL=ws://localhost:9100/ws' +require_env "$local_env" 'LOCAL_UPLOAD_BASE_URL=http://localhost:9100' +require_env "$local_env" 'PLAYWRIGHT_BASE_URL=http://localhost:3100' + +echo "self-host env derivation ok"