diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 96448d4..dd454e6 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -111,6 +111,22 @@ MUX_TOKEN_SECRET = test_secret_456`, expect(result.MUX_TOKEN_SECRET).toBe('test_secret_456'); }); + it('should parse MUX_BASE_URL', async () => { + const envPath = join(testDir, '.env'); + await Bun.write( + envPath, + `MUX_TOKEN_ID=test_id_123 +MUX_TOKEN_SECRET=test_secret_456 +MUX_BASE_URL=https://api.staging.mux.com`, + ); + + const result = await parseEnvFile(envPath); + + expect(result.MUX_TOKEN_ID).toBe('test_id_123'); + expect(result.MUX_TOKEN_SECRET).toBe('test_secret_456'); + expect(result.MUX_BASE_URL).toBe('https://api.staging.mux.com'); + }); + it('should ignore other environment variables', async () => { const envPath = join(testDir, '.env'); await Bun.write( diff --git a/src/commands/login.ts b/src/commands/login.ts index 6bf6ed1..ef2a87a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -2,13 +2,18 @@ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { Command } from '@cliffy/command'; import { listEnvironments, setEnvironment } from '../lib/config.ts'; -import { validateCredentials } from '../lib/mux.ts'; +import { + DEFAULT_BASE_URL, + getMuxBaseUrl, + validateCredentials, +} from '../lib/mux.ts'; import { inputPrompt, secretPrompt } from '../lib/prompt.ts'; import { getConfigPath } from '../lib/xdg.ts'; export interface EnvVars { MUX_TOKEN_ID?: string; MUX_TOKEN_SECRET?: string; + MUX_BASE_URL?: string; } /** @@ -48,6 +53,8 @@ export async function parseEnvFile(filePath: string): Promise { envVars.MUX_TOKEN_ID = value; } else if (key === 'MUX_TOKEN_SECRET') { envVars.MUX_TOKEN_SECRET = value; + } else if (key === 'MUX_BASE_URL') { + envVars.MUX_BASE_URL = value; } } } @@ -80,6 +87,8 @@ export const loginCommand = new Command() ); } + let baseUrl: string; + if (options.envFile) { // Read from .env file console.log(`Reading credentials from ${options.envFile}...`); @@ -95,6 +104,9 @@ export const loginCommand = new Command() tokenId = envVars.MUX_TOKEN_ID; tokenSecret = envVars.MUX_TOKEN_SECRET; + baseUrl = getMuxBaseUrl({ + environment: { baseUrl: envVars.MUX_BASE_URL }, + }); } else { // Interactive prompts console.log('Enter your Mux API credentials.'); @@ -111,13 +123,15 @@ export const loginCommand = new Command() if (!tokenSecret.trim()) { throw new Error('Token Secret is required'); } + + baseUrl = getMuxBaseUrl(null); } - // Validate credentials console.log('Validating credentials...'); const validation = await validateCredentials( tokenId.trim(), tokenSecret.trim(), + baseUrl, ); if (!validation.valid) { @@ -131,6 +145,7 @@ export const loginCommand = new Command() tokenId: tokenId.trim(), tokenSecret: tokenSecret.trim(), environmentId: validation.environmentId, + ...(baseUrl !== DEFAULT_BASE_URL && { baseUrl }), }); console.log( diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index b1809f4..d7985fd 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -61,7 +61,7 @@ export const listenCommand = new Command() } const authHeaders = await getAuthHeaders(); - const baseUrl = getMuxBaseUrl(); + const baseUrl = getMuxBaseUrl(env); const url = `${baseUrl}/system/v1/webhook-events/stream`; let signingSecret: string | undefined; diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 17e2036..91554b4 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,6 +1,6 @@ import { Command } from '@cliffy/command'; import { handleCommandError } from '@/lib/errors.ts'; -import { getAuthHeaders, getMuxBaseUrl } from '../lib/mux.ts'; +import { DEFAULT_BASE_URL, getAuthContext } from '../lib/mux.ts'; interface WhoAmIOptions { json?: boolean; @@ -11,8 +11,7 @@ export const whoamiCommand = new Command() .option('--json', 'Output JSON instead of pretty format') .action(async (options: WhoAmIOptions) => { try { - const headers = await getAuthHeaders(); - const baseUrl = getMuxBaseUrl(); + const { headers, baseUrl } = await getAuthContext(); const response = await fetch(`${baseUrl}/system/v1/whoami`, { headers }); if (!response.ok) { @@ -38,6 +37,9 @@ export const whoamiCommand = new Command() console.log( `Permissions: ${(data.permissions as string[]).join(', ')}`, ); + if (baseUrl !== DEFAULT_BASE_URL) { + console.log(`API endpoint: ${baseUrl}`); + } } catch (error) { await handleCommandError(error, 'whoami', 'get', options); } diff --git a/src/lib/config.ts b/src/lib/config.ts index bc8659f..ca36caa 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -7,6 +7,7 @@ export interface Environment { tokenId: string; tokenSecret: string; environmentId?: string; + baseUrl?: string; signingKeyId?: string; signingPrivateKey?: string; forwardUrl?: string; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 731f8a7..52be4ee 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,5 +1,5 @@ import { AuthenticationError, NotFoundError } from '@mux/mux-node'; -import { getAuthHeaders, getMuxBaseUrl } from './mux.ts'; +import { getAuthContext } from './mux.ts'; /** * Format a permission error message for display. @@ -47,8 +47,7 @@ async function fetchTokenInfo(): Promise<{ tokenName?: string; } | null> { try { - const headers = await getAuthHeaders(); - const baseUrl = getMuxBaseUrl(); + const { headers, baseUrl } = await getAuthContext(); const response = await fetch(`${baseUrl}/system/v1/whoami`, { headers }); if (!response.ok) return null; diff --git a/src/lib/mux.test.ts b/src/lib/mux.test.ts new file mode 100644 index 0000000..8553d11 --- /dev/null +++ b/src/lib/mux.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { getCurrentEnvironment } from './config.ts'; +import { setEnvironment } from './config.ts'; +import { DEFAULT_BASE_URL, getMuxBaseUrl } from './mux.ts'; + +describe('getMuxBaseUrl', () => { + let testConfigDir: string; + let originalXdgConfigHome: string | undefined; + let originalMuxBaseUrl: string | undefined; + + beforeEach(async () => { + testConfigDir = await mkdtemp(join(tmpdir(), 'mux-cli-test-')); + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + originalMuxBaseUrl = process.env.MUX_BASE_URL; + process.env.XDG_CONFIG_HOME = testConfigDir; + delete process.env.MUX_BASE_URL; + }); + + afterEach(async () => { + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (originalMuxBaseUrl === undefined) { + delete process.env.MUX_BASE_URL; + } else { + process.env.MUX_BASE_URL = originalMuxBaseUrl; + } + await rm(testConfigDir, { recursive: true, force: true }); + }); + + it('should return default when no env var or config', async () => { + const env = await getCurrentEnvironment(); + expect(getMuxBaseUrl(env)).toBe(DEFAULT_BASE_URL); + }); + + it('should prefer MUX_BASE_URL env var over everything', async () => { + process.env.MUX_BASE_URL = 'https://env-var.example.com'; + await setEnvironment('default', { + tokenId: 'id', + tokenSecret: 'secret', + baseUrl: 'https://config.example.com', + }); + + const env = await getCurrentEnvironment(); + expect(getMuxBaseUrl(env)).toBe('https://env-var.example.com'); + }); + + it('should use config baseUrl when no env var is set', async () => { + await setEnvironment('default', { + tokenId: 'id', + tokenSecret: 'secret', + baseUrl: 'https://api.staging.mux.com', + }); + + const env = await getCurrentEnvironment(); + expect(getMuxBaseUrl(env)).toBe('https://api.staging.mux.com'); + }); + + it('should fall back to default when config has no baseUrl', async () => { + await setEnvironment('default', { + tokenId: 'id', + tokenSecret: 'secret', + }); + + const env = await getCurrentEnvironment(); + expect(getMuxBaseUrl(env)).toBe(DEFAULT_BASE_URL); + }); +}); diff --git a/src/lib/mux.ts b/src/lib/mux.ts index a09949f..2c4bd4a 100644 --- a/src/lib/mux.ts +++ b/src/lib/mux.ts @@ -3,7 +3,7 @@ import pkg from '../../package.json'; import { getCurrentEnvironment } from './config.ts'; import { isAgentMode } from './context.ts'; -const DEFAULT_BASE_URL = 'https://api.mux.com'; +export const DEFAULT_BASE_URL = 'https://api.mux.com'; function getUserAgent(): string { return isAgentMode() @@ -12,16 +12,24 @@ function getUserAgent(): string { } /** - * Get the Mux API base URL, respecting MUX_BASE_URL env var + * Resolve the Mux API base URL. + * Priority: MUX_BASE_URL env var > config baseUrl > default */ -export function getMuxBaseUrl(): string { - return process.env.MUX_BASE_URL || DEFAULT_BASE_URL; +export function getMuxBaseUrl( + env?: { environment: { baseUrl?: string } } | null, +): string { + return ( + process.env.MUX_BASE_URL || env?.environment.baseUrl || DEFAULT_BASE_URL + ); } /** - * Get auth headers for raw fetch requests to Mux API + * Get auth headers and base URL in a single config read. */ -export async function getAuthHeaders(): Promise> { +export async function getAuthContext(): Promise<{ + headers: Record; + baseUrl: string; +}> { const env = await getCurrentEnvironment(); if (!env) { throw new Error("Not logged in. Please run 'mux login' to authenticate."); @@ -31,11 +39,21 @@ export async function getAuthHeaders(): Promise> { `${env.environment.tokenId}:${env.environment.tokenSecret}`, ); return { - Authorization: `Basic ${credentials}`, - 'User-Agent': getUserAgent(), + headers: { + Authorization: `Basic ${credentials}`, + 'User-Agent': getUserAgent(), + }, + baseUrl: getMuxBaseUrl(env), }; } +/** + * Get auth headers for raw fetch requests to Mux API + */ +export async function getAuthHeaders(): Promise> { + return (await getAuthContext()).headers; +} + /** * Create an authenticated Mux client using stored credentials * Throws an error if not logged in @@ -46,9 +64,12 @@ export async function createAuthenticatedMuxClient(): Promise { throw new Error("Not logged in. Please run 'mux login' to authenticate."); } + const baseURL = getMuxBaseUrl(env); + return new Mux({ tokenId: env.environment.tokenId, tokenSecret: env.environment.tokenSecret, + ...(baseURL !== DEFAULT_BASE_URL && { baseURL }), defaultHeaders: { 'User-Agent': getUserAgent() }, }); } @@ -60,9 +81,11 @@ export async function createAuthenticatedMuxClient(): Promise { export async function validateCredentials( tokenId: string, tokenSecret: string, + overrideBaseUrl?: string, ): Promise<{ valid: boolean; environmentId?: string; error?: string }> { try { - const baseUrl = getMuxBaseUrl(); + const baseUrl = + overrideBaseUrl || getMuxBaseUrl(await getCurrentEnvironment()); const credentials = btoa(`${tokenId}:${tokenSecret}`); const response = await fetch(`${baseUrl}/system/v1/whoami`, { headers: {