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
417 changes: 398 additions & 19 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"test:watch": "bun test --watch"
},
"dependencies": {
"@clack/prompts": "^0.7.0"
"@clack/prompts": "^0.7.0",
"undici": "^7.0.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
Expand Down
7 changes: 4 additions & 3 deletions src/auth/oauth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { OAuthTokens } from './types';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { proxyFetch } from '../client/proxy';

// OAuth configuration — exact endpoints TBD pending MiniMax OAuth docs
export interface OAuthConfig {
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function startBrowserFlow(
const code = await waitForCallback(config.callbackPort, state);

// Exchange code for tokens
const tokenRes = await fetch(config.tokenUrl, {
const tokenRes = await proxyFetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
Expand Down Expand Up @@ -132,7 +133,7 @@ export async function startDeviceCodeFlow(
config: OAuthConfig = DEFAULT_OAUTH_CONFIG,
): Promise<OAuthTokens> {
// Request device code
const codeRes = await fetch(config.deviceCodeUrl, {
const codeRes = await proxyFetch(config.deviceCodeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
Expand Down Expand Up @@ -168,7 +169,7 @@ export async function startDeviceCodeFlow(
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, pollInterval));

const tokenRes = await fetch(config.tokenUrl, {
const tokenRes = await proxyFetch(config.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
Expand Down
3 changes: 2 additions & 1 deletion src/auth/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OAuthTokens, CredentialFile } from "./types";
import { saveCredentials } from "./credentials";
import { CLIError } from "../errors/base";
import { ExitCode } from "../errors/codes";
import { proxyFetch } from "../client/proxy";

// OAuth config — endpoints TBD pending MiniMax OAuth documentation
const TOKEN_URL = "https://api.minimax.io/v1/oauth/token";
Expand All @@ -11,7 +12,7 @@ export async function refreshAccessToken(
): Promise<OAuthTokens> {
let res: Response;
try {
res = await fetch(TOKEN_URL, {
res = await proxyFetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
Expand Down
3 changes: 2 additions & 1 deletion src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ExitCode } from '../errors/codes';
import { resolveCredential } from '../auth/resolver';
import { mapApiError } from '../errors/api';
import { maybeShowStatusBar } from '../output/status-bar';
import { proxyFetch } from './proxy';

export interface RequestOpts {
url: string;
Expand Down Expand Up @@ -54,7 +55,7 @@ export async function request(config: Config, opts: RequestOpts): Promise<Respon

const timeoutMs = (opts.timeout ?? config.timeout) * 1000;

const res = await fetch(opts.url, {
const res = await proxyFetch(opts.url, {
method: opts.method ?? 'GET',
headers,
body: opts.body
Expand Down
120 changes: 120 additions & 0 deletions src/client/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Proxy-aware fetch wrapper.
*
* Node.js native fetch() does not respect HTTP_PROXY/HTTPS_PROXY environment
* variables. This module provides a drop-in replacement that routes requests
* through the configured proxy when present.
*
* Environment variables checked (in order of precedence):
* - HTTPS_PROXY / https_proxy — for HTTPS requests
* - HTTP_PROXY / http_proxy — for HTTP requests (or as fallback for HTTPS)
* - NO_PROXY / no_proxy — comma-separated list of hosts to bypass
*/

import { ProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';

/**
* Get the proxy URL for a given request URL.
* Returns undefined if no proxy should be used.
*/
function getProxyUrl(targetUrl: string): string | undefined {
const url = new URL(targetUrl);
const hostname = url.hostname.toLowerCase();

// Check NO_PROXY
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
if (noProxy) {
const noProxyList = noProxy.split(',').map(h => h.trim().toLowerCase());
for (const pattern of noProxyList) {
if (!pattern) continue;
// Handle wildcard patterns like *.example.com or .example.com
if (pattern.startsWith('*.')) {
const suffix = pattern.slice(1); // .example.com
if (hostname.endsWith(suffix) || hostname === pattern.slice(2)) {
return undefined;
}
} else if (pattern.startsWith('.')) {
if (hostname.endsWith(pattern) || hostname === pattern.slice(1)) {
return undefined;
}
} else if (hostname === pattern || hostname.endsWith('.' + pattern)) {
return undefined;
}
// Special case: * means no proxy for anything
if (pattern === '*') {
return undefined;
}
}
}

// Select proxy based on protocol
if (url.protocol === 'https:') {
return process.env.HTTPS_PROXY || process.env.https_proxy ||
process.env.HTTP_PROXY || process.env.http_proxy;
}

return process.env.HTTP_PROXY || process.env.http_proxy;
}

// Cache the proxy agent to avoid creating a new one for each request
let cachedProxyAgent: ProxyAgent | undefined;
let cachedProxyUrl: string | undefined;

function getProxyAgent(proxyUrl: string): ProxyAgent {
if (cachedProxyAgent && cachedProxyUrl === proxyUrl) {
return cachedProxyAgent;
}
cachedProxyAgent = new ProxyAgent(proxyUrl);
cachedProxyUrl = proxyUrl;
return cachedProxyAgent;
}

/**
* Proxy-aware fetch function.
* Drop-in replacement for global fetch() that respects HTTP_PROXY/HTTPS_PROXY.
*/
export async function proxyFetch(
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> {
const url = typeof input === 'string' ? input :
input instanceof URL ? input.toString() :
input.url;

const proxyUrl = getProxyUrl(url);

if (proxyUrl) {
// Use undici fetch with proxy dispatcher
const dispatcher = getProxyAgent(proxyUrl);
// Cast through unknown because undici Response type differs slightly from global Response
return undiciFetch(url, {
...init,
dispatcher,
} as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
}

// No proxy configured, use native fetch
return fetch(input, init);
}

/**
* Check if proxy is configured for the current environment.
*/
export function isProxyConfigured(): boolean {
return !!(
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy
);
}

/**
* Get the currently configured proxy URL (for debugging/logging).
*/
export function getConfiguredProxy(): string | undefined {
return process.env.HTTPS_PROXY ||
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy;
}
3 changes: 2 additions & 1 deletion src/commands/vision/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readFileSync, existsSync } from 'fs';
import { extname } from 'path';
import { isInteractive } from '../../utils/env';
import { promptText } from '../../utils/prompt';
import { proxyFetch } from '../../client/proxy';

interface VlmResponse {
content: string;
Expand All @@ -26,7 +27,7 @@ async function toDataUri(image: string): Promise<string> {
if (image.startsWith('data:')) return image;

if (image.startsWith('http://') || image.startsWith('https://')) {
const res = await fetch(image);
const res = await proxyFetch(image);
if (!res.ok) throw new CLIError(`Failed to download image: HTTP ${res.status}`, ExitCode.GENERAL);
const contentType = res.headers.get('content-type') || 'image/jpeg';
const mime = contentType.split(';')[0]!.trim();
Expand Down
3 changes: 2 additions & 1 deletion src/config/detect-region.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { REGIONS, type Region } from "./schema";
import { readConfigFile, writeConfigFile } from "./loader";
import { proxyFetch } from "../client/proxy";

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

Expand All @@ -23,7 +24,7 @@ async function probeRegion(

for (const authHeader of authHeaders) {
try {
const res = await fetch(quotaUrl(region), {
const res = await proxyFetch(quotaUrl(region), {
headers: { ...authHeader, "Content-Type": "application/json" },
signal: AbortSignal.timeout(timeoutMs),
});
Expand Down
3 changes: 2 additions & 1 deletion src/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { createWriteStream, unlinkSync } from 'fs';
import { createProgressBar } from '../output/progress';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { proxyFetch } from '../client/proxy';

export async function downloadFile(
url: string,
destPath: string,
opts?: { quiet?: boolean },
): Promise<{ size: number }> {
const res = await fetch(url);
const res = await proxyFetch(url);

if (!res.ok) {
throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL);
Expand Down
3 changes: 2 additions & 1 deletion src/update/checker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from 'path';
import { readFileSync, writeFileSync } from 'fs';
import { getConfigDir } from '../config/paths';
import { proxyFetch } from '../client/proxy';

const STATE_FILE = () => join(getConfigDir(), 'update-state.json');
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
Expand Down Expand Up @@ -29,7 +30,7 @@ function writeState(state: UpdateState): void {

async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(
const res = await proxyFetch(
`https://api.github.com/repos/${REPO}/releases/latest`,
{
headers: { 'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
Expand Down
7 changes: 4 additions & 3 deletions src/update/self-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from 'path';
import { tmpdir } from 'os';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { proxyFetch } from '../client/proxy';

const REPO = 'MiniMax-AI-Dev/minimax-cli';
const GH_API = 'https://api.github.com';
Expand Down Expand Up @@ -57,7 +58,7 @@ async function ghFetch(path: string): Promise<Response> {
'X-GitHub-Api-Version': '2022-11-28',
};
if (process.env.GITHUB_TOKEN) headers['Authorization'] = `Bearer ${process.env.GITHUB_TOKEN}`;
return fetch(`${GH_API}${path}`, { headers, signal: AbortSignal.timeout(10_000) });
return proxyFetch(`${GH_API}${path}`, { headers, signal: AbortSignal.timeout(10_000) });
}

async function resolveVersion(channel: Channel): Promise<string> {
Expand All @@ -84,7 +85,7 @@ async function resolveVersion(channel: Channel): Promise<string> {

async function fetchManifest(version: string): Promise<Manifest> {
const url = `https://github.com/${REPO}/releases/download/${version}/manifest.json`;
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const res = await proxyFetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new CLIError(`manifest.json not found for ${version}.`, ExitCode.GENERAL);
return res.json() as Promise<Manifest>;
}
Expand All @@ -102,7 +103,7 @@ async function verifySha256(filePath: string, expected: string): Promise<void> {
}

async function downloadFile(url: string, dest: string, onProgress?: (pct: number) => void): Promise<void> {
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
const res = await proxyFetch(url, { signal: AbortSignal.timeout(120_000) });
if (!res.ok) throw new CLIError(`Download failed: ${res.status} ${res.statusText}`, ExitCode.GENERAL);

const total = Number(res.headers.get('content-length') ?? 0);
Expand Down
Loading