Have Task choose free ports for dev servers (#6145)

This commit is contained in:
James Brunton
2026-04-28 17:26:04 +01:00
committed by GitHub
parent 12a00fe9c5
commit 49f24fa7a9
9 changed files with 216 additions and 114 deletions
+7 -2
View File
@@ -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:
+5 -1
View File
@@ -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"
+6 -2
View File
@@ -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"
+57 -59
View File
@@ -15,42 +15,21 @@ tasks:
CI: '{{ .CI | default "false" }}'
prepare:env:
desc: "Generate .env from example if missing"
run: once
internal: true
run: when_changed
deps: [install]
vars:
MODE: '{{.MODE | default ""}}'
cmds:
- npx tsx scripts/setup-env.ts
- npx tsx scripts/setup-env.ts{{if .MODE}} --{{.MODE}}{{end}}
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
deps: [install]
cmds:
- npx tsx scripts/setup-env.ts --desktop
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
+3 -1
View File
@@ -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 <command>`. 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
+37 -7
View File
@@ -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
+17 -42
View File
@@ -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}` : "./",
+41
View File
@@ -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")
}
+43
View File
@@ -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