diff --git a/README.md b/README.md index 3414efc7..5197948d 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ By default the CLI and MCP server connect to `https://api.runloop.ai`. To use a export RUNLOOP_BASE_URL=https://api.runloop.pro ``` -The URL must be of the form `https://api.`. The CLI derives other service hostnames from the domain portion: +The URL must be of the form `https://api.`, optionally with a `:port` (e.g. `https://api.runloop.pro:8443`). The CLI derives other service hostnames from the domain portion: | Service | Host | |----------|------| diff --git a/src/utils/config.ts b/src/utils/config.ts index 3c29af1a..0e7ff36a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -70,15 +70,10 @@ export function checkBaseDomain(): void { process.exit(1); } - if ( - parsed.port || - parsed.pathname.replace(/\/+$/, "") || - parsed.search || - parsed.hash - ) { + if (parsed.pathname.replace(/\/+$/, "") || parsed.search || parsed.hash) { console.error( - `Error: RUNLOOP_BASE_URL must not contain port, path, query, or fragment: ${raw}\n` + - `Expected format: https://api.`, + `Error: RUNLOOP_BASE_URL must not contain path, query, or fragment: ${raw}\n` + + `Expected format: https://api. (an optional :port is allowed)`, ); process.exit(1); } diff --git a/tests/__tests__/utils/baseUrl.test.ts b/tests/__tests__/utils/baseUrl.test.ts new file mode 100644 index 00000000..2c1a446c --- /dev/null +++ b/tests/__tests__/utils/baseUrl.test.ts @@ -0,0 +1,88 @@ +/** + * Tests for RUNLOOP_BASE_URL handling, including optional port overrides. + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from "@jest/globals"; +import { + checkBaseDomain, + baseUrl, + runloopBaseDomain, + _resetBaseDomainCache, +} from "../../../src/utils/config.js"; + +describe("RUNLOOP_BASE_URL", () => { + const original = process.env.RUNLOOP_BASE_URL; + let exitSpy: jest.SpiedFunction; + let errorSpy: jest.SpiedFunction; + + beforeEach(() => { + _resetBaseDomainCache(); + exitSpy = jest.spyOn(process, "exit").mockImplementation((( + code?: number, + ) => { + throw new Error(`process.exit(${code})`); + }) as never); + errorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + if (original === undefined) delete process.env.RUNLOOP_BASE_URL; + else process.env.RUNLOOP_BASE_URL = original; + exitSpy.mockRestore(); + errorSpy.mockRestore(); + _resetBaseDomainCache(); + }); + + describe("checkBaseDomain", () => { + it("accepts a bare api. URL", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro"; + expect(() => checkBaseDomain()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("accepts an api. URL with a port override", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443"; + expect(() => checkBaseDomain()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it("rejects a URL with a path", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro/v1"; + expect(() => checkBaseDomain()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects a URL with a query string", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro?foo=bar"; + expect(() => checkBaseDomain()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it("rejects a non-https URL", () => { + process.env.RUNLOOP_BASE_URL = "http://api.runloop.pro:8443"; + expect(() => checkBaseDomain()).toThrow(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe("baseUrl", () => { + it("includes the port override in the API base URL", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443"; + expect(baseUrl()).toBe("https://api.runloop.pro:8443"); + }); + }); + + describe("runloopBaseDomain", () => { + it("strips the api. prefix and the port", () => { + process.env.RUNLOOP_BASE_URL = "https://api.runloop.pro:8443"; + expect(runloopBaseDomain()).toBe("runloop.pro"); + }); + }); +});