From 6637d12935f06ee328e336ecfae1c64c67f9125e Mon Sep 17 00:00:00 2001 From: Mark Percival Date: Tue, 17 Mar 2026 15:02:02 -0400 Subject: [PATCH] feat(cli): add --host flag to bind server to a custom IP Allows binding the Kanban server to a specific network interface (e.g. a Tailscale IP) instead of the default 127.0.0.1. Also supports the KANBAN_RUNTIME_HOST environment variable. --- src/cli.ts | 13 ++++++++++++- src/core/runtime-endpoint.ts | 17 ++++++++++++++--- src/server/runtime-server.ts | 4 ++-- test/runtime/runtime-endpoint.test.ts | 27 +++++++++++++++++++++++++-- 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 33c6662..0651d64 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,9 +15,11 @@ import { createGitProcessEnv } from "./core/git-process-env.js"; import { buildKanbanRuntimeUrl, DEFAULT_KANBAN_RUNTIME_PORT, + getKanbanRuntimeHost, getKanbanRuntimeOrigin, getKanbanRuntimePort, parseRuntimePort, + setKanbanRuntimeHost, setKanbanRuntimePort, } from "./core/runtime-endpoint.js"; import { resolveProjectInputPath } from "./projects/project-path.js"; @@ -39,6 +41,7 @@ interface CliOptions { noOpen: boolean; skipShutdownCleanup: boolean; agent: RuntimeAgentId | null; + host: string | null; port: { mode: "fixed"; value: number } | { mode: "auto" } | null; } @@ -77,6 +80,7 @@ function parseCliPortValue(rawValue: string): { mode: "fixed"; value: number } | interface RootCommandOptions { agent?: RuntimeAgentId; + host?: string; port?: { mode: "fixed"; value: number } | { mode: "auto" }; open?: boolean; skipShutdownCleanup?: boolean; @@ -88,7 +92,7 @@ async function isPortAvailable(port: number): Promise { probe.once("error", () => { resolve(false); }); - probe.listen(port, "127.0.0.1", () => { + probe.listen(port, getKanbanRuntimeHost(), () => { probe.close(() => { resolve(true); }); @@ -371,6 +375,11 @@ async function startServerWithAutoPortRetry(options: CliOptions): Promise { + if (options.host) { + setKanbanRuntimeHost(options.host); + console.log(`Binding to host ${options.host}.`); + } + const selectedPort = await applyRuntimePortOption(options.port); if (selectedPort !== null) { console.log(`Using runtime port ${selectedPort}.`); @@ -476,6 +485,7 @@ function createProgram(): Command { .description("Local orchestration board for coding agents.") .version(KANBAN_VERSION, "-v, --version", "Output the version number") .option("--agent ", `Default agent ID (${CLI_AGENT_IDS.join(", ")}).`, parseCliAgentId) + .option("--host ", "Host IP to bind the server to (default: 127.0.0.1).") .option("--port ", "Runtime port (1-65535) or auto.", parseCliPortValue) .option("--no-open", "Do not open browser automatically.") .option("--skip-shutdown-cleanup", "Do not move sessions to trash or delete task worktrees on shutdown.") @@ -495,6 +505,7 @@ function createProgram(): Command { program.action(async (options: RootCommandOptions) => { await runMainCommand({ agent: options.agent ?? null, + host: options.host ?? null, port: options.port ?? null, noOpen: options.open === false, skipShutdownCleanup: options.skipShutdownCleanup === true, diff --git a/src/core/runtime-endpoint.ts b/src/core/runtime-endpoint.ts index 08815f1..b44e3c0 100644 --- a/src/core/runtime-endpoint.ts +++ b/src/core/runtime-endpoint.ts @@ -1,6 +1,17 @@ -export const KANBAN_RUNTIME_HOST = "127.0.0.1"; +export const DEFAULT_KANBAN_RUNTIME_HOST = "127.0.0.1"; export const DEFAULT_KANBAN_RUNTIME_PORT = 3484; +let runtimeHost: string = process.env.KANBAN_RUNTIME_HOST?.trim() || DEFAULT_KANBAN_RUNTIME_HOST; + +export function getKanbanRuntimeHost(): string { + return runtimeHost; +} + +export function setKanbanRuntimeHost(host: string): void { + runtimeHost = host; + process.env.KANBAN_RUNTIME_HOST = host; +} + export function parseRuntimePort(rawPort: string | undefined): number { if (!rawPort) { return DEFAULT_KANBAN_RUNTIME_PORT; @@ -25,11 +36,11 @@ export function setKanbanRuntimePort(port: number): void { } export function getKanbanRuntimeOrigin(): string { - return `http://${KANBAN_RUNTIME_HOST}:${getKanbanRuntimePort()}`; + return `http://${getKanbanRuntimeHost()}:${getKanbanRuntimePort()}`; } export function getKanbanRuntimeWsOrigin(): string { - return `ws://${KANBAN_RUNTIME_HOST}:${getKanbanRuntimePort()}`; + return `ws://${getKanbanRuntimeHost()}:${getKanbanRuntimePort()}`; } export function buildKanbanRuntimeUrl(pathname: string): string { diff --git a/src/server/runtime-server.ts b/src/server/runtime-server.ts index 96c7209..66a4777 100644 --- a/src/server/runtime-server.ts +++ b/src/server/runtime-server.ts @@ -9,7 +9,7 @@ import { buildKanbanRuntimeUrl, getKanbanRuntimeOrigin, getKanbanRuntimePort, - KANBAN_RUNTIME_HOST, + getKanbanRuntimeHost, } from "../core/runtime-endpoint.js"; import { loadWorkspaceContextById } from "../state/workspace-state.js"; import type { TerminalSessionManager } from "../terminal/session-manager.js"; @@ -224,7 +224,7 @@ export async function createRuntimeServer(deps: CreateRuntimeServerDependencies) await new Promise((resolveListen, rejectListen) => { server.once("error", rejectListen); - server.listen(getKanbanRuntimePort(), KANBAN_RUNTIME_HOST, () => { + server.listen(getKanbanRuntimePort(), getKanbanRuntimeHost(), () => { server.off("error", rejectListen); resolveListen(); }); diff --git a/test/runtime/runtime-endpoint.test.ts b/test/runtime/runtime-endpoint.test.ts index f5bb056..c933562 100644 --- a/test/runtime/runtime-endpoint.test.ts +++ b/test/runtime/runtime-endpoint.test.ts @@ -4,21 +4,31 @@ import { buildKanbanRuntimeUrl, buildKanbanRuntimeWsUrl, DEFAULT_KANBAN_RUNTIME_PORT, + getKanbanRuntimeHost, getKanbanRuntimePort, parseRuntimePort, + setKanbanRuntimeHost, setKanbanRuntimePort, } from "../../src/core/runtime-endpoint.js"; const originalRuntimePort = getKanbanRuntimePort(); +const originalRuntimeHost = getKanbanRuntimeHost(); const originalEnvPort = process.env.KANBAN_RUNTIME_PORT; +const originalEnvHost = process.env.KANBAN_RUNTIME_HOST; afterEach(() => { setKanbanRuntimePort(originalRuntimePort); + setKanbanRuntimeHost(originalRuntimeHost); if (originalEnvPort === undefined) { delete process.env.KANBAN_RUNTIME_PORT; - return; + } else { + process.env.KANBAN_RUNTIME_PORT = originalEnvPort; + } + if (originalEnvHost === undefined) { + delete process.env.KANBAN_RUNTIME_HOST; + } else { + process.env.KANBAN_RUNTIME_HOST = originalEnvHost; } - process.env.KANBAN_RUNTIME_PORT = originalEnvPort; }); describe("runtime-endpoint", () => { @@ -39,4 +49,17 @@ describe("runtime-endpoint", () => { expect(buildKanbanRuntimeUrl("/api/trpc")).toBe("http://127.0.0.1:4567/api/trpc"); expect(buildKanbanRuntimeWsUrl("api/terminal/ws")).toBe("ws://127.0.0.1:4567/api/terminal/ws"); }); + + it("updates runtime url builders when host changes", () => { + setKanbanRuntimeHost("100.64.0.1"); + setKanbanRuntimePort(4567); + expect(getKanbanRuntimeHost()).toBe("100.64.0.1"); + expect(process.env.KANBAN_RUNTIME_HOST).toBe("100.64.0.1"); + expect(buildKanbanRuntimeUrl("/api/trpc")).toBe("http://100.64.0.1:4567/api/trpc"); + expect(buildKanbanRuntimeWsUrl("api/terminal/ws")).toBe("ws://100.64.0.1:4567/api/terminal/ws"); + }); + + it("defaults host to 127.0.0.1", () => { + expect(getKanbanRuntimeHost()).toBe("127.0.0.1"); + }); });