diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 7e487d092c..66f2e376d3 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -4,10 +4,15 @@ tasks: dev: desc: "Start backend dev server" ignore_error: true + vars: + PORT: '{{.PORT | default "8080"}}' + AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' + env: + SERVER_PORT: '{{.PORT}}' cmds: - - cmd: cmd /c gradlew.bat :stirling-pdf:bootRun + - cmd: '{{if .AIENGINE_URL}}set "AIENGINE_URL={{.AIENGINE_URL}}" && set "AIENGINE_ENABLED=true" && {{end}}cmd /c gradlew.bat :stirling-pdf:bootRun' platforms: [windows] - - cmd: ./gradlew :stirling-pdf:bootRun + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] build: diff --git a/.taskfiles/desktop.yml b/.taskfiles/desktop.yml index 0bf938e806..c18728767e 100644 --- a/.taskfiles/desktop.yml +++ b/.taskfiles/desktop.yml @@ -6,7 +6,11 @@ vars: tasks: prepare: desc: "Prepare desktop build dependencies" - deps: [jlink, ":frontend:prepare:desktop", provisioner] + deps: + - jlink + - task: ":frontend:prepare" + vars: { MODE: desktop } + - provisioner provisioner: desc: "Build installer provisioner" diff --git a/.taskfiles/engine.yml b/.taskfiles/engine.yml index 9d830e0808..339ae1b297 100644 --- a/.taskfiles/engine.yml +++ b/.taskfiles/engine.yml @@ -28,20 +28,24 @@ tasks: deps: [prepare] ignore_error: true dir: src + vars: + PORT: '{{.PORT | default "5001"}}' env: PYTHONUNBUFFERED: "1" cmds: - - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port 5001 + - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} dev: desc: "Start engine dev server with hot reload" deps: [prepare] ignore_error: true dir: src + vars: + PORT: '{{.PORT | default "5001"}}' env: PYTHONUNBUFFERED: "1" cmds: - - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port 5001 --reload + - uv run uvicorn stirling.api.app:app --host 0.0.0.0 --port {{.PORT}} --reload lint: desc: "Run linting" diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 984ae4b322..43f0511176 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -15,42 +15,21 @@ tasks: CI: '{{ .CI | default "false" }}' prepare:env: - desc: "Generate .env from example if missing" - run: once - deps: [install] - cmds: - - npx tsx scripts/setup-env.ts - sources: - - scripts/setup-env.ts - generates: - - .env.local - - prepare:env:saas: - desc: "Generate .env and .env.saas from examples if missing" - run: once - deps: [install] - cmds: - - npx tsx scripts/setup-env.ts --saas - sources: - - scripts/setup-env.ts - generates: - - .env.local - - .env.saas.local - - prepare:env:desktop: - desc: "Generate .env and .env.desktop from examples if missing" - run: once + internal: true + run: when_changed deps: [install] + vars: + MODE: '{{.MODE | default ""}}' cmds: - - npx tsx scripts/setup-env.ts --desktop + - npx tsx scripts/setup-env.ts{{if .MODE}} --{{.MODE}}{{end}} sources: - scripts/setup-env.ts generates: - .env.local - - .env.desktop.local + - .env{{if .MODE}}.{{.MODE}}{{end}}.local prepare:icons: - desc: "Generate icon bundle from source references" + internal: true run: once deps: [install] cmds: @@ -58,64 +37,75 @@ tasks: prepare: desc: "Set up dev environment" - run: once - deps: [prepare:env, prepare:icons] - - prepare:saas: - desc: "Prepare for SaaS mode" - run: once - deps: [prepare:env:saas, prepare:icons] - - prepare:desktop: - desc: "Prepare for desktop mode" - run: once - deps: [prepare:env:desktop, prepare:icons] + run: when_changed + vars: + MODE: '{{.MODE | default ""}}' + deps: + - task: prepare:env + vars: { MODE: '{{.MODE}}' } + - prepare:icons # ============================================================ # Development # ============================================================ + dev:_run: + internal: true + ignore_error: true + vars: + MODE: '{{.MODE}}' + PORT: '{{.PORT | default "5173"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + OPEN: '{{.OPEN | default ""}}' + env: + BACKEND_URL: '{{.BACKEND_URL}}' + cmds: + - npx vite --mode {{.MODE}} --port {{.PORT}}{{if .OPEN}} --open{{end}} + dev: desc: "Start frontend dev server" - deps: [prepare] - ignore_error: true cmds: - - npx vite + - task: dev:proprietary + vars: { PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:core: desc: "Start frontend dev server in core mode" deps: [prepare] - ignore_error: true cmds: - - npx vite --mode core + - task: dev:_run + vars: { MODE: core, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:proprietary: desc: "Start frontend dev server in proprietary mode" deps: [prepare] - ignore_error: true cmds: - - npx vite --mode proprietary + - task: dev:_run + vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:saas: desc: "Start frontend dev server in SaaS mode" - deps: [prepare:saas] - ignore_error: true + deps: + - task: prepare + vars: { MODE: saas } cmds: - - npx vite --mode saas + - task: dev:_run + vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:desktop: desc: "Start frontend dev server in desktop mode" - deps: [prepare:desktop] - ignore_error: true + deps: + - task: prepare + vars: { MODE: desktop } cmds: - - npx vite --mode desktop + - task: dev:_run + vars: { MODE: desktop, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } dev:prototypes: desc: "Start frontend dev server in prototypes mode" deps: [prepare] - ignore_error: true cmds: - - npx vite --mode prototypes + - task: dev:_run + vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } # ============================================================ # Build @@ -141,13 +131,17 @@ tasks: build:saas: desc: "Build for SaaS mode" - deps: [prepare:saas] + deps: + - task: prepare + vars: { MODE: saas } cmds: - npx vite build --mode saas build:desktop: desc: "Build for desktop mode" - deps: [prepare:desktop] + deps: + - task: prepare + vars: { MODE: desktop } cmds: - npx vite build --mode desktop @@ -211,13 +205,17 @@ tasks: typecheck:saas: desc: "Typecheck SaaS build variant" - deps: [prepare:saas] + deps: + - task: prepare + vars: { MODE: saas } cmds: - npx tsc --noEmit --project src/saas/tsconfig.json typecheck:desktop: desc: "Typecheck desktop build variant" - deps: [prepare:desktop] + deps: + - task: prepare + vars: { MODE: desktop } cmds: - npx tsc --noEmit --project src/desktop/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 49e456eb71..a90ceecb5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,8 @@ This file provides guidance to AI Agents when working with code in this reposito This project uses [Task](https://taskfile.dev/) as a unified command runner. All build, dev, test, lint, and docker commands can be run from the repo root via `task `. Run `task --list` to see all available commands. +Task `desc:` fields should describe **what** the task does, not **how** it does it. Keep them generic and stable: don't reference implementation details like aliases, internal helpers, mode flags, or which other task delegates to which. The description is for users picking a command from `task --list`, not a changelog of refactors. + ### Quick Reference - `task install` — install all dependencies - `task dev` — start backend + frontend concurrently @@ -143,7 +145,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie - These files are committed to Git and must not contain private keys - Local overrides (API keys, machine-specific settings) go in uncommitted sibling `.env.local` / `.env.saas.local` / `.env.desktop.local` files — Vite automatically layers them on top - Never use `|| 'hardcoded-fallback'` inline — put defaults in the committed env files -- `task frontend:prepare` / `prepare:saas` / `prepare:desktop` create empty `.local` override files on first run +- `task frontend:prepare` creates empty `.local` override files on first run; pass `MODE=saas` or `MODE=desktop` to also create the mode-specific `.local` file - Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks - See `frontend/README.md#environment-variables` for full documentation diff --git a/Taskfile.yml b/Taskfile.yml index 4840a82697..ecdb97879c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -2,6 +2,10 @@ version: '3' output: prefixed +vars: + FIND_FREE_PORT_PS: powershell -NoProfile -File scripts\find-free-port.ps1 -Preferred + FIND_FREE_PORT_SH: bash scripts/find-free-port.sh + includes: backend: taskfile: .taskfiles/backend.yml @@ -35,17 +39,43 @@ tasks: # ============================================================ dev: - desc: "Start backend + frontend concurrently" + desc: "Start backend + frontend concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' deps: - - backend:dev - - frontend:dev + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + - task: frontend:dev + vars: + PORT: '{{.FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" dev:all: - desc: "Start backend + frontend + engine concurrently" + desc: "Start backend + frontend + engine concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080,5173,5001{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5001{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + FRONTEND_PORT: '{{index (splitList "\n" .PORTS) 1}}' + ENGINE_PORT: '{{index (splitList "\n" .PORTS) 2}}' deps: - - backend:dev - - frontend:dev:prototypes - - engine:dev + - task: engine:dev + vars: + PORT: '{{.ENGINE_PORT}}' + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}' + - task: frontend:dev:prototypes + vars: + PORT: '{{.FRONTEND_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" # ============================================================ # Build diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 174f0a23b6..0d6052c3cb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -39,6 +39,16 @@ export default defineConfig(({ mode }) => { const tsconfigProject = TSCONFIG_MAP[effectiveMode]; + // Backend proxy target: default localhost:8080. Override via BACKEND_URL env var + // so the top-level dev launcher can wire a dynamically-assigned backend port. + const backendUrl = process.env.BACKEND_URL || "http://localhost:8080"; + const backendProxy = { + target: backendUrl, + changeOrigin: true, + secure: false, + xfwd: true, + }; + return { plugins: [ react(), @@ -88,48 +98,13 @@ export default defineConfig(({ mode }) => { effectiveMode === "desktop" ? undefined : { - "/api": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/oauth2": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/saml2": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/login/oauth2": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/login/saml2": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/swagger-ui": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, - "/v1/api-docs": { - target: "http://localhost:8080", - changeOrigin: true, - secure: false, - xfwd: true, - }, + "/api": backendProxy, + "/oauth2": backendProxy, + "/saml2": backendProxy, + "/login/oauth2": backendProxy, + "/login/saml2": backendProxy, + "/swagger-ui": backendProxy, + "/v1/api-docs": backendProxy, }, }, base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./", diff --git a/scripts/find-free-port.ps1 b/scripts/find-free-port.ps1 new file mode 100644 index 0000000000..7b6697456d --- /dev/null +++ b/scripts/find-free-port.ps1 @@ -0,0 +1,41 @@ +# Prints one free TCP port per preferred port given as an argument. +# +# For each element of -Preferred, emits that port if it's free; otherwise +# emits a random free port in 20000-49999. Probes by attempting to bind a +# TcpListener on loopback. Tracks picks within this run so outputs are +# guaranteed distinct from each other. +param([int[]]$Preferred) + +$script:picked = @() + +function Test-PortFree { + param([int]$Port) + if ($script:picked -contains $Port) { return $false } + try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $Port) + $listener.Start() + $listener.Stop() + return $true + } catch { + return $false + } +} + +function Get-RandomFreePort { + while ($true) { + $port = Get-Random -Minimum 20000 -Maximum 50000 + if (Test-PortFree $port) { return $port } + } +} + +foreach ($p in $Preferred) { + if (Test-PortFree $p) { + $script:picked += $p + } else { + $script:picked += Get-RandomFreePort + } +} + +foreach ($p in $script:picked) { + [Console]::Out.Write("$p`n") +} diff --git a/scripts/find-free-port.sh b/scripts/find-free-port.sh new file mode 100755 index 0000000000..0d22c1f017 --- /dev/null +++ b/scripts/find-free-port.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Prints one free TCP port per preferred port given as an argument. +# +# For each argument, emits that port if it's free; otherwise emits a random +# free port in 20000-49999. Probes via bash's /dev/tcp pseudo-device (connect +# failure = nobody listening). Tracks picks within this run so outputs are +# guaranteed distinct from each other. +set -euo pipefail + +declare -a picked=() + +is_free() { + local port=$1 + for p in ${picked[@]+"${picked[@]}"}; do + if [ "$p" = "$port" ]; then return 1; fi + done + if (exec 3<>"/dev/tcp/127.0.0.1/$port") >/dev/null 2>&1; then + return 1 + fi + return 0 +} + +random_free_port() { + while true; do + local port=$((RANDOM % 30000 + 20000)) + if is_free "$port"; then + echo "$port" + return + fi + done +} + +for preferred in "$@"; do + if is_free "$preferred"; then + picked+=("$preferred") + else + picked+=("$(random_free_port)") + fi +done + +for p in "${picked[@]}"; do + echo "$p" +done