diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index 3029a0e9f..38c0d8bc8 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort, getServerUrl } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon } from "./shared-handlers"; @@ -343,7 +343,7 @@ export async function startAnnotateServer( } const port = server.port!; - const serverUrl = `http://localhost:${port}`; + const serverUrl = getServerUrl(port); // Notify caller that server is ready if (onReady) { diff --git a/packages/server/index.ts b/packages/server/index.ts index 8b1356a49..53c115511 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -14,7 +14,7 @@ import type { Origin } from "@plannotator/shared/agents"; import { resolve } from "path"; -import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort, getServerUrl } from "./remote"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -53,7 +53,7 @@ import { createExternalAnnotationHandler } from "./external-annotations"; import { isWSL } from "./browser"; // Re-export utilities -export { isRemoteSession, getServerPort } from "./remote"; +export { isRemoteSession, getServerPort, getServerUrl, getServerHostname } from "./remote"; export { openBrowser } from "./browser"; export * from "./integrations"; export * from "./storage"; @@ -607,7 +607,7 @@ export async function startPlannotatorServer( } const port = server.port!; - const serverUrl = `http://localhost:${port}`; + const serverUrl = getServerUrl(port); // Notify caller that server is ready if (onReady) { diff --git a/packages/server/remote.ts b/packages/server/remote.ts index 57fc7e49e..1af047555 100644 --- a/packages/server/remote.ts +++ b/packages/server/remote.ts @@ -70,5 +70,27 @@ export function getServerPort(): number { * container or host network interface for SSH/devcontainer/Docker forwarding. */ export function getServerHostname(): string { + // Explicit host override (e.g., Tailscale IP) + const hostOverride = process.env.PLANNOTATOR_HOST; + if (hostOverride) return hostOverride; + return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST; } + +/** + * Get the URL for accessing the server. + * + * Priority: + * 1. PLANNOTATOR_SERVER_URL — full URL override (e.g., "https://myhost.example.com") + * 2. PLANNOTATOR_HOST — hostname override with the actual port + * 3. Default — http://localhost:{port} + */ +export function getServerUrl(port: number): string { + const serverUrl = process.env.PLANNOTATOR_SERVER_URL; + if (serverUrl) return serverUrl; + + const hostname = getServerHostname(); + // For 0.0.0.0 (remote), advertise localhost since 0.0.0.0 isn't clickable + const displayHost = hostname === "0.0.0.0" ? "localhost" : hostname; + return `http://${displayHost}:${port}`; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index c31646398..daaa99c43 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerHostname, getServerPort } from "./remote"; +import { isRemoteSession, getServerHostname, getServerPort, getServerUrl } from "./remote"; import type { Origin } from "@plannotator/shared/agents"; import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, gitRuntime } from "./vcs"; import { parseWorktreeDiffType, resolveBaseBranch } from "@plannotator/shared/review-core"; @@ -1185,7 +1185,7 @@ export async function startReviewServer( } const port = server.port!; - serverUrl = `http://localhost:${port}`; + serverUrl = getServerUrl(port); const exitHandler = () => agentJobs.killAll(); process.once("exit", exitHandler); diff --git a/packages/server/server-url.test.ts b/packages/server/server-url.test.ts new file mode 100644 index 000000000..6367de2c4 --- /dev/null +++ b/packages/server/server-url.test.ts @@ -0,0 +1,80 @@ +/** + * PR C: Tests for getServerUrl() respecting PLANNOTATOR_SERVER_URL + * + * When PLANNOTATOR_SERVER_URL is set, the server should use that URL + * instead of hardcoded http://localhost:{port}. + */ +import { describe, test, expect, afterAll, beforeAll, afterEach } from "bun:test"; + +// We test getServerUrl directly since it's a pure function reading env vars. +// Import once and test with env manipulation. + +describe("getServerUrl", () => { + const savedEnv: Record = {}; + + beforeAll(() => { + for (const key of ["PLANNOTATOR_SERVER_URL", "PLANNOTATOR_PORT", "PLANNOTATOR_REMOTE", "PLANNOTATOR_HOST"]) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterAll(() => { + for (const [key, val] of Object.entries(savedEnv)) { + if (val !== undefined) process.env[key] = val; + else delete process.env[key]; + } + }); + + // We need to re-import each time because remote.ts reads env at module level + // for isRemoteSession. But getServerUrl reads env on each call. + // Let's just test the function directly. + + test("returns localhost URL when no env vars set", async () => { + const { getServerUrl } = await import("./remote"); + const url = getServerUrl(9999); + expect(url).toBe("http://127.0.0.1:9999"); + }); + + test("respects PLANNOTATOR_SERVER_URL when set", async () => { + process.env.PLANNOTATOR_SERVER_URL = "http://192.168.1.100:8080"; + try { + const { getServerUrl } = await import("./remote"); + expect(getServerUrl(0)).toBe("http://192.168.1.100:8080"); + } finally { + delete process.env.PLANNOTATOR_SERVER_URL; + } + }); + + test("PLANNOTATOR_SERVER_URL takes precedence over port", async () => { + process.env.PLANNOTATOR_SERVER_URL = "https://plannotator.example.com"; + process.env.PLANNOTATOR_PORT = "9999"; + try { + const { getServerUrl } = await import("./remote"); + expect(getServerUrl(0)).toBe("https://plannotator.example.com"); + } finally { + delete process.env.PLANNOTATOR_SERVER_URL; + delete process.env.PLANNOTATOR_PORT; + } + }); + + test("PLANNOTATOR_HOST overrides hostname", async () => { + process.env.PLANNOTATOR_HOST = "100.64.0.1"; + try { + const { getServerUrl } = await import("./remote"); + expect(getServerUrl(8080)).toBe("http://100.64.0.1:8080"); + } finally { + delete process.env.PLANNOTATOR_HOST; + } + }); + + test("PLANNOTATOR_HOST + custom port", async () => { + process.env.PLANNOTATOR_HOST = "100.64.0.1"; + try { + const { getServerUrl } = await import("./remote"); + expect(getServerUrl(3000)).toBe("http://100.64.0.1:3000"); + } finally { + delete process.env.PLANNOTATOR_HOST; + } + }); +});