Spaces:
Running
Running
| 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<Response>} | |
| */ | |
| 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<string|null>} 最新版本号或 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<Object>} 更新信息 | |
| */ | |
| 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<Object>} 更新结果 | |
| */ | |
| 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<Object>} 更新结果 | |
| */ | |
| 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; | |
| } | |
| } |