feat(更新检查): 添加GitHub API和tarball更新支持

实现非Git环境下的更新检查功能,通过GitHub API获取最新版本信息。添加tarball下载更新方式,适用于Docker等非Git环境。优化更新流程,支持多种更新方式自动切换,并完善错误处理和日志记录。
This commit is contained in:
hex2077 2026-01-07 23:49:23 +08:00
parent 071e81a09d
commit 44d09d0713

View file

@ -2780,8 +2780,58 @@ function compareVersions(v1, v2) {
return 0;
}
/**
* 通过 GitHub API 获取最新版本
* @returns {Promise<string|null>} 最新版本号或 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<Object>} 更新信息
*/
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<Object>} 更新结果
*/
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);
}
}