Skip to content

Commit 2be7c33

Browse files
kapelamekapelame
authored andcommitted
refactor(auth): unify OAuth credentials into config.json
Move OAuth tokens from a separate ~/.mmx/credentials.json file into an 'oauth' subobject inside the existing ~/.mmx/config.json. Single source of truth for all CLI state, simpler logout, one file to back up. Also makes Config.oauthApiHost optional and derives it lazily via a new oauthApiHostFor(config) helper, removing the cascade of test-fixture churn that adding it as a required field would have caused.
1 parent 9f21e35 commit 2be7c33

9 files changed

Lines changed: 83 additions & 79 deletions

File tree

src/auth/credentials.ts

Lines changed: 24 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,34 @@
1-
import { readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, statSync } from 'fs';
2-
import { getCredentialsPath, ensureConfigDir } from '../config/paths';
1+
import { readConfigFile, writeConfigFile } from '../config/loader';
32
import type { CredentialFile } from './types';
43

5-
export async function loadCredentials(): Promise<CredentialFile | null> {
6-
const path = getCredentialsPath();
7-
if (!existsSync(path)) return null;
4+
/**
5+
* OAuth credentials live inside the user's main config file
6+
* (`~/.mmx/config.json`) under the `oauth` subobject. This keeps a
7+
* single source of truth for all CLI state.
8+
*/
89

9-
try {
10-
checkPermissions(path);
11-
const raw = readFileSync(path, 'utf-8');
12-
const data = JSON.parse(raw) as CredentialFile;
13-
if (!data.access_token || !data.refresh_token) return null;
14-
return data;
15-
} catch (err) {
16-
const e = err as Error;
17-
if (e instanceof SyntaxError || e.message.includes('JSON')) {
18-
process.stderr.write(`Warning: credentials file is corrupted. Run 'mmx auth logout' to reset.\n`);
19-
}
20-
return null;
21-
}
10+
export async function loadCredentials(): Promise<CredentialFile | null> {
11+
const cfg = readConfigFile();
12+
if (!cfg.oauth) return null;
13+
return {
14+
access_token: cfg.oauth.access_token,
15+
refresh_token: cfg.oauth.refresh_token,
16+
expires_at: cfg.oauth.expires_at,
17+
token_type: cfg.oauth.token_type,
18+
resource_url: cfg.oauth.resource_url,
19+
account: cfg.oauth.account,
20+
};
2221
}
2322

2423
export async function saveCredentials(creds: CredentialFile): Promise<void> {
25-
await ensureConfigDir();
26-
const path = getCredentialsPath();
27-
const tmp = path + '.tmp';
28-
writeFileSync(tmp, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
29-
renameSync(tmp, path);
24+
const existing = readConfigFile() as Record<string, unknown>;
25+
existing.oauth = creds;
26+
await writeConfigFile(existing);
3027
}
3128

3229
export async function clearCredentials(): Promise<void> {
33-
const path = getCredentialsPath();
34-
if (existsSync(path)) {
35-
unlinkSync(path);
36-
}
37-
}
38-
39-
function checkPermissions(path: string): void {
40-
try {
41-
const stat = statSync(path);
42-
const mode = stat.mode & 0o777;
43-
if (mode !== 0o600) {
44-
process.stderr.write(
45-
`Warning: ${path} has permissions ${mode.toString(8)}, expected 600.\n`,
46-
);
47-
}
48-
} catch {
49-
// Ignore permission check errors on platforms that don't support it
50-
}
30+
const existing = readConfigFile() as Record<string, unknown>;
31+
if (!('oauth' in existing)) return;
32+
delete existing.oauth;
33+
await writeConfigFile(existing);
5134
}

src/auth/resolver.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Config } from '../config/schema';
2+
import { oauthApiHostFor } from '../config/schema';
23
import type { ResolvedCredential } from './types';
34
import { loadCredentials } from './credentials';
45
import { ensureFreshToken } from './refresh';
@@ -11,11 +12,11 @@ export async function resolveCredential(config: Config): Promise<ResolvedCredent
1112
return { token: config.apiKey, method: 'api-key', source: 'flag' };
1213
}
1314

14-
// 2. OAuth credentials file
15+
// 2. OAuth credentials in config file
1516
const oauth = await loadCredentials();
1617
if (oauth) {
17-
const token = await ensureFreshToken(oauth, `${config.oauthApiHost}/oauth2/token`);
18-
return { token, method: 'oauth', source: 'credentials.json' };
18+
const token = await ensureFreshToken(oauth, `${oauthApiHostFor(config)}/oauth2/token`);
19+
return { token, method: 'oauth', source: 'config.json' };
1920
}
2021

2122
// 3. API key from config file

src/auth/setup.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Config } from '../config/schema';
2+
import { oauthApiHostFor } from '../config/schema';
23
import { readConfigFile, writeConfigFile } from '../config/loader';
34
import { promptText, promptConfirm } from '../utils/prompt';
45
import { isInteractive } from '../utils/env';
@@ -54,11 +55,12 @@ export async function ensureAuth(config: Config): Promise<void> {
5455
}
5556

5657
if (method === 'oauth') {
58+
const host = oauthApiHostFor(config);
5759
const oauthConfig: OAuthConfig = {
5860
clientId: '659cf4c1-615c-45f6-a5f6-4bf15eb476e5',
5961
clientName: 'MiniMax CLI',
60-
tokenUrl: `${config.oauthApiHost}/oauth2/token`,
61-
deviceCodeUrl: `${config.oauthApiHost}/oauth2/device/code`,
62+
tokenUrl: `${host}/oauth2/token`,
63+
deviceCodeUrl: `${host}/oauth2/device/code`,
6264
scopes: ['openid', 'profile', 'coding_plan'],
6365
};
6466
const tokens = await startDeviceCodeFlow(oauthConfig);

src/commands/auth/login.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export default defineCommand({
154154

155155
await saveCredentials(creds);
156156
process.stderr.write('Logged in successfully.\n');
157-
process.stderr.write('Credentials saved to ~/.mmx/credentials.json\n');
157+
process.stderr.write('Credentials saved to ~/.mmx/config.json\n');
158158

159159
await showQuotaAfterLogin({ ...config, apiKey: creds.access_token });
160160
},

src/commands/auth/logout.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,29 +22,28 @@ export default defineCommand({
2222
const hasConfigKey = !!fileConfig.api_key;
2323

2424
if (config.dryRun) {
25-
if (creds) console.log('Would remove ~/.mmx/credentials.json');
25+
if (creds) console.log('Would clear oauth from ~/.mmx/config.json');
2626
if (hasConfigKey) console.log('Would clear api_key from ~/.mmx/config.json');
2727
if (!creds && !hasConfigKey) console.log('No credentials to clear.');
2828
console.log('No changes made.');
2929
return;
3030
}
3131

32+
if (!creds && !hasConfigKey) {
33+
process.stderr.write('No credentials to clear.\n');
34+
return;
35+
}
36+
3237
if (creds) {
3338
await clearCredentials();
34-
process.stderr.write('Removed ~/.mmx/credentials.json\n');
39+
process.stderr.write('Cleared oauth from ~/.mmx/config.json\n');
3540
}
3641

3742
if (hasConfigKey) {
38-
try {
39-
const updated = fileConfig as Record<string, unknown>;
40-
delete updated.api_key;
41-
await writeConfigFile(updated);
42-
process.stderr.write('Cleared api_key from ~/.mmx/config.json\n');
43-
} catch { /* ignore */ }
44-
}
45-
46-
if (!creds && !hasConfigKey) {
47-
process.stderr.write('No credentials to clear.\n');
43+
const updated = readConfigFile() as Record<string, unknown>;
44+
delete updated.api_key;
45+
await writeConfigFile(updated);
46+
process.stderr.write('Cleared api_key from ~/.mmx/config.json\n');
4847
}
4948
},
5049
});

src/commands/auth/refresh.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ExitCode } from '../../errors/codes';
44
import { loadCredentials, saveCredentials } from '../../auth/credentials';
55
import { refreshAccessToken } from '../../auth/refresh';
66
import { formatOutput, detectOutputFormat } from '../../output/formatter';
7-
import type { Config } from '../../config/schema';
7+
import { oauthApiHostFor, type Config } from '../../config/schema';
88
import type { GlobalFlags } from '../../types/flags';
99
import type { CredentialFile } from '../../auth/types';
1010

@@ -33,7 +33,7 @@ export default defineCommand({
3333

3434
const tokens = await refreshAccessToken(
3535
creds.refresh_token,
36-
`${config.oauthApiHost}/oauth2/token`,
36+
`${oauthApiHostFor(config)}/oauth2/token`,
3737
);
3838

3939
const updated: CredentialFile = {

src/config/loader.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
22
import { parseConfigFile, REGIONS, OAUTH_API_HOSTS, type Config, type ConfigFile, type Region } from './schema';
3-
import { ensureConfigDir, getConfigPath, getCredentialsPath } from './paths';
3+
import { ensureConfigDir, getConfigPath } from './paths';
44
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
55
import { CLIError } from '../errors/base';
66
import { ExitCode } from '../errors/codes';
@@ -28,17 +28,6 @@ export async function writeConfigFile(data: Record<string, unknown>): Promise<vo
2828
renameSync(tmp, path);
2929
}
3030

31-
function readCredentialResourceUrl(): string | undefined {
32-
const path = getCredentialsPath();
33-
if (!existsSync(path)) return undefined;
34-
try {
35-
const data = JSON.parse(readFileSync(path, 'utf-8'));
36-
return data.resource_url || undefined;
37-
} catch {
38-
return undefined;
39-
}
40-
}
41-
4231
export function loadConfig(flags: GlobalFlags): Config {
4332
const file = readConfigFile();
4433

@@ -63,7 +52,7 @@ export function loadConfig(flags: GlobalFlags): Config {
6352
const baseUrl = flags.baseUrl
6453
|| process.env.MINIMAX_BASE_URL
6554
|| file.base_url
66-
|| readCredentialResourceUrl()
55+
|| file.oauth?.resource_url
6756
|| REGIONS[region]
6857
|| REGIONS.global;
6958

src/config/paths.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ export function getConfigPath(): string {
1111
return join(getConfigDir(), 'config.json');
1212
}
1313

14-
export function getCredentialsPath(): string {
15-
return join(getConfigDir(), 'credentials.json');
16-
}
17-
1814
export async function ensureConfigDir(): Promise<void> {
1915
const dir = getConfigDir();
2016
const fs = await import('fs/promises');

src/config/schema.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,28 @@ export const OAUTH_API_HOSTS: Record<Region, string> = {
1414
cn: 'https://account.minimaxi.com',
1515
global: 'https://account.minimax.io',
1616
};
17+
18+
export function oauthApiHostFor(config: { region: Region; oauthApiHost?: string }): string {
19+
return config.oauthApiHost || OAUTH_API_HOSTS[config.region];
20+
}
21+
22+
export interface OAuthCredentials {
23+
access_token: string;
24+
refresh_token: string;
25+
expires_at: string; // ISO 8601
26+
token_type: 'Bearer';
27+
resource_url?: string;
28+
account?: string;
29+
}
30+
1731
export interface ConfigFile {
1832
api_key?: string;
1933
region?: Region;
2034
base_url?: string;
2135
output?: 'text' | 'json';
2236
timeout?: number;
2337
proxy?: string;
38+
oauth?: OAuthCredentials;
2439
default_text_model?: string;
2540
default_speech_model?: string;
2641
default_video_model?: string;
@@ -30,6 +45,23 @@ export interface ConfigFile {
3045
const VALID_REGIONS = new Set<string>(['global', 'cn']);
3146
const VALID_OUTPUTS = new Set<string>(['text', 'json']);
3247

48+
function parseOAuth(raw: unknown): OAuthCredentials | undefined {
49+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
50+
const o = raw as Record<string, unknown>;
51+
if (typeof o.access_token !== 'string' || !o.access_token) return undefined;
52+
if (typeof o.refresh_token !== 'string') return undefined;
53+
if (typeof o.expires_at !== 'string') return undefined;
54+
const out: OAuthCredentials = {
55+
access_token: o.access_token,
56+
refresh_token: o.refresh_token,
57+
expires_at: o.expires_at,
58+
token_type: 'Bearer',
59+
};
60+
if (typeof o.resource_url === 'string' && o.resource_url.length > 0) out.resource_url = o.resource_url;
61+
if (typeof o.account === 'string' && o.account.length > 0) out.account = o.account;
62+
return out;
63+
}
64+
3365
export function parseConfigFile(raw: unknown): ConfigFile {
3466
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
3567
const obj = raw as Record<string, unknown>;
@@ -41,6 +73,8 @@ export function parseConfigFile(raw: unknown): ConfigFile {
4173
if (typeof obj.output === 'string' && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile['output'];
4274
if (typeof obj.timeout === 'number' && obj.timeout > 0) out.timeout = obj.timeout;
4375
if (typeof obj.proxy === 'string' && obj.proxy.startsWith('http')) out.proxy = obj.proxy;
76+
const oauth = parseOAuth(obj.oauth);
77+
if (oauth) out.oauth = oauth;
4478
if (typeof obj.default_text_model === 'string' && obj.default_text_model.length > 0) out.default_text_model = obj.default_text_model;
4579
if (typeof obj.default_speech_model === 'string' && obj.default_speech_model.length > 0) out.default_speech_model = obj.default_speech_model;
4680
if (typeof obj.default_video_model === 'string' && obj.default_video_model.length > 0) out.default_video_model = obj.default_video_model;
@@ -56,7 +90,7 @@ export interface Config {
5690
configPath?: string;
5791
region: Region;
5892
baseUrl: string;
59-
oauthApiHost: string;
93+
oauthApiHost?: string;
6094
output: 'text' | 'json';
6195
timeout: number;
6296
defaultTextModel?: string;

0 commit comments

Comments
 (0)