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: dev:
desc: "Start backend dev server" desc: "Start backend dev server"
ignore_error: true ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds: 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] 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] platforms: [linux, darwin]
build: build:
+5 -1
View File
@@ -6,7 +6,11 @@ vars:
tasks: tasks:
prepare: prepare:
desc: "Prepare desktop build dependencies" desc: "Prepare desktop build dependencies"
deps: [jlink, ":frontend:prepare:desktop", provisioner] deps:
- jlink
- task: ":frontend:prepare"
vars: { MODE: desktop }
- provisioner
provisioner: provisioner:
desc: "Build installer provisioner" desc: "Build installer provisioner"
+6 -2
View File
@@ -28,20 +28,24 @@ tasks:
deps: [prepare] deps: [prepare]
ignore_error: true ignore_error: true
dir: src dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
env: env:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
cmds: 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: dev:
desc: "Start engine dev server with hot reload" desc: "Start engine dev server with hot reload"
deps: [prepare] deps: [prepare]
ignore_error: true ignore_error: true
dir: src dir: src
vars:
PORT: '{{.PORT | default "5001"}}'
env: env:
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
cmds: 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: lint:
desc: "Run linting" desc: "Run linting"
+57 -59
View File
@@ -15,42 +15,21 @@ tasks:
CI: '{{ .CI | default "false" }}' CI: '{{ .CI | default "false" }}'
prepare:env: prepare:env:
desc: "Generate .env from example if missing" internal: true
run: once run: when_changed
deps: [install] deps: [install]
vars:
MODE: '{{.MODE | default ""}}'
cmds: cmds:
- npx tsx scripts/setup-env.ts - npx tsx scripts/setup-env.ts{{if .MODE}} --{{.MODE}}{{end}}
sources: sources:
- scripts/setup-env.ts - scripts/setup-env.ts
generates: generates:
- .env.local - .env.local
- .env{{if .MODE}}.{{.MODE}}{{end}}.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
prepare:icons: prepare:icons:
desc: "Generate icon bundle from source references" internal: true
run: once run: once
deps: [install] deps: [install]
cmds: cmds:
@@ -58,64 +37,75 @@ tasks:
prepare: prepare:
desc: "Set up dev environment" desc: "Set up dev environment"
run: once run: when_changed
deps: [prepare:env, prepare:icons] vars:
MODE: '{{.MODE | default ""}}'
prepare:saas: deps:
desc: "Prepare for SaaS mode" - task: prepare:env
run: once vars: { MODE: '{{.MODE}}' }
deps: [prepare:env:saas, prepare:icons] - prepare:icons
prepare:desktop:
desc: "Prepare for desktop mode"
run: once
deps: [prepare:env:desktop, prepare:icons]
# ============================================================ # ============================================================
# Development # 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: dev:
desc: "Start frontend dev server" desc: "Start frontend dev server"
deps: [prepare]
ignore_error: true
cmds: cmds:
- npx vite - task: dev:proprietary
vars: { PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:core: dev:core:
desc: "Start frontend dev server in core mode" desc: "Start frontend dev server in core mode"
deps: [prepare] deps: [prepare]
ignore_error: true
cmds: cmds:
- npx vite --mode core - task: dev:_run
vars: { MODE: core, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:proprietary: dev:proprietary:
desc: "Start frontend dev server in proprietary mode" desc: "Start frontend dev server in proprietary mode"
deps: [prepare] deps: [prepare]
ignore_error: true
cmds: cmds:
- npx vite --mode proprietary - task: dev:_run
vars: { MODE: proprietary, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:saas: dev:saas:
desc: "Start frontend dev server in SaaS mode" desc: "Start frontend dev server in SaaS mode"
deps: [prepare:saas] deps:
ignore_error: true - task: prepare
vars: { MODE: saas }
cmds: cmds:
- npx vite --mode saas - task: dev:_run
vars: { MODE: saas, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:desktop: dev:desktop:
desc: "Start frontend dev server in desktop mode" desc: "Start frontend dev server in desktop mode"
deps: [prepare:desktop] deps:
ignore_error: true - task: prepare
vars: { MODE: desktop }
cmds: cmds:
- npx vite --mode desktop - task: dev:_run
vars: { MODE: desktop, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:prototypes: dev:prototypes:
desc: "Start frontend dev server in prototypes mode" desc: "Start frontend dev server in prototypes mode"
deps: [prepare] deps: [prepare]
ignore_error: true
cmds: cmds:
- npx vite --mode prototypes - task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
# ============================================================ # ============================================================
# Build # Build
@@ -141,13 +131,17 @@ tasks:
build:saas: build:saas:
desc: "Build for SaaS mode" desc: "Build for SaaS mode"
deps: [prepare:saas] deps:
- task: prepare
vars: { MODE: saas }
cmds: cmds:
- npx vite build --mode saas - npx vite build --mode saas
build:desktop: build:desktop:
desc: "Build for desktop mode" desc: "Build for desktop mode"
deps: [prepare:desktop] deps:
- task: prepare
vars: { MODE: desktop }
cmds: cmds:
- npx vite build --mode desktop - npx vite build --mode desktop
@@ -211,13 +205,17 @@ tasks:
typecheck:saas: typecheck:saas:
desc: "Typecheck SaaS build variant" desc: "Typecheck SaaS build variant"
deps: [prepare:saas] deps:
- task: prepare
vars: { MODE: saas }
cmds: cmds:
- npx tsc --noEmit --project src/saas/tsconfig.json - npx tsc --noEmit --project src/saas/tsconfig.json
typecheck:desktop: typecheck:desktop:
desc: "Typecheck desktop build variant" desc: "Typecheck desktop build variant"
deps: [prepare:desktop] deps:
- task: prepare
vars: { MODE: desktop }
cmds: cmds:
- npx tsc --noEmit --project src/desktop/tsconfig.json - 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. 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 ### Quick Reference
- `task install` — install all dependencies - `task install` — install all dependencies
- `task dev` — start backend + frontend concurrently - `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 - 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 - 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 - 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 - Prepare runs automatically as a dependency of all `dev*`, `build*`, and `desktop*` tasks
- See `frontend/README.md#environment-variables` for full documentation - See `frontend/README.md#environment-variables` for full documentation
+37 -7
View File
@@ -2,6 +2,10 @@ version: '3'
output: prefixed 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: includes:
backend: backend:
taskfile: .taskfiles/backend.yml taskfile: .taskfiles/backend.yml
@@ -35,17 +39,43 @@ tasks:
# ============================================================ # ============================================================
dev: 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: deps:
- backend:dev - task: backend:dev
- frontend:dev vars:
PORT: '{{.BACKEND_PORT}}'
- task: frontend:dev
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
OPEN: "true"
dev:all: 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: deps:
- backend:dev - task: engine:dev
- frontend:dev:prototypes vars:
- engine:dev 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 # Build
+17 -42
View File
@@ -39,6 +39,16 @@ export default defineConfig(({ mode }) => {
const tsconfigProject = TSCONFIG_MAP[effectiveMode]; 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 { return {
plugins: [ plugins: [
react(), react(),
@@ -88,48 +98,13 @@ export default defineConfig(({ mode }) => {
effectiveMode === "desktop" effectiveMode === "desktop"
? undefined ? undefined
: { : {
"/api": { "/api": backendProxy,
target: "http://localhost:8080", "/oauth2": backendProxy,
changeOrigin: true, "/saml2": backendProxy,
secure: false, "/login/oauth2": backendProxy,
xfwd: true, "/login/saml2": backendProxy,
}, "/swagger-ui": backendProxy,
"/oauth2": { "/v1/api-docs": backendProxy,
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,
},
}, },
}, },
base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : "./", 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