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
1 change: 1 addition & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This document lists all error scenarios and the messages users will see.
| OAuth error in callback | `OAuth error: ${error}` |
| OAuth token exchange failed | `OAuth token exchange failed: ${body}` |
| `MINIMAX_API_KEY` already set (non-interactive) | `Warning: MINIMAX_API_KEY is already set in environment.` |
| No credentials found (non-interactive) | `No credentials found.` + the searched `config.json` path and hint: `Log in: mmx auth login`, `--api-key sk-xxxxx`, or `MMX_CONFIG_DIR=/path/to/.mmx` |

### `mmx auth logout`

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,17 @@ Useful for CI/CD (`mmx auth login --api-key sk-xxxxx`), or pass per-command via
OAuth and API key are mutually exclusive — logging in with one clears the other.
Credential priority: `--api-key` flag > OAuth (config) > `api_key` (config).

### Environment variables

| Variable | Description |
|---|---|
| `MINIMAX_REGION` | `global` or `cn`. |
| `MINIMAX_BASE_URL` | Override the API base URL. |
| `MINIMAX_OUTPUT` | `text` or `json`. |
| `MINIMAX_TIMEOUT` | Request timeout in seconds. |
| `MINIMAX_VERBOSE` | `1` to enable verbose HTTP logging. |
| `MMX_CONFIG_DIR` | Directory containing the `config.json` file (default: `~/.mmx`). Set this when `mmx` runs from a subprocess, service, or CI job whose home directory differs from where you logged in. |

### `mmx config` · `mmx quota`

```bash
Expand Down
11 changes: 11 additions & 0 deletions src/auth/hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { getConfigPath } from '../config/paths';

const NO_CREDENTIALS_REMEDIATION = [
'Log in: mmx auth login',
'Pass per-call: --api-key sk-xxxxx',
'Or set env var: MMX_CONFIG_DIR=/path/to/.mmx',
].join('\n');

export function buildNoCredentialsHint(configPath?: string): string {
return `Looked for credentials in: ${configPath ?? getConfigPath()}\n${NO_CREDENTIALS_REMEDIATION}`;
}
3 changes: 2 additions & 1 deletion src/auth/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadCredentials } from './credentials';
import { ensureFreshToken } from './refresh';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { buildNoCredentialsHint } from './hints';

export async function resolveCredential(config: Config): Promise<ResolvedCredential> {
// 1. --api-key flag
Expand All @@ -26,6 +27,6 @@ export async function resolveCredential(config: Config): Promise<ResolvedCredent
throw new CLIError(
'No credentials found.',
ExitCode.AUTH,
'Log in: mmx auth login\nPass directly: --api-key sk-xxxxx',
buildNoCredentialsHint(config.configPath),
);
}
3 changes: 2 additions & 1 deletion src/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { deviceCodeLogin } from './oauth';
import { loadCredentials } from './credentials';
import { buildNoCredentialsHint } from './hints';

interface AuthChoice {
value: 'oauth-global' | 'oauth-cn' | 'api-key';
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function ensureAuth(config: Config): Promise<void> {
throw new CLIError(
'No credentials found.',
ExitCode.AUTH,
'Log in: mmx auth login\nPass directly: --api-key sk-xxxxx',
buildNoCredentialsHint(config.configPath),
);
}

Expand Down
65 changes: 62 additions & 3 deletions test/auth/resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { resolveCredential } from '../../src/auth/resolver';
import { ensureAuth } from '../../src/auth/setup';
import { CLIError } from '../../src/errors/base';
import type { Config } from '../../src/config/schema';
import { mkdirSync, rmSync } from 'fs';
import { join } from 'path';
Expand All @@ -24,18 +26,23 @@ function makeConfig(overrides: Partial<Config> = {}): Config {

describe('resolveCredential', () => {
const testDir = join(tmpdir(), `mmx-resolver-test-${Date.now()}`);
const originalConfigDir = process.env.MMX_CONFIG_DIR;
let originalConfigDir: string | undefined;
let originalApiKey: string | undefined;

beforeEach(() => {
originalConfigDir = process.env.MMX_CONFIG_DIR;
originalApiKey = process.env.MINIMAX_API_KEY;
const configDir = join(testDir, '.mmx');
mkdirSync(configDir, { recursive: true });
process.env.MMX_CONFIG_DIR = configDir;
delete process.env.MINIMAX_API_KEY;
});

afterEach(() => {
if (originalConfigDir) process.env.MMX_CONFIG_DIR = originalConfigDir;
if (originalConfigDir !== undefined) process.env.MMX_CONFIG_DIR = originalConfigDir;
else delete process.env.MMX_CONFIG_DIR;
delete process.env.MINIMAX_API_KEY;
if (originalApiKey !== undefined) process.env.MINIMAX_API_KEY = originalApiKey;
else delete process.env.MINIMAX_API_KEY;
rmSync(testDir, { recursive: true, force: true });
});

Expand All @@ -59,9 +66,61 @@ describe('resolveCredential', () => {
await expect(resolveCredential(config)).rejects.toThrow('No credentials found');
});

it('no-credentials hint mentions MMX_CONFIG_DIR and working remediation options', async () => {
const config = makeConfig();
try {
await resolveCredential(config);
throw new Error('expected resolveCredential to throw');
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
const hint = (err as CLIError).hint ?? '';
expect(hint).toContain('mmx auth login');
expect(hint).toContain('--api-key');
expect(hint).toContain('MMX_CONFIG_DIR');
expect(hint).toContain(join(testDir, '.mmx', 'config.json'));
}
});

it('prefers flag over file api key', async () => {
const config = makeConfig({ apiKey: 'sk-flag', fileApiKey: 'sk-file' });
const cred = await resolveCredential(config);
expect(cred.token).toBe('sk-flag');
});
});

describe('ensureAuth (non-interactive, no credentials)', () => {
const testDir = join(tmpdir(), `mmx-setup-test-${Date.now()}`);
let originalConfigDir: string | undefined;
let originalApiKey: string | undefined;

beforeEach(() => {
originalConfigDir = process.env.MMX_CONFIG_DIR;
originalApiKey = process.env.MINIMAX_API_KEY;
mkdirSync(join(testDir, '.mmx'), { recursive: true });
process.env.MMX_CONFIG_DIR = join(testDir, '.mmx');
delete process.env.MINIMAX_API_KEY;
});

afterEach(() => {
if (originalConfigDir !== undefined) process.env.MMX_CONFIG_DIR = originalConfigDir;
else delete process.env.MMX_CONFIG_DIR;
if (originalApiKey !== undefined) process.env.MINIMAX_API_KEY = originalApiKey;
else delete process.env.MINIMAX_API_KEY;
rmSync(testDir, { recursive: true, force: true });
});

it('no-credentials hint mentions MMX_CONFIG_DIR and working remediation options', async () => {
const config = makeConfig({ nonInteractive: true });
try {
await ensureAuth(config);
throw new Error('expected ensureAuth to throw');
} catch (err) {
expect(err).toBeInstanceOf(CLIError);
const hint = (err as CLIError).hint ?? '';
expect(hint).toContain('mmx auth login');
expect(hint).toContain('--api-key');
expect(hint).toContain('MMX_CONFIG_DIR');
expect(hint).toContain(join(testDir, '.mmx', 'config.json'));
}
});
});
Loading