Skip to content
Open
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
49 changes: 33 additions & 16 deletions src/auth/refresh.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
import type { OAuthTokens, CredentialFile } from './types';
import { saveCredentials } from './credentials';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import type { OAuthTokens, CredentialFile } from "./types";
import { saveCredentials } from "./credentials";
import { CLIError } from "../errors/base";
import { ExitCode } from "../errors/codes";

// OAuth config — endpoints TBD pending MiniMax OAuth documentation
const TOKEN_URL = 'https://api.minimax.io/v1/oauth/token';
const TOKEN_URL = "https://api.minimax.io/v1/oauth/token";

export async function refreshAccessToken(
refreshToken: string,
): Promise<OAuthTokens> {
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
});
let res: Response;
try {
res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
const isTimeout =
err instanceof Error &&
(err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out"));
throw new CLIError(
isTimeout
? "Token refresh timed out — auth server did not respond within 10 s."
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
ExitCode.AUTH,
"Check your network connection.\nRe-authenticate: mmx auth login",
);
}

if (!res.ok) {
throw new CLIError(
'OAuth session expired and could not be refreshed.',
"OAuth session expired and could not be refreshed.",
ExitCode.AUTH,
'Re-authenticate: mmx auth login',
"Re-authenticate: mmx auth login",
);
}

Expand All @@ -45,7 +62,7 @@ export async function ensureFreshToken(creds: CredentialFile): Promise<string> {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: new Date(Date.now() + tokens.expires_in * 1000).toISOString(),
token_type: 'Bearer',
token_type: "Bearer",
account: creds.account,
};

Expand Down
61 changes: 42 additions & 19 deletions src/config/detect-region.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,60 @@
import { REGIONS, type Region } from './schema';
import { readConfigFile, writeConfigFile } from './loader';
import { REGIONS, type Region } from "./schema";
import { readConfigFile, writeConfigFile } from "./loader";

const QUOTA_PATH = '/v1/api/openplatform/coding_plan/remains';
const QUOTA_PATH = "/v1/api/openplatform/coding_plan/remains";

function quotaUrl(region: Region): string {
return REGIONS[region] + QUOTA_PATH;
}

async function probeRegion(region: Region, apiKey: string, timeoutMs: number): Promise<boolean> {
try {
const res = await fetch(quotaUrl(region), {
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) return false;
const data = await res.json() as { base_resp?: { status_code?: number } };
return data.base_resp?.status_code === 0;
} catch {
return false;
async function probeRegion(
region: Region,
apiKey: string,
timeoutMs: number,
): Promise<boolean> {
// MiniMax endpoints accept either Bearer or x-api-key auth — try both.
// Some API key types only work with one style; trying both prevents false
// negatives that would cause the wrong region to be selected, leading to
// every subsequent request timing out or returning 401.
const authHeaders: Record<string, string>[] = [
{ Authorization: `Bearer ${apiKey}` },
{ "x-api-key": apiKey },
];

for (const authHeader of authHeaders) {
try {
const res = await fetch(quotaUrl(region), {
headers: { ...authHeader, "Content-Type": "application/json" },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) continue;
const data = (await res.json()) as {
base_resp?: { status_code?: number };
};
if (data.base_resp?.status_code === 0) return true;
} catch {
// Try next auth style before giving up on this region
}
}
return false;
}

export async function detectRegion(apiKey: string): Promise<Region> {
process.stderr.write('Detecting region...');
process.stderr.write("Detecting region...");
const regions = Object.keys(REGIONS) as Region[];
const results = await Promise.all(
regions.map(async (r) => ({ region: r, ok: await probeRegion(r, apiKey, 5000) })),
regions.map(async (r) => ({
region: r,
ok: await probeRegion(r, apiKey, 5000),
})),
);
const match = results.find((r) => r.ok);
if (!match) {
process.stderr.write(' failed\n');
process.stderr.write('Warning: API key failed validation against all regions. Falling back to global.\n');
return 'global';
process.stderr.write(" failed\n");
process.stderr.write(
"Warning: API key failed validation against all regions. Falling back to global.\n",
);
return "global";
}
const detected: Region = match.region;
process.stderr.write(` ${detected}\n`);
Expand Down
95 changes: 52 additions & 43 deletions src/errors/handler.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,75 @@
import { CLIError } from './base';
import { ExitCode } from './codes';
import { detectOutputFormat } from '../output/formatter';
import { CLIError } from "./base";
import { ExitCode } from "./codes";
import { detectOutputFormat } from "../output/formatter";

export function handleError(err: unknown): never {
if (err instanceof CLIError) {
const format = detectOutputFormat(process.env.MINIMAX_OUTPUT);

if (format === 'json') {
process.stderr.write(JSON.stringify(err.toJSON(), null, 2) + '\n');
if (format === "json") {
process.stderr.write(JSON.stringify(err.toJSON(), null, 2) + "\n");
} else {
process.stderr.write(`\nError: ${err.message}\n`);
if (err.hint) {
process.stderr.write(`\n ${err.hint.split('\n').join('\n ')}\n`);
process.stderr.write(`\n ${err.hint.split("\n").join("\n ")}\n`);
}
process.stderr.write(` (exit code ${err.exitCode})\n`);
}
process.exit(err.exitCode);
}

if (err instanceof Error) {
if (err.name === 'AbortError' || err.message.includes('timed out')) {
if (
err.name === "AbortError" ||
err.name === "TimeoutError" ||
err.message.includes("timed out")
) {
const timeout = new CLIError(
'Request timed out.',
"Request timed out.",
ExitCode.TIMEOUT,
'Try increasing --timeout or retry later.',
"Try increasing --timeout (e.g. --timeout 60).\n" +
"If this happens on every request with a valid API key, you may be hitting the wrong region.\n" +
"Run: mmx auth status — to check your credentials and region.\n" +
"Run: mmx config set region global (or cn) — to override the region.",
);
return handleError(timeout);
}

// Detect TypeError from fetch with invalid URL (e.g., malformed MINIMAX_BASE_URL)
if (err instanceof TypeError && err.message === 'fetch failed') {
if (err instanceof TypeError && err.message === "fetch failed") {
const networkErr = new CLIError(
'Network request failed.',
"Network request failed.",
ExitCode.NETWORK,
'Check your network connection and proxy settings. Also verify MINIMAX_BASE_URL is a valid URL.',
"Check your network connection and proxy settings. Also verify MINIMAX_BASE_URL is a valid URL.",
);
return handleError(networkErr);
}

// Detect network-level errors (proxy, connection refused, DNS, etc.)
const msg = err.message.toLowerCase();
const isNetworkError =
msg.includes('failed to fetch') ||
msg.includes('connection refused') ||
msg.includes('econnrefused') ||
msg.includes('connection reset') ||
msg.includes('econnreset') ||
msg.includes('network error') ||
msg.includes('enotfound') ||
msg.includes('getaddrinfo') ||
msg.includes('proxy') ||
msg.includes('socket') ||
msg.includes('etimedout') ||
msg.includes('timeout') ||
msg.includes('eai_AGAIN');
msg.includes("failed to fetch") ||
msg.includes("connection refused") ||
msg.includes("econnrefused") ||
msg.includes("connection reset") ||
msg.includes("econnreset") ||
msg.includes("network error") ||
msg.includes("enotfound") ||
msg.includes("getaddrinfo") ||
msg.includes("proxy") ||
msg.includes("socket") ||
msg.includes("etimedout") ||
msg.includes("timeout") ||
msg.includes("eai_AGAIN");

if (isNetworkError) {
let hint = 'Check your network connection and proxy settings.';
if (msg.includes('proxy')) {
hint = 'Proxy error — check HTTP_PROXY / HTTPS_PROXY environment variables and proxy authentication.';
let hint = "Check your network connection and proxy settings.";
if (msg.includes("proxy")) {
hint =
"Proxy error — check HTTP_PROXY / HTTPS_PROXY environment variables and proxy authentication.";
}
const networkErr = new CLIError(
'Network request failed.',
"Network request failed.",
ExitCode.NETWORK,
hint,
);
Expand All @@ -71,36 +79,37 @@ export function handleError(err: unknown): never {
// Detect filesystem-level errors (ENOENT, EACCES, ENOSPC, etc.)
const ecode = (err as NodeJS.ErrnoException).code;
if (
ecode === 'ENOENT' ||
ecode === 'EACCES' ||
ecode === 'ENOSPC' ||
ecode === 'ENOTDIR' ||
ecode === 'EISDIR' ||
ecode === 'EPERM' ||
ecode === 'EBUSY'
ecode === "ENOENT" ||
ecode === "EACCES" ||
ecode === "ENOSPC" ||
ecode === "ENOTDIR" ||
ecode === "EISDIR" ||
ecode === "EPERM" ||
ecode === "EBUSY"
) {
let hint = 'Check the file path and permissions.';
if (ecode === 'ENOENT') hint = 'File or directory not found.';
if (ecode === 'EACCES' || ecode === 'EPERM') hint = 'Permission denied — check file or directory permissions.';
if (ecode === 'ENOSPC') hint = 'Disk full — free up space and try again.';
let hint = "Check the file path and permissions.";
if (ecode === "ENOENT") hint = "File or directory not found.";
if (ecode === "EACCES" || ecode === "EPERM")
hint = "Permission denied — check file or directory permissions.";
if (ecode === "ENOSPC") hint = "Disk full — free up space and try again.";
const fsErr = new CLIError(
`File system error: ${err.message}`,
ExitCode.GENERAL,
hint,
);
return handleError(fsErr);
} else if (typeof ecode === 'string' && ecode.startsWith('E')) {
} else if (typeof ecode === "string" && ecode.startsWith("E")) {
// All other Node.js filesystem error codes (EMFILE, EEXIST, EROFS, etc.)
const fsErr = new CLIError(
`File system error: ${err.message}`,
ExitCode.GENERAL,
'Check the file path and permissions.',
"Check the file path and permissions.",
);
return handleError(fsErr);
}

process.stderr.write(`\nError: ${err.message}\n`);
if (process.env.MINIMAX_VERBOSE === '1') {
if (process.env.MINIMAX_VERBOSE === "1") {
process.stderr.write(`${err.stack}\n`);
}
} else {
Expand Down
Loading