Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 140 additions & 21 deletions extras/dev-scripts/worktree-ports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function worktreeRoot() {
try {
Expand All @@ -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);
}
6 changes: 5 additions & 1 deletion scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions scripts/worktree-ports.sh
Original file line number Diff line number Diff line change
Expand Up @@ -214,15 +214,17 @@ 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}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute WEB_URL after direct port overrides

When using the documented exec form with a direct port override, such as ./scripts/worktree-ports.sh exec WEB_PORT=9999 -- ..., run_with_env calls export_env and then re-exports the override, so WEB_PORT becomes 9999 while this newly added WEB_URL still points at the generated port. Any tool or service that consumes WEB_URL in that context opens the wrong address; recompute derived URLs after overrides or derive them from the final port values. The Python helper has the same stale WEB_URL pattern.

Useful? React with 👍 / 👎.

WEB_API_BASE_URL="http://127.0.0.1:${API_PORT}"
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:${OTEL_HTTP_PORT}"
}

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"
Expand All @@ -239,7 +241,42 @@ 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
}

has_override() {
key="$1"
while IFS= read -r assignment; do
case "$assignment" in
"$key="*) return 0 ;;
esac
done <<EOF
$overrides
EOF
return 1
}

refresh_derived_env_after_overrides() {
if ! has_override POSTGRES_URL; then
POSTGRES_URL="postgresql://app:app@127.0.0.1:${POSTGRES_HOST_PORT}/app"
fi
if ! has_override DATABASE_URL; then
DATABASE_URL="$POSTGRES_URL"
fi
if ! has_override REDIS_URL; then
REDIS_URL="redis://127.0.0.1:${REDIS_HOST_PORT}/0"
fi
if ! has_override WEB_URL; then
WEB_URL="http://127.0.0.1:${WEB_PORT}"
fi
if ! has_override WEB_API_BASE_URL; then
WEB_API_BASE_URL="http://127.0.0.1:${API_PORT}"
fi
if ! has_override OTEL_EXPORTER_OTLP_ENDPOINT; then
OTEL_EXPORTER_OTLP_ENDPOINT="http://127.0.0.1:${OTEL_HTTP_PORT}"
fi
export POSTGRES_URL DATABASE_URL REDIS_URL
export WEB_URL WEB_API_BASE_URL OTEL_EXPORTER_OTLP_ENDPOINT
}

run_with_env() {
Expand Down Expand Up @@ -284,6 +321,8 @@ $1"
$overrides
EOF

refresh_derived_env_after_overrides

exec "$@"
}

Expand Down
55 changes: 42 additions & 13 deletions stacks/python/scripts/worktree-ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -226,19 +226,45 @@ def ports_for_environment(env: dict[str, str], root: Path | None = None) -> 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"]
api = values["API_PORT"]
result = {name: str(port) for name, port in values.items()}
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']}",
}
)
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}",
"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"],
}
return result


Expand Down Expand Up @@ -279,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

Expand Down
Loading