Skip to content

Commit d0170c3

Browse files
kapelamekapelame
authored andcommitted
feat(auth): mentor feedback round — endpoint URLs, mutex, --recommend, region detection
- Menu labels show the API endpoint: MiniMax (OAuth login → api.minimax.io) MiniMax (OAuth login → api.minimaxi.com) API key - config.json now stores oauth XOR api_key: OAuth login clears any existing api_key API-key login clears any existing oauth subobject - Status bar shows the API URL instead of the region label: MINIMAX ~/.mmx/config.json | URL: api.minimaxi.com | Key: oat-...vBBY - API-key login auto-detects the region by probing both Global and CN (uses existing detectRegion helper); detected region is persisted. - New --recommend flag jumps straight to OAuth, skipping the 3-option menu. Combine with --region=global|cn to skip the region picker too: mmx auth login --recommend mmx auth login --recommend --region=global mmx auth login --recommend --region=cn
1 parent ba82db1 commit d0170c3

3 files changed

Lines changed: 79 additions & 25 deletions

File tree

src/auth/setup.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
import type { Config, Region } from '../config/schema';
2+
import { REGIONS } from '../config/schema';
23
import { readConfigFile, writeConfigFile } from '../config/loader';
34
import { promptText, promptConfirm } from '../utils/prompt';
45
import { isInteractive } from '../utils/env';
56
import { maskToken } from '../utils/token';
67
import { CLIError } from '../errors/base';
78
import { ExitCode } from '../errors/codes';
89
import { deviceCodeLogin } from './oauth';
9-
import { saveCredentials, loadCredentials } from './credentials';
10+
import { loadCredentials } from './credentials';
1011

1112
interface AuthChoice {
1213
value: 'oauth-global' | 'oauth-cn' | 'api-key';
1314
label: string;
1415
}
1516

1617
const AUTH_CHOICES: AuthChoice[] = [
17-
{ value: 'oauth-global', label: 'MiniMax (OAuth login, Global)' },
18-
{ value: 'oauth-cn', label: 'MiniMax (OAuth login, China)' },
18+
{ value: 'oauth-global', label: `MiniMax (OAuth login${stripScheme(REGIONS.global)})` },
19+
{ value: 'oauth-cn', label: `MiniMax (OAuth login${stripScheme(REGIONS.cn)})` },
1920
{ value: 'api-key', label: 'API key' },
2021
];
2122

23+
function stripScheme(url: string): string {
24+
return url.replace(/^https?:\/\//, '');
25+
}
26+
2227
export async function ensureAuth(config: Config): Promise<void> {
2328
if (config.apiKey || config.fileApiKey) return;
2429
if (await loadCredentials()) return;
@@ -66,9 +71,31 @@ export async function pickAuthMethod(): Promise<AuthChoice['value']> {
6671
return value as AuthChoice['value'];
6772
}
6873

74+
/**
75+
* Region-only picker used by `mmx auth login --recommend` (no API-key option).
76+
*/
77+
export async function pickOAuthRegion(): Promise<Region> {
78+
const { select, isCancel } = await import('@clack/prompts');
79+
const value = await select({
80+
message: 'Select an OAuth region:',
81+
options: [
82+
{ value: 'global', label: `Global → ${stripScheme(REGIONS.global)}` },
83+
{ value: 'cn', label: `China → ${stripScheme(REGIONS.cn)}` },
84+
],
85+
});
86+
if (isCancel(value)) throw new CLIError('Authentication cancelled.', ExitCode.AUTH);
87+
return value as Region;
88+
}
89+
6990
export async function runOAuthLogin(region: Region): Promise<void> {
7091
const creds = await deviceCodeLogin(region);
71-
await saveCredentials(creds);
92+
// OAuth and api_key are mutually exclusive — drop any stale api_key
93+
// so `mmx auth status` and the resolver see a single source of truth.
94+
const existing = readConfigFile() as Record<string, unknown>;
95+
delete existing.api_key;
96+
existing.oauth = creds;
97+
existing.region = region;
98+
await writeConfigFile(existing);
7299
process.stderr.write('Logged in successfully.\n');
73100
process.stderr.write('Credentials saved to ~/.mmx/config.json\n');
74101
}

src/commands/auth/login.ts

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { defineCommand } from '../../command';
22
import { CLIError } from '../../errors/base';
33
import { ExitCode } from '../../errors/codes';
4-
import { pickAuthMethod, runOAuthLogin } from '../../auth/setup';
4+
import { pickAuthMethod, pickOAuthRegion, runOAuthLogin } from '../../auth/setup';
55
import { requestJson } from '../../client/http';
66
import { quotaEndpoint } from '../../client/endpoints';
77
import { renderQuotaTable } from '../../output/quota-table';
8+
import { detectRegion } from '../../config/detect-region';
89

910
import { getConfigPath } from '../../config/paths';
1011
import { readConfigFile, writeConfigFile } from '../../config/loader';
@@ -13,7 +14,7 @@ import { maskToken } from '../../utils/token';
1314
import type { Config, Region } from '../../config/schema';
1415
import { REGIONS } from '../../config/schema';
1516
import type { GlobalFlags } from '../../types/flags';
16-
import type { QuotaResponse, QuotaModelRemain } from '../../types/api';
17+
import type { QuotaModelRemain } from '../../types/api';
1718

1819
interface QuotaApiResponse {
1920
model_remains: QuotaModelRemain[];
@@ -43,14 +44,17 @@ async function showDashboardAfterLogin(config: Config): Promise<void> {
4344
export default defineCommand({
4445
name: 'auth login',
4546
description: 'Authenticate via OAuth or API key',
46-
usage: 'mmx auth login [--api-key <key>] [--region global|cn]',
47+
usage: 'mmx auth login [--api-key <key>] [--recommend] [--region=global|cn]',
4748
options: [
4849
{ flag: '--api-key <key>', description: 'Skip the menu and save this API key directly' },
50+
{ flag: '--recommend', description: 'Skip the menu, go straight to OAuth (combine with --region= to skip the region picker)' },
4951
],
5052
examples: [
5153
'mmx auth login',
5254
'mmx auth login --api-key sk-cp-xxxxx',
53-
'mmx auth login --region cn',
55+
'mmx auth login --recommend',
56+
'mmx auth login --recommend --region=global',
57+
'mmx auth login --recommend --region=cn',
5458
],
5559
async run(config: Config, flags: GlobalFlags) {
5660
const envKey = process.env.MINIMAX_API_KEY;
@@ -89,6 +93,14 @@ export default defineCommand({
8993
);
9094
}
9195

96+
// --recommend: skip the 3-option menu, go straight to OAuth.
97+
// With --region, skip the region picker too.
98+
if (flags.recommend) {
99+
const region = (flags.region as Region) || await pickOAuthRegion();
100+
await completeOAuthLogin(config, region);
101+
return;
102+
}
103+
92104
const choice = await pickAuthMethod();
93105
if (choice === 'api-key') {
94106
const { text, isCancel } = await import('@clack/prompts');
@@ -102,30 +114,38 @@ export default defineCommand({
102114
}
103115

104116
const region: Region = (flags.region as Region) || (choice === 'oauth-cn' ? 'cn' : 'global');
105-
await runOAuthLogin(region);
106-
107-
// Reload config so the OAuth subobject (and its resource_url) are picked up.
108-
const fresh = readConfigFile();
109-
const cfg: Config = {
110-
...config,
111-
region,
112-
baseUrl: fresh.oauth?.resource_url || REGIONS[region],
113-
};
114-
await showDashboardAfterLogin(cfg);
117+
await completeOAuthLogin(config, region);
115118
},
116119
});
117120

121+
async function completeOAuthLogin(config: Config, region: Region): Promise<void> {
122+
await runOAuthLogin(region);
123+
124+
// Reload so the OAuth subobject (and any resource_url override) is picked up.
125+
const fresh = readConfigFile();
126+
const cfg: Config = {
127+
...config,
128+
region,
129+
baseUrl: fresh.oauth?.resource_url || REGIONS[region],
130+
};
131+
await showDashboardAfterLogin(cfg);
132+
}
133+
118134
async function loginWithApiKey(config: Config, key: string): Promise<void> {
119135
if (config.dryRun) {
120136
console.log('Would validate and save API key.');
121137
return;
122138
}
123139

124-
process.stderr.write('Testing key... ');
140+
// Probe both regions and pick the one the key actually authenticates against.
141+
// This doubles as key validation — if neither region accepts it, the key is bad.
142+
const detected = await detectRegion(key);
143+
const cfg: Config = { ...config, region: detected, baseUrl: REGIONS[detected], apiKey: key };
144+
145+
// Verify the detection actually authorizes the quota endpoint (defends against
146+
// detectRegion's graceful 'global' fallback when the network is unreachable).
125147
try {
126-
const test = { ...config, apiKey: key };
127-
await requestJson<QuotaResponse>(test, { url: quotaEndpoint(test.baseUrl) });
128-
process.stderr.write('Valid\n');
148+
await requestJson<QuotaApiResponse>(cfg, { url: quotaEndpoint(cfg.baseUrl) });
129149
} catch {
130150
throw new CLIError(
131151
'API key validation failed.',
@@ -134,10 +154,13 @@ async function loginWithApiKey(config: Config, key: string): Promise<void> {
134154
);
135155
}
136156

157+
// OAuth and api_key are mutually exclusive — drop any stale oauth block.
137158
const existing = readConfigFile() as Record<string, unknown>;
159+
delete existing.oauth;
138160
existing.api_key = key;
161+
existing.region = detected;
139162
await writeConfigFile(existing);
140163
process.stderr.write(`API key saved to ${getConfigPath()}\n`);
141164

142-
await showDashboardAfterLogin({ ...config, apiKey: key });
165+
await showDashboardAfterLogin(cfg);
143166
}

src/output/status-bar.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ function tildePath(p: string): string {
2020
return p.startsWith(homedir()) ? p.replace(homedir(), '~') : p;
2121
}
2222

23+
function stripScheme(url: string): string {
24+
return url.replace(/^https?:\/\//, '');
25+
}
26+
2327
export function maybeShowStatusBar(config: Config, token: string, model?: string): void {
2428
if (config.quiet || printed || !process.stderr.isTTY) return;
2529
printed = true;
2630

2731
const filePath = config.configPath ? tildePath(config.configPath) : '~/.mmx/config.json';
28-
const regionSrc = config.fileRegion ? `${config.fileRegion} (file)` : 'global (default)';
32+
const baseUrlStr = stripScheme(config.baseUrl);
2933
const keySrc = config.apiKey ? '(flag)' : '(file)';
3034
const maskedKey = maskToken(token);
3135
const modelStr = model ? ` ${dim}|${reset} ${dim}Model:${reset} ${mmPurple}${model}${reset}` : '';
@@ -34,7 +38,7 @@ export function maybeShowStatusBar(config: Config, token: string, model?: string
3438
`${bold}${mmBlue}MINIMAX${reset} ` +
3539
`${dim}${filePath}${reset} ` +
3640
`${dim}|${reset} ` +
37-
`${dim}Region:${reset} ${mmCyan}${regionSrc}${reset} ` +
41+
`${dim}URL:${reset} ${mmCyan}${baseUrlStr}${reset} ` +
3842
`${dim}|${reset} ` +
3943
`${dim}Key:${reset} ${mmPink}${maskedKey}${reset} ${dim}${keySrc}${reset}` +
4044
`${modelStr}\n`,

0 commit comments

Comments
 (0)