diff --git a/package-lock.json b/package-lock.json index 092190a..68dd7f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,23 +1,28 @@ { "name": "mmx-cli", - "version": "1.0.5", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mmx-cli", - "version": "1.0.5", + "version": "1.0.7", "dependencies": { "@clack/prompts": "^0.7.0", - "yaml": "^2.7.1" + "undici": "^7.0.0" }, "bin": { "mmx": "dist/mmx.mjs" }, "devDependencies": { + "@eslint/js": "^9.0.0", "@types/bun": "latest", "eslint": "^9.24.0", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "typescript-eslint": "^8.58.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@clack/core": { @@ -286,6 +291,288 @@ "undici-types": "~7.18.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", @@ -659,6 +946,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -1010,6 +1315,19 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1040,6 +1358,19 @@ "node": ">=4" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1095,6 +1426,36 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", @@ -1122,6 +1483,39 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", @@ -1165,21 +1559,6 @@ "node": ">=0.10.0" } }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 0998b41..76d6191 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 41ad861..0710fec 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -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 { @@ -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({ @@ -132,7 +133,7 @@ export async function startDeviceCodeFlow( config: OAuthConfig = DEFAULT_OAUTH_CONFIG, ): Promise { // 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({ @@ -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({ diff --git a/src/auth/refresh.ts b/src/auth/refresh.ts index c0b97c7..cf29cfa 100644 --- a/src/auth/refresh.ts +++ b/src/auth/refresh.ts @@ -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"; @@ -11,7 +12,7 @@ export async function refreshAccessToken( ): Promise { 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({ diff --git a/src/client/http.ts b/src/client/http.ts index 8382b55..a020d01 100644 --- a/src/client/http.ts +++ b/src/client/http.ts @@ -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; @@ -54,7 +55,7 @@ export async function request(config: Config, opts: RequestOpts): Promise 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 { + 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[1]) as unknown as Promise; + } + + // 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; +} diff --git a/src/commands/vision/describe.ts b/src/commands/vision/describe.ts index a9f9a41..fdea6bb 100644 --- a/src/commands/vision/describe.ts +++ b/src/commands/vision/describe.ts @@ -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; @@ -26,7 +27,7 @@ async function toDataUri(image: string): Promise { 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(); diff --git a/src/config/detect-region.ts b/src/config/detect-region.ts index 22423c0..7200da8 100644 --- a/src/config/detect-region.ts +++ b/src/config/detect-region.ts @@ -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"; @@ -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), }); diff --git a/src/files/download.ts b/src/files/download.ts index 4562fb1..ebd4d57 100644 --- a/src/files/download.ts +++ b/src/files/download.ts @@ -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); diff --git a/src/update/checker.ts b/src/update/checker.ts index 6f1fa0b..41673a2 100644 --- a/src/update/checker.ts +++ b/src/update/checker.ts @@ -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 @@ -29,7 +30,7 @@ function writeState(state: UpdateState): void { async function fetchLatestVersion(): Promise { 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' }, diff --git a/src/update/self-update.ts b/src/update/self-update.ts index 1542b69..7a7920a 100644 --- a/src/update/self-update.ts +++ b/src/update/self-update.ts @@ -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'; @@ -57,7 +58,7 @@ async function ghFetch(path: string): Promise { '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 { @@ -84,7 +85,7 @@ async function resolveVersion(channel: Channel): Promise { async function fetchManifest(version: string): Promise { 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; } @@ -102,7 +103,7 @@ async function verifySha256(filePath: string, expected: string): Promise { } async function downloadFile(url: string, dest: string, onProgress?: (pct: number) => void): Promise { - 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); diff --git a/test/client/proxy.test.ts b/test/client/proxy.test.ts new file mode 100644 index 0000000..ae15f42 --- /dev/null +++ b/test/client/proxy.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +// Test the proxy URL resolution logic without actually making network requests +describe('proxy module', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all proxy-related env vars before each test + delete process.env.HTTP_PROXY; + delete process.env.http_proxy; + delete process.env.HTTPS_PROXY; + delete process.env.https_proxy; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + }); + + afterEach(() => { + // Restore original environment + process.env = { ...originalEnv }; + }); + + describe('getProxyUrl logic', () => { + // We can't easily test getProxyUrl directly since it's not exported, + // but we can test the behavior through isProxyConfigured and getConfiguredProxy + + it('detects no proxy when env vars are not set', async () => { + const { isProxyConfigured } = await import('../../src/client/proxy'); + expect(isProxyConfigured()).toBe(false); + }); + + it('detects proxy when HTTP_PROXY is set', async () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + + // Re-import to pick up the new env + const mod = await import('../../src/client/proxy'); + expect(mod.isProxyConfigured()).toBe(true); + expect(mod.getConfiguredProxy()).toBe('http://proxy.example.com:8080'); + }); + + it('detects proxy when HTTPS_PROXY is set', async () => { + process.env.HTTPS_PROXY = 'http://secure-proxy.example.com:8443'; + + const mod = await import('../../src/client/proxy'); + expect(mod.isProxyConfigured()).toBe(true); + expect(mod.getConfiguredProxy()).toBe('http://secure-proxy.example.com:8443'); + }); + + it('prefers HTTPS_PROXY over HTTP_PROXY', async () => { + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8443'; + + const mod = await import('../../src/client/proxy'); + expect(mod.getConfiguredProxy()).toBe('http://https-proxy.example.com:8443'); + }); + + it('detects lowercase http_proxy', async () => { + process.env.http_proxy = 'http://lowercase-proxy.example.com:8080'; + + const mod = await import('../../src/client/proxy'); + expect(mod.isProxyConfigured()).toBe(true); + expect(mod.getConfiguredProxy()).toBe('http://lowercase-proxy.example.com:8080'); + }); + + it('detects lowercase https_proxy', async () => { + process.env.https_proxy = 'http://lowercase-https-proxy.example.com:8443'; + + const mod = await import('../../src/client/proxy'); + expect(mod.isProxyConfigured()).toBe(true); + expect(mod.getConfiguredProxy()).toBe('http://lowercase-https-proxy.example.com:8443'); + }); + }); + + describe('NO_PROXY handling', () => { + // These tests verify the NO_PROXY logic conceptually + // The actual bypass happens inside proxyFetch + + it('recognizes NO_PROXY environment variable', async () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + process.env.NO_PROXY = 'localhost,127.0.0.1,.internal.com'; + + const mod = await import('../../src/client/proxy'); + // Proxy is still "configured" even with NO_PROXY + // The bypass happens per-request based on the target URL + expect(mod.isProxyConfigured()).toBe(true); + }); + + it('recognizes lowercase no_proxy', async () => { + process.env.HTTP_PROXY = 'http://proxy.example.com:8080'; + process.env.no_proxy = 'localhost,127.0.0.1'; + + const mod = await import('../../src/client/proxy'); + expect(mod.isProxyConfigured()).toBe(true); + }); + }); + + describe('proxyFetch function', () => { + it('is exported and callable', async () => { + const { proxyFetch } = await import('../../src/client/proxy'); + expect(typeof proxyFetch).toBe('function'); + }); + + it('returns a Response when no proxy is configured', async () => { + // Without proxy, proxyFetch should behave like regular fetch + const { proxyFetch } = await import('../../src/client/proxy'); + + // Test with a simple request that should work + // Using a reliable public endpoint + const response = await proxyFetch('https://httpbin.org/status/200', { + signal: AbortSignal.timeout(5000), + }); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + }); + }); +});