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;
|
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>} 更新信息
|
* @returns {Promise<Object>} 更新信息
|
||||||
*/
|
*/
|
||||||
async function checkForUpdates() {
|
async function checkForUpdates() {
|
||||||
|
|
@ -2798,64 +2848,68 @@ async function checkForUpdates() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否在 git 仓库中
|
// 检查是否在 git 仓库中
|
||||||
|
let isGitRepo = false;
|
||||||
try {
|
try {
|
||||||
await execAsync('git rev-parse --git-dir');
|
await execAsync('git rev-parse --git-dir');
|
||||||
|
isGitRepo = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
isGitRepo = false;
|
||||||
hasUpdate: false,
|
console.log('[Update] Not in a Git repository, will use GitHub API to check for updates');
|
||||||
localVersion,
|
|
||||||
latestVersion: null,
|
|
||||||
error: 'Current directory is not a Git repository, cannot 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;
|
let latestTag = null;
|
||||||
const isWindows = process.platform === 'win32';
|
let updateMethod = 'unknown';
|
||||||
|
|
||||||
try {
|
if (isGitRepo) {
|
||||||
if (isWindows) {
|
// Git 仓库模式:使用 git 命令
|
||||||
// Windows: 使用 git for-each-ref,这是跨平台兼容的方式
|
updateMethod = 'git';
|
||||||
const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1');
|
|
||||||
latestTag = stdout.trim();
|
// 获取远程 tags
|
||||||
} else {
|
|
||||||
// Linux/macOS: 使用 head 命令,更高效
|
|
||||||
const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1');
|
|
||||||
latestTag = stdout.trim();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 备用方案:获取所有 tags 并在 JavaScript 中排序
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('git tag');
|
console.log('[Update] Fetching remote tags...');
|
||||||
const tags = stdout.trim().split('\n').filter(t => t);
|
await execAsync('git fetch --tags');
|
||||||
if (tags.length > 0) {
|
} catch (error) {
|
||||||
// 按版本号排序(降序)
|
console.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message);
|
||||||
tags.sort((a, b) => compareVersions(b, a));
|
// 如果 git fetch 失败,回退到 GitHub API
|
||||||
latestTag = tags[0];
|
latestTag = await getLatestVersionFromGitHub();
|
||||||
}
|
updateMethod = 'github_api';
|
||||||
} 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'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果 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) {
|
if (!latestTag) {
|
||||||
|
|
@ -2863,7 +2917,8 @@ async function checkForUpdates() {
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
localVersion,
|
localVersion,
|
||||||
latestVersion: null,
|
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 comparison = compareVersions(latestTag, localVersion);
|
||||||
const hasUpdate = comparison > 0;
|
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 {
|
return {
|
||||||
hasUpdate,
|
hasUpdate,
|
||||||
localVersion,
|
localVersion,
|
||||||
latestVersion: latestTag,
|
latestVersion: latestTag,
|
||||||
|
updateMethod,
|
||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -2905,6 +2961,13 @@ async function performUpdate() {
|
||||||
|
|
||||||
const latestTag = updateInfo.latestVersion;
|
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}...`);
|
console.log(`[Update] Starting update to ${latestTag}...`);
|
||||||
|
|
||||||
// 检查是否有未提交的更改
|
// 检查是否有未提交的更改
|
||||||
|
|
@ -2961,7 +3024,177 @@ async function performUpdate() {
|
||||||
localVersion: updateInfo.localVersion,
|
localVersion: updateInfo.localVersion,
|
||||||
latestVersion: latestTag,
|
latestVersion: latestTag,
|
||||||
updated: true,
|
updated: true,
|
||||||
|
updateMethod: 'git',
|
||||||
needsRestart: needsRestart,
|
needsRestart: needsRestart,
|
||||||
restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null
|
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