diff --git a/src/ui-manager.js b/src/ui-manager.js index 82d4a0d..660d1aa 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -2780,8 +2780,58 @@ function compareVersions(v1, v2) { return 0; } +/** + * 通过 GitHub API 获取最新版本 + * @returns {Promise} 最新版本号或 null + */ +async function getLatestVersionFromGitHub() { + const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; + const apiUrl = `https://api.github.com/repos/${GITHUB_REPO}/tags`; + + try { + console.log('[Update] Fetching latest version from GitHub API...'); + const response = await fetch(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} 更新信息 */ async function checkForUpdates() { @@ -2798,64 +2848,68 @@ async function checkForUpdates() { } // 检查是否在 git 仓库中 + let isGitRepo = false; try { await execAsync('git rev-parse --git-dir'); + isGitRepo = true; } catch (error) { - return { - hasUpdate: false, - localVersion, - latestVersion: null, - error: 'Current directory is not a Git repository, cannot check for updates' - }; + isGitRepo = false; + console.log('[Update] Not in a Git repository, will use GitHub API to check for updates'); } - // 获取远程 tags - try { - console.log('[Update] Fetching remote tags...'); - await execAsync('git fetch --tags'); - } catch (error) { - console.warn('[Update] Failed to fetch tags:', error.message); - return { - hasUpdate: false, - localVersion, - latestVersion: null, - error: 'Unable to fetch remote tags: ' + error.message - }; - } - - // 获取最新的 tag(根据操作系统选择合适的命令) let latestTag = null; - const isWindows = process.platform === 'win32'; + let updateMethod = 'unknown'; - 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 中排序 + if (isGitRepo) { + // Git 仓库模式:使用 git 命令 + updateMethod = 'git'; + + // 获取远程 tags 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:', e.message); - return { - hasUpdate: false, - localVersion, - latestVersion: null, - error: 'Unable to get latest version tag' - }; + 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) { @@ -2863,7 +2917,8 @@ async function checkForUpdates() { hasUpdate: false, localVersion, latestVersion: null, - error: 'No version tags found' + updateMethod, + error: 'Unable to get latest version information' }; } @@ -2871,12 +2926,13 @@ async function checkForUpdates() { const comparison = compareVersions(latestTag, localVersion); const hasUpdate = comparison > 0; - console.log(`[Update] Local version: ${localVersion}, Latest tag: ${latestTag}, Has update: ${hasUpdate}`); + console.log(`[Update] Local version: ${localVersion}, Latest version: ${latestTag}, Has update: ${hasUpdate}, Method: ${updateMethod}`); return { hasUpdate, localVersion, latestVersion: latestTag, + updateMethod, error: null }; } @@ -2905,6 +2961,13 @@ async function performUpdate() { 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}...`); // 检查是否有未提交的更改 @@ -2961,7 +3024,177 @@ async function performUpdate() { 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://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 fetch(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'), 'utf8') + : null; + + // 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'), 'utf8'); + 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); + } +}