+44
-2
@@ -145,2 +145,3 @@ const http = require('http') | ||
| async function httpGet(url, options = {}) { | ||
| const redirectCount = options.redirectCount || 0 | ||
| const reqOptions = await buildRequestOptions(url, options) | ||
@@ -150,5 +151,20 @@ | ||
| const req = httpsModule.get(reqOptions, (res) => { | ||
| if (res.statusCode === 301 || res.statusCode === 302) { | ||
| if ([301, 302, 303, 307, 308].includes(res.statusCode)) { | ||
| res.resume() | ||
| httpGet(new URL(res.headers.location, url).href, options) | ||
| if (!res.headers.location) { | ||
| reject( | ||
| new Error(`Redirect ${res.statusCode} missing Location header.`), | ||
| ) | ||
| return | ||
| } | ||
| if (redirectCount >= (options.maxRedirects ?? 10)) { | ||
| reject(new Error('Too many redirects.')) | ||
| return | ||
| } | ||
| httpGet(new URL(res.headers.location, url).href, { | ||
| ...options, | ||
| redirectCount: redirectCount + 1, | ||
| }) | ||
| .then(resolve) | ||
@@ -170,5 +186,31 @@ .catch(reject) | ||
| async function withRetries( | ||
| operation, | ||
| { | ||
| maxAttempts = 1, | ||
| baseDelayMs = 1000, | ||
| shouldRetry = () => true, | ||
| onRetry = () => {}, | ||
| sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), | ||
| }, | ||
| ) { | ||
| for (let attempt = 1; ; attempt++) { | ||
| try { | ||
| return await operation(attempt) | ||
| } catch (error) { | ||
| if (attempt >= maxAttempts || !shouldRetry(error)) { | ||
| throw error | ||
| } | ||
| const delayMs = baseDelayMs * 2 ** (attempt - 1) | ||
| await onRetry({ error, attempt, nextAttempt: attempt + 1, delayMs }) | ||
| await sleep(delayMs) | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| getProxyUrl, | ||
| httpGet, | ||
| withRetries, | ||
| } | ||
@@ -175,0 +217,0 @@ } |
+119
-63
@@ -9,2 +9,3 @@ #!/usr/bin/env node | ||
| const path = require('path') | ||
| const { pipeline } = require('stream/promises') | ||
| const zlib = require('zlib') | ||
@@ -103,2 +104,4 @@ | ||
| requestTimeout: 20000, | ||
| downloadRequestTimeout: 120000, | ||
| downloadMaxAttempts: 3, | ||
| } | ||
@@ -108,3 +111,3 @@ } | ||
| const CONFIG = createConfig(packageName) | ||
| const { getProxyUrl, httpGet } = createReleaseHttpClient({ | ||
| const { httpGet, withRetries } = createReleaseHttpClient({ | ||
| env: process.env, | ||
@@ -515,17 +518,17 @@ userAgent: CONFIG.userAgent, | ||
| async function downloadBinary(version, targetKey = getDownloadTargetKey()) { | ||
| const fileName = PLATFORM_TARGETS[targetKey] | ||
| function isRetryableDownloadStatus(statusCode) { | ||
| return ( | ||
| statusCode === 408 || | ||
| statusCode === 425 || | ||
| statusCode === 429 || | ||
| statusCode >= 500 | ||
| ) | ||
| } | ||
| if (!fileName) { | ||
| const error = new Error(`Unsupported platform: ${process.platform} ${process.arch}`) | ||
| trackUpdateFailed(error.message, version, { stage: 'platform_check', target: targetKey }) | ||
| throw error | ||
| } | ||
| function isRetryableDownloadError(error) { | ||
| if (error && typeof error.retryable === 'boolean') return error.retryable | ||
| return !['EACCES', 'ENOSPC', 'EPERM', 'EROFS'].includes(error?.code) | ||
| } | ||
| const downloadUrl = `${ | ||
| process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com' | ||
| }/api/releases/download/${version}/${fileName}` | ||
| fs.mkdirSync(CONFIG.configDir, { recursive: true }) | ||
| function prepareTempDownloadDir() { | ||
| if (fs.existsSync(CONFIG.tempDownloadDir)) { | ||
@@ -535,56 +538,119 @@ fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) | ||
| fs.mkdirSync(CONFIG.tempDownloadDir, { recursive: true }) | ||
| } | ||
| term.write('Downloading...') | ||
| async function downloadAndExtract(downloadUrl, version, targetKey) { | ||
| let attempts = 0 | ||
| const res = await httpGet(downloadUrl) | ||
| try { | ||
| return await withRetries( | ||
| async (attempt) => { | ||
| attempts = attempt | ||
| prepareTempDownloadDir() | ||
| term.write('Downloading...') | ||
| if (res.statusCode !== 200) { | ||
| fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) | ||
| const error = new Error(`Download failed: HTTP ${res.statusCode}`) | ||
| trackUpdateFailed(error.message, version, { stage: 'http_download', statusCode: res.statusCode, target: targetKey }) | ||
| throw error | ||
| } | ||
| const res = await httpGet(downloadUrl, { | ||
| timeout: CONFIG.downloadRequestTimeout, | ||
| }) | ||
| const totalSize = parseInt(res.headers['content-length'] || '0', 10) | ||
| let downloadedSize = 0 | ||
| let lastProgressTime = Date.now() | ||
| if (res.statusCode !== 200) { | ||
| res.resume() | ||
| const error = new Error(`Download failed: HTTP ${res.statusCode}`) | ||
| error.statusCode = res.statusCode | ||
| error.retryable = isRetryableDownloadStatus(res.statusCode) | ||
| throw error | ||
| } | ||
| res.on('data', (chunk) => { | ||
| downloadedSize += chunk.length | ||
| const now = Date.now() | ||
| if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { | ||
| lastProgressTime = now | ||
| if (totalSize > 0) { | ||
| const pct = Math.round((downloadedSize / totalSize) * 100) | ||
| term.write( | ||
| `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( | ||
| totalSize, | ||
| )}`, | ||
| const totalSize = parseInt(res.headers['content-length'] || '0', 10) | ||
| let downloadedSize = 0 | ||
| let lastProgressTime = Date.now() | ||
| res.on('data', (chunk) => { | ||
| downloadedSize += chunk.length | ||
| const now = Date.now() | ||
| if (now - lastProgressTime >= 100 || downloadedSize === totalSize) { | ||
| lastProgressTime = now | ||
| if (totalSize > 0) { | ||
| const pct = Math.round((downloadedSize / totalSize) * 100) | ||
| term.write( | ||
| `Downloading... ${createProgressBar(pct)} ${pct}% of ${formatBytes( | ||
| totalSize, | ||
| )}`, | ||
| ) | ||
| } else { | ||
| term.write(`Downloading... ${formatBytes(downloadedSize)}`) | ||
| } | ||
| } | ||
| }) | ||
| await pipeline( | ||
| res, | ||
| zlib.createGunzip(), | ||
| tar.x({ cwd: CONFIG.tempDownloadDir }), | ||
| ) | ||
| } else { | ||
| term.write(`Downloading... ${formatBytes(downloadedSize)}`) | ||
| } | ||
| const tempBinaryPath = path.join( | ||
| CONFIG.tempDownloadDir, | ||
| CONFIG.binaryName, | ||
| ) | ||
| if (!fs.existsSync(tempBinaryPath)) { | ||
| const files = fs.readdirSync(CONFIG.tempDownloadDir) | ||
| const error = new Error( | ||
| `Binary not found after extraction. Expected: ${CONFIG.binaryName}, Available files: ${files.join(', ')}`, | ||
| ) | ||
| error.retryable = false | ||
| throw error | ||
| } | ||
| return tempBinaryPath | ||
| }, | ||
| { | ||
| maxAttempts: CONFIG.downloadMaxAttempts, | ||
| shouldRetry: isRetryableDownloadError, | ||
| onRetry: ({ nextAttempt, delayMs }) => { | ||
| term.writeLine( | ||
| `Download interrupted. Retrying in ${delayMs / 1000}s (${nextAttempt}/${CONFIG.downloadMaxAttempts})...`, | ||
| ) | ||
| }, | ||
| }, | ||
| ) | ||
| } catch (error) { | ||
| if (fs.existsSync(CONFIG.tempDownloadDir)) { | ||
| fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) | ||
| } | ||
| }) | ||
| await new Promise((resolve, reject) => { | ||
| res | ||
| .pipe(zlib.createGunzip()) | ||
| .pipe(tar.x({ cwd: CONFIG.tempDownloadDir })) | ||
| .on('finish', resolve) | ||
| .on('error', reject) | ||
| }) | ||
| trackUpdateFailed(error.message, version, { | ||
| stage: 'download', | ||
| statusCode: error.statusCode, | ||
| target: targetKey, | ||
| attempts, | ||
| }) | ||
| throw error | ||
| } | ||
| } | ||
| const tempBinaryPath = path.join(CONFIG.tempDownloadDir, CONFIG.binaryName) | ||
| async function downloadBinary(version, targetKey = getDownloadTargetKey()) { | ||
| const fileName = PLATFORM_TARGETS[targetKey] | ||
| if (!fs.existsSync(tempBinaryPath)) { | ||
| const files = fs.readdirSync(CONFIG.tempDownloadDir) | ||
| fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) | ||
| if (!fileName) { | ||
| const error = new Error( | ||
| `Binary not found after extraction. Expected: ${CONFIG.binaryName}, Available files: ${files.join(', ')}`, | ||
| `Unsupported platform: ${process.platform} ${process.arch}`, | ||
| ) | ||
| trackUpdateFailed(error.message, version, { stage: 'extraction', target: targetKey }) | ||
| trackUpdateFailed(error.message, version, { | ||
| stage: 'platform_check', | ||
| target: targetKey, | ||
| }) | ||
| throw error | ||
| } | ||
| const downloadUrl = `${ | ||
| process.env.NEXT_PUBLIC_CODEBUFF_APP_URL || 'https://codebuff.com' | ||
| }/api/releases/download/${version}/${fileName}` | ||
| fs.mkdirSync(CONFIG.configDir, { recursive: true }) | ||
| const tempBinaryPath = await downloadAndExtract( | ||
| downloadUrl, | ||
| version, | ||
| targetKey, | ||
| ) | ||
| if (process.platform !== 'win32') { | ||
@@ -658,7 +724,2 @@ fs.chmodSync(tempBinaryPath, 0o755) | ||
| console.error('Please check your internet connection and try again') | ||
| if (!getProxyUrl()) { | ||
| console.error( | ||
| 'If you are behind a proxy, set the HTTPS_PROXY environment variable', | ||
| ) | ||
| } | ||
| process.exit(1) | ||
@@ -673,7 +734,2 @@ } | ||
| console.error('Please check your internet connection and try again') | ||
| if (!getProxyUrl()) { | ||
| console.error( | ||
| 'If you are behind a proxy, set the HTTPS_PROXY environment variable', | ||
| ) | ||
| } | ||
| process.exit(1) | ||
@@ -680,0 +736,0 @@ } |
+1
-1
| { | ||
| "name": "freebuff", | ||
| "version": "0.0.121", | ||
| "version": "0.0.122", | ||
| "description": "The world's strongest free coding agent", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
37824
6.94%1079
9.1%