Skip to content
Merged
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
16 changes: 16 additions & 0 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -48,6 +53,8 @@ export async function parseEnvFile(filePath: string): Promise<EnvVars> {
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;
}
}
}
Expand Down Expand Up @@ -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}...`);
Expand All @@ -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.');
Expand All @@ -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) {
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/commands/webhooks/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions src/commands/whoami.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) {
Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface Environment {
tokenId: string;
tokenSecret: string;
environmentId?: string;
baseUrl?: string;
signingKeyId?: string;
signingPrivateKey?: string;
forwardUrl?: string;
Expand Down
5 changes: 2 additions & 3 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions src/lib/mux.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 32 additions & 9 deletions src/lib/mux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<Record<string, string>> {
export async function getAuthContext(): Promise<{
headers: Record<string, string>;
baseUrl: string;
}> {
const env = await getCurrentEnvironment();
if (!env) {
throw new Error("Not logged in. Please run 'mux login' to authenticate.");
Expand All @@ -31,11 +39,21 @@ export async function getAuthHeaders(): Promise<Record<string, string>> {
`${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<Record<string, string>> {
return (await getAuthContext()).headers;
}

/**
* Create an authenticated Mux client using stored credentials
* Throws an error if not logged in
Expand All @@ -46,9 +64,12 @@ export async function createAuthenticatedMuxClient(): Promise<Mux> {
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() },
});
}
Expand All @@ -60,9 +81,11 @@ export async function createAuthenticatedMuxClient(): Promise<Mux> {
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: {
Expand Down
Loading