Skip to content
Open
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: 2 additions & 2 deletions packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 3 additions & 3 deletions packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
22 changes: 22 additions & 0 deletions packages/server/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
4 changes: 2 additions & 2 deletions packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
80 changes: 80 additions & 0 deletions packages/server/server-url.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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;
}
});
});