From e6d673d14ec0777b6de4a8743d4a326f4a7614b1 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 4 Jun 2026 09:51:01 +0800 Subject: [PATCH 1/2] Prioritize web port output --- README.md | 4 + docs/development.md | 5 + extras/dev-scripts/worktree-ports.mjs | 161 ++++++++++++++++++--- scripts/dev.sh | 6 +- scripts/worktree-ports.sh | 6 +- stacks/python/scripts/worktree-ports.py | 13 +- stacks/python/tests/test_worktree_ports.py | 11 +- 7 files changed, 179 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 326d45d..9981cf8 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ extras Optional workflow, deployment, and support add-ons 8. Run `./scripts/docker-compose.sh up -d postgres redis`. 9. Run `./scripts/dev.sh`. +The port helper prints `WEB_URL` first, followed by `WEB_PORT` and the rest of +the assigned worktree ports, so coding orchestrators that scan startup output +for a URL open the web surface first. + ## Worktree And Docker Hygiene Keep `.worktreeinclude` as a short allowlist of ignored local files that should follow a developer into new sibling worktrees. Typical entries are `.env`, `.env.local`, and `.sops.yaml`; never include generated directories or large state. diff --git a/docs/development.md b/docs/development.md index a7a7c73..a7f3379 100644 --- a/docs/development.md +++ b/docs/development.md @@ -15,6 +15,11 @@ Local development follows the pattern used across existing projects: ./scripts/check-all.sh ``` +`worktree-ports.sh env` prints `WEB_URL` first, then `WEB_PORT`, then the +remaining assigned ports and derived connection strings. Keep that order when +adapting the helper so coding workspace tools discover the web surface before +API or infrastructure URLs. + ## Worktree Port Reservations `scripts/worktree-ports.sh` normally hashes the absolute git worktree path and diff --git a/extras/dev-scripts/worktree-ports.mjs b/extras/dev-scripts/worktree-ports.mjs index 0192ccf..a1955ca 100755 --- a/extras/dev-scripts/worktree-ports.mjs +++ b/extras/dev-scripts/worktree-ports.mjs @@ -6,13 +6,30 @@ import { resolve } from "node:path"; const BASE_PORT = 8700; const SPAN = 1000; const PORT_BLOCK_SIZE = 100; +const RESERVED_BLOCK_SIZE_DEFAULT = 10; const OFFSETS = { - API_PORT: 20, WEB_PORT: 30, + API_PORT: 20, WORKER_HEALTH_PORT: 35, POSTGRES_HOST_PORT: 40, REDIS_HOST_PORT: 50, + OTEL_HTTP_PORT: 80, }; +const RESERVED_BLOCK_OFFSETS = { + WEB_PORT: 0, + API_PORT: 1, + WORKER_HEALTH_PORT: 2, + POSTGRES_HOST_PORT: 3, + REDIS_HOST_PORT: 4, + OTEL_HTTP_PORT: 5, +}; +const WEB_RESTRICTED_PORTS = new Set([ + 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, + 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, + 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, + 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, + 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080, +]); function worktreeRoot() { try { @@ -33,33 +50,135 @@ function portBlock(root) { ); } -function envValues() { - const base = portBlock(worktreeRoot()); +function chromeSafePort(port) { + let nextPort = port; + while (WEB_RESTRICTED_PORTS.has(nextPort)) { + nextPort += 1; + } + return nextPort; +} + +function positiveInteger(value) { + if (!/^[0-9]+$/.test(value ?? "")) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return parsed > 0 ? parsed : undefined; +} + +function unusedPortInBlock(start, end, used, { browserSafe = false } = {}) { + for (let port = start; port <= end; port += 1) { + const blocked = browserSafe && WEB_RESTRICTED_PORTS.has(port); + if (!blocked && !used.has(port)) { + return port; + } + } + const kind = browserSafe ? "browser-safe free" : "free"; + throw new Error(`reserved port block does not have enough ${kind} ports`); +} + +function portsForBase(base) { const values = Object.fromEntries( - Object.entries(OFFSETS).map(([key, offset]) => [key, String(base + offset)]), + Object.entries(OFFSETS).map(([key, offset]) => [key, base + offset]), ); - values.POSTGRES_URL = `postgresql://app:app@127.0.0.1:${values.POSTGRES_HOST_PORT}/app`; - values.DATABASE_URL = values.POSTGRES_URL; - values.REDIS_URL = `redis://127.0.0.1:${values.REDIS_HOST_PORT}/0`; - values.WEB_API_BASE_URL = `http://127.0.0.1:${values.API_PORT}`; + values.WEB_PORT = chromeSafePort(values.WEB_PORT); + values.API_PORT = chromeSafePort(values.API_PORT); return values; } -const command = process.argv[2] ?? "env"; -const values = envValues(); +function portsForReservedBlock(start, size) { + if (size < Object.keys(RESERVED_BLOCK_OFFSETS).length) { + throw new Error("WORKTREE_PORT_BLOCK_SIZE must be at least 6"); + } + + const end = start + size - 1; + const used = new Set(); + const values = {}; + for (const [key, offset] of Object.entries(RESERVED_BLOCK_OFFSETS)) { + values[key] = unusedPortInBlock(start + offset, end, used, { + browserSafe: key === "WEB_PORT" || key === "API_PORT", + }); + used.add(values[key]); + } + return values; +} + +function validateUniquePorts(values) { + const seen = new Set(); + for (const port of Object.values(values)) { + if (seen.has(port)) { + throw new Error( + `worktree port reservation produced duplicate port ${port}; adjust WORKTREE_PRIMARY_PORT, WORKTREE_PRIMARY_PORT_TARGET, or WORKTREE_PORT_BLOCK_*`, + ); + } + seen.add(port); + } +} -if (command === "env") { - for (const [key, value] of Object.entries(values)) { - // biome-ignore lint/suspicious/noConsole: CLI output is consumed by humans and shell scripts. - console.log(`${key}=${value}`); +function portsForEnvironment(env = process.env) { + const blockStart = positiveInteger(env.WORKTREE_PORT_BLOCK_START); + const values = + blockStart === undefined + ? portsForBase(portBlock(worktreeRoot())) + : portsForReservedBlock( + blockStart, + positiveInteger(env.WORKTREE_PORT_BLOCK_SIZE) ?? RESERVED_BLOCK_SIZE_DEFAULT, + ); + + const primary = positiveInteger(env.WORKTREE_PRIMARY_PORT); + if (primary !== undefined) { + const target = env.WORKTREE_PRIMARY_PORT_TARGET ?? "WEB_PORT"; + if (target !== "API_PORT" && target !== "WEB_PORT") { + throw new Error("WORKTREE_PRIMARY_PORT_TARGET must be API_PORT or WEB_PORT"); + } + values[target] = chromeSafePort(primary); } -} else if (command === "export") { - for (const [key, value] of Object.entries(values)) { - // biome-ignore lint/suspicious/noConsole: CLI output is consumed by humans and shell scripts. - console.log(`export ${key}=${value}`); + + validateUniquePorts(values); + return values; +} + +function envValues(env = process.env) { + const values = portsForEnvironment(env); + const postgresUrl = `postgresql://app:app@127.0.0.1:${values.POSTGRES_HOST_PORT}/app`; + return { + WEB_URL: `http://127.0.0.1:${values.WEB_PORT}`, + WEB_PORT: String(values.WEB_PORT), + API_PORT: String(values.API_PORT), + WORKER_HEALTH_PORT: String(values.WORKER_HEALTH_PORT), + POSTGRES_HOST_PORT: String(values.POSTGRES_HOST_PORT), + REDIS_HOST_PORT: String(values.REDIS_HOST_PORT), + OTEL_HTTP_PORT: String(values.OTEL_HTTP_PORT), + POSTGRES_URL: postgresUrl, + DATABASE_URL: postgresUrl, + REDIS_URL: `redis://127.0.0.1:${values.REDIS_HOST_PORT}/0`, + WEB_API_BASE_URL: `http://127.0.0.1:${values.API_PORT}`, + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${values.OTEL_HTTP_PORT}`, + }; +} + +const command = process.argv[2] ?? "env"; + +try { + if (command === "env") { + const values = envValues(); + for (const [key, value] of Object.entries(values)) { + // biome-ignore lint/suspicious/noConsole: CLI output is consumed by humans and shell scripts. + console.log(`${key}=${value}`); + } + } else if (command === "export") { + const values = envValues(); + for (const [key, value] of Object.entries(values)) { + // biome-ignore lint/suspicious/noConsole: CLI output is consumed by humans and shell scripts. + console.log(`export ${key}=${value}`); + } + } else { + // biome-ignore lint/suspicious/noConsole: CLI usage errors belong on stderr. + console.error("usage: worktree-ports.mjs [env|export]"); + process.exit(2); } -} else { +} catch (error) { // biome-ignore lint/suspicious/noConsole: CLI usage errors belong on stderr. - console.error("usage: worktree-ports.mjs [env|export]"); - process.exit(2); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); } diff --git a/scripts/dev.sh b/scripts/dev.sh index de5a11b..906fb2f 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -10,7 +10,11 @@ export WEB_HOST="${WEB_HOST:-127.0.0.1}" # watcher. Runtime-specific services, such as the Python API, live in stacks and # should be started from their stack scripts when selected for a target repo. echo "508 Devkit local stack" -echo " Web: framework-neutral TypeScript watcher; reserve WEB_PORT=${WEB_PORT} for your chosen web framework" +echo "Assigned worktree ports:" +./scripts/worktree-ports.sh env | sed 's/^/ /' +echo +echo "Starting services" +echo " Web: ${WEB_URL} (framework-neutral TypeScript watcher)" echo " Postgres: 127.0.0.1:${POSTGRES_HOST_PORT}" echo " Redis: 127.0.0.1:${REDIS_HOST_PORT}" echo diff --git a/scripts/worktree-ports.sh b/scripts/worktree-ports.sh index 8ed11ce..577a4b0 100755 --- a/scripts/worktree-ports.sh +++ b/scripts/worktree-ports.sh @@ -214,6 +214,7 @@ calculate_ports() { POSTGRES_URL="postgresql://app:app@127.0.0.1:${POSTGRES_HOST_PORT}/app" DATABASE_URL="$POSTGRES_URL" REDIS_URL="redis://127.0.0.1:${REDIS_HOST_PORT}/0" + WEB_URL="http://127.0.0.1:${WEB_PORT}" WEB_API_BASE_URL="http://127.0.0.1:${API_PORT}" OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:${OTEL_HTTP_PORT}" } @@ -221,8 +222,9 @@ calculate_ports() { print_env() { prefix="$1" calculate_ports - printf '%sAPI_PORT=%s\n' "$prefix" "$API_PORT" + printf '%sWEB_URL=%s\n' "$prefix" "$WEB_URL" printf '%sWEB_PORT=%s\n' "$prefix" "$WEB_PORT" + printf '%sAPI_PORT=%s\n' "$prefix" "$API_PORT" printf '%sWORKER_HEALTH_PORT=%s\n' "$prefix" "$WORKER_HEALTH_PORT" printf '%sPOSTGRES_HOST_PORT=%s\n' "$prefix" "$POSTGRES_HOST_PORT" printf '%sREDIS_HOST_PORT=%s\n' "$prefix" "$REDIS_HOST_PORT" @@ -239,7 +241,7 @@ export_env() { export API_PORT WEB_PORT WORKER_HEALTH_PORT export POSTGRES_HOST_PORT REDIS_HOST_PORT export OTEL_HTTP_PORT POSTGRES_URL DATABASE_URL REDIS_URL - export WEB_API_BASE_URL OTEL_EXPORTER_OTLP_ENDPOINT + export WEB_URL WEB_API_BASE_URL OTEL_EXPORTER_OTLP_ENDPOINT } run_with_env() { diff --git a/stacks/python/scripts/worktree-ports.py b/stacks/python/scripts/worktree-ports.py index 9d2ccb9..b674a09 100755 --- a/stacks/python/scripts/worktree-ports.py +++ b/stacks/python/scripts/worktree-ports.py @@ -15,8 +15,8 @@ PORT_BLOCK_SIZE = 100 RESERVED_BLOCK_SIZE_DEFAULT = 10 HASH_OFFSETS = { - "API_PORT": 20, "WEB_PORT": 30, + "API_PORT": 20, "WORKER_HEALTH_PORT": 35, "POSTGRES_HOST_PORT": 40, "REDIS_HOST_PORT": 50, @@ -228,8 +228,17 @@ def env_values(env: dict[str, str] | None = None) -> dict[str, str]: values = ports_for_environment(env or os.environ) postgres = values["POSTGRES_HOST_PORT"] redis = values["REDIS_HOST_PORT"] + web = values["WEB_PORT"] api = values["API_PORT"] - result = {name: str(port) for name, port in values.items()} + result = { + "WEB_URL": f"http://127.0.0.1:{web}", + "WEB_PORT": str(values["WEB_PORT"]), + "API_PORT": str(values["API_PORT"]), + "WORKER_HEALTH_PORT": str(values["WORKER_HEALTH_PORT"]), + "POSTGRES_HOST_PORT": str(values["POSTGRES_HOST_PORT"]), + "REDIS_HOST_PORT": str(values["REDIS_HOST_PORT"]), + "OTEL_HTTP_PORT": str(values["OTEL_HTTP_PORT"]), + } result.update( { "POSTGRES_URL": f"postgresql://app:app@127.0.0.1:{postgres}/app", diff --git a/stacks/python/tests/test_worktree_ports.py b/stacks/python/tests/test_worktree_ports.py index 882671a..3f18479 100644 --- a/stacks/python/tests/test_worktree_ports.py +++ b/stacks/python/tests/test_worktree_ports.py @@ -26,13 +26,22 @@ def test_ports_for_base_uses_expected_offsets() -> None: ports = module.ports_for_base(8700) - assert ports["API_PORT"] == 8720 assert ports["WEB_PORT"] == 8730 + assert ports["API_PORT"] == 8720 assert ports["POSTGRES_HOST_PORT"] == 8740 assert ports["REDIS_HOST_PORT"] == 8750 assert ports["OTEL_HTTP_PORT"] == 8780 +def test_env_values_print_web_url_first() -> None: + module = load_worktree_ports_module() + + values = module.env_values({"WORKTREE_PORT_BLOCK_START": "9000"}) + + assert list(values)[:3] == ["WEB_URL", "WEB_PORT", "API_PORT"] + assert values["WEB_URL"] == "http://127.0.0.1:9000" + + def test_reserved_block_uses_compact_offsets() -> None: module = load_worktree_ports_module() From e7654cc361a7f406b6b446215eedc2a879bd1880 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 4 Jun 2026 10:03:51 +0800 Subject: [PATCH 2/2] Fix worktree port CI issues --- extras/dev-scripts/worktree-ports.mjs | 10 ++-- scripts/worktree-ports.sh | 37 +++++++++++++ stacks/python/scripts/worktree-ports.py | 60 ++++++++++++++-------- stacks/python/tests/test_worktree_ports.py | 24 +++++++++ 4 files changed, 106 insertions(+), 25 deletions(-) diff --git a/extras/dev-scripts/worktree-ports.mjs b/extras/dev-scripts/worktree-ports.mjs index a1955ca..0e5b884 100755 --- a/extras/dev-scripts/worktree-ports.mjs +++ b/extras/dev-scripts/worktree-ports.mjs @@ -24,11 +24,11 @@ const RESERVED_BLOCK_OFFSETS = { OTEL_HTTP_PORT: 5, }; const WEB_RESTRICTED_PORTS = new Set([ - 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, - 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, - 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, - 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, - 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 10080, + 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, + 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, + 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, + 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, + 6697, 10080, ]); function worktreeRoot() { diff --git a/scripts/worktree-ports.sh b/scripts/worktree-ports.sh index 577a4b0..81fcaa3 100755 --- a/scripts/worktree-ports.sh +++ b/scripts/worktree-ports.sh @@ -244,6 +244,41 @@ export_env() { export WEB_URL WEB_API_BASE_URL OTEL_EXPORTER_OTLP_ENDPOINT } +has_override() { + key="$1" + while IFS= read -r assignment; do + case "$assignment" in + "$key="*) return 0 ;; + esac + done < dict def env_values(env: dict[str, str] | None = None) -> dict[str, str]: """Return shell-friendly strings consumed by Compose and dev scripts.""" values = ports_for_environment(env or os.environ) - postgres = values["POSTGRES_HOST_PORT"] - redis = values["REDIS_HOST_PORT"] - web = values["WEB_PORT"] - api = values["API_PORT"] - result = { + result = {name: str(port) for name, port in values.items()} + result.update(derived_env_values(result)) + return ordered_env_values(result) + + +def derived_env_values(env: dict[str, str]) -> dict[str, str]: + """Return URL values derived from the final port environment.""" + web = env["WEB_PORT"] + api = env["API_PORT"] + postgres = env["POSTGRES_HOST_PORT"] + redis = env["REDIS_HOST_PORT"] + otel = env["OTEL_HTTP_PORT"] + postgres_url = f"postgresql://app:app@127.0.0.1:{postgres}/app" + return { "WEB_URL": f"http://127.0.0.1:{web}", - "WEB_PORT": str(values["WEB_PORT"]), - "API_PORT": str(values["API_PORT"]), - "WORKER_HEALTH_PORT": str(values["WORKER_HEALTH_PORT"]), - "POSTGRES_HOST_PORT": str(values["POSTGRES_HOST_PORT"]), - "REDIS_HOST_PORT": str(values["REDIS_HOST_PORT"]), - "OTEL_HTTP_PORT": str(values["OTEL_HTTP_PORT"]), + "POSTGRES_URL": postgres_url, + "DATABASE_URL": postgres_url, + "REDIS_URL": f"redis://127.0.0.1:{redis}/0", + "WEB_API_BASE_URL": f"http://127.0.0.1:{api}", + "OTEL_EXPORTER_OTLP_ENDPOINT": f"http://127.0.0.1:{otel}", + } + + +def ordered_env_values(env: dict[str, str]) -> dict[str, str]: + """Keep WEB_URL first for URL scanners while preserving shell-friendly keys.""" + result = { + "WEB_URL": env["WEB_URL"], + "WEB_PORT": env["WEB_PORT"], + "API_PORT": env["API_PORT"], + "WORKER_HEALTH_PORT": env["WORKER_HEALTH_PORT"], + "POSTGRES_HOST_PORT": env["POSTGRES_HOST_PORT"], + "REDIS_HOST_PORT": env["REDIS_HOST_PORT"], + "OTEL_HTTP_PORT": env["OTEL_HTTP_PORT"], + "POSTGRES_URL": env["POSTGRES_URL"], + "DATABASE_URL": env["DATABASE_URL"], + "REDIS_URL": env["REDIS_URL"], + "WEB_API_BASE_URL": env["WEB_API_BASE_URL"], + "OTEL_EXPORTER_OTLP_ENDPOINT": env["OTEL_EXPORTER_OTLP_ENDPOINT"], } - result.update( - { - "POSTGRES_URL": f"postgresql://app:app@127.0.0.1:{postgres}/app", - "DATABASE_URL": f"postgresql://app:app@127.0.0.1:{postgres}/app", - "REDIS_URL": f"redis://127.0.0.1:{redis}/0", - "WEB_API_BASE_URL": f"http://127.0.0.1:{api}", - "OTEL_EXPORTER_OTLP_ENDPOINT": f"http://127.0.0.1:{values['OTEL_HTTP_PORT']}", - } - ) return result @@ -288,6 +305,9 @@ def run_with_env(args: list[str]) -> int: env.update(overrides) env.update(env_values(env)) env.update(overrides) + for key, value in derived_env_values(env).items(): + if key not in overrides: + env[key] = value return subprocess.run(command, env=env, check=False).returncode diff --git a/stacks/python/tests/test_worktree_ports.py b/stacks/python/tests/test_worktree_ports.py index 3f18479..866aece 100644 --- a/stacks/python/tests/test_worktree_ports.py +++ b/stacks/python/tests/test_worktree_ports.py @@ -1,6 +1,8 @@ from __future__ import annotations import importlib.util +import subprocess +import sys from pathlib import Path @@ -42,6 +44,28 @@ def test_env_values_print_web_url_first() -> None: assert values["WEB_URL"] == "http://127.0.0.1:9000" +def test_exec_direct_web_port_override_refreshes_web_url() -> None: + script = Path(__file__).resolve().parents[1] / "scripts" / "worktree-ports.py" + + result = subprocess.run( + [ + sys.executable, + str(script), + "exec", + "WEB_PORT=9999", + "--", + sys.executable, + "-c", + "import os; print(os.environ['WEB_PORT']); print(os.environ['WEB_URL'])", + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.splitlines() == ["9999", "http://127.0.0.1:9999"] + + def test_reserved_block_uses_compact_offsets() -> None: module = load_worktree_ports_module()