import { existsSync, readFileSync, writeFileSync } from 'fs'; import { promises as fs } from 'fs'; import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import { CONFIG } from '../core/config-manager.js'; import { parseProxyUrl } from '../utils/proxy-utils.js'; const execAsync = promisify(exec); /** * 获取更新检查使用的代理配置 * @returns {Object|null} 代理配置对象或 null */ function getUpdateProxyConfig() { if (!CONFIG || !CONFIG.PROXY_URL) { return null; } const proxyConfig = parseProxyUrl(CONFIG.PROXY_URL); if (proxyConfig) { console.log(`[Update] Using ${proxyConfig.proxyType} proxy for update check: ${CONFIG.PROXY_URL}`); } return proxyConfig; } /** * 带代理支持的 fetch 封装 * @param {string} url - 请求 URL * @param {Object} options - fetch 选项 * @returns {Promise} */ async function fetchWithProxy(url, options = {}) { const proxyConfig = getUpdateProxyConfig(); if (proxyConfig) { // 使用 undici 的 fetch 支持代理 const fetchOptions = { ...options, dispatcher: undefined }; // 根据 URL 协议选择合适的 agent const urlObj = new URL(url); if (urlObj.protocol === 'https:') { fetchOptions.agent = proxyConfig.httpsAgent; } else { fetchOptions.agent = proxyConfig.httpAgent; } // Node.js 原生 fetch 不直接支持 agent,需要使用 undici 或 node-fetch // 这里使用动态导入 undici 来支持代理 try { const { fetch: undiciFetch, ProxyAgent } = await import('undici'); const proxyAgent = new ProxyAgent(CONFIG.PROXY_URL); return await undiciFetch(url, { ...options, dispatcher: proxyAgent }); } catch (importError) { // 如果 undici 不可用,回退到原生 fetch(不使用代理) console.warn('[Update] undici not available, falling back to native fetch without proxy'); return await fetch(url, options); } } return await fetch(url, options); } /** * 比较版本号 * @param {string} v1 - 版本号1 * @param {string} v2 - 版本号2 * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal */ function compareVersions(v1, v2) { // 移除 'v' 前缀(如果有) const clean1 = v1.replace(/^v/, ''); const clean2 = v2.replace(/^v/, ''); const parts1 = clean1.split('.').map(Number); const parts2 = clean2.split('.').map(Number); const maxLen = Math.max(parts1.length, parts2.length); for (let i = 0; i < maxLen; i++) { const num1 = parts1[i] || 0; const num2 = parts2[i] || 0; if (num1 > num2) return 1; if (num1 < num2) return -1; } return 0; } /** * 通过 GitHub API 获取最新版本 * @returns {Promise} 最新版本号或 null */ async function getLatestVersionFromGitHub() { const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; const apiUrl = `https://gh-proxy.org/https://api.github.com/repos/${GITHUB_REPO}/tags`; try { console.log('[Update] Fetching latest version from GitHub API...'); const response = await fetchWithProxy(apiUrl, { headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'AIClient2API-UpdateChecker' }, timeout: 10000 }); if (!response.ok) { throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); } const tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { return null; } // 提取版本号并排序 const versions = tags .map(tag => tag.name) .filter(name => /^v?\d+\.\d+/.test(name)); // 只保留符合版本号格式的 tag if (versions.length === 0) { return null; } // 按版本号排序(降序) versions.sort((a, b) => compareVersions(b, a)); return versions[0]; } catch (error) { console.warn('[Update] Failed to fetch from GitHub API:', error.message); return null; } } /** * 检查是否有新版本可用 * 支持两种模式: * 1. Git 仓库模式:通过 git 命令获取最新 tag * 2. Docker/非 Git 模式:通过 GitHub API 获取最新版本 * @returns {Promise} 更新信息 */ export async function checkForUpdates() { const versionFilePath = path.join(process.cwd(), 'VERSION'); // 读取本地版本 let localVersion = 'unknown'; try { if (existsSync(versionFilePath)) { localVersion = readFileSync(versionFilePath, 'utf-8').trim(); } } catch (error) { console.warn('[Update] Failed to read local VERSION file:', error.message); } // 检查是否在 git 仓库中 let isGitRepo = false; try { await execAsync('git rev-parse --git-dir'); isGitRepo = true; } catch (error) { isGitRepo = false; console.log('[Update] Not in a Git repository, will use GitHub API to check for updates'); } let latestTag = null; let updateMethod = 'unknown'; if (isGitRepo) { // Git 仓库模式:使用 git 命令 updateMethod = 'git'; // 获取远程 tags try { console.log('[Update] Fetching remote tags...'); await execAsync('git fetch --tags'); } catch (error) { console.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); // 如果 git fetch 失败,回退到 GitHub API latestTag = await getLatestVersionFromGitHub(); updateMethod = 'github_api'; } // 如果 git fetch 成功,获取最新的 tag if (!latestTag && updateMethod === 'git') { const isWindows = process.platform === 'win32'; try { if (isWindows) { // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); latestTag = stdout.trim(); } else { // Linux/macOS: 使用 head 命令,更高效 const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); latestTag = stdout.trim(); } } catch (error) { // 备用方案:获取所有 tags 并在 JavaScript 中排序 try { const { stdout } = await execAsync('git tag'); const tags = stdout.trim().split('\n').filter(t => t); if (tags.length > 0) { // 按版本号排序(降序) tags.sort((a, b) => compareVersions(b, a)); latestTag = tags[0]; } } catch (e) { console.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message); latestTag = await getLatestVersionFromGitHub(); updateMethod = 'github_api'; } } } } else { // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API updateMethod = 'github_api'; latestTag = await getLatestVersionFromGitHub(); } if (!latestTag) { return { hasUpdate: false, localVersion, latestVersion: null, updateMethod, error: 'Unable to get latest version information' }; } // 比较版本 const comparison = compareVersions(latestTag, localVersion); const hasUpdate = comparison > 0; console.log(`[Update] Local version: ${localVersion}, Latest version: ${latestTag}, Has update: ${hasUpdate}, Method: ${updateMethod}`); return { hasUpdate, localVersion, latestVersion: latestTag, updateMethod, error: null }; } /** * 执行更新操作 * @returns {Promise} 更新结果 */ export async function performUpdate() { // 首先检查是否有更新 const updateInfo = await checkForUpdates(); if (updateInfo.error) { throw new Error(updateInfo.error); } if (!updateInfo.hasUpdate) { return { success: true, message: 'Already at the latest version', localVersion: updateInfo.localVersion, latestVersion: updateInfo.latestVersion, updated: false }; } const latestTag = updateInfo.latestVersion; // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 if (updateInfo.updateMethod === 'github_api') { // Docker/非 Git 环境,通过下载 tarball 更新 console.log('[Update] Running in Docker/non-Git environment, will download and extract tarball'); return await performTarballUpdate(updateInfo.localVersion, latestTag); } console.log(`[Update] Starting update to ${latestTag}...`); // 检查是否有未提交的更改 try { const { stdout: statusOutput } = await execAsync('git status --porcelain'); if (statusOutput.trim()) { // 有未提交的更改,先 stash console.log('[Update] Stashing local changes...'); await execAsync('git stash'); } } catch (error) { console.warn('[Update] Failed to check git status:', error.message); } // 执行 checkout 到最新 tag try { console.log(`[Update] Checking out to ${latestTag}...`); await execAsync(`git checkout ${latestTag}`); } catch (error) { console.error('[Update] Failed to checkout:', error.message); throw new Error('Failed to switch to new version: ' + error.message); } // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) const versionFilePath = path.join(process.cwd(), 'VERSION'); try { const newVersion = latestTag.replace(/^v/, ''); writeFileSync(versionFilePath, newVersion, 'utf-8'); console.log(`[Update] VERSION file updated to ${newVersion}`); } catch (error) { console.warn('[Update] Failed to update VERSION file:', error.message); } // 检查是否需要安装依赖 let needsRestart = false; try { // 确保本地版本号有 v 前缀,以匹配 git tag 格式 const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { console.log('[Update] package.json changed, running npm install...'); await execAsync('npm install'); needsRestart = true; } } catch (error) { console.warn('[Update] Failed to check package changes:', error.message); } console.log(`[Update] Update completed successfully to ${latestTag}`); return { success: true, message: `Successfully updated to version ${latestTag}`, localVersion: updateInfo.localVersion, latestVersion: latestTag, updated: true, updateMethod: 'git', needsRestart: needsRestart, restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null }; } /** * 通过下载 tarball 执行更新(用于 Docker/非 Git 环境) * @param {string} localVersion - 本地版本 * @param {string} latestTag - 最新版本 tag * @returns {Promise} 更新结果 */ async function performTarballUpdate(localVersion, latestTag) { const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; const tarballUrl = `https://gh-proxy.org/https://github.com/${GITHUB_REPO}/archive/refs/tags/${latestTag}.tar.gz`; const appDir = process.cwd(); const tempDir = path.join(appDir, '.update_temp'); const tarballPath = path.join(tempDir, 'update.tar.gz'); console.log(`[Update] Starting tarball update to ${latestTag}...`); console.log(`[Update] Download URL: ${tarballUrl}`); try { // 1. 创建临时目录 await fs.mkdir(tempDir, { recursive: true }); console.log('[Update] Created temp directory'); // 2. 下载 tarball console.log('[Update] Downloading tarball...'); const response = await fetchWithProxy(tarballUrl, { headers: { 'User-Agent': 'AIClient2API-Updater' }, redirect: 'follow' }); if (!response.ok) { throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); const buffer = Buffer.from(arrayBuffer); await fs.writeFile(tarballPath, buffer); console.log(`[Update] Downloaded tarball (${buffer.length} bytes)`); // 3. 解压 tarball console.log('[Update] Extracting tarball...'); await execAsync(`tar -xzf "${tarballPath}" -C "${tempDir}"`); // 4. 找到解压后的目录(格式通常是 repo-name-tag) const extractedItems = await fs.readdir(tempDir); const extractedDir = extractedItems.find(item => item.startsWith('AIClient-2-API-') || item.startsWith('AIClient2API-') ); if (!extractedDir) { throw new Error('Could not find extracted directory'); } const sourcePath = path.join(tempDir, extractedDir); console.log(`[Update] Extracted to: ${sourcePath}`); // 5. 备份当前的 package.json 用于比较 const oldPackageJson = existsSync(path.join(appDir, 'package.json')) ? readFileSync(path.join(appDir, 'package.json'), 'utf-8') : null; // 5.5 在解压前删除 src/ 和 static/ 目录,确保旧代码被完全清除 const dirsToClean = ['src', 'static']; for (const dirName of dirsToClean) { const dirPath = path.join(appDir, dirName); if (existsSync(dirPath)) { console.log(`[Update] Removing old ${dirName}/ directory before extraction...`); await fs.rm(dirPath, { recursive: true, force: true }); console.log(`[Update] Old ${dirName}/ directory removed`); } } // 6. 定义需要保留的目录和文件(不被覆盖) const preservePaths = [ 'configs', // 用户配置目录 'node_modules', // 依赖目录 '.update_temp', // 临时更新目录 'logs' // 日志目录 ]; // 7. 复制新文件到应用目录 console.log('[Update] Copying new files...'); const sourceItems = await fs.readdir(sourcePath); for (const item of sourceItems) { // 跳过需要保留的目录 if (preservePaths.includes(item)) { console.log(`[Update] Skipping preserved path: ${item}`); continue; } const srcItemPath = path.join(sourcePath, item); const destItemPath = path.join(appDir, item); // 删除旧文件/目录(如果存在) if (existsSync(destItemPath)) { const stat = await fs.stat(destItemPath); if (stat.isDirectory()) { await fs.rm(destItemPath, { recursive: true, force: true }); } else { await fs.unlink(destItemPath); } } // 复制新文件/目录 await copyRecursive(srcItemPath, destItemPath); console.log(`[Update] Copied: ${item}`); } // 8. 检查是否需要更新依赖 let needsRestart = true; // tarball 更新后总是建议重启 let needsNpmInstall = false; if (oldPackageJson) { const newPackageJson = readFileSync(path.join(appDir, 'package.json'), 'utf-8'); if (oldPackageJson !== newPackageJson) { console.log('[Update] package.json changed, running npm install...'); needsNpmInstall = true; try { await execAsync('npm install', { cwd: appDir }); console.log('[Update] npm install completed'); } catch (npmError) { console.error('[Update] npm install failed:', npmError.message); // 不抛出错误,继续更新流程 } } } // 9. 清理临时目录 console.log('[Update] Cleaning up...'); await fs.rm(tempDir, { recursive: true, force: true }); console.log(`[Update] Tarball update completed successfully to ${latestTag}`); return { success: true, message: `Successfully updated to version ${latestTag}`, localVersion: localVersion, latestVersion: latestTag, updated: true, updateMethod: 'tarball', needsRestart: needsRestart, needsNpmInstall: needsNpmInstall, restartMessage: 'Code updated, please restart the service to apply changes' }; } catch (error) { // 清理临时目录 try { if (existsSync(tempDir)) { await fs.rm(tempDir, { recursive: true, force: true }); } } catch (cleanupError) { console.warn('[Update] Failed to cleanup temp directory:', cleanupError.message); } console.error('[Update] Tarball update failed:', error.message); throw new Error(`Tarball update failed: ${error.message}`); } } /** * 递归复制文件或目录 * @param {string} src - 源路径 * @param {string} dest - 目标路径 */ async function copyRecursive(src, dest) { const stat = await fs.stat(src); if (stat.isDirectory()) { await fs.mkdir(dest, { recursive: true }); const items = await fs.readdir(src); for (const item of items) { await copyRecursive(path.join(src, item), path.join(dest, item)); } } else { await fs.copyFile(src, dest); } } /** * 检查更新 */ export async function handleCheckUpdate(req, res) { try { const updateInfo = await checkForUpdates(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(updateInfo)); return true; } catch (error) { console.error('[UI API] Failed to check for updates:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Failed to check for updates: ' + error.message } })); return true; } } /** * 执行更新 */ export async function handlePerformUpdate(req, res) { try { const updateResult = await performUpdate(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(updateResult)); return true; } catch (error) { console.error('[UI API] Failed to perform update:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'Update failed: ' + error.message } })); return true; } }