feat(更新检查): 添加GitHub API和tarball更新支持
实现非Git环境下的更新检查功能,通过GitHub API获取最新版本信息。添加tarball下载更新方式,适用于Docker等非Git环境。优化更新流程,支持多种更新方式自动切换,并完善错误处理和日志记录。
This commit is contained in:
parent
071e81a09d
commit
44d09d0713
1 changed files with 284 additions and 51 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue