From 9649824a40c9dd0f5715a668ee456eca8f8f63e9 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Wed, 22 Apr 2026 16:01:42 -0700 Subject: [PATCH 1/2] Better handling of RUNLOOP_BASE_URL --- .github/workflows/e2e.yml | 2 +- CLAUDE_SETUP.md | 8 +- MCP_COMMANDS.md | 8 +- MCP_README.md | 7 +- README.md | 19 +++- src/cli.ts | 4 +- src/components/Breadcrumb.tsx | 11 +- src/components/DevboxActionsMenu.tsx | 4 +- src/components/DevboxDetailPage.tsx | 6 +- src/components/HomeBaseUrlText.tsx | 16 +++ src/components/MainMenu.tsx | 13 +++ src/mcp/server.ts | 17 +-- src/services/devboxService.ts | 4 +- src/utils/client.ts | 22 +--- src/utils/config.ts | 105 ++++++++++++++++-- src/utils/ssh.ts | 11 +- src/utils/url.ts | 44 ++------ .../__tests__/components/Breadcrumb.test.tsx | 19 ++-- tests/setup-components.ts | 1 - tests/setup-e2e.ts | 3 +- tests/setup-router.ts | 1 - tests/setup.ts | 1 - 22 files changed, 203 insertions(+), 123 deletions(-) create mode 100644 src/components/HomeBaseUrlText.tsx diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9f215724..96bffb3b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -37,5 +37,5 @@ jobs: - name: Run e2e smoke tests env: RUNLOOP_API_KEY: ${{ inputs.environment == 'prod' && secrets.RUNLOOP_SMOKETEST_PROD_API_KEY || secrets.RUNLOOP_SMOKETEST_DEV_API_KEY }} - RUNLOOP_ENV: ${{ inputs.environment }} + RUNLOOP_BASE_URL: ${{ inputs.environment == 'prod' && 'https://api.runloop.ai' || 'https://api.runloop.pro' }} run: pnpm run test:e2e diff --git a/CLAUDE_SETUP.md b/CLAUDE_SETUP.md index bbcf7f28..15b2cd55 100644 --- a/CLAUDE_SETUP.md +++ b/CLAUDE_SETUP.md @@ -133,9 +133,9 @@ Press Ctrl+C to stop it. ## Advanced Configuration -### Using Development Environment +### Custom API endpoint -If you want to connect to Runloop's development environment: +To connect to a non-default Runloop deployment, set `RUNLOOP_BASE_URL` to the full API URL (must be `https://api.`): ```json { @@ -144,13 +144,15 @@ If you want to connect to Runloop's development environment: "command": "rli", "args": ["mcp", "start"], "env": { - "RUNLOOP_ENV": "dev" + "RUNLOOP_BASE_URL": "https://api.runloop.pro" } } } } ``` +See the repository [README](README.md) **Setup → Custom API endpoint (`RUNLOOP_BASE_URL`)** for the hostname table. + ### Using a Specific Path If `rli` isn't in your PATH, you can specify the full path: diff --git a/MCP_COMMANDS.md b/MCP_COMMANDS.md index 80d9cdbe..a6d56498 100644 --- a/MCP_COMMANDS.md +++ b/MCP_COMMANDS.md @@ -65,9 +65,9 @@ When you run `rli mcp install`, it creates this configuration in your Claude Des } ``` -### With Environment Variables +### Custom API endpoint -To use the development environment: +To point MCP at a different deployment, set `RUNLOOP_BASE_URL` (must be `https://api.`): ```json { @@ -76,13 +76,15 @@ To use the development environment: "command": "rli", "args": ["mcp", "start"], "env": { - "RUNLOOP_ENV": "dev" + "RUNLOOP_BASE_URL": "https://api.runloop.pro" } } } } ``` +See [README.md](README.md) (Setup → Custom API endpoint) for the hostname table. + ## Available Tools Once configured, Claude can use these tools: diff --git a/MCP_README.md b/MCP_README.md index 4aa5c1fe..ae977058 100644 --- a/MCP_README.md +++ b/MCP_README.md @@ -102,9 +102,6 @@ If you prefer to configure manually, add this to your Claude configuration file: "runloop": { "command": "rli", "args": ["mcp", "start"], - "env": { - "RUNLOOP_ENV": "prod" - } } } } @@ -151,8 +148,8 @@ Claude will use the MCP tools to interact with your Runloop account and provide ## Environment Variables -- `RUNLOOP_ENV` - Set to `dev` for development environment, `prod` (or leave unset) for production -- API key is read from the CLI configuration (~/.config/rli/config.json) +- `RUNLOOP_BASE_URL` — Optional. Full API URL of the form `https://api.` (e.g. `https://api.runloop.pro`). Defaults to `https://api.runloop.ai`. See [README](README.md) **Setup → Custom API endpoint (`RUNLOOP_BASE_URL`)**. +- API key is read from the CLI configuration (`~/.runloop/config.json`) ## Troubleshooting diff --git a/README.md b/README.md index 94134f1e..518ed882 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ pnpm add -g @runloop/rl-cli ## Setup -Configure your API key: +### API key ```bash export RUNLOOP_API_KEY=your_api_key_here @@ -54,6 +54,23 @@ export RUNLOOP_API_KEY=your_api_key_here Get your API key from [https://runloop.ai/settings](https://runloop.ai/settings) +### Custom API endpoint (`RUNLOOP_BASE_URL`, optional) + +By default the CLI and MCP server connect to `https://api.runloop.ai`. To use a different deployment, set `RUNLOOP_BASE_URL` to the full API URL: + +```bash +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: + +| Service | Host | +|----------|------| +| API | `https://api.` (the value of `RUNLOOP_BASE_URL`) | +| Platform | `https://platform.` | +| SSH | `ssh.:443` | +| Tunnels | `tunnel.` | + ## Usage ### TUI (Interactive Mode) diff --git a/src/cli.ts b/src/cli.ts index 056e0f82..cc2fe9fe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import { exitAlternateScreenBuffer } from "./utils/screen.js"; import { processUtils } from "./utils/processUtils.js"; import { createProgram } from "./utils/commands.js"; -import { getApiKeyErrorMessage } from "./utils/config.js"; +import { getApiKeyErrorMessage, checkBaseDomain } from "./utils/config.js"; // Global Ctrl+C handler to ensure it always exits processUtils.on("SIGINT", () => { @@ -21,6 +21,8 @@ const program = createProgram(); const { initializeTheme } = await import("./utils/theme.js"); await initializeTheme(); + checkBaseDomain(); + // Check if API key is configured (except for mcp commands) const args = process.argv.slice(2); if (!process.env.RUNLOOP_API_KEY) { diff --git a/src/components/Breadcrumb.tsx b/src/components/Breadcrumb.tsx index 1a2b84bf..6ae6e497 100644 --- a/src/components/Breadcrumb.tsx +++ b/src/components/Breadcrumb.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Text, useStdout } from "ink"; import { colors } from "../utils/theme.js"; +import { runloopBaseDomain } from "../utils/config.js"; import { UpdateNotification } from "./UpdateNotification.js"; export interface BreadcrumbItem { @@ -38,8 +39,8 @@ export const Breadcrumb = ({ showVersionCheck = false, compactMode: compactModeProp, }: BreadcrumbProps) => { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - const isDevEnvironment = env === "dev"; + const baseDomain = runloopBaseDomain(); + const isNonDefaultDomain = baseDomain !== "runloop.ai"; const { stdout } = useStdout(); const [terminalWidth, setTerminalWidth] = React.useState(() => @@ -109,10 +110,10 @@ export const Breadcrumb = ({ rl - {isDevEnvironment && mode !== "minimal" && ( - + {isNonDefaultDomain && mode !== "minimal" && ( + {" "} - (dev) + ({baseDomain}) )} {displayItems.length > 0 && } diff --git a/src/components/DevboxActionsMenu.tsx b/src/components/DevboxActionsMenu.tsx index 3299183f..e135d669 100644 --- a/src/components/DevboxActionsMenu.tsx +++ b/src/components/DevboxActionsMenu.tsx @@ -12,6 +12,7 @@ import { ConfirmationPrompt } from "./ConfirmationPrompt.js"; import { colors } from "../utils/theme.js"; import { openInBrowser } from "../utils/browser.js"; import { copyToClipboard } from "../utils/clipboard.js"; +import { sshGatewayHostname } from "../utils/config.js"; import { useViewportHeight } from "../hooks/useViewportHeight.js"; import { useNavigation } from "../store/navigationStore.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; @@ -661,8 +662,7 @@ export const DevboxActionsMenu = ({ const sshUser = devbox.launch_parameters?.user_parameters?.username || "user"; - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - const sshHost = env === "dev" ? "ssh.runloop.pro" : "ssh.runloop.ai"; + const sshHost = sshGatewayHostname(); // macOS openssl doesn't support -verify_quiet, use compatible flags // servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command // This matches the reference implementation where servername is the target hostname diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index dd75415b..278f52bb 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -12,7 +12,7 @@ import { type DetailSection, type ResourceOperation, } from "./ResourceDetailPage.js"; -import { getDevboxUrl, getDevboxTunnelUrlPattern } from "../utils/url.js"; +import { getDevboxUrl, getTunnelUrl } from "../utils/url.js"; import { colors } from "../utils/theme.js"; import { formatTimeAgo } from "../utils/time.js"; import { getMcpConfig } from "../services/mcpConfigService.js"; @@ -339,7 +339,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => { if (devbox.tunnel && devbox.tunnel.tunnel_key) { const tunnelKey = devbox.tunnel.tunnel_key; const authMode = devbox.tunnel.auth_mode; - const tunnelUrl = getDevboxTunnelUrlPattern(tunnelKey); + const tunnelUrl = getTunnelUrl("{port}", tunnelKey); detailFields.push({ label: "Tunnel", @@ -651,7 +651,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => { , ); - const tunnelUrl = getDevboxTunnelUrlPattern(devbox.tunnel.tunnel_key); + const tunnelUrl = getTunnelUrl("{port}", devbox.tunnel.tunnel_key); lines.push( {" "} diff --git a/src/components/HomeBaseUrlText.tsx b/src/components/HomeBaseUrlText.tsx new file mode 100644 index 00000000..b4ea1cea --- /dev/null +++ b/src/components/HomeBaseUrlText.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Text } from "ink"; +import { runloopBaseDomain } from "../utils/config.js"; +import { colors } from "../utils/theme.js"; + +/** Shows the active domain in the home footer when it differs from the default. */ +export function HomeBaseUrlText() { + const baseDomain = React.useMemo(() => runloopBaseDomain(), []); + if (baseDomain === "runloop.ai") return null; + return ( + + {"\n"} + Domain: {baseDomain} + + ); +} diff --git a/src/components/MainMenu.tsx b/src/components/MainMenu.tsx index 14df4a8e..0b7cdcda 100644 --- a/src/components/MainMenu.tsx +++ b/src/components/MainMenu.tsx @@ -13,6 +13,7 @@ import { useVerticalLayout } from "../hooks/useVerticalLayout.js"; import { useBetaFeatures } from "../store/betaFeatureStore.js"; import type { BetaFeature } from "../store/betaFeatureStore.js"; import { useMenuStore } from "../store/menuStore.js"; +import { HomeBaseUrlText } from "./HomeBaseUrlText.js"; interface MenuItem { key: string; @@ -284,6 +285,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -332,6 +336,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -390,6 +397,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { })} {navTips} + + + ); } @@ -494,6 +504,9 @@ export const MainMenu = ({ onSelect }: MainMenuProps) => { {navTips} + + + ); }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d5755cc2..4d4839f1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,7 @@ import Runloop from "@runloop/api-client"; import { VERSION } from "@runloop/api-client/version.js"; import Conf from "conf"; import { processUtils } from "../utils/processUtils.js"; +import { checkBaseDomain } from "../utils/config.js"; // Client configuration interface Config { @@ -45,18 +46,6 @@ function getConfig(): Config { } } -function getBaseUrl(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - - switch (env) { - case "dev": - return "https://api.runloop.pro"; - case "prod": - default: - return "https://api.runloop.ai"; - } -} - function getClient(): Runloop { const config = getConfig(); @@ -66,11 +55,8 @@ function getClient(): Runloop { ); } - const baseURL = getBaseUrl(); - return new Runloop({ bearerToken: config.apiKey, - baseURL, timeout: 10000, // 10 seconds instead of default 30 seconds maxRetries: 2, // 2 retries instead of default 5 (only for retryable errors) defaultHeaders: { @@ -489,6 +475,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { // Start the server async function main() { try { + checkBaseDomain(); console.error("[MCP] Starting Runloop MCP server..."); const transport = new StdioServerTransport(); console.error("[MCP] Created stdio transport"); diff --git a/src/services/devboxService.ts b/src/services/devboxService.ts index 3684e705..63ddb95e 100644 --- a/src/services/devboxService.ts +++ b/src/services/devboxService.ts @@ -3,7 +3,7 @@ * Returns plain data objects with no SDK reference retention */ import { getClient } from "../utils/client.js"; -import { getTunnelBaseHost } from "../utils/url.js"; +import { getTunnelUrl } from "../utils/url.js"; import type { Devbox } from "../store/devboxStore.js"; import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination"; import type { @@ -263,7 +263,7 @@ export async function createTunnel( ): Promise<{ url: string }> { const client = getClient(); const tunnel = await client.devboxes.enableTunnel(id); - const url = `https://${port}-${tunnel.tunnel_key}.${getTunnelBaseHost()}`; + const url = getTunnelUrl(port, tunnel.tunnel_key); return { url: url.substring(0, 500), diff --git a/src/utils/client.ts b/src/utils/client.ts index b5003d2b..fb438c05 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -1,24 +1,7 @@ -import Runloop from "@runloop/api-client"; +import { Runloop } from "@runloop/api-client"; import { VERSION } from "@runloop/api-client/version.js"; import { getConfig } from "./config.js"; -/** - * Get the base URL based on RUNLOOP_ENV environment variable - * - dev: https://api.runloop.pro - * - prod or unset: https://api.runloop.ai (default) - */ -function getBaseUrl(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - - switch (env) { - case "dev": - return "https://api.runloop.pro"; - case "prod": - default: - return "https://api.runloop.ai"; - } -} - export function getClient(): Runloop { const config = getConfig(); @@ -28,11 +11,8 @@ export function getClient(): Runloop { ); } - const baseURL = getBaseUrl(); - return new Runloop({ bearerToken: config.apiKey, - baseURL, defaultHeaders: { "User-Agent": `Runloop/JS - CLI ${VERSION}`, }, diff --git a/src/utils/config.ts b/src/utils/config.ts index 80dac7e5..dab07a77 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -31,16 +31,107 @@ export function clearConfig(): void { config.clear(); } +const DEFAULT_BASE_URL = "https://api.runloop.ai"; +const DEFAULT_BASE_DOMAIN = "runloop.ai"; +let _cachedBaseDomain: string | null = null; + +/** + * Validate RUNLOOP_BASE_URL at startup. If set, it must be a well-formed + * `https://api.` URL. Exits with an error if malformed. No-op if unset. + */ +export function checkBaseDomain(): void { + const raw = process.env.RUNLOOP_BASE_URL?.trim(); + if (!raw) return; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + console.error( + `Error: RUNLOOP_BASE_URL is not a valid URL: ${raw}\n` + + `Expected format: https://api. (e.g. https://api.runloop.ai)`, + ); + process.exit(1); + } + + if (parsed.protocol !== "https:") { + console.error( + `Error: RUNLOOP_BASE_URL must use https, got ${parsed.protocol}\n` + + `Expected format: https://api.`, + ); + process.exit(1); + } + + if (!parsed.hostname.startsWith("api.") || parsed.hostname === "api.") { + console.error( + `Error: RUNLOOP_BASE_URL hostname must start with "api." followed by a domain: ${raw}\n` + + `Expected format: https://api. (e.g. https://api.runloop.ai)`, + ); + process.exit(1); + } + + if (parsed.port || 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.`, + ); + process.exit(1); + } +} + +/** + * Returns the bare domain from RUNLOOP_BASE_URL (e.g. "runloop.ai" from + * "https://api.runloop.ai"). Defaults to "runloop.ai" when unset. + */ +export function runloopBaseDomain(): string { + if (_cachedBaseDomain !== null) return _cachedBaseDomain; + + const raw = process.env.RUNLOOP_BASE_URL?.trim(); + if (!raw) { + _cachedBaseDomain = DEFAULT_BASE_DOMAIN; + return _cachedBaseDomain; + } + + try { + const hostname = new URL(raw).hostname; + _cachedBaseDomain = hostname.startsWith("api.") + ? hostname.slice(4) + : hostname; + } catch { + _cachedBaseDomain = DEFAULT_BASE_DOMAIN; + } + + return _cachedBaseDomain; +} + +/** @internal — for tests only */ +export function _resetBaseDomainCache(): void { + _cachedBaseDomain = null; +} + +/** Full API base URL from RUNLOOP_BASE_URL, or the default. */ export function baseUrl(): string { - return process.env.RUNLOOP_ENV === "dev" - ? "https://api.runloop.pro" - : "https://api.runloop.ai"; + return process.env.RUNLOOP_BASE_URL?.trim() || DEFAULT_BASE_URL; +} + +/** Web platform origin for deep links (settings, devbox pages). */ +export function platformBaseUrl(): string { + return `https://platform.${runloopBaseDomain()}`; +} + +/** Hostname for devbox tunnel URLs (`{port}-{key}.tunnel.`). */ +export function tunnelBaseHostname(): string { + return `tunnel.${runloopBaseDomain()}`; +} + +/** SSH gateway hostname (TLS/SNI), without port. */ +export function sshGatewayHostname(): string { + return `ssh.${runloopBaseDomain()}`; } +/** `host:443` for `openssl s_client -connect` (SSH over HTTPS). */ export function sshUrl(): string { - return process.env.RUNLOOP_ENV === "dev" - ? "ssh.runloop.pro:443" - : "ssh.runloop.ai:443"; + return `${sshGatewayHostname()}:443`; } export function getCacheDir(): string { @@ -119,7 +210,7 @@ export function getApiKeyErrorMessage(): string { ❌ API key not configured. To get started: -1. Go to https://platform.runloop.ai/settings and create an API key +1. Go to ${platformBaseUrl()}/settings and create an API key 2. Set the environment variable: export RUNLOOP_API_KEY=your_api_key_here diff --git a/src/utils/ssh.ts b/src/utils/ssh.ts index 641997e2..944e8bbd 100644 --- a/src/utils/ssh.ts +++ b/src/utils/ssh.ts @@ -6,6 +6,7 @@ import { homedir } from "os"; import { getClient } from "./client.js"; import { cliStatus } from "./cliStatus.js"; import { processUtils } from "./processUtils.js"; +import { sshUrl as sshTlsConnectEndpoint } from "./config.js"; const execAsync = promisify(exec); @@ -147,19 +148,11 @@ export async function waitForReady( } } -/** - * Get SSH URL based on environment - */ -export function getSSHUrl(): string { - const env = processUtils.env.RUNLOOP_ENV?.toLowerCase(); - return env === "dev" ? "ssh.runloop.pro:443" : "ssh.runloop.ai:443"; -} - /** * Get proxy command for SSH over HTTPS */ export function getProxyCommand(): string { - const sshUrl = getSSHUrl(); + const sshUrl = sshTlsConnectEndpoint(); // macOS openssl doesn't support -verify_quiet, use compatible flags // servername should be %h (target hostname) - SSH will replace %h with the actual hostname from the SSH command return `openssl s_client -quiet -servername %h -connect ${sshUrl} 2>/dev/null`; diff --git a/src/utils/url.ts b/src/utils/url.ts index d856f190..1ab65544 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -2,58 +2,36 @@ * Utility functions for generating URLs */ -/** - * Get the base URL for the Runloop platform based on environment - * - dev: https://platform.runloop.pro - * - prod or unset: https://platform.runloop.ai (default) - */ -export function getBaseUrl(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - - switch (env) { - case "dev": - return "https://platform.runloop.pro"; - case "prod": - default: - return "https://platform.runloop.ai"; - } -} +import { platformBaseUrl, tunnelBaseHostname } from "./config.js"; /** * Generate a devbox URL for the given devbox ID */ export function getDevboxUrl(devboxId: string): string { - const baseUrl = getBaseUrl(); - return `${baseUrl}/devboxes/${devboxId}`; + return `${platformBaseUrl()}/devboxes/${devboxId}`; } /** * Generate a blueprint URL for the given blueprint ID */ export function getBlueprintUrl(blueprintId: string): string { - const baseUrl = getBaseUrl(); - return `${baseUrl}/blueprints/${blueprintId}`; + return `${platformBaseUrl()}/blueprints/${blueprintId}`; } /** * Generate a settings URL */ export function getSettingsUrl(): string { - const baseUrl = getBaseUrl(); - return `${baseUrl}/settings`; -} - -/** - * Hostname for V2 devbox tunnel URLs (matches RUNLOOP_ENV / API host). - */ -export function getTunnelBaseHost(): string { - const env = process.env.RUNLOOP_ENV?.toLowerCase(); - return env === "dev" ? "tunnel.runloop.pro" : "tunnel.runloop.ai"; + return `${platformBaseUrl()}/settings`; } /** - * Tunnel URL pattern with a literal `{port}` placeholder for display. + * Generate a tunnel URL for the given port and tunnel key. + * Pass a number for a real URL, or a string like "{port}" for a display pattern. */ -export function getDevboxTunnelUrlPattern(tunnelKey: string): string { - return `https://{port}-${tunnelKey}.${getTunnelBaseHost()}`; +export function getTunnelUrl( + port: number | string, + tunnelKey: string, +): string { + return `https://${port}-${tunnelKey}.${tunnelBaseHostname()}`; } diff --git a/tests/__tests__/components/Breadcrumb.test.tsx b/tests/__tests__/components/Breadcrumb.test.tsx index 2cf0a634..e9d64d06 100644 --- a/tests/__tests__/components/Breadcrumb.test.tsx +++ b/tests/__tests__/components/Breadcrumb.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { render } from 'ink-testing-library'; import { Breadcrumb } from '../../../src/components/Breadcrumb.js'; +import { _resetBaseDomainCache } from '../../../src/utils/config.js'; describe('Breadcrumb', () => { it('renders without crashing', () => { @@ -55,17 +56,19 @@ describe('Breadcrumb', () => { expect(frame).not.toContain(longLabel); }); - it('shows dev environment indicator when RUNLOOP_ENV is dev', () => { - const originalEnv = process.env.RUNLOOP_ENV; - process.env.RUNLOOP_ENV = 'dev'; - + it('shows non-default domain indicator when RUNLOOP_BASE_URL is custom', () => { + const originalUrl = process.env.RUNLOOP_BASE_URL; + process.env.RUNLOOP_BASE_URL = 'https://api.runloop.pro'; + _resetBaseDomainCache(); + const { lastFrame } = render( ); - - expect(lastFrame()).toContain('(dev)'); - - process.env.RUNLOOP_ENV = originalEnv; + + expect(lastFrame()).toContain('runloop.pro'); + + process.env.RUNLOOP_BASE_URL = originalUrl; + _resetBaseDomainCache(); }); }); diff --git a/tests/setup-components.ts b/tests/setup-components.ts index 9e40d14f..2ff490a6 100644 --- a/tests/setup-components.ts +++ b/tests/setup-components.ts @@ -15,7 +15,6 @@ if (existsSync(envPath)) { } // Set default test environment variables -process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || "dev"; process.env.RUNLOOP_API_KEY = process.env.RUNLOOP_API_KEY || "ak_test_key"; process.env.RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL || "https://api.runloop.pro"; diff --git a/tests/setup-e2e.ts b/tests/setup-e2e.ts index 9dab0363..36dfc671 100644 --- a/tests/setup-e2e.ts +++ b/tests/setup-e2e.ts @@ -24,4 +24,5 @@ if (!process.env.RUNLOOP_API_KEY) { ); } -process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || "dev"; +process.env.RUNLOOP_BASE_URL = + process.env.RUNLOOP_BASE_URL || "https://api.runloop.pro"; diff --git a/tests/setup-router.ts b/tests/setup-router.ts index 7b5bb9da..954eaf70 100644 --- a/tests/setup-router.ts +++ b/tests/setup-router.ts @@ -16,7 +16,6 @@ if (existsSync(envPath)) { } // Set default test environment variables -process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || "dev"; process.env.RUNLOOP_API_KEY = process.env.RUNLOOP_API_KEY || "ak_test_key"; process.env.RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL || "https://api.runloop.pro"; diff --git a/tests/setup.ts b/tests/setup.ts index a025467b..fad235be 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -11,7 +11,6 @@ if (existsSync(envPath)) { } // Set default test environment variables -process.env.RUNLOOP_ENV = process.env.RUNLOOP_ENV || "dev"; process.env.RUNLOOP_API_KEY = process.env.RUNLOOP_API_KEY || "ak_test_key"; process.env.RUNLOOP_BASE_URL = process.env.RUNLOOP_BASE_URL || "https://api.runloop.pro"; From ec8cb21ad073ceedf0b94568e6db2ee4f0509be3 Mon Sep 17 00:00:00 2001 From: Rob von Behren Date: Wed, 22 Apr 2026 16:02:55 -0700 Subject: [PATCH 2/2] fmt --- src/utils/config.ts | 7 ++++++- src/utils/url.ts | 5 +---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils/config.ts b/src/utils/config.ts index dab07a77..e9e8801c 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -70,7 +70,12 @@ export function checkBaseDomain(): void { process.exit(1); } - if (parsed.port || parsed.pathname.replace(/\/+$/, "") || parsed.search || parsed.hash) { + if ( + parsed.port || + 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.`, diff --git a/src/utils/url.ts b/src/utils/url.ts index 1ab65544..2ac450e1 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -29,9 +29,6 @@ export function getSettingsUrl(): string { * Generate a tunnel URL for the given port and tunnel key. * Pass a number for a real URL, or a string like "{port}" for a display pattern. */ -export function getTunnelUrl( - port: number | string, - tunnelKey: string, -): string { +export function getTunnelUrl(port: number | string, tunnelKey: string): string { return `https://${port}-${tunnelKey}.${tunnelBaseHostname()}`; }