From 98cd1cce32cafc32f24bdbbc30c9348ea4d5a427 Mon Sep 17 00:00:00 2001 From: victor0602 Date: Mon, 6 Apr 2026 12:06:19 +0800 Subject: [PATCH 1/2] feat(errors): improve global error interception for filesystem, network and format errors - Add SIGINT handler (exit 130) in main.ts - Detect filesystem errors (ENOENT/EACCES/ENOSPC/EPERM/EBUSY/EISDIR/ENOTDIR) with specific hints, plus catch-all for other "E" prefix Node.js codes - Detect TypeError "fetch failed" as network error with MINIMAX_BASE_URL hint - Handle non-JSON API responses gracefully in requestJson - Warn when credentials file is corrupted (credentials.ts) - Warn when config file is corrupted (loader.ts) - Add ERRORS.md documenting all error scenarios for all commands --- ERRORS.md | 281 ++++++++++++++++++++++++++++++++++++++++ src/auth/credentials.ts | 6 +- src/client/http.ts | 13 +- src/config/loader.ts | 6 +- src/errors/handler.ts | 72 ++++++++++ src/main.ts | 6 + 6 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 ERRORS.md diff --git a/ERRORS.md b/ERRORS.md new file mode 100644 index 0000000..3e81711 --- /dev/null +++ b/ERRORS.md @@ -0,0 +1,281 @@ +# MiniMax CLI Error Reference + +This document lists all error scenarios and the messages users will see. + +## Auth Commands + +### `minimax auth login` + +| Scenario | Error Message | +|---|---| +| `--method api-key` without `--api-key` | `--api-key is required when using --method api-key.` | +| API key validation failed | `API key validation failed.` | +| OAuth callback timeout (120s) | `OAuth callback timed out.` | +| OAuth state mismatch | `Invalid OAuth callback.` | +| 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.` | + +### `minimax auth logout` + +| Scenario | Error Message | +|---|---| +| No credentials to clear | `No credentials to clear.` | + +### `minimax auth refresh` + +| Scenario | Error Message | +|---|---| +| No OAuth credentials (api-key mode) | `Not applicable: not authenticated via OAuth.` | +| Refresh token expired | `OAuth session expired and could not be refreshed.` | + +### `minimax auth status` + +| Scenario | Error Message | +|---|---| +| No credentials | `authenticated: false` + `Not authenticated.` | +| Quota request failed | `Failed to fetch quota: ${err.message}` | + +--- + +## Text Commands + +### `minimax text chat` + +| Scenario | Error Message | +|---|---| +| No `--message` in non-interactive mode | `Missing required argument: --message` | +| `--messages-file` file not found | `File not found: ${filePath}` | +| `--messages-file` content is not valid JSON | `--messages-file content is not valid JSON.` | +| `--tool` not valid JSON (not a file path) | `--tool argument "${t}" is not valid JSON.` | +| `--tool` file not found | `Tool definition file not found: ${t}` | +| `--tool` file exists but invalid JSON | `Tool definition file "${t}" contains invalid JSON.` | +| Stream disconnected mid-response | `Stream disconnected before response completed.` | + +--- + +## Image Commands + +### `minimax image generate` + +| Scenario | Error Message | +|---|---| +| No `--prompt` in non-interactive mode | `Missing required argument: --prompt` | +| `--subject-ref` local image not found | `Subject reference image not found: ${params.image}` | +| `--subject-ref` image unreadable | `Cannot read image file: ${e.message}` | +| `--out-dir` no write permission | `Permission denied: cannot create directory "${outDir}".` | +| `--out-dir` other error | `Cannot create directory "${outDir}": ${e.message}` | +| `success_count === 0` (all rejected) | `Image generation failed: all images were rejected (content policy or model error).` | + +--- + +## Video Commands + +### `minimax video generate` + +| Scenario | Error Message | +|---|---| +| No `--prompt` in non-interactive mode | `Missing required argument: --prompt` | +| `--first-frame` file not found | `First-frame image not found: ${framePath}` | +| `--first-frame` file unreadable | `Cannot read image file: ${e.message}` | +| Task status `Failed` | `Task Failed: ${status_msg}` (when `base_resp.status_code` is 0 or absent); otherwise [API Errors](#api-errors) apply | +| Task status `Unknown` | `Task Unknown: ${status_msg}` (when `base_resp.status_code` is 0 or absent); otherwise [API Errors](#api-errors) apply | +| Success but no `file_id` | `Task completed but no file_id returned.` | +| `file_id` has no `download_url` | `No download URL available for this file.` | +| Polling timeout | `Polling timed out.` | +| Download network interrupted | `Network request failed.` | +| Disk full | `Disk full — cannot write video file.` | +| `--download` path no write permission | `Cannot write file: ${e.message}` | + +### `minimax video task get` + +| Scenario | Error Message | +|---|---| +| No `--task-id` | `--task-id is required.` | + +### `minimax video download` + +| Scenario | Error Message | +|---|---| +| No `--file-id` | `--file-id is required.` | +| No `--out` | `--out is required.` | +| `download_url` is empty | `No download URL available for this file.` | +| Download failed (HTTP error) | `Download failed: HTTP ${res.status}` | +| Disk full | `Disk full — cannot write video file.` | +| Output path no write permission | `Cannot write file: ${e.message}` | + +--- + +## Speech Commands + +### `minimax speech synthesize` + +| Scenario | Error Message | +|---|---| +| No `--text` and no `--text-file` | `--text or --text-file is required.` | +| `--text-file` not found | `File not found: ${flags.textFile}` | +| `--text-file` unreadable | `Cannot read file: ${e.message}` | +| `--out` path no write permission | `Permission denied: cannot write to "${outPath}".` | +| Disk full | `Disk full — cannot write audio file.` | + +### `minimax speech voices` + +All errors fall under [Network Errors](#networkerrors). + +--- + +## Music Commands + +### `minimax music generate` + +| Scenario | Error Message | +|---|---| +| Neither `--prompt` nor `--lyrics` provided | `At least one of --prompt or --lyrics is required.` | +| `--lyrics-file` not found | `File not found: ${flags.lyricsFile}` | +| `--lyrics-file` unreadable | `Cannot read file: ${e.message}` | +| `--out` path no write permission | `Permission denied: cannot write to "${outPath}".` | +| Disk full | `Disk full — cannot write audio file.` | + +--- + +## Vision Commands + +### `minimax vision describe` + +| Scenario | Error Message | +|---|---| +| Neither `--image` nor `--file-id` in non-interactive mode | `Missing required argument. Must provide either --image or --file-id.` | +| Both `--image` and `--file-id` provided | `Conflicting arguments: cannot provide both --image and --file-id.` | +| Local image file not found | `File not found: ${image}` | +| Image format not supported | `Unsupported image format "${ext}". Supported: jpg, jpeg, png, webp` | +| Remote image URL download failed | `Failed to download image: HTTP ${res.status}` | + +--- + +## Search Commands + +### `minimax search query` + +| Scenario | Error Message | +|---|---| +| No `--q` in non-interactive mode | `--q is required.` | + +--- + +## Quota Commands + +### `minimax quota show` + +All errors fall under [Network Errors](#networkerrors). + +--- + +## Config Commands + +### `minimax config set` + +| Scenario | Error Message | +|---|---| +| `--key` or `--value` missing | `--key and --value are required.` | +| `key` not in valid list | `Invalid config key "${key}". Valid keys: region, base_url, output, timeout, api_key` | +| `region` value invalid | `Invalid region "${value}". Valid values: global, cn` | +| `output` value invalid | `Invalid output format "${value}". Valid values: text, json` | +| `timeout` not a positive number | `Invalid timeout "${value}". Must be a positive number.` | + +### `minimax config export-schema` + +| Scenario | Error Message | +|---|---| +| `--command` specifies non-existent command | `Command "${targetCommand}" not found.` | + +--- + +## Update Commands + +### `minimax update` + +No error scenarios — prints a message directing users to run `npm update -g minimax-cli` manually. + +--- + +## File Commands + +### `minimax file upload` + +| Scenario | Error Message | +|---|---| +| `--file` local file not found | `File not found: ${fullPath}` | +| API error (size limit, unsupported type, etc.) | [API Errors](#api-errors) apply | + +### `minimax file delete` + +| Scenario | Error Message | +|---|---| +| No `--file-id` in non-interactive mode | `Missing required argument: --file-id` | + +### `minimax file list` + +| Scenario | Error Message | +|---|---| +| All errors fall under [Network Errors](#networkerrors) | | + +--- + +## Global Errors (All Commands) + +### Network Errors + +| Scenario | Error Message | +|---|---| +| Network/connection failure | `Network request failed.` + hint: `Check your network connection and proxy settings.` | +| Proxy error detected | `Network request failed.` + hint: `Proxy error — check HTTP_PROXY / HTTPS_PROXY environment variables and proxy authentication.` | +| Request timeout (AbortSignal) | `Request timed out.` | +| HTTP 408 / 504 | `Request timed out (HTTP ${status}).` | + +### API Errors + +| Scenario | Error Message | +|---|---| +| HTTP 401 / 403 | `API key rejected (HTTP ${status}).` | +| HTTP 429 | `Rate limit or quota exceeded. ${apiMsg}` | +| `status_code` 1002 / 1039 (content filter) | `Input content flagged by sensitivity filter (${filterType}).` | +| `status_code` 1028 / 1030 (quota exhausted) | `Quota exhausted. ${apiMsg}` | +| `status_code` 2061 (model not on plan) | `This model is not available on your current Token Plan. ${apiMsg}` | +| Other API errors | `API error: ${apiMsg} (HTTP ${status})` | +| Non-JSON response body (e.g., gateway 502) | `API returned non-JSON response (${contentType}). Server may be experiencing issues.` | + +### File System Errors + +| Scenario | Error Message | +|---|---| +| File not found | `File system error: ENOENT: no such file or directory...` + hint | +| Permission denied | `File system error: EACCES: permission denied...` + hint | +| Disk full | `File system error: ENOSPC: no space left on device...` + hint | +| Other FS errors | `File system error: ${err.message}` + hint | + +### Process Signals + +| Scenario | Error Message | +|---|---| +| Ctrl+C / SIGINT | `Interrupted. Exiting.` (exit code 130) | + +### Config / Credentials File Corruption + +| Scenario | Behavior | +|---|---| +| `~/.minimax/credentials.json` corrupted | Warning written to stderr; treated as no credentials | +| `~/.minimax/config.json` corrupted | Warning written to stderr; treated as empty config | + +### Exit Codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | General error | +| 2 | Usage error (invalid arguments) | +| 3 | Authentication error | +| 4 | Quota error | +| 5 | Timeout | +| 6 | Network error | +| 10 | Content filter | +| 130 | Interrupted (Ctrl+C / SIGINT) | diff --git a/src/auth/credentials.ts b/src/auth/credentials.ts index 8a12fd4..48b77e6 100644 --- a/src/auth/credentials.ts +++ b/src/auth/credentials.ts @@ -12,7 +12,11 @@ export async function loadCredentials(): Promise { const data = JSON.parse(raw) as CredentialFile; if (!data.access_token || !data.refresh_token) return null; return data; - } catch { + } catch (err) { + const e = err as Error; + if (e instanceof SyntaxError || e.message.includes('JSON')) { + process.stderr.write(`Warning: credentials file is corrupted. Run 'minimax auth logout' to reset.\n`); + } return null; } } diff --git a/src/client/http.ts b/src/client/http.ts index 4376954..0856d3a 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -1,5 +1,7 @@ import type { Config } from '../config/schema'; import type { ApiErrorBody } from '../errors/api'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; import { resolveCredential } from '../auth/resolver'; import { mapApiError } from '../errors/api'; import { maybeShowStatusBar } from '../output/status-bar'; @@ -78,7 +80,16 @@ export async function request(config: Config, opts: RequestOpts): Promise(config: Config, opts: RequestOpts): Promise { const res = await request(config, opts); - const data = (await res.json()) as T & { base_resp?: { status_code?: number; status_msg?: string } }; + let data: T & { base_resp?: { status_code?: number; status_msg?: string } }; + try { + data = (await res.json()) as T & { base_resp?: { status_code?: number; status_msg?: string } }; + } catch { + const contentType = res.headers.get('content-type') || ''; + throw new CLIError( + `API returned non-JSON response (${contentType || 'unknown type'}). Server may be experiencing issues.`, + ExitCode.GENERAL, + ); + } if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { throw mapApiError(200, { base_resp: data.base_resp }, opts.url); diff --git a/src/config/loader.ts b/src/config/loader.ts index c91ef4e..36ff17c 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -9,7 +9,11 @@ export function readConfigFile(): ConfigFile { if (!existsSync(path)) return {}; try { return parseConfigFile(JSON.parse(readFileSync(path, 'utf-8'))); - } catch { + } catch (err) { + const e = err as Error; + if (e instanceof SyntaxError || e.message.includes('JSON')) { + process.stderr.write(`Warning: config file is corrupted. Run 'minimax config set' to reset.\n`); + } return {}; } } diff --git a/src/errors/handler.ts b/src/errors/handler.ts index cb2074e..ef1939a 100644 --- a/src/errors/handler.ts +++ b/src/errors/handler.ts @@ -27,6 +27,78 @@ export function handleError(err: unknown): never { ); return handleError(timeout); } + + // Detect TypeError from fetch with invalid URL (e.g., malformed MINIMAX_BASE_URL) + if (err instanceof TypeError && err.message === 'fetch failed') { + const networkErr = new CLIError( + 'Network request failed.', + ExitCode.NETWORK, + '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'); + + 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.'; + } + const networkErr = new CLIError( + 'Network request failed.', + ExitCode.NETWORK, + hint, + ); + return handleError(networkErr); + } + + // 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' + ) { + 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')) { + // 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.', + ); + return handleError(fsErr); + } + process.stderr.write(`\nError: ${err.message}\n`); if (process.env.MINIMAX_VERBOSE === '1') { process.stderr.write(`${err.stack}\n`); diff --git a/src/main.ts b/src/main.ts index 6827c31..498801d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,12 @@ import { ensureApiKey } from './auth/setup'; const CLI_VERSION = process.env.CLI_VERSION ?? '0.3.1'; +// Handle Ctrl+C gracefully +process.on('SIGINT', () => { + process.stderr.write('\nInterrupted. Exiting.\n'); + process.exit(130); +}); + // Commands that manage their own auth or need no key const NO_AUTH_SETUP = [ ['auth', 'login'], From c27f9fb34633383b1e1846149f255b4cfa8858fc Mon Sep 17 00:00:00 2001 From: victor0602 Date: Mon, 6 Apr 2026 14:18:20 +0800 Subject: [PATCH 2/2] fix(errors): add NETWORK exit code to resolve typecheck failure The handler.ts uses ExitCode.NETWORK but NETWORK was not defined in the ExitCode enum, causing TypeScript error TS2339 during CI typecheck. --- src/errors/codes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/errors/codes.ts b/src/errors/codes.ts index a2c1c81..83e4f8b 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -5,6 +5,7 @@ export const ExitCode = { AUTH: 3, QUOTA: 4, TIMEOUT: 5, + NETWORK: 6, CONTENT_FILTER: 10, } as const;