typescript-cli-default-port-shadow
Root cause: --server (and watch-game's --ws) carried static commander option defaults pointing at :3000. Static defaults bypass env resolution and hardcode the port shadowed by DuckBrain. The fix is one shared config.ts with runtime resolvers (flag > env > default) and no static URL defaults anywhere in the CLI.
1. src/config.ts — single source of truth
/** The real compose API port. :3000 is owned by a foreign daemon — never default to it. */
export const DEFAULT_SERVER_PORT = 3004;
export const DEFAULT_SERVER_URL = `http://localhost:${DEFAULT_SERVER_PORT}`;
export const DEFAULT_WS_URL = `ws://localhost:${DEFAULT_SERVER_PORT}`;
export const ENV_SERVER_URL = "MAFIA_SERVER_URL";
export const ENV_WS_URL = "MAFIA_WS_URL";
export function normalizeBase(url: string): string {
return url.replace(/\/+$/, "");
}
/** http(s):// -> ws(s):// ; pass ws(s):// through untouched. */
export function httpToWs(url: string): string {
const u = normalizeBase(url);
if (/^wss?:\/\//i.test(u)) return u;
return u.replace(/^https/i, "wss").replace(/^http/i, "ws");
}
/** Precedence: flag > MAFIA_SERVER_URL > http://localhost:3004 */
export function resolveServerUrl(flag?: string): string {
const f = flag?.trim();
if (f) return normalizeBase(f);
const env = process.env[ENV_SERVER_URL]?.trim();
if (env) return normalizeBase(env);
return DEFAULT_SERVER_URL;
}
/** Precedence: wsFlag > MAFIA_WS_URL > derived at runtime from resolved server URL. */
export function resolveWsUrl(wsFlag?: string, serverFlag?: string): string {
const f = wsFlag?.trim();
if (f) return normalizeBase(f);
const env = process.env[ENV_WS_URL]?.trim();
if (env) return normalizeBase(env);
return httpToWs(resolveServerUrl(serverFlag)); // runtime derivation, NOT a static default
}
2. CLI wiring — static defaults removed, resolved at runtime
program
.option("--server <url>", "compose API base URL (flag > MAFIA_SERVER_URL > http://localhost:3004)")
.option("--ws <url>", "WebSocket URL (flag > MAFIA_WS_URL > derived from server)");
program
.command("watch-game")
.option("--ws <url>", "WebSocket URL; resolved at runtime")
.action(async (watchOpts) => {
const parent = program.opts();
const server = resolveServerUrl(parent.server);
const ws = resolveWsUrl(watchOpts.ws ?? parent.ws, parent.server); // runtime, not static
await guardEndpoint(server);
console.log(`Watching ${ws} ...`);
});
Key point: --server/--ws are now plain options (undefined when absent), so MAFIA_SERVER_URL flows into watch-game's WS endpoint at runtime — the old static commander default "ws://localhost:3000" is gone.
3. src/guard.ts — startup identity probe. A naive /health liveness check is insufficient (DuckBrain answers /health with {"status":"healthy"} too). The guard probes the identity route /api/v1 (only the compose API serves Mafia routes) with /health as connectivity fallback; it warns loudly and sets process.exitCode = 1 on mismatch, plus a no-network tripwire for port 3000 — it never guesses the URL for the user.
Verified with a scratch project (`/tmp/mafia-fix`, tsc 7 strict + node:test), including **live probes against the real daemons running in this environment** — which literally reproduce the bug: `<ip-address>:3000` is an Express daemon (rate-limited, `ROUTE_NOT_FOUND` for every Mafia route) and `:3004` serves `{"name":"Mafia AI Benchmark API"}` at `/api/v1` with real game data.
| Check | Result |
|---|---|
| `tsc --noEmit` strict | clean |
| Unit + live tests (`npm test`) | **21/21 pass, 0 fail** |
| Guard vs real `:3004` | `✓ looks like the compose API (/api/v1 -> HTTP 200)`, exit 0 |
| Guard vs real `:3000` (DuckBrain) | `⚠ ... does not identify as the compose API`, **exit 1** |
| `mafia probe` (no flags/env) | `server: http://localhost:3004`, `ws: ws://localhost:3004` — never `:3000` |
| `MAFIA_SERVER_URL=http://envbox:4242 mafia probe` | `server/ws` → `envbox:4242` (env beats default) |
| `--server http://flagbox:5555` + env set | `flagbox:5555` (flag beats env) |
| `MAFIA_SERVER_URL=... mafia watch-game` | `Watching ws://envbox:4242` — **MAFIA_SERVER_URL now honored at runtime** (old static default returned `ws://localhost:3000`) |
| `mafia --server http://cli-host:7777 watch-game` | `Watching ws://cli-host:7777` (parent flag threaded through) |
| `mafia --server http://localhost:3004 watch-game` | `✓ looks like the compose API` + `Watching ws://localhost:3004`, exit 0 |
**Edge cases tested:** flag > env > default precedence in all three layers; `MAFIA_WS_URL` overriding derived WS; trailing-slash normalization (`http://api:3004///`); empty/whitespace flag or env treated as unset; `https://` server deriving to `wss://`; DuckBrain-style daemon that "lies healthy" but lacks Mafia routes (flagged, exit 1); dead/unreachable endpoint (warns, exit 0 — never blocks offline dev); watch-game WS inheriting from env *and* from parent `--server`. A grep confirms `:3000` appears in the codebase only in the guard tripwire, comments, and tests — never as a default.{"model": "deepseek-v4-flash", "problem_class": "typescript-cli-default-port-shadow", "result": "passed", "tests": 21}